Compare commits

..
Author SHA256 Message Date
Yuvomi Mobile Contributors e0938df256 docs: record verified PWA design preview and recovered Apple validation
Android validation / emulator (35) (push) Failing after 4s
Android validation / emulator (37.0) (push) Failing after 4s
iOS validation / simulator (push) Successful in 58s
Android validation / build (push) Successful in 5m15s
2026-10-21 10:51:40 +02:00
Yuvomi Mobile Contributors 7ac9882307 feat(android): align native preview with measured PWA design
Android validation / emulator (37.0) (push) Failing after 3s
Android validation / emulator (35) (push) Failing after 4s
iOS validation / simulator (push) Successful in 59s
Android validation / build (push) Successful in 5m30s
2026-10-21 10:45:16 +02:00
Yuvomi Mobile Contributors bd0e4cc4fb docs: record verified Android login fix artifact and regression results
Android validation / emulator (35) (push) Failing after 4s
Android validation / emulator (37.0) (push) Failing after 4s
iOS validation / simulator (push) Successful in 1m4s
Android validation / build (push) Successful in 5m45s
2026-10-21 10:00:28 +02:00
Yuvomi Mobile Contributors 4b483c20e3 fix(android): retain HTTPS sessions with upstream default cookie flags
Android validation / emulator (37.0) (push) Failing after 3s
Android validation / emulator (35) (push) Failing after 9s
iOS validation / simulator (push) Successful in 1m48s
Android validation / build (push) Successful in 5m10s
Reproduce browser-success/native-401 with unmodified upstream SESSION_SECURE=false behind HTTPS. Keep cookies in the existing HTTPS-only origin-bound encrypted jar regardless of the server Secure attribute; preserve TLS, redirect, expiry and origin protections. Distinguish credential/2FA rejection from missing/rejected session handoff and clear stale login errors on retry. Verify 13 JVM tests plus the 7-test real-server suite in both Secure/default-cookie modes, including bad-password then successful native login. Bump Android preview to 0.2.1/code 3; extend CI and document the defect without claiming access to the user's production server.
2026-10-21 09:53:55 +02:00
Yuvomi Mobile Contributors 129eac675c docs: record confirmed CoreSimulator runner blocker
Android validation / emulator (35) (push) Failing after 4s
Android validation / emulator (37.0) (push) Failing after 9s
iOS validation / simulator (push) Failing after 1m5s
Android validation / build (push) Successful in 5m16s
Follow-up Mac job 1066 passed Node/checkout/Xcode/Swift lookup, then correctly failed the bounded simctl device-list probe after sixty seconds. Record the exact administrator-owned blocker without claiming Apple app validation.
2026-10-21 09:38:59 +02:00
Yuvomi Mobile Contributors 92dbd0e06d fix(ci): bound stalled Apple probes and record verified native APK
Android validation / emulator (35) (push) Failing after 4s
Android validation / emulator (37.0) (push) Failing after 9s
iOS validation / simulator (push) Failing after 1m5s
Android validation / build (push) Canceled after 2m0s
Mac Node bootstrap and checkout now pass, but Apple-tool preflight stalled after Xcode version output for twenty minutes. Add per-command deadlines and diagnostics without changing licenses/host settings; fourteen preflight/probe regressions pass. Record successful Android build/upload, downloaded APK signature/version and emulator install/launch, and unchanged missing-KVM matrix failures. No Apple app success is claimed.
2026-10-21 09:37:12 +02:00
Yuvomi Mobile Contributors 40a7962307 docs: record verified native preview and unfinished PWA parity
Android validation / emulator (35) (push) Failing after 4s
Android validation / emulator (37.0) (push) Failing after 6s
Android validation / build (push) Successful in 5m27s
iOS validation / simulator (push) Canceled after 23m26s
Replace current wrapper claims with native architecture/security/build evidence, preserve historical documents, track all thirty pages and explicit release gates, correct brand attribution and provide the twelve-section delivery report without claiming completion. Native signing remains paused and Apple application/device validation outstanding.
2026-10-21 09:13:36 +02:00
Yuvomi Mobile Contributors 1433e1670a ci: collect native APK and screenshot evidence on Gitea
Bootstrap checksum-pinned ARM64 Node before Mac checkout, upload a dedicated installable native debug APK, collect AGP test output before UTP uninstall and reject stale/missing screenshots. Add five screenshot-output regressions and enforce native shipping-source invariants. Workflow lint, 10 runner and 8 JUnit verifier regressions pass; remote execution is not yet verified.
2026-10-21 09:12:56 +02:00
Yuvomi Mobile Contributors 8c480ddca3 feat(android): replace WebView with tested native Compose API slice
Add real native onboarding/auth/dashboard/tasks/notes/shopping, origin-bound HTTPS and encrypted AndroidKeyStore session persistence. Archive the superseded browser renderer and tests. Verify 10 JVM tests, 6 native API-35 emulator tests, lintDebug/lintRelease and debug/unsigned release APK/AAB builds; the real upstream suite passes 7 tests and exports a nonblank native fixture screenshot. Tink host-tool update removes the old generated-protobuf warning. Full module/visual parity remains explicitly incomplete.
2026-10-21 09:12:47 +02:00
Yuvomi Mobile Contributors 81973c3c45 fix(ios): clear private native state and apply upstream dark tokens
Discard editor drafts on account/server changes, obscure inactive sheets and clear loaded records even when vault erasure fails. Serialize local removal and distinguish dark tint from primary button fill. Swift format/parsing and sixteen portable tests pass; Apple SDK validation remains outstanding.
2026-10-21 09:09:39 +02:00
Yuvomi Mobile Contributors 97570b7fdf feat(ios): replace embedded website with initial native SwiftUI API client
Introduce real SwiftUI auth/dashboard/task/note/shopping screens, a bounded origin-bound URLSession transport and Keychain-backed native sessions. Archive the rejected WebKit renderer rather than embedding it for unfinished modules. Sixteen portable URL/contract/transport tests and Swift format/parsing pass on Linux; Apple SDK application and UI validation remains mandatory. This is an incomplete native slice, not full PWA parity or a release.
2026-10-21 08:57:31 +02:00
Yuvomi Mobile Contributors 11bf4ca620 ci: target Gitea Ubuntu and native Apple Silicon runners
iOS validation / simulator (push) Failing after 3s
Android validation / emulator (35) (push) Failing after 9s
Android validation / emulator (37.0) (push) Failing after 9s
Android validation / build (push) Successful in 3m40s
Move workflows into Gitea's workflow directory and use the supplied ubuntu-latest and macos-arm64 labels. Provision the Android SDK, require KVM without changing host permissions, support SHA-256 checkout and Gitea artifact uploads, and isolate scratch output on persistent runners. Skip untrusted fork PRs and keep release builds unsigned because Gitea ignores GitHub environment approval gates. Local workflow lint, repository invariants, ten preflight regressions, eight evidence-verifier tests, inline shell checks, scratch cleanup and SHA-256 fetch checks pass; remote runner execution remains unverified.
2026-10-21 07:40:19 +02:00
Yuvomi Mobile Contributors 2d12491516 docs: record verified development delivery and explicit release gates
iOS validation / simulator (push) Canceled after 0s
Android validation / build (push) Canceled after 0s
Android validation / emulator (35) (push) Canceled after 0s
Android validation / emulator (37.0) (push) Canceled after 0s
Document independent native architecture, server compatibility, build/signing procedures, privacy limitations and persistent continuation steps. Record two consecutive seven-test API 35 HTTPS/OIDC passes, portable and host-tool checks, and inspected unsigned artifacts. Explicitly withhold production readiness pending Apple SDK validation, API 37/current-device testing, real IdPs, resource-limit review, human security review and maintainer signing.
2026-10-21 07:32:50 +02:00
Yuvomi Mobile Contributors 9ca11e4fb5 test: exercise native Android flows against pinned upstream HTTPS and OIDC
Add an ephemeral HTTPS proxy and standards-shaped RS256/S256-PKCE test IdP around the unmodified upstream checkout. Require unlocked devices and nonempty, failure-free JUnit evidence. The extended API 35 suite passes all seven cases, including native Blob/file transfer, session recreation, outage recovery, isolated OIDC and back navigation; eight evidence-verifier regressions also pass. API 37 and real-provider/device validation remain release gates.
2026-10-21 07:32:17 +02:00
Yuvomi Mobile Contributors f7b9dcd91e build: collect inspected unsigned Android artifacts with checksums
Verify the production manifest, system-only trust, development APK signature and unsigned release outputs before collecting local artifacts. Record source identity and hashes outside Git. Exercised successfully against the built APKs and AAB; collection must follow a clean-source build.
2026-10-21 07:30:51 +02:00
Yuvomi Mobile Contributors 9088ee7e6a ci: add pinned platform validation and maintainer-only signing workflows
Provide Android API 35/37 and Xcode jobs, dependency advisory checks, source invariants, and checksum-verified tools. Actions lint and available Linux static checks pass; hosted CI and all Apple-platform builds still require authorized runners. The manual release workflow never publishes and uses only owner-supplied signing credentials.
2026-10-21 07:29:12 +02:00
Yuvomi Mobile Contributors d70c5cc2de fix(ios): bind export cancellation to the originating download
Ignore late picker callbacks that no longer match the active download. Add an Apple-platform regression; Swift formatting, parsing and seven portable policy tests pass locally, but the new WKWebView regression requires Xcode and has not run here.
2026-10-21 07:27:34 +02:00
Yuvomi Mobile Contributors 2b9996ff3f fix(android): harden origin isolation and native file lifecycles
Block cross-origin POSTs, preserve bounded generated exports without relaxing CSP, bind picker results to their initiating context, erase camera cache and constrain platform permissions. Upgrade vulnerable build/test tooling and lock runtime/build dependencies. JVM regressions, lint and unsigned release builds pass; extended emulator validation remains separate.
2026-10-21 07:25:00 +02:00
Yuvomi Mobile Contributors f776c853c6 test(ios): cover context erasure and native capability restrictions
Add Apple-platform tests without presenting them as Linux results. Improve native dark-mode contrast and keep the portable policy suite separate from WebKit tests. Swift formatting and syntax checks pass; Xcode execution remains required.
2026-10-21 07:18:42 +02:00
Yuvomi Mobile Contributors 69c48169e2 fix(ios): restrict authentication capabilities and validate bootstrap types
Require iOS 18.4 for explicit upload denial in the isolated authentication window. Add origin-bound generated download policy, app-switcher privacy, typed metadata regressions and original community icon assets. Seven portable tests and Swift formatting checks pass; Apple SDK validation remains external.
2026-10-20 23:45:01 +02:00
Yuvomi Mobile Contributors 74c2c9e6e8 feat(ios): implement SwiftUI companion with isolated WKWebView sessions
Add HTTPS onboarding, origin policy, native recovery/settings, isolated web sign-in and file export. Portable production URL policy tests pass on Linux; all Swift sources parse. Xcode build and simulator validation remain required on macOS.
2026-10-20 23:28:07 +02:00
119 changed files with 10888 additions and 451 deletions

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4
[*.{yml,yaml,json,xml}]
indent_size = 2
[*.{kt,kts}]
max_line_length = 140
ktlint_code_style = android_studio
ij_kotlin_allow_trailing_comma = true
ij_kotlin_allow_trailing_comma_on_call_site = true
ktlint_function_naming_ignore_when_annotated_with = Composable
+117
View File
@@ -0,0 +1,117 @@
name: Android validation
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: android-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
env:
GIT_DEFAULT_HASH: sha256
with:
persist-credentials: false
- run: bash scripts/check-ci-runner.sh android
- uses: https://github.com/actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
with:
distribution: temurin
java-version: '17'
- uses: https://github.com/android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
with:
packages: 'platform-tools platforms;android-37.0 build-tools;36.0.0'
log-accepted-android-sdk-licenses: false
- name: Build, test and lint (release remains unsigned)
run: bash android/gradlew -p android testDebugUnitTest lintDebug lintRelease assembleDebug assembleRelease bundleRelease
- name: Source formatting and repository invariants
run: |
bash scripts/check-format.sh
python3 scripts/check-repository.py
python3 -m py_compile scripts/*.py
python3 scripts/test-verifier.py
python3 scripts/test-ci-runner.py
python3 scripts/test-integration-evidence.py
- name: Check dependencies against OSV
run: python3 scripts/check-dependencies.py
- name: Upload installable native debug preview
uses: https://gitea.com/actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de
with:
name: android-native-debug-apk
path: android/app/build/outputs/apk/debug/app-debug.apk
if-no-files-found: error
retention-days: 14
- uses: https://gitea.com/actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20 (Gitea artifact protocol)
if: always()
with:
name: android-builds-and-reports
path: |
android/app/build/outputs/apk/debug/*.apk
android/app/build/outputs/apk/release/*unsigned.apk
android/app/build/outputs/bundle/release/*.aab
android/app/build/reports/
retention-days: 14
emulator:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
api: ['35', '37.0']
timeout-minutes: 60
steps:
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262
env:
GIT_DEFAULT_HASH: sha256
with:
persist-credentials: false
- run: bash scripts/check-ci-runner.sh emulator
- uses: https://github.com/actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3
with:
distribution: temurin
java-version: '17'
- uses: https://github.com/android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407
with:
packages: 'platform-tools platforms;android-37.0 build-tools;36.0.0'
log-accepted-android-sdk-licenses: false
- uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22.22.1'
- name: Prepare pinned, unmodified upstream
run: |
upstream=$(mktemp -d "$RUNNER_TEMP/yuvomi-upstream.XXXXXX")
echo "YUVOMI_UPSTREAM=$upstream" >> "$GITHUB_ENV"
git clone --filter=blob:none https://github.com/ulsklyc/yuvomi.git "$upstream"
git -C "$upstream" checkout 10bfef7686ed7303b4c74c7fcc55a5eb16356c9d
npm ci --omit=dev --prefix "$upstream"
- uses: https://github.com/reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
with:
api-level: ${{ matrix.api }}
arch: x86_64
target: google_apis
disable-animations: true
script: |
bash android/gradlew -p android connectedDebugAndroidTest
python3 scripts/verify-test-results.py android/app/build/outputs/androidTest-results/connected/debug --minimum 9
python3 scripts/test-integration.py "$YUVOMI_UPSTREAM" --android
python3 scripts/test-integration.py "$YUVOMI_UPSTREAM" --android --cookie-without-secure
- uses: https://gitea.com/actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de
if: always()
with:
name: android-device-test-reports-${{ matrix.api }}
path: |
android/app/build/reports/androidTests/
android/app/build/outputs/native-screenshots/
retention-days: 14
- name: Remove only this job's allocated upstream fixture
if: always()
run: |
case "${YUVOMI_UPSTREAM:-}" in
"$RUNNER_TEMP"/yuvomi-upstream.*) rm -rf -- "$YUVOMI_UPSTREAM" ;;
esac
+74
View File
@@ -0,0 +1,74 @@
name: iOS validation
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ios-${{ github.ref }}
cancel-in-progress: true
jobs:
simulator:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: macos-arm64
timeout-minutes: 60
steps:
- name: Bootstrap verified native Node before JavaScript actions
run: |
set -euo pipefail
test "$(uname -s)" = Darwin && test "$(uname -m)" = arm64
cache="${RUNNER_TOOL_CACHE:-$HOME/.cache/yuvomi-tools}"
target="$cache/yuvomi-node-22.22.1-darwin-arm64"
mkdir -p "$cache"
if [ ! -x "$target/bin/node" ]; then
test ! -e "$target"
temporary=$(mktemp -d "$cache/yuvomi-node-bootstrap.XXXXXX")
trap 'rm -rf -- "$temporary"' EXIT
curl --fail --location https://nodejs.org/dist/v22.22.1/node-v22.22.1-darwin-arm64.tar.gz -o "$temporary/node.tar.gz"
printf '%s %s\n' 679ad4966339e4ef4900f57996714864e4211b898825bb840c3086c419fbcef2 "$temporary/node.tar.gz" | shasum -a 256 -c -
tar -xzf "$temporary/node.tar.gz" -C "$temporary"
mv "$temporary/node-v22.22.1-darwin-arm64" "$target"
fi
test "$("$target/bin/node" -p 'process.arch')" = arm64
echo "$target/bin" >> "$GITHUB_PATH"
"$target/bin/node" --version
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262
env:
GIT_DEFAULT_HASH: sha256
with:
persist-credentials: false
- run: bash scripts/check-ci-runner.sh ios
- name: Allocate isolated scratch space on the persistent Mac runner
run: |
scratch=$(mktemp -d "$RUNNER_TEMP/yuvomi-ios.XXXXXX")
echo "YUVOMI_CI_TEMP=$scratch" >> "$GITHUB_ENV"
- name: Install verified XcodeGen and generate project
run: |
bash scripts/install-xcodegen.sh "$YUVOMI_CI_TEMP/xcodegen"
"$YUVOMI_CI_TEMP/xcodegen/xcodegen/bin/xcodegen" generate --spec ios/project.yml
xcodebuild -version
- name: Portable native API/contract tests and Swift syntax
run: |
swift test --package-path ios --scratch-path "$YUVOMI_CI_TEMP/SwiftPolicy"
swiftc -frontend -parse ios/Sources/*.swift ios/Tests/*.swift ios/UITests/*.swift
- name: Simulator build and XCTest (no production signing)
run: |
device=$(python3 scripts/select-simulator.py)
xcodebuild -project ios/Yuvomi.xcodeproj -scheme Yuvomi -destination "platform=iOS Simulator,id=$device" -derivedDataPath "$YUVOMI_CI_TEMP/DerivedData" -resultBundlePath "$YUVOMI_CI_TEMP/Simulator.xcresult" CODE_SIGNING_ALLOWED=NO test
- name: Unsigned device archive validation
run: xcodebuild -project ios/Yuvomi.xcodeproj -scheme Yuvomi -destination 'generic/platform=iOS' -derivedDataPath "$YUVOMI_CI_TEMP/DerivedData" -archivePath "$YUVOMI_CI_TEMP/Yuvomi-unsigned.xcarchive" CODE_SIGNING_ALLOWED=NO archive
- uses: https://gitea.com/actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de
if: always() && env.YUVOMI_CI_TEMP != ''
with:
name: ios-unsigned-archive-and-test-results
path: |
${{ env.YUVOMI_CI_TEMP }}/Simulator.xcresult
${{ env.YUVOMI_CI_TEMP }}/Yuvomi-unsigned.xcarchive
retention-days: 14
- name: Remove only this job's allocated scratch directory
if: always()
run: |
case "${YUVOMI_CI_TEMP:-}" in
"$RUNNER_TEMP"/yuvomi-ios.*) rm -rf -- "$YUVOMI_CI_TEMP" ;;
esac
+33
View File
@@ -0,0 +1,33 @@
name: Maintainer unsigned release build (never publishes)
on:
workflow_dispatch:
permissions:
contents: read
jobs:
android:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262
env:
GIT_DEFAULT_HASH: sha256
with:
persist-credentials: false
- run: bash scripts/check-ci-runner.sh android
- uses: https://github.com/actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3
with:
distribution: temurin
java-version: '17'
- uses: https://github.com/android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407
with:
packages: 'platform-tools platforms;android-37.0 build-tools;36.0.0'
log-accepted-android-sdk-licenses: false
- run: bash android/gradlew -p android testDebugUnitTest lintRelease assembleRelease bundleRelease
# Gitea ignores GitHub environment approval gates: never load signing keys here.
- uses: https://gitea.com/actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de
with:
name: maintainer-android-release-builds
path: |
android/app/build/outputs/apk/release/*.apk
android/app/build/outputs/bundle/release/*.aab
retention-days: 7
+10
View File
@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: gradle
directory: /android
schedule:
interval: weekly
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
+2
View File
@@ -1,6 +1,7 @@
.DS_Store .DS_Store
.idea/ .idea/
.gradle/ .gradle/
.kotlin/
**/build/ **/build/
local.properties local.properties
*.keystore *.keystore
@@ -13,6 +14,7 @@ DerivedData/
*.xcuserstate *.xcuserstate
xcuserdata/ xcuserdata/
ios/Yuvomi.xcodeproj/ ios/Yuvomi.xcodeproj/
ios/Info.plist
.swiftpm/ .swiftpm/
.build/ .build/
artifacts/ artifacts/
+6
View File
@@ -0,0 +1,6 @@
{
"version": 1,
"indentation": { "spaces": 4 },
"lineLength": 120,
"respectsExistingLineBreaks": true
}
+48
View File
@@ -0,0 +1,48 @@
# Changelog
## [Unreleased]
### Android 0.2.2 native preview (code 4)
- Replace the initial Material-style slice with native PWA-derived typography,
light/dark colors, module icons/seals, compact cards, list rows, capsule
navigation and bottom-sheet editors. Remove the duplicate overview heading.
- Read personal dashboard/favorite preferences, select the first shopping list,
and save supported widget visibility without deleting unsupported config.
- Add real-PWA reference capture and a native ten-view light/dark screenshot
gallery with measured content viewport; browser automation is tooling only.
- Preserve the confirmed HTTPS login/session fix. This is an Android design pass,
**not accepted 1:1 parity**; iOS and remaining modules are unchanged/incomplete.
### Android 0.2.1 native preview (code 3)
- Fix login against upstream-default session cookies without `Secure`: Android
now retains them in its origin-bound HTTPS-only jar instead of losing the
authenticated session. HTTP, cross-origin redirects and invalid TLS remain denied.
- Distinguish credential/code rejection from a missing/rejected native session;
clear a previous login error after a successful retry.
- Exercise both upstream cookie configurations over real HTTPS, including a wrong
password followed by a successful native login. See `docs/login-troubleshooting.md`.
### Initial native preview
- Begin the genuine native rebuild: Compose/SwiftUI screens, direct HTTPS API,
protected native session storage and initial auth/dashboard/task/note/shopping
flows. Archive the rejected WebView renderer and its tests. Full PWA parity is
explicitly incomplete; see `docs/native-parity.md`.
- Gitea workflows for the maintainer's Ubuntu pool and native M4 Pro runner,
explicit SDK/KVM prerequisites, SHA-256 checkout and unsigned-only CI release builds.
### Archived browser prototype (not shipped)
- Native Android HTTPS onboarding, origin-bound browsing, session persistence,
recovery, server erasure, camera/document selection, downloads and blob exports.
- SwiftUI/WKWebView iOS implementation, isolated web sign-in, native settings,
recovery, file export, and strict URL/metadata policies; Xcode validation pending.
- JVM, Android emulator, portable Swift, and real pinned-upstream HTTPS tests.
- Reproducible build configuration, CI and maintainer-controlled release procedures.
- MIT licensing, upstream audit, security model and release documentation.
No stable release has been published. No native push or browser-to-app session
handoff is claimed. See validation records for what has actually been executed.
+29
View File
@@ -0,0 +1,29 @@
# Contributing
This is an independent mobile repository, not the upstream Yuvomi project.
Discuss substantial changes with the community maintainer before submitting a PR.
Do not open upstream issues or submit upstream patches on somebody else's behalf.
Use `feat/`, `fix/`, or `docs/` branches and Conventional Commits. Keep commits
reviewable and scoped. Preserve existing work. Run the relevant tests, lint,
`python3 scripts/check-repository.py`, and `git diff --check` before committing.
Add regression tests with security fixes, and demonstrate that the test fails
without the fix. Do not commit build outputs, credentials, private signing keys,
local servers, generated fixture certificates, or production household data.
Keep all application screens native (Compose/SwiftUI), not just platform
integrations. No WebView fallback for unfinished modules; use the parity tracker
and never count archived browser tests as native evidence. Keep dependencies minimal. The server owns
business logic, authentication, CSRF, and authorization. Never solve compatibility
by disabling TLS, accepting every origin, or copying a browser's session cookies.
Upstream prohibits frontend frameworks, bundlers, and transpilers in its own
codebase; we make no changes to it. Read its CONTRIBUTING, SECURITY, and DESIGN
before proposing an upstream integration.
This code was drafted with OpenAI through OpenCode. Disclose AI assistance in PRs
(one sentence is sufficient), as upstream requests. Generated code and automated
reviews do not replace human review. No merge, release, public issue, or store
publication happens automatically. Security-sensitive code needs human review.
See `docs/build-android.md`, `docs/build-ios.md`, and `docs/validation.md`.
The portable Swift package checks shared production policy, not the iOS app.
+17 -8
View File
@@ -1,23 +1,32 @@
# Yuvomi Mobile # Yuvomi Mobile
Independent, community-maintained Android (Kotlin/WebView) and iOS Independent native Android (Kotlin/Jetpack Compose) and iOS (SwiftUI) clients for
(SwiftUI/WKWebView) companions for user-owned [Yuvomi](https://github.com/ulsklyc/yuvomi) user-owned [Yuvomi](https://github.com/ulsklyc/yuvomi) servers. Not affiliated with
servers. Not affiliated with or endorsed by upstream. or endorsed by upstream.
**Development build - not production-ready.** See [validation status](docs/validation.md). **Native rebuild in progress — not full PWA parity or production-ready.**
The previous WebView wrapper did not meet the requested native implementation.
It is archived under `legacy/` and is not the shipped application surface.
Read the [feature-by-feature parity tracker](docs/native-parity.md) and
[current validation](docs/validation.md) before installing a preview.
No mobile backend, advertisements, telemetry, tracking, subscriptions, or mandatory cloud. No mobile backend, advertisements, telemetry, tracking, subscriptions, or mandatory cloud.
The configured server supplies the application and retains responsibility for its own The apps render native controls and call the existing server API. The server owns
integrations, version checks, security and privacy. authorization, recurrence, synchronization and household business rules.
Use a root-hosted HTTPS instance with a valid certificate, including private servers Use a root-hosted HTTPS instance with a valid certificate, including private servers
accessible through your VPN. Custom ports are supported. HTTP and subpath deployments accessible through your VPN. Custom ports are supported. HTTP and subpath deployments
are intentionally rejected. Enter the server on first launch; use Server settings to are intentionally rejected. Enter the server on first launch; use **Mehr** to
reconnect or erase local browsing data and switch installations. remove the native session and switch installations. The native client does not
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)
- [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)
- [Gitea CI runners](docs/ci-runners.md)
- [Maintainer continuation](docs/continuation.md)
MIT licensed. Upstream notices are preserved in `THIRD_PARTY_NOTICES.md`. MIT licensed. Upstream notices are preserved in `THIRD_PARTY_NOTICES.md`.
This implementation is AI-assisted (OpenAI via OpenCode) and requires human security This implementation is AI-assisted (OpenAI via OpenCode) and requires human security
+124
View File
@@ -0,0 +1,124 @@
# Security policy — native rebuild
**The current native client is an incomplete development preview, not an audited
release or full PWA replacement.** See [native parity](docs/native-parity.md).
Report vulnerabilities privately to the repository owner through an established
private channel. No reporting mailbox has been provided; do not invent one or put
cookies, keys, household records or exploit details into public issues. Upstream
server vulnerabilities belong in its private reporting channel.
## Current native security model
- Native Compose/SwiftUI screens call one configured HTTPS origin. System TLS
validation is mandatory and API redirects are refused. No WebView/WKWebView is
shipped as the app surface or used as a missing-feature fallback.
- Passwords and second-factor codes exist transiently for the API request; they
are not saved to preferences, keychains, logs, artifacts or application state
restoration. Server cookies, not passwords, persist the session.
- Android treats every allow-listed native cookie as HTTPS-only, bound to the
configured origin/port. This also applies when upstream omits the `Secure`
attribute (`SESSION_SECURE=false` is upstream's default); no HTTP transport is
enabled. Persistent cookies retain their absolute expiry in AES-GCM ciphertext
backed by AndroidKeyStore, with the origin authenticated as
additional data. iOS uses a private ephemeral URLSession jar and origin-scoped
Keychain items protected with WhenUnlockedThisDeviceOnly.
- CSRF tokens follow the upstream API protocol. Only an explicit `csrf_invalid`
rejection allows one refresh/retry. Other permission errors or ambiguous network
failures never cause automatic mutation replay. The server remains authoritative
for authorization, recurrence, reward accounting and cross-module rules.
- Reads are bounded and timed out. There is no offline mutation queue or native
remote push implementation. Missing features are documented, not simulated.
- Logout attempts remote revocation and clears the native local session. Local
server removal is not remote revocation. Old prototype WebKit data is not
imported; use OS app-data removal/reinstall to erase that legacy installation.
- Android screenshots are blocked. The iOS view is obscured while inactive. The
Android integration test temporarily permits a screenshot only of its synthetic
private fixture, restores the restriction and never uses real household data.
- The owner-authorized initial signing key is backed up outside the repository.
Four repository signing secrets exist, but signing/release changes are parked
during the native rebuild. No private key belongs in Git or downloadable CI
artifacts. Protect trusted-branch write access and isolate persistent runners.
## Mandatory gates
Native transports, secure storage, session erasure, actual Apple SDK compilation,
real-device behavior, accessibility, supported authentication flows and dependency
resolution need review and evidence before release. OIDC, file transfers, offline
storage and the remaining modules are not implemented native capabilities yet.
Advisory scans and parser/unit checks are not a guarantee that code is secure.
The superseded prototype policy is retained below as historical context only;
it must not be used to claim capabilities of the native rebuild.
---
## Historical WebView prototype policy
**Development software: no production-readiness claim.** Only the current
development branch is maintained; no stable mobile release exists yet.
Do not post vulnerabilities, cookies, private URLs, household records or keys in a
public issue. Contact the community repository's owner through an established
private channel or enable the host's private vulnerability-reporting mechanism
before public distribution. No private reporting mailbox has been supplied; we
will not invent one. If none is available, request a private contact without
including exploit details. Upstream Yuvomi vulnerabilities belong in
[upstream's private reporting channel](https://github.com/ulsklyc/yuvomi/security/advisories/new),
not its public issue tracker. This project cannot promise upstream response times.
Include affected app/OS/WebView/server versions, reproduction steps, impact,
and a minimal sanitized demonstration. Never include real credentials.
## Security model
- The user selects one HTTPS origin, including its port. A valid TLS chain is
required. No certificate-error bypass, HTTP production mode, or custom scheme
handler exists. Use a VPN for private-network access.
- Main application navigation is origin-bound. Android also blocks cross-origin
subresource and POST requests before the network. External links need a native
choice; dangerous URI schemes are rejected. No exported deep-link receiver exists.
- No `addJavascriptInterface` bridge or native credential store. Android blob
exports capture at most 25 MB / eight same-origin Blob references using an
origin-restricted document-start script, then pull bounded chunks with
`evaluateJavascript` only after a user chooses a destination. Every pull
rechecks origin and lifecycle. No blob fetch or CSP relaxation is used.
- WebView/WKWebView owns persistent HttpOnly session cookies. Native code neither
handles passwords nor bypasses the server's CSRF rules. Native Android download
code obtains a cookie snapshot only for the configured origin and refuses redirects.
- Live microphone, geolocation and media-capture requests are denied. The trusted
app's file input uses explicit system pickers; Android image capture delegates
to the camera app with a narrowly scoped content URI and no broad storage access.
- External SSO uses a separate capability-restricted web view and the same OS
website cookie store. It does not gain app navigation/file handlers. iOS 18.4+
is required so uploads can be explicitly denied in that untrusted auth window.
- Android backups/device transfer are excluded and screenshots are blocked.
iOS hides content when inactive and excludes temporary exports from backups.
OS-managed WKWebView storage remains governed by iOS device/backup protection;
do not assume the app can exclude every WebKit-managed cookie from an OS backup.
- Server changes stop and destroy the old web context and erase website data.
Older Android engines that lack complete browsing-data deletion use an OS
app-data reset, which closes the app. A local wipe is not remote session revocation.
- No app logs contain passwords, tokens, cookie headers or raw network exceptions.
System/browser diagnostics are outside the app's complete control.
## Limitations and release gates
The configured server and any server-installed extension are trusted same-origin
code. An XSS or malicious administrator remains able to act as the signed-in user;
native origin checks cannot fix compromised application content. Structural metadata
probing does not authenticate the Yuvomi product or expose an anonymous version number.
Embedded SSO providers that reject web views need a browser session instead; no
secure native-session handoff exists upstream. A future handoff needs upstream
review and single-use, short-lived, session-bound exchange semantics.
Downloaded files saved by the user remain outside app erasure. Android network
downloads are capped at 100 MB / 120 seconds; generated blob exports at 25 MB /
45 seconds. iOS checks declared size before downloads and actual size before
export (100 MB); an unknown/chunked size may consume disk before rejection.
This resource-limit gap requires review and device testing before release.
Android WebView Safe Browsing remains enabled. The installed engine/OS may perform
its own security checks; this app does not implement or log those network services.
Keep the OS and WebView patched. OSV advisory checks are build-time, never runtime,
and do not prove the absence of vulnerabilities. See `docs/security-review.md`.
+28 -1
View File
@@ -2,7 +2,14 @@
Yuvomi Mobile is an independent community contribution, not an official Yuvomi Yuvomi Mobile is an independent community contribution, not an official Yuvomi
product. Yuvomi's name identifies the server with which it interoperates. product. Yuvomi's name identifies the server with which it interoperates.
Visual color values follow upstream DESIGN.md. No upstream logo is redistributed. Native design-token mappings follow upstream DESIGN.md/styles. The native
three-circle brand mark is redrawn from upstream's published brand geometry and
colors (`public/utils/brand-mark.js` and `docs/logo.svg`); its MIT attribution is
preserved below. No affiliation or trademark ownership is implied.
Android's `PwaIcons.kt` also adapts the module paths from upstream
`public/nav-icons.js`; the native palette, spacing and typography follow its
`public/styles/{tokens,typography,layout,dashboard,auth,tasks,notes,shopping}.css`.
The reference browser tooling is development-only and is not shipped in the apps.
Upstream Yuvomi: https://github.com/ulsklyc/yuvomi Upstream Yuvomi: https://github.com/ulsklyc/yuvomi
@@ -31,3 +38,23 @@ SOFTWARE.
Android build dependencies retain their own licenses (Android SDK/platform APIs, Android build dependencies retain their own licenses (Android SDK/platform APIs,
AndroidX: Apache-2.0; Kotlin: Apache-2.0; Gradle: Apache-2.0). AndroidX: Apache-2.0; Kotlin: Apache-2.0; Gradle: Apache-2.0).
Apple system frameworks are provided by the operating system, not vendored here. Apple system frameworks are provided by the operating system, not vendored here.
## Gradle wrapper notice
The unmodified official Gradle wrapper JAR retains `META-INF/LICENSE` (Apache-2.0).
The corresponding Gradle distribution NOTICE is reproduced below:
This product includes software developed by
The Apache Software Foundation (http://www.apache.org/).
It includes the following other software:
Groovy (http://groovy-lang.org)
SLF4J (http://www.slf4j.org)
JUnit (http://www.junit.org)
JCIFS (http://jcifs.samba.org)
HttpClient (https://hc.apache.org/httpcomponents-client-4.5.x/)
For licenses, see the LICENSE file supplied in the Gradle distribution.
If any software distributed with Gradle does not have an Apache 2 License, its
license is explicitly listed in that distribution's LICENSE file.
+73 -8
View File
@@ -1,42 +1,107 @@
plugins { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.compose")
} }
android { android {
namespace = "community.yuvomi.mobile" namespace = "community.yuvomi.mobile"
compileSdk = 36 compileSdk { version = release(37) { minorApiLevel = 0 } }
defaultConfig { defaultConfig {
applicationId = "community.yuvomi.mobile" applicationId = "community.yuvomi.mobile"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 37
versionCode = 1 versionCode = 4
versionName = "0.1.0" versionName = "0.2.2-native-preview"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
buildTypes { buildTypes {
release { release {
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
// Signing is deliberately supplied by the authorized release maintainer. // Signing is deliberately supplied by the authorized release maintainer.
} }
create("integration") {
initWith(getByName("debug"))
applicationIdSuffix = ".integration"
matchingFallbacks += "debug"
}
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
lint { abortOnError = true; warningsAsErrors = true } buildFeatures { compose = true }
sourceSets.getByName("androidTest").kotlin.directories.apply {
clear()
add("src/androidTestNative/java")
}
lint {
abortOnError = true
warningsAsErrors = true
}
if (providers.gradleProperty("integration").isPresent) {
testBuildType = "integration"
sourceSets.getByName(
"androidTest",
).kotlin.directories.add("src/androidTestNativeIntegration/java")
val testResources = providers.environmentVariable("YUVOMI_TEST_RES").get()
sourceSets.getByName("integration").res.directories.add(testResources)
defaultConfig.testInstrumentationRunnerArguments["server"] =
providers.environmentVariable("YUVOMI_TEST_URL").get()
defaultConfig.testInstrumentationRunnerArguments["password"] =
providers.environmentVariable("YUVOMI_TEST_PASSWORD").get()
}
} }
kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } } kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } }
dependencyLocking { lockAllConfigurations() }
// AGP's device-test tools still request vulnerable older wire libraries. These
// are host-only, not APK dependencies; stay within binary-compatible minor lines.
configurations.configureEach {
if (name.startsWith("_internal-unified-test-platform") || name == "androidLintTool" ||
name == "unified-test-platform-gradle-work-action"
) {
resolutionStrategy.eachDependency {
when (requested.group) {
"io.netty" -> {
useVersion("4.1.139.Final")
because("Remediate published host-test HTTP/TLS advisories")
}
"com.google.protobuf" -> {
useVersion("3.25.9")
because("Remediate protobuf recursion/DoS advisories")
}
"org.bouncycastle" -> {
useVersion("1.86")
because("Remediate published build-tool cryptography advisories")
}
"org.apache.commons" -> if (requested.name == "commons-lang3") useVersion("3.21.0")
"org.apache.httpcomponents" -> if (requested.name ==
"httpclient"
) {
useVersion("4.5.14")
}
}
}
}
}
dependencies { dependencies {
implementation("androidx.activity:activity-ktx:1.10.1") implementation("androidx.activity:activity-ktx:1.10.1")
implementation("androidx.core:core-ktx:1.16.0") implementation("androidx.core:core-ktx:1.16.0")
implementation("androidx.webkit:webkit:1.14.0") implementation("androidx.activity:activity-compose:1.10.1")
implementation("androidx.compose.material3:material3:1.4.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.4")
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
testImplementation("org.json:json:20260814")
androidTestImplementation("androidx.test:runner:1.6.2") androidTestImplementation("androidx.test:runner:1.6.2")
androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.9.0")
} }
+220
View File
@@ -0,0 +1,220 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
# To regenerate this file, run: ./gradlew :app:dependencies --write-locks
androidx.activity:activity-compose:1.10.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.activity:activity-ktx:1.10.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.activity:activity:1.10.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.annotation:annotation-experimental:1.4.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.annotation:annotation-jvm:1.9.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.annotation:annotation:1.9.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.arch.core:core-common:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.arch.core:core-runtime:2.2.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.autofill:autofill:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.collection:collection-jvm:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.collection:collection-ktx:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.collection:collection:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.animation:animation-android:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.animation:animation-core-android:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.animation:animation-core:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.animation:animation:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.foundation:foundation-android:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.foundation:foundation-layout-android:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.foundation:foundation-layout:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.foundation:foundation:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.material3:material3-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.material3:material3:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.material:material-ripple-android:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.material:material-ripple:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime-android:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime-annotation-android:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime-annotation:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime-saveable-android:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime-saveable:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-android:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-geometry-android:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-geometry:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-graphics-android:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-graphics:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-test-android:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-test-junit4-android:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-test-junit4:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-test:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-text-android:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-text:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-unit-android:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-unit:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-util-android:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-util:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.concurrent:concurrent-futures-ktx:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.concurrent:concurrent-futures:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.core:core-ktx:1.16.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.core:core-viewtree:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.core:core:1.16.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.customview:customview-poolingcontainer:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.emoji2:emoji2:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.graphics:graphics-path:1.0.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.interpolator:interpolator:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-common-java8:2.9.4=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-common-jvm:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-common:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-livedata-core:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-process:2.9.4=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-runtime-android:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-runtime-compose-android:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-runtime-compose:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-runtime-ktx-android:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-runtime-ktx:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-runtime:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-android:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-compose-android:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-compose:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.profileinstaller:profileinstaller:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.savedstate:savedstate-android:1.3.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.savedstate:savedstate-compose-android:1.3.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.savedstate:savedstate-compose:1.3.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.savedstate:savedstate-ktx:1.3.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.savedstate:savedstate:1.3.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.startup:startup-runtime:1.1.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.test.espresso:espresso-core:3.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test.espresso:espresso-idling-resource:3.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test.ext:junit:1.2.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test.services:storage:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test:core:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test:monitor:1.7.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test:runner:1.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.tracing:tracing:1.1.0=debugAndroidTestCompileClasspath
androidx.tracing:tracing:1.2.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.versionedparcelable:versionedparcelable:1.1.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.android.tools.analytics-library:protos:32.4.1=androidLintTool
com.android.tools.analytics-library:shared:32.4.1=androidLintTool
com.android.tools.analytics-library:tracker:32.4.1=androidLintTool
com.android.tools.androidtest:android-test-engine-result-listener:1.0.1=unified-test-platform-gradle-work-action
com.android.tools.androidtest:android-test-engine:1.0.1=unified-test-platform-gradle-work-action
com.android.tools.androidtest:test-result-event-proto:1.0.1=unified-test-platform-gradle-work-action
com.android.tools.androidtest:test-result-proto:1.0.1=unified-test-platform-gradle-work-action
com.android.tools.build:aapt2-proto:9.4.1-15978811=androidLintTool
com.android.tools.build:builder-model:9.4.1=androidLintTool
com.android.tools.build:manifest-merger:32.4.1=androidLintTool
com.android.tools.ddms:ddmlib:32.4.1=androidLintTool
com.android.tools.external.com-intellij:intellij-core:32.4.1=androidLintTool
com.android.tools.external.com-intellij:kotlin-compiler:32.4.1=androidLintTool
com.android.tools.external.org-jetbrains:uast:32.4.1=androidLintTool
com.android.tools.layoutlib:layoutlib-api:32.4.1=androidLintTool
com.android.tools.lint:lint-api:32.4.1=androidLintTool
com.android.tools.lint:lint-checks:32.4.1=androidLintTool
com.android.tools.lint:lint-gradle:32.4.1=androidLintTool
com.android.tools.lint:lint-model:32.4.1=androidLintTool
com.android.tools.lint:lint-typedef-remover:32.4.1=androidLintTool
com.android.tools.lint:lint:32.4.1=androidLintTool
com.android.tools.utp:gradle-work-action:32.4.1=unified-test-platform-gradle-work-action
com.android.tools:annotations:32.4.1=androidLintTool
com.android.tools:common:32.4.1=androidLintTool
com.android.tools:dvlib:32.4.1=androidLintTool
com.android.tools:play-sdk-proto:32.4.1=androidLintTool
com.android.tools:repository:32.4.1=androidLintTool
com.android.tools:sdk-common:32.4.1=androidLintTool
com.android.tools:sdklib:32.4.1=androidLintTool
com.google.code.findbugs:jsr305:3.0.2=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
com.google.code.gson:gson:2.11.0=androidLintTool
com.google.errorprone:error_prone_annotations:2.18.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
com.google.errorprone:error_prone_annotations:2.36.0=androidLintTool
com.google.guava:failureaccess:1.0.2=androidLintTool
com.google.guava:guava:33.4.0-jre=androidLintTool
com.google.guava:listenablefuture:1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=androidLintTool
com.google.j2objc:j2objc-annotations:3.0.0=androidLintTool
com.google.jimfs:jimfs:1.1=androidLintTool
com.google.protobuf:protobuf-java:3.25.9=androidLintTool,unified-test-platform-gradle-work-action
com.sun.istack:istack-commons-runtime:3.0.8=androidLintTool
com.sun.xml.fastinfoset:FastInfoset:1.2.16=androidLintTool
commons-codec:commons-codec:1.17.1=androidLintTool
commons-io:commons-io:2.16.1=androidLintTool
commons-logging:commons-logging:1.2=androidLintTool
jakarta.activation:jakarta.activation-api:1.2.1=androidLintTool
jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=androidLintTool
javax.inject:javax.inject:1=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
junit:junit:4.13.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
net.java.dev.jna:jna-platform:5.6.0=androidLintTool
net.java.dev.jna:jna:5.6.0=androidLintTool
net.sf.kxml:kxml2:2.3.0=androidLintTool
org.apache.commons:commons-compress:1.27.1=androidLintTool
org.apache.commons:commons-lang3:3.21.0=androidLintTool
org.apache.groovy:groovy-bom:4.0.29=androidLintTool
org.apache.groovy:groovy:4.0.29=androidLintTool
org.apache.httpcomponents:httpclient:4.5.14=androidLintTool
org.apache.httpcomponents:httpcore:4.4.16=androidLintTool
org.apache.httpcomponents:httpmime:4.5.6=androidLintTool
org.bouncycastle:bcpkix-jdk18on:1.86=androidLintTool
org.bouncycastle:bcprov-jdk18on:1.86=androidLintTool
org.bouncycastle:bcutil-jdk18on:1.86=androidLintTool
org.checkerframework:checker-qual:3.43.0=androidLintTool
org.glassfish.jaxb:jaxb-runtime:2.3.2=androidLintTool
org.glassfish.jaxb:txw2:2.3.2=androidLintTool
org.hamcrest:hamcrest-core:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.hamcrest:hamcrest-library:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
org.jetbrains.kotlin:compose-group-mapping:2.4.21=composeMappingProducerClasspath
org.jetbrains.kotlin:kotlin-build-tools-api:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-build-tools-api:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.21=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-compiler-runner:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-compiler-runner:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable:2.4.21=kotlin-extension,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathIntegration,kotlinCompilerPluginClasspathRelease
org.jetbrains.kotlin:kotlin-daemon-client:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-daemon-client:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-reflect:2.2.10=androidLintTool
org.jetbrains.kotlin:kotlin-script-runtime:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-script-runtime:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-stdlib-common:2.4.21=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.10=androidLintTool,unified-test-platform-gradle-work-action
org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.10=androidLintTool,unified-test-platform-gradle-work-action
org.jetbrains.kotlin:kotlin-stdlib:2.2.10=androidLintTool,unified-test-platform-gradle-work-action
org.jetbrains.kotlin:kotlin-stdlib:2.2.21=composeMappingProducerClasspath
org.jetbrains.kotlin:kotlin-stdlib:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-stdlib:2.4.21=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlin:kotlin-tooling-core:2.4.21=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=androidLintTool,unified-test-platform-gradle-work-action
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0=androidLintTool,unified-test-platform-gradle-work-action
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0=androidLintTool,unified-test-platform-gradle-work-action
org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains:annotations:13.0=composeMappingProducerClasspath,kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains:annotations:23.0.0=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-gradle-work-action
org.json:json:20260814=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.jspecify:jspecify:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.junit.platform:junit-platform-commons:1.13.3=unified-test-platform-gradle-work-action
org.junit.platform:junit-platform-engine:1.13.3=unified-test-platform-gradle-work-action
org.junit.platform:junit-platform-launcher:1.13.3=unified-test-platform-gradle-work-action
org.junit:junit-bom:5.13.3=unified-test-platform-gradle-work-action
org.jvnet.staxex:stax-ex:1.8.1=androidLintTool
org.opentest4j:opentest4j:1.3.0=unified-test-platform-gradle-work-action
org.ow2.asm:asm-analysis:9.9=androidLintTool
org.ow2.asm:asm-commons:9.9=androidLintTool
org.ow2.asm:asm-tree:9.9=androidLintTool
org.ow2.asm:asm-tree:9.9.1=composeMappingProducerClasspath
org.ow2.asm:asm:9.9=androidLintTool
org.ow2.asm:asm:9.9.1=composeMappingProducerClasspath
empty=androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugReverseMetadataValues,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,integrationAnnotationProcessorClasspath,integrationImplementationDependenciesMetadata,integrationReverseMetadataValues,kotlinCompilerPluginClasspath,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata,releaseReverseMetadataValues
@@ -0,0 +1,90 @@
package community.yuvomi.mobile
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.ExternalResource
class NativeOnboardingTest {
@get:Rule(order = 0)
val reset = object : ExternalResource() {
override fun before() {
ApplicationProvider.getApplicationContext<Context>().getSharedPreferences(
"native-server",
Context.MODE_PRIVATE,
).edit().clear().commit()
}
}
@get:Rule(order = 1)
val compose = createAndroidComposeRule<MainActivity>()
@Test fun onboardingIsNativeAndHasNoWebView() {
compose.onNodeWithTag("server-url").assertIsDisplayed()
compose.onNodeWithTag("connect").assertIsDisplayed()
compose.activityRule.scenario.onActivity { assertFalse(containsWebView(it.window.decorView)) }
}
@Test fun rejectsHttpBeforeAnyNetworkCall() {
compose.onNodeWithTag("server-url").performTextInput("http://family.example")
compose.onNodeWithTag("connect").performClick()
compose.onNodeWithText("Bitte eine gültige HTTPS-Adresse ohne Unterpfad eingeben.").assertIsDisplayed()
}
@Test fun rejectsSubpathBeforeAnyNetworkCall() {
compose.onNodeWithTag("server-url").performTextInput("https://family.example/subpath")
compose.onNodeWithTag("connect").performClick()
compose.onNodeWithText("Bitte eine gültige HTTPS-Adresse ohne Unterpfad eingeben.").assertIsDisplayed()
}
@Test fun recreatesNativeOnboarding() {
compose.activityRule.scenario.recreate()
compose.onNodeWithTag("server-url").assertIsDisplayed()
compose.onNodeWithTag("connect").assertIsDisplayed()
}
@Test fun blocksScreenshots() {
compose.activityRule.scenario.onActivity {
assertTrue(it.window.attributes.flags and WindowManager.LayoutParams.FLAG_SECURE != 0)
}
}
@Test fun nativeCookiesAreEncryptedAndBoundToOrigin() {
val context = ApplicationProvider.getApplicationContext<Context>()
val first = NativeSession(context, "https://native-vault-test.example")
val second = NativeSession(context, "https://other-vault-test.example")
try {
first.save("synthetic-private-session")
assertEquals("synthetic-private-session", first.load())
val preferences = context.getSharedPreferences("native-session", Context.MODE_PRIVATE)
val encrypted = preferences.getString("https://native-vault-test.example", null)
assertNotNull(encrypted)
assertFalse(requireNotNull(encrypted).contains("synthetic-private-session"))
preferences.edit().putString("https://other-vault-test.example", encrypted).commit()
assertEquals(null, second.load())
} finally {
first.clear()
second.clear()
}
}
private fun containsWebView(view: View): Boolean {
if (view.javaClass.name.contains("WebView")) return true
if (view is ViewGroup) for (index in 0 until view.childCount) if (containsWebView(view.getChildAt(index))) return true
return false
}
}
@@ -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()
}
}
@@ -0,0 +1,304 @@
package community.yuvomi.mobile
import android.app.UiModeManager
import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import android.os.SystemClock
import android.view.WindowManager
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isEnabled
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.test.core.app.ApplicationProvider
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.platform.io.PlatformTestStorageRegistry
import java.time.LocalDate
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.ExternalResource
/** Drives native semantics and real upstream API calls; never evaluates JavaScript. */
class NativeUpstreamTest {
@get:Rule(order = 0)
val reset = object : ExternalResource() {
override fun before() {
val context = ApplicationProvider.getApplicationContext<Context>()
if (Build.VERSION.SDK_INT >= 31) {
context.getSystemService(UiModeManager::class.java).setApplicationNightMode(UiModeManager.MODE_NIGHT_NO)
}
context.getSharedPreferences("native-server", Context.MODE_PRIVATE).edit().clear().commit()
context.getSharedPreferences("native-session", Context.MODE_PRIVATE).edit().clear().commit()
if (android.os.Build.VERSION.SDK_INT >= 37) {
InstrumentationRegistry.getInstrumentation().uiAutomation.grantRuntimePermission(
context.packageName,
android.Manifest.permission.ACCESS_LOCAL_NETWORK,
)
}
}
}
@get:Rule(order = 1)
val compose = createAndroidComposeRule<MainActivity>()
@Test fun nativeLoginDashboardTaskNoteShoppingAndLogoutAgainstRealServer() {
val arguments = InstrumentationRegistry.getArguments()
val origin = requireNotNull(arguments.getString("server"))
val password = requireNotNull(arguments.getString("password"))
val seed = NativeApi(ServerAddress.parse(origin), NativeCookies(ServerAddress.parse(origin)), {})
seed.request("/auth/login", "POST", org.json.JSONObject().put("username", "mobiletest").put("password", password))
seed.request(
"/tasks",
"POST",
org.json.JSONObject().put(
"title",
"Gemeinsam den Alltag planen",
).put("priority", "high").put("due_date", LocalDate.now().toString()),
)
seed.request(
"/notes",
"POST",
org.json.JSONObject().put("title", "Willkommen zuhause").put("content", "Native Oberfläche, eigener Server.").put("pinned", 1),
)
val seedList = seed.request(
"/shopping",
"POST",
org.json.JSONObject().put("name", "Wochenmarkt"),
).getJSONObject("data").getLong("id")
seed.request("/shopping/$seedList/items", "POST", org.json.JSONObject().put("name", "Äpfel").put("quantity", "4"))
seed.request(
"/preferences",
"PUT",
org.json.JSONObject()
.put(
"dashboard_widgets",
org.json.JSONArray(
listOf("tasks", "calendar", "shopping", "notes").mapIndexed { order, id ->
org.json.JSONObject().put("id", id).put("visible", true).put("order", order).put("size", "1x1")
},
),
)
.put("mobile_nav_order", org.json.JSONArray(listOf("tasks", "kitchen", "notes")))
.put("timezone", "Europe/Berlin"),
)
seed.request("/auth/logout", "POST")
compose.onNodeWithTag("server-url").performTextInput(origin)
compose.onNodeWithTag("connect").performClick()
awaitTag("username")
captureFixtureScreen("login-light.png")
nightMode(true)
awaitTag("username")
captureFixtureScreen("login-dark.png")
nightMode(false)
awaitTag("username")
compose.onNodeWithTag("username").performTextInput("mobiletest")
compose.onNodeWithTag("password").performTextInput(password + "-invalid")
compose.onNodeWithTag("login").performScrollTo().performClick()
awaitTag("auth-error")
compose.onNodeWithText("Benutzername oder Passwort wurden vom Server nicht akzeptiert.").assertIsDisplayed()
compose.onNodeWithTag("password").performTextInput(password)
compose.onNodeWithTag("login").performScrollTo().performClick()
awaitTag("native-dashboard")
compose.activityRule.scenario.onActivity {
assertEquals(null, ViewModelProvider(it)[NativeController::class.java].state.error)
}
captureFixtureScreen("native-dashboard.png")
captureFixtureScreen("dashboard-light.png")
captureModuleGallery("light")
nightMode(true)
awaitTag("native-dashboard")
captureFixtureScreen("dashboard-dark.png")
captureModuleGallery("dark")
nightMode(false)
awaitTag("native-dashboard")
compose.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
compose.activityRule.scenario.recreate()
awaitTag("native-dashboard")
compose.onNodeWithTag("nav-TASKS").performClick()
awaitTag("add-entry")
compose.onNodeWithTag("add-entry").performClick()
compose.onNodeWithTag("entry-title").performTextInput("Native task regression")
compose.onNodeWithTag("entry-detail").performTextInput("Created with native controls")
compose.onNodeWithTag("entry-save").performClick()
awaitText("Native task regression")
assertTrue(readApi(origin).request("/tasks").rows().any { it.text("title") == "Native task regression" })
compose.onNodeWithTag("nav-NOTES").performClick()
awaitTag("add-entry")
compose.onNodeWithTag("add-entry").performClick()
compose.onNodeWithTag("entry-title").performTextInput("Native note regression")
compose.onNodeWithTag("entry-detail").performTextInput("Not an embedded web page")
compose.onNodeWithTag("entry-save").performClick()
awaitText("Native note regression")
assertTrue(readApi(origin).request("/notes").rows().any { it.text("content") == "Not an embedded web page" })
compose.onNodeWithTag("nav-SHOPPING").performClick()
awaitTag("add-list")
compose.onNodeWithTag("add-list").performClick()
compose.onNodeWithTag("entry-title").performTextInput("Native shopping regression")
compose.onNodeWithTag("entry-save").performClick()
awaitText("Native shopping regression")
compose.onNodeWithText("Native shopping regression").performClick()
awaitTag("add-entry")
compose.onNodeWithTag("add-entry").performClick()
compose.onNodeWithTag("entry-title").performTextInput("Native apples")
compose.onNodeWithTag("entry-detail").performTextInput("3")
compose.onNodeWithTag("entry-save").performClick()
awaitText("Native apples")
val api = readApi(origin)
val list = api.request("/shopping").rows().first { it.text("name") == "Native shopping regression" }
assertEquals(
"3",
api.request("/shopping/${list.getLong("id")}/items").rows().first {
it.text("name") == "Native apples"
}.text("quantity"),
)
// Verify the same native transport rejects a cross-origin OIDC redirect.
try {
api.request("/auth/oidc/start")
throw AssertionError("Native API followed an authentication redirect")
} catch (error: NativeApiError) {
assertEquals(302, error.status)
}
compose.onNodeWithTag("nav-MORE").performClick()
awaitText("Abmelden")
compose.onNodeWithText("Abmelden").performScrollTo().performClick()
awaitTag("username")
val context = ApplicationProvider.getApplicationContext<Context>()
assertEquals(null, NativeSession(context, origin).load())
}
private fun readApi(origin: String): NativeApi {
val context = ApplicationProvider.getApplicationContext<Context>()
val vault = NativeSession(context, origin)
val api = NativeApi(ServerAddress.parse(origin), NativeCookies(ServerAddress.parse(origin), vault.load()), {})
api.request("/auth/me")
return api
}
private fun nightMode(dark: Boolean) {
check(Build.VERSION.SDK_INT >= 31) { "The reference gallery requires per-app night mode (API 31+)" }
compose.activityRule.scenario.onActivity {
it.getSystemService(UiModeManager::class.java).setApplicationNightMode(
if (dark) UiModeManager.MODE_NIGHT_YES else UiModeManager.MODE_NIGHT_NO,
)
}
compose.waitUntil(10_000) {
var matches = false
compose.activityRule.scenario.onActivity {
matches = (
it.resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK ==
android.content.res.Configuration.UI_MODE_NIGHT_YES
) == dark
}
matches
}
compose.waitForIdle()
}
private fun captureModuleGallery(theme: String) {
listOf(NativePage.TASKS, NativePage.NOTES, NativePage.SHOPPING).forEach { page ->
compose.onNodeWithTag("nav-${page.name}").performClick()
compose.waitUntil(30_000) {
var ready = false
compose.activityRule.scenario.onActivity {
val state = ViewModelProvider(it)[NativeController::class.java].state
ready = !state.busy && state.page == page && state.rows.isNotEmpty()
}
ready
}
captureFixtureScreen("${page.name.lowercase()}-$theme.png")
}
compose.onNodeWithTag("nav-HOME").performClick()
awaitTag("native-dashboard")
}
private fun captureFixtureScreen(name: String) {
// Only this temporary-fixture test clears the screenshot restriction.
// Ordinary builds always retain FLAG_SECURE, including native login.
compose.activityRule.scenario.onActivity {
it.currentFocus?.clearFocus()
it.window.insetsController?.hide(android.view.WindowInsets.Type.ime())
it.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
try {
compose.waitForIdle()
compose.waitUntil(10_000) {
var hidden = false
compose.activityRule.scenario.onActivity {
hidden = it.window.decorView.rootWindowInsets?.isVisible(android.view.WindowInsets.Type.ime()) != true
}
hidden
}
// PixelCopy must run after the real window/inset animation, not only Compose idleness.
SystemClock.sleep(600)
// Capture the native window through PixelCopy, not a potentially stale
// secure compositor frame returned by UiAutomation immediately after clearing.
val node = compose.onNodeWithTag("native-screen")
val bounds = node.fetchSemanticsNode().boundsInRoot
val image = node.captureToImage().asAndroidBitmap()
try {
val first = image.getPixel(0, 0)
assertTrue(
"Native fixture screenshot is blank",
(0 until image.height step 8).any { y ->
(0 until image.width step 8).any { x -> image.getPixel(x, y) != first }
},
)
// AGP collects this test output before uninstalling the fixture APK.
PlatformTestStorageRegistry.getInstance().openOutputFile(name).use {
check(image.compress(Bitmap.CompressFormat.PNG, 100, it))
}
if (name == "native-dashboard.png") {
val resources = compose.activity.resources
val metadata = org.json.JSONObject()
.put("widthPx", image.width).put("heightPx", image.height)
.put("density", resources.displayMetrics.density).put("fontScale", resources.configuration.fontScale)
.put("sdk", Build.VERSION.SDK_INT)
.put("boundsInRoot", org.json.JSONArray(listOf(bounds.left, bounds.top, bounds.right, bounds.bottom)))
PlatformTestStorageRegistry.getInstance().openOutputFile("viewport.json").use {
it.write(metadata.toString(2).toByteArray())
}
}
} finally {
image.recycle()
}
} finally {
compose.activityRule.scenario.onActivity { it.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) }
}
}
private fun awaitTag(tag: String) {
try {
compose.waitUntil(30_000) { compose.onAllNodes(androidx.compose.ui.test.hasTestTag(tag)).fetchSemanticsNodes().isNotEmpty() }
} catch (error: Exception) {
var diagnostic = ""
compose.activityRule.scenario.onActivity {
val state = ViewModelProvider(it)[NativeController::class.java].state
diagnostic = "busy=${state.busy}, signedIn=${state.user != null}, error=${state.error}"
}
throw AssertionError("Native screen '$tag' not reached: $diagnostic", error)
}
compose.onNodeWithTag(tag).assertIsDisplayed()
}
private fun awaitText(text: String) {
compose.waitUntil(30_000) { compose.onAllNodes(hasText(text) and isEnabled()).fetchSemanticsNodes().isNotEmpty() }
compose.onNodeWithText(text).assertIsDisplayed()
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-feature android:name="android.hardware.camera" android:required="false" /> <uses-feature android:name="android.hardware.camera" android:required="false" />
<application <application
android:allowBackup="false" android:allowBackup="false"
@@ -13,7 +14,7 @@
android:theme="@style/AppTheme" android:theme="@style/AppTheme"
android:supportsRtl="true"> android:supportsRtl="true">
<activity android:name=".MainActivity" android:exported="true" <activity android:name=".MainActivity" android:exported="true"
android:configChanges="orientation|screenSize|keyboardHidden|uiMode" android:configChanges="orientation|screenSize|keyboardHidden"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -1,392 +1,31 @@
package community.yuvomi.mobile package community.yuvomi.mobile
import android.annotation.SuppressLint import android.Manifest
import android.app.AlertDialog import android.content.pm.PackageManager
import android.content.Intent import android.os.Build
import android.graphics.Bitmap
import android.net.Uri
import android.net.http.SslError
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import android.text.InputType
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager import android.view.WindowManager
import android.webkit.*
import android.widget.*
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.FileProvider import androidx.lifecycle.ViewModelProvider
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.webkit.WebStorageCompat
import androidx.webkit.WebViewFeature
import java.io.ByteArrayInputStream
import java.io.File
import java.net.URI
import java.util.concurrent.Executors
/** The shipped entry point is Compose. No WebView is created or embedded. */
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val worker = Executors.newSingleThreadExecutor() private val localNetwork = registerForActivityResult(ActivityResultContracts.RequestPermission()) { recreate() }
private val handler = Handler(Looper.getMainLooper())
private val preferences by lazy { getSharedPreferences("server", MODE_PRIVATE) }
private var server: ServerAddress? = null
private var web: WebView? = null
private var authDialog: AlertDialog? = null
private lateinit var root: LinearLayout
private lateinit var content: FrameLayout
private lateinit var progress: ProgressBar
private var fileCallback: ValueCallback<Array<Uri>>? = null
private var capture: Uri? = null
private var pendingDownload: String? = null
private var generation = 0
private val loadTimeout = Runnable { showError("The server is taking too long. Check your connection or try again.") }
private val picker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val callback = fileCallback
fileCallback = null
val uris = if (result.resultCode != RESULT_OK) null else {
val data = result.data
data?.clipData?.let { clip -> Array(clip.itemCount.coerceAtMost(50)) { clip.getItemAt(it).uri } }
?: data?.data?.let { arrayOf(it) } ?: capture?.let { arrayOf(it) }
}
callback?.onReceiveValue(uris?.filter { it.scheme == "content" }?.toTypedArray())
capture = null
}
private val saveDocument = registerForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { destination ->
val url = pendingDownload
pendingDownload = null
val current = server
if (destination != null && url != null && current != null && current.owns(url)) {
val cookies = CookieManager.getInstance().getCookie(url)
val epoch = generation
worker.execute {
val success = runCatching { FileTransfer.save(current, url, cookies, contentResolver, destination) }.isSuccess
runOnUiThread {
if (!isDestroyed && epoch == generation) {
if (success) toast("Download saved") else {
runCatching { contentResolver.delete(destination, null, null) }
toast("Download failed. Check your connection and sign-in, then retry.")
}
}
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Household data is hidden from screenshots and the recent-apps preview.
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
WebView.setWebContentsDebuggingEnabled(false) val controller = ViewModelProvider(this)[NativeController::class.java]
root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } setContent {
ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets -> NativeApp(
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime()) controller,
view.setPadding(bars.left, bars.top, bars.right, bars.bottom) needsLocalNetwork =
insets Build.VERSION.SDK_INT >= 37 &&
} checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) != PackageManager.PERMISSION_GRANTED,
setContentView(root) requestLocalNetwork = { if (Build.VERSION.SDK_INT >= 37) localNetwork.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) },
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { )
override fun handleOnBackPressed() {
if (web?.canGoBack() == true) web?.goBack()
else if (web != null) settings()
else { isEnabled = false; onBackPressedDispatcher.onBackPressed() }
}
})
File(cacheDir, "captures").listFiles()?.filter { it.lastModified() < System.currentTimeMillis() - 86_400_000 }
?.forEach { it.delete() }
server = preferences.getString("origin", null)?.let { runCatching { ServerAddress.parse(it) }.getOrNull() }
if (server == null) onboarding() else browser()
}
private fun column(): LinearLayout = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(24), dp(20), dp(24), dp(20))
}
private fun text(value: String, size: Float = 17f) = TextView(this).apply {
text = value; textSize = size; setPadding(0, dp(8), 0, dp(8))
}
private fun button(label: String, action: () -> Unit) = Button(this).apply {
text = label; isAllCaps = false; minHeight = dp(48); setOnClickListener { action() }
}
private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt()
private fun onboarding() {
root.removeAllViews()
val form = column()
form.addView(text("Your home, connected", 30f))
form.addView(text("Connect to your own Yuvomi server. Your data stays with your household."))
val address = EditText(this).apply {
hint = "https://family.example.com"
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI
setSingleLine(true)
contentDescription = "Yuvomi server URL"
setText(server?.origin.orEmpty())
id = View.generateViewId()
}
val label = text("HTTPS required. Custom ports supported. Use a dedicated hostname, not a subpath.")
label.accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE
lateinit var connect: Button
connect = button("Connect securely") {
val candidate = runCatching { ServerAddress.parse(address.text.toString()) }.getOrElse {
address.error = it.message; return@button
}
connect.isEnabled = false; address.isEnabled = false; label.setText(R.string.checking_server)
val epoch = generation
worker.execute {
val result = runCatching { ServerProbe.check(candidate) }
runOnUiThread {
if (isDestroyed || epoch != generation) return@runOnUiThread
connect.isEnabled = true; address.isEnabled = true
if (result.isSuccess) {
server = candidate
preferences.edit { putString("origin", candidate.origin); remove("path") }
browser()
} else {
// Exception details may contain URL parameters; never expose them.
label.setText(R.string.probe_failed)
} }
} }
} }
}
form.addView(address)
form.addView(label)
form.addView(connect)
form.addView(text("Independent community app. No telemetry or mandatory cloud service.", 14f))
root.addView(ScrollView(this).apply { addView(form) })
}
private fun browser() {
val current = server ?: return onboarding()
root.removeAllViews()
val toolbar = LinearLayout(this)
toolbar.addView(button("Server settings") { settings() })
toolbar.addView(button("Share server") {
startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply {
type = "text/plain"; putExtra(Intent.EXTRA_TEXT, current.origin)
}, "Share server address"))
})
root.addView(toolbar)
progress = ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply {
contentDescription = "Loading Yuvomi"; max = 100
}
root.addView(progress, LinearLayout.LayoutParams(-1, dp(4)))
content = FrameLayout(this)
root.addView(content, LinearLayout.LayoutParams(-1, 0, 1f))
val view = WebView(this)
web = view
secure(view)
CookieManager.getInstance().setAcceptCookie(true)
CookieManager.getInstance().setAcceptThirdPartyCookies(view, false)
view.webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(v: WebView, request: WebResourceRequest): WebResourceResponse? {
if (!request.isForMainFrame && !current.owns(request.url.toString())) {
return WebResourceResponse("text/plain", "UTF-8", 403, "Blocked", emptyMap(), ByteArrayInputStream(ByteArray(0)))
}
return null
}
override fun shouldOverrideUrlLoading(v: WebView, request: WebResourceRequest): Boolean {
val url = request.url.toString()
if (current.owns(url)) return false
if (request.isForMainFrame) external(url)
return true
}
override fun onPageStarted(v: WebView, url: String, icon: Bitmap?) {
progress.visibility = View.VISIBLE
handler.removeCallbacks(loadTimeout); handler.postDelayed(loadTimeout, 30_000)
}
override fun onPageFinished(v: WebView, url: String) {
handler.removeCallbacks(loadTimeout)
progress.visibility = View.GONE
CookieManager.getInstance().flush()
if (current.owns(url)) {
val path = URI(url).rawPath.orEmpty()
if (!listOf("auth", "reset", "invite", "login").any { path.contains(it, true) }) {
preferences.edit { putString("path", path) }
}
}
}
override fun onReceivedError(v: WebView, r: WebResourceRequest, e: WebResourceError) {
if (r.isForMainFrame) showError("Unable to connect. Cached content may still be available. Check your network or VPN.")
}
override fun onReceivedHttpError(v: WebView, r: WebResourceRequest, e: WebResourceResponse) {
if (r.isForMainFrame && e.statusCode >= 400) showError("The server is unavailable (HTTP ${e.statusCode}).")
}
override fun onReceivedSslError(v: WebView, h: SslErrorHandler, e: SslError) {
h.cancel(); showError("The server certificate could not be verified. Contact your server administrator.")
}
override fun onRenderProcessGone(v: WebView, detail: RenderProcessGoneDetail): Boolean {
releaseWeb(); browser(); toast("Yuvomi recovered after a browser interruption."); return true
}
}
view.webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(v: WebView, value: Int) { progress.progress = value }
override fun onPermissionRequest(request: PermissionRequest) { request.deny() }
override fun onGeolocationPermissionsShowPrompt(origin: String, callback: GeolocationPermissions.Callback) {
callback.invoke(origin, false, false)
}
override fun onShowFileChooser(v: WebView, callback: ValueCallback<Array<Uri>>, params: FileChooserParams): Boolean {
if (!current.owns(v.url.orEmpty())) return false
fileCallback?.onReceiveValue(null)
fileCallback = callback
val types = params.acceptTypes.filter { it.matches(Regex("[a-zA-Z0-9.+*-]+/[a-zA-Z0-9.+*-]+")) }
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = types.singleOrNull() ?: "*/*"
if (types.size > 1) putExtra(Intent.EXTRA_MIME_TYPES, types.toTypedArray())
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, params.mode == FileChooserParams.MODE_OPEN_MULTIPLE)
}
val chooser = Intent.createChooser(intent, "Select a file")
if (types.any { it.startsWith("image/") }) {
val directory = File(cacheDir, "captures").apply { mkdirs() }
capture = FileProvider.getUriForFile(this@MainActivity, "$packageName.files", File.createTempFile("capture-", ".jpg", directory))
chooser.putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
putExtra(MediaStore.EXTRA_OUTPUT, capture)
addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION)
}))
}
runCatching { picker.launch(chooser) }.onFailure {
fileCallback?.onReceiveValue(null); fileCallback = null; toast("No compatible file picker is available.")
}
return true
}
}
view.setDownloadListener { url, _, disposition, mime, _ ->
if (!current.owns(url)) toast("Only HTTPS downloads from your server are supported.")
else if (pendingDownload == null) {
pendingDownload = url
val name = URLUtil.guessFileName(url, disposition, mime).replace(Regex("[^a-zA-Z0-9._ -]"), "_").take(120)
saveDocument.launch(name.ifBlank { "download" })
}
}
content.addView(view, FrameLayout.LayoutParams(-1, -1))
val path = preferences.getString("path", "/").orEmpty()
view.loadUrl(current.origin + if (path.startsWith("/") && !path.startsWith("//")) path else "/")
}
@SuppressLint("SetJavaScriptEnabled")
private fun secure(view: WebView) {
view.settings.apply {
javaScriptEnabled = true; domStorageEnabled = true
allowFileAccess = false; allowContentAccess = false
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
javaScriptCanOpenWindowsAutomatically = false
setSupportMultipleWindows(false)
mediaPlaybackRequiresUserGesture = true
safeBrowsingEnabled = true
setGeolocationEnabled(false)
}
}
private fun external(value: String) {
val uri = runCatching { URI(value) }.getOrNull() ?: return
if (uri.scheme != "https" || uri.host.isNullOrBlank() || uri.rawUserInfo != null) {
toast("This link type is blocked for your security."); return
}
AlertDialog.Builder(this).setTitle("Leave your Yuvomi server?")
.setMessage("Destination: ${uri.host}\n\nOpen normal links in your browser. For SSO, use the isolated sign-in window only if you trust this provider. Providers that require a system browser cannot transfer a session back to this app.")
.setPositiveButton("Browser") { _, _ -> openBrowser(value) }
.setNeutralButton("Sign-in window") { _, _ -> authentication(value) }
.setNegativeButton("Cancel", null).show()
}
private fun authentication(value: String) {
if (authDialog != null) return
val auth = WebView(this)
secure(auth)
CookieManager.getInstance().setAcceptThirdPartyCookies(auth, false)
auth.webChromeClient = object : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest) { request.deny() }
}
auth.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(v: WebView, request: WebResourceRequest): Boolean {
val uri = request.url
return uri.scheme != "https" || !uri.userInfo.isNullOrEmpty()
}
override fun onReceivedSslError(v: WebView, h: SslErrorHandler, e: SslError) { h.cancel() }
override fun onPageFinished(v: WebView, url: String) {
if (server?.owns(url) == true && !URI(url).path.startsWith("/api/")) {
CookieManager.getInstance().flush()
authDialog?.dismiss(); web?.loadUrl(url)
}
}
}
authDialog = AlertDialog.Builder(this).setTitle("Isolated web sign-in")
.setView(auth).setNegativeButton("Close", null).create().also { dialog ->
dialog.setOnDismissListener {
auth.stopLoading(); auth.removeAllViews(); auth.destroy(); authDialog = null
}
dialog.show()
dialog.window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
}
auth.loadUrl(value)
}
private fun openBrowser(value: String) {
runCatching { startActivity(Intent(Intent.ACTION_VIEW, value.toUri()).addCategory(Intent.CATEGORY_BROWSABLE)) }
.onFailure { toast("No browser is available.") }
}
private fun showError(message: String) {
if (isDestroyed || web == null) return
handler.removeCallbacks(loadTimeout)
progress.visibility = View.GONE
if (content.childCount > 1) return
val panel = column().apply {
addView(text("Connection interrupted", 25f)); addView(text(message))
addView(button("Retry") { content.removeView(this); web?.reload() })
addView(button("Keep viewing") { content.removeView(this) })
addView(button("Server settings") { settings() })
}
val dark = resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK == android.content.res.Configuration.UI_MODE_NIGHT_YES
panel.setBackgroundColor(if (dark) 0xff191816.toInt() else 0xfff5f3ed.toInt())
content.addView(panel, FrameLayout.LayoutParams(-1, -1))
panel.accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE
ViewCompat.setAccessibilityPaneTitle(panel, "Connection interrupted")
}
private fun settings() {
AlertDialog.Builder(this).setTitle("Server settings")
.setMessage("${server?.origin.orEmpty()}\n\nSwitching servers erases all app cookies, storage and cached household data. Downloads saved outside the app remain. This does not revoke the server session; sign out in Yuvomi first where possible.")
.setPositiveButton("Erase and change server") { _, _ ->
generation++
authDialog?.dismiss(); releaseWeb()
if (WebViewFeature.isFeatureSupported(WebViewFeature.DELETE_BROWSING_DATA)) {
WebStorageCompat.deleteBrowsingData(WebStorage.getInstance()) {
CookieManager.getInstance().removeAllCookies {
CookieManager.getInstance().flush()
preferences.edit { clear() }; server = null; onboarding()
}
}
} else {
// Older engines cannot reliably erase CacheStorage/IndexedDB.
// OS reset is complete and scoped to this application, but closes it.
getSystemService(android.app.ActivityManager::class.java).clearApplicationUserData()
}
}
.setNeutralButton("Open server in browser") { _, _ -> server?.let { openBrowser(it.origin) } }
.setNegativeButton("Continue", null).show()
}
private fun releaseWeb() {
handler.removeCallbacks(loadTimeout)
fileCallback?.onReceiveValue(null); fileCallback = null
web?.let { view ->
view.stopLoading()
(view.parent as? ViewGroup)?.removeView(view)
view.removeAllViews(); view.destroy()
}
web = null
}
private fun toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_LONG).show()
override fun onPause() { web?.onPause(); CookieManager.getInstance().flush(); super.onPause() }
override fun onResume() { super.onResume(); web?.onResume() }
override fun onDestroy() {
generation++; authDialog?.dismiss(); releaseWeb(); worker.shutdownNow(); super.onDestroy()
}
}
@@ -0,0 +1,180 @@
package community.yuvomi.mobile
import java.io.ByteArrayOutputStream
import java.net.HttpCookie
import java.net.URI
import javax.net.ssl.HttpsURLConnection
import org.json.JSONArray
import org.json.JSONObject
data class SessionCookie(val name: String, val value: String, val expires: Long)
/** Origin-bound HTTPS-only jar; never installs a process-wide CookieHandler.
* The native transport enforces HTTPS even when the server omits cookie Secure.
*/
class NativeCookies(private val server: ServerAddress, serialized: String? = null) {
private val values = linkedMapOf<String, SessionCookie>()
init {
if (serialized != null) {
runCatching {
val rows = JSONArray(serialized)
for (index in 0 until rows.length()) {
val row = rows.getJSONObject(index)
val cookie = SessionCookie(row.getString("name"), row.getString("value"), row.getLong("expires"))
if (allowed(cookie.name) && safe(cookie.value) &&
cookie.expires > System.currentTimeMillis()
) {
values[cookie.name] = cookie
}
}
}.onFailure { values.clear() }
}
}
fun accept(headers: List<String>, now: Long = System.currentTimeMillis()) {
val host = URI(server.origin).host
for (header in headers) {
val parsed = runCatching { HttpCookie.parse(header) }.getOrDefault(emptyList())
for (cookie in parsed) {
// Upstream defaults SESSION_SECURE to false, even behind an HTTPS
// proxy. Never downgrade transport: all of this jar's cookies are
// bound to ServerAddress's HTTPS origin regardless of this flag.
if (!allowed(cookie.name) || !safe(cookie.value)) continue
if (cookie.domain != null && !cookie.domain.trimStart('.').equals(host, true)) continue
if (cookie.path != null && cookie.path != "/") continue
if (cookie.maxAge == 0L) {
values.remove(cookie.name)
} else {
// Session-only cookies never become persistent across launches.
val expiry = if (cookie.maxAge < 0) Long.MAX_VALUE else now + cookie.maxAge.coerceAtMost(31_536_000L) * 1000
values[cookie.name] = SessionCookie(cookie.name, cookie.value, expiry)
}
}
}
}
fun header(now: Long = System.currentTimeMillis()): String = values.values.filter {
it.expires > now
}.joinToString("; ") { "${it.name}=${it.value}" }
fun serialized(now: Long = System.currentTimeMillis()): String = JSONArray().apply {
values.values.filter { it.expires > now && it.expires != Long.MAX_VALUE }.forEach {
put(JSONObject().put("name", it.name).put("value", it.value).put("expires", it.expires))
}
}.toString()
fun clear() = values.clear()
fun hasSession(now: Long = System.currentTimeMillis()): Boolean = values["yuvomi.sid"]?.let {
it.value.isNotEmpty() && it.expires > now
} == true
private fun allowed(name: String) = name == "yuvomi.sid" || name == "csrf-token"
private fun safe(value: String) = value.length <= 8192 && value.none { it <= ' ' || it == ';' || it == ',' || it == '\u007f' }
}
class NativeApiError(val status: Int, val reason: String = "", val authenticationStep: String = "") : Exception() {
fun authenticationMessage(): String? = when {
status == 401 && reason == "session_cookie_missing" ->
"Der Server hat die Anmeldung bestätigt, aber kein nutzbares Sitzungscookie geliefert. Bitte Server und Reverse Proxy prüfen."
status == 401 && reason == "session_not_accepted" ->
"Die Anmeldung wurde bestätigt, aber der Server akzeptiert die Sitzung nicht. Bitte Server und Reverse Proxy prüfen."
status == 401 && authenticationStep == "/auth/login" -> "Benutzername oder Passwort wurden vom Server nicht akzeptiert."
status == 401 && authenticationStep == "/auth/2fa/verify" ->
"Der Bestätigungscode ist ungültig oder die ausstehende Anmeldung ist abgelaufen. Bitte erneut anmelden."
else -> null
}
fun userMessage(): String = authenticationMessage() ?: when (status) {
401 -> "Anmeldung oder Sitzung ist nicht mehr gültig."
403 -> "Diese Aktion ist nicht erlaubt. Bitte prüfe deine Berechtigungen."
409 -> "Der Eintrag wurde inzwischen geändert. Bitte neu laden."
429 -> "Zu viele Versuche. Bitte warte einen Moment."
in 500..599 -> "Der Server ist derzeit nicht verfügbar."
else -> "Die Anfrage konnte nicht abgeschlossen werden (HTTP $status)."
}
}
/** Synchronous transport; the controller always invokes it on its worker thread. */
class NativeApi(val server: ServerAddress, private val cookies: NativeCookies, private val persist: (String) -> Unit) {
private var csrf = ""
fun clear() {
csrf = ""
cookies.clear()
}
fun requireSessionCookie() {
if (!cookies.hasSession()) throw NativeApiError(401, "session_cookie_missing")
}
fun request(path: String, method: String = "GET", body: JSONObject? = null, retry: Boolean = true): JSONObject {
require(path.startsWith("/") && !path.startsWith("//") && !path.contains('#') && !path.contains('\\'))
val target = URI(server.origin + "/api/v1" + path)
require(server.owns(target.toString()))
val connection = target.toURL().openConnection() as HttpsURLConnection
val mutation = method in setOf("POST", "PUT", "PATCH", "DELETE")
try {
connection.instanceFollowRedirects = false
connection.requestMethod = method
connection.connectTimeout = 10_000
connection.readTimeout = 15_000
connection.setRequestProperty("Accept", "application/json")
connection.setRequestProperty("Cache-Control", "no-store")
connection.setRequestProperty("Cookie", cookies.header())
if (mutation) {
connection.setRequestProperty("Content-Type", "application/json")
connection.setRequestProperty("X-CSRF-Token", csrf)
connection.doOutput = true
connection.outputStream.use { it.write((body ?: JSONObject()).toString().toByteArray()) }
}
val status = connection.responseCode
if (status in 300..399) throw NativeApiError(status, "redirect_refused")
cookies.accept(connection.headerFields.filterKeys { it.equals("Set-Cookie", true) }.values.flatten())
persist(cookies.serialized())
val input = if (status in 200..299) connection.inputStream else connection.errorStream
val bytes = input?.use { stream ->
val output = ByteArrayOutputStream()
val buffer = ByteArray(8192)
val deadline = System.nanoTime() + 30_000_000_000L
while (true) {
check(System.nanoTime() < deadline)
val count = stream.read(buffer)
if (count < 0) break
require(output.size() + count <= 4 * 1024 * 1024)
output.write(buffer, 0, count)
}
output.toByteArray()
} ?: ByteArray(0)
val data = if (bytes.isEmpty()) {
JSONObject()
} else {
runCatching {
JSONObject(String(bytes, Charsets.UTF_8))
}.getOrElse { throw NativeApiError(status, "invalid_json") }
}
val updated = connection.getHeaderField("X-CSRF-Token") ?: data.optString("csrfToken")
if (updated.isNotBlank()) csrf = updated
if (status == 403 && mutation && retry && data.optString("reason") == "csrf_invalid") {
request("/auth/me", retry = false)
return request(path, method, body, retry = false)
}
if (status !in 200..299) {
val step = path.takeIf { it == "/auth/login" || it == "/auth/2fa/verify" }.orEmpty()
throw NativeApiError(status, data.optString("reason"), step)
}
return data
} finally {
connection.disconnect()
}
}
}
fun JSONObject.rows(key: String = "data"): List<JSONObject> = optJSONArray(key)?.let { array ->
(0 until array.length()).mapNotNull { array.optJSONObject(it) }
}.orEmpty()
fun JSONObject.text(key: String): String = if (isNull(key)) "" else optString(key)
fun JSONObject.countText(key: String): String = if (opt(key) is Number) get(key).toString() else "—"
@@ -0,0 +1,424 @@
package community.yuvomi.mobile
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.json.JSONObject
/** Native controls only. The PWA is a measured design reference, not the renderer. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NativeApp(controller: NativeController, needsLocalNetwork: Boolean = false, requestLocalNetwork: () -> Unit = {}) {
PwaTheme {
val state = controller.state
val colors = LocalPwaPalette.current
var editing by remember { mutableStateOf(false) }
var editor by remember { mutableStateOf<JSONObject?>(null) }
var creatingList by remember { mutableStateOf(false) }
var submitted by remember { mutableStateOf(false) }
var pendingCreate by remember { mutableStateOf<NativePage?>(null) }
var more by remember { mutableStateOf(false) }
var createMenu by remember { mutableStateOf(false) }
var forget by remember { mutableStateOf(false) }
var remove by remember { mutableStateOf<String?>(null) }
var info by remember { mutableStateOf<String?>(null) }
var customize by remember { mutableStateOf(false) }
val layer = rememberGraphicsLayer()
var contentOrigin by remember { mutableStateOf(Offset.Zero) }
fun create(page: NativePage, list: Boolean = false) {
editor = null
creatingList = list
createMenu = false
more = false
if (state.page == page) {
editing = true
} else {
pendingCreate = page
controller.navigate(page)
}
}
LaunchedEffect(state.user?.text("id"), state.server) {
editing = false
editor = null
submitted = false
more = false
createMenu = false
pendingCreate = null
}
LaunchedEffect(state.busy, state.error) {
if (submitted && !state.busy) {
submitted = false
if (state.error == null) editing = false
}
if (pendingCreate != null && !state.busy) {
editing = state.page == pendingCreate && state.error == null
pendingCreate = null
}
}
BackHandler(state.user != null && state.page != NativePage.HOME && !more && !editing) {
if (!state.busy) controller.navigate(NativePage.HOME)
}
Box(Modifier.fillMaxSize().background(colors.background)) {
if (state.user == null) PwaBrandField(Modifier.fillMaxSize())
Box(Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing).testTag("native-screen")) {
if (state.server.isEmpty() || state.user == null) {
NativeAuthentication(
state, needsLocalNetwork, requestLocalNetwork, controller::connect, controller::login,
controller::verify, controller::cancelTwoFactor, { forget = true },
{
info =
"SSO wird im nativen Client noch nicht unterstützt. Bitte die direkte Yuvomi-Passwortanmeldung verwenden."
},
)
} else {
Box(Modifier.fillMaxSize()) {
Column(
Modifier.fillMaxSize().onGloballyPositioned { contentOrigin = it.positionInRoot() }
.drawWithContent {
layer.record { this@drawWithContent.drawContent() }
drawLayer(layer)
},
) {
if (state.busy) LinearProgressIndicator(Modifier.fillMaxWidth().height(2.dp))
state.error?.let { PwaError(it, controller::dismissError) }
when (state.page) {
NativePage.HOME -> NativeDashboard(
state,
controller::navigate,
{ customize = true },
)
NativePage.TASKS, NativePage.NOTES, NativePage.SHOPPING -> NativeCollection(
state,
onEdit = {
editor = it
creatingList = false
editing = true
},
onTask = controller::completeTask, onPin = controller::pinNote, onItem = controller::checkItem,
onList = { controller.openList(it.getLong("id"), it.text("name")) },
onNewList = { create(NativePage.SHOPPING, list = true) },
onRemove = { remove = it }, onRefresh = controller::refresh,
)
NativePage.MORE -> Unit
}
}
PwaBottomNavigation(
state,
layer,
contentOrigin,
onNavigate = { if (it == NativePage.MORE) more = true else controller.navigate(it) },
onCreate = {
if (state.page == NativePage.HOME) {
createMenu = true
} else {
create(state.page, list = state.page == NativePage.SHOPPING && state.listId == null)
}
},
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
}
if (editing) {
NativeEntryEditor(state, editor, creatingList, { if (!state.busy) editing = false }) { title, detail, priority, date ->
when (state.page) {
NativePage.TASKS -> controller.saveTask(editor?.getLong("id"), title, detail, priority, date)
NativePage.NOTES -> controller.saveNote(editor?.getLong("id"), title, detail)
NativePage.SHOPPING -> if (creatingList) {
controller.createShoppingList(title)
} else {
controller.saveShopping(editor?.getLong("id"), title, detail)
}
else -> Unit
}
submitted = true
}
}
if (more) {
ModalBottomSheet(
onDismissRequest = { more = false },
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = colors.raised,
shape = RoundedCornerShape(topStart = 26.dp, topEnd = 26.dp),
) {
NativeMore(
state,
{
more = false
controller.navigate(it)
},
{
more = false
controller.logout()
},
{
more = false
forget = true
},
)
}
}
if (createMenu) {
ModalBottomSheet(
onDismissRequest = { createMenu = false },
containerColor = colors.raised,
shape = RoundedCornerShape(topStart = 26.dp, topEnd = 26.dp),
) {
Column(Modifier.padding(horizontal = 16.dp).padding(bottom = 24.dp)) {
Text("Neu", style = PwaType.section, modifier = Modifier.padding(bottom = 12.dp))
listOf(
NativePage.TASKS to "Neue Aufgabe",
NativePage.NOTES to "Neue Notiz",
NativePage.SHOPPING to "Neue Einkaufsliste",
)
.filter { state.access(it.first.name.lowercase()) == "write" }.forEach { (page, label) ->
PwaMenuRow(page.pwaGlyph(), label, {
create(page, page == NativePage.SHOPPING)
}, tint = colors.module(page))
}
}
}
}
if (customize) {
NativeDashboardOptions(state, { customize = false }) {
customize = false
controller.saveDashboardVisibility(it)
}
}
if (forget) {
AlertDialog(
onDismissRequest = { forget = false },
shape = RoundedCornerShape(26.dp),
title = { Text("Server entfernen?", style = PwaType.section) },
text = {
Text("Die lokale Sitzung wird gelöscht. Melde dich möglichst zuerst ab, um sie auch auf dem Server zu widerrufen.")
},
confirmButton = {
PwaTextAction("Lokal entfernen", {
forget = false
controller.forgetLocalServer()
}, enabled = !state.busy, color = colors.danger)
},
dismissButton = { PwaTextAction("Abbrechen", { forget = false }) },
)
}
remove?.let { path ->
AlertDialog(
onDismissRequest = { remove = null },
shape = RoundedCornerShape(26.dp),
title = { Text("Eintrag löschen?", style = PwaType.section) },
text = { Text("Der Eintrag wird auf deinem Server gelöscht. Diese Aktion kann nicht zurückgenommen werden.") },
confirmButton = {
PwaTextAction("Löschen", {
remove = null
controller.remove(path)
}, color = colors.danger)
},
dismissButton = { PwaTextAction("Abbrechen", { remove = null }) },
)
}
info?.let { message ->
AlertDialog(
onDismissRequest = { info = null },
shape = RoundedCornerShape(26.dp),
title = { Text("Nativer Client", style = PwaType.section) },
text = { Text(message) },
confirmButton = { PwaTextAction("Verstanden", { info = null }) },
)
}
}
}
}
@Composable
private fun NativeAuthentication(
state: NativeState,
needsNetwork: Boolean,
requestNetwork: () -> Unit,
connect: (String) -> Unit,
login: (String, String, String) -> Unit,
verify: (String) -> Unit,
cancelTwoFactor: () -> Unit,
forget: () -> Unit,
sso: () -> Unit,
) {
val colors = LocalPwaPalette.current
val keyboard = LocalSoftwareKeyboardController.current
var address by remember { mutableStateOf("") }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var name by remember { mutableStateOf("") }
var code by remember { mutableStateOf("") }
var invalid by remember { mutableStateOf(false) }
LaunchedEffect(state.server) {
username = ""
password = ""
code = ""
name = ""
}
Column(
Modifier.fillMaxSize().imePadding().verticalScroll(rememberScrollState()).padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
PwaCard(
Modifier.widthIn(max = 412.dp).fillMaxWidth().testTag("auth-panel")
.border(1.dp, Color.White.copy(alpha = if (colors.dark) .12f else .65f), RoundedCornerShape(26.dp)),
color = (if (colors.dark) Color(0xFF2D2A27) else Color(0xFFFBFAF7)).copy(alpha = if (colors.dark) .88f else .86f),
radius = 26.dp,
) {
Column(Modifier.padding(horizontal = 17.dp).padding(top = 25.dp, bottom = 16.dp)) {
Column(Modifier.fillMaxWidth().padding(bottom = 24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
PwaBrandMark(Modifier.padding(bottom = 22.dp))
Text(state.appName, style = PwaType.largeTitle, color = colors.text, textAlign = TextAlign.Center)
Spacer(Modifier.height(8.dp))
Text(
if (state.server.isEmpty()) {
"Dein Zuhause. Dein Server."
} else if (state.twoFactor) {
"Bestätige deine Anmeldung."
} else {
"Willkommen zurück bei deinem Familienplaner."
},
style = PwaType.field.copy(lineHeight = 25.6.sp),
color = colors.secondary,
textAlign = TextAlign.Center,
)
}
if (state.server.isEmpty()) {
PwaField(address, {
address = it
invalid = false
}, "Server-Adresse", Modifier.testTag("server-url"), keyboard = KeyboardType.Uri, placeholder = "https://")
if (invalid) {
Text(
"Bitte eine gültige HTTPS-Adresse ohne Unterpfad eingeben.",
style = PwaType.caption,
color = colors.danger,
)
}
Spacer(Modifier.height(16.dp))
PwaButton("Verbinden", {
keyboard?.hide()
if (runCatching { ServerAddress.parse(address) }.isSuccess) connect(address) else invalid = true
}, Modifier.fillMaxWidth().testTag("connect"), !state.busy && address.isNotBlank())
if (needsNetwork) PwaTextAction("Zugriff auf private Netzwerke erlauben", requestNetwork)
} else if (state.twoFactor) {
PwaField(code, { code = it }, "Code oder Wiederherstellungscode", Modifier.testTag("two-factor-code"))
Spacer(Modifier.height(16.dp))
PwaButton(
"Bestätigen",
{
keyboard?.hide()
val value = code
code = ""
verify(value)
},
Modifier.fillMaxWidth(),
!state.busy && code.isNotBlank(),
)
PwaTextAction("Zurück zur Anmeldung", cancelTwoFactor, enabled = !state.busy)
} else {
if (state.setup) {
PwaField(name, { name = it }, "Anzeigename")
Spacer(Modifier.height(16.dp))
}
if (state.passwordAllowed || state.setup) {
PwaField(username, { username = it }, "Benutzername", Modifier.testTag("username"))
Spacer(Modifier.height(16.dp))
PwaField(password, { password = it }, "Passwort", Modifier.testTag("password"), password = true)
Spacer(Modifier.height(16.dp))
PwaButton(if (state.setup) "Haushalt einrichten" else "Anmelden", {
keyboard?.hide()
val value = password
password = ""
login(username, value, name)
}, Modifier.fillMaxWidth().testTag("login"), !state.busy && username.isNotBlank() && password.isNotEmpty())
}
if (state.oidc) PwaTextAction("SSO – noch in Umsetzung", sso, Modifier.align(Alignment.CenterHorizontally))
}
state.error?.let {
Text(it, style = PwaType.caption, color = colors.danger, modifier = Modifier.padding(top = 12.dp).testTag("auth-error"))
}
if (state.busy) {
Box(Modifier.fillMaxWidth().padding(top = 12.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
}
}
if (state.server.isNotEmpty() && !state.twoFactor) {
PwaTextAction(
"Anderen Server verwenden",
forget,
Modifier.align(Alignment.CenterHorizontally).padding(top = 2.dp),
!state.busy,
colors.tertiary,
)
} else {
Spacer(Modifier.height(26.dp))
}
}
}
}
}
@Composable
fun PwaError(message: String, dismiss: () -> Unit) {
val colors = LocalPwaPalette.current
Row(
Modifier.fillMaxWidth().background(colors.danger.copy(alpha = .08f)).padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(message, Modifier.weight(1f), style = PwaType.caption, color = colors.danger)
PwaIconButton(PwaGlyph.CLOSE, "Fehler schließen", dismiss, outlined = false, tint = colors.danger)
}
}
@@ -0,0 +1,373 @@
package community.yuvomi.mobile
import android.app.Application
import android.content.Context
import android.os.Handler
import android.os.Looper
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import java.util.concurrent.Executors
import org.json.JSONObject
enum class NativePage(val title: String) {
HOME("Übersicht"),
TASKS("Aufgaben"),
SHOPPING("Einkauf"),
NOTES("Notizen"),
MORE("Mehr"),
}
data class NativeState(
val server: String = "",
val appName: String = "Yuvomi",
val user: JSONObject? = null,
val permissions: JSONObject = JSONObject(),
val setup: Boolean = false,
val passwordAllowed: Boolean = true,
val oidc: Boolean = false,
val twoFactor: Boolean = false,
val resetAllowed: Boolean = false,
val page: NativePage = NativePage.HOME,
val dashboard: JSONObject = JSONObject(),
val preferences: JSONObject = JSONObject(),
val shoppingLists: List<JSONObject> = emptyList(),
val rows: List<JSONObject> = emptyList(),
val listId: Long? = null,
val listName: String = "",
val busy: Boolean = false,
val error: String? = null,
) {
fun access(module: String): String = if (permissions.optBoolean("admin")) {
"write"
} else {
permissions.optJSONObject("modules")?.optString(module, "write")
?: "write"
}
fun widget(id: String): Boolean = permissions.optBoolean("admin") || permissions.optJSONObject("widgets")?.optString(id) != "none"
}
class NativeController(application: Application) : AndroidViewModel(application) {
var state by mutableStateOf(NativeState())
private set
private val preferences = application.getSharedPreferences("native-server", Context.MODE_PRIVATE)
private val worker = Executors.newSingleThreadExecutor()
private val main = Handler(Looper.getMainLooper())
private var generation = 0
private var client: NativeApi? = null
private var vault: NativeSession? = null
init {
preferences.getString("origin", null)?.let { connect(it) }
}
private fun perform(clearOnFailure: Boolean = false, block: (NativeState) -> NativeState) {
if (state.busy) return
val previous = state
val selectedGeneration = generation
state = previous.copy(busy = true, error = null)
worker.execute {
val result = runCatching { block(previous.copy(error = null)) }
main.post {
if (generation != selectedGeneration) return@post
state = result.fold(
{ it.copy(busy = false) },
{ error ->
if (clearOnFailure || error is NativeApiError && error.status == 401 && previous.user != null) {
previous.copy(
user = null,
permissions = JSONObject(),
dashboard = JSONObject(),
preferences = JSONObject(),
shoppingLists = emptyList(),
rows = emptyList(),
page = NativePage.HOME,
listId = null,
twoFactor = false,
busy = false,
error = if (clearOnFailure) {
"Der lokale Sitzungsspeicher konnte nicht vollständig gelöscht werden. " +
"Bitte die App-Daten über Android entfernen."
} else {
"Deine Sitzung ist abgelaufen. Bitte erneut anmelden."
},
)
} else {
previous.copy(
busy = false,
error = if (error is NativeApiError) {
error.userMessage()
} else {
"Verbindung fehlgeschlagen. Bitte Server, Zertifikat und Netzwerk prüfen."
},
)
}
},
)
}
}
}
fun dismissError() {
state = state.copy(error = null)
}
fun connect(input: String) = perform {
val server = ServerAddress.parse(input)
val storage = NativeSession(getApplication(), server.origin)
val api = NativeApi(server, NativeCookies(server, storage.load()), storage::save)
val metadata = api.request("/version")
require(
metadata.opt("app_name") is String && metadata.opt("setup_required") is Boolean &&
metadata.opt("password_reset_enabled") is Boolean,
)
val oidc = api.request("/auth/oidc/config")
require(oidc.opt("enabled") is Boolean && oidc.opt("password_login_enabled") is Boolean)
client = api
vault = storage
preferences.checkedEdit { putString("origin", server.origin) }
val base = NativeState(
server = server.origin,
appName = metadata.getString("app_name"),
setup = metadata.getBoolean("setup_required"),
passwordAllowed = oidc.getBoolean("password_login_enabled") || oidc.optBoolean("guest_password_login_enabled"),
oidc = oidc.getBoolean("enabled"),
resetAllowed = metadata.getBoolean("password_reset_enabled"),
)
try {
authenticated(base, api.request("/auth/me"))
} catch (error: NativeApiError) {
if (error.status == 401) base else throw error
}
}
fun login(username: String, password: String, displayName: String = "") = perform { previous ->
val api = requireNotNull(client)
if (previous.setup) {
api.request(
"/auth/setup",
"POST",
JSONObject().put("username", username.trim()).put("password", password).put(
"display_name",
displayName.trim().ifEmpty {
username.trim()
},
),
)
}
val result = api.request("/auth/login", "POST", JSONObject().put("username", username.trim()).put("password", password))
if (result.optBoolean("twoFactorRequired")) previous.copy(setup = false, twoFactor = true) else authenticated(previous, result)
}
fun verify(code: String) = perform { previous ->
authenticated(previous, requireNotNull(client).request("/auth/2fa/verify", "POST", JSONObject().put("code", code.trim())))
}
fun cancelTwoFactor() {
if (!state.busy) state = state.copy(twoFactor = false, error = null)
}
private fun authenticated(previous: NativeState, response: JSONObject): NativeState {
requireNotNull(client).requireSessionCookie()
val user = response.getJSONObject("user")
val permissions = response.getJSONObject("permissions")
val preferences = try {
requireNotNull(client).request("/preferences").optJSONObject("data") ?: JSONObject()
} catch (error: Exception) {
if (error is NativeApiError && error.status == 401) throw error
JSONObject()
}
val signedIn = previous.copy(
user = user,
permissions = permissions,
setup = false,
twoFactor = false,
page = NativePage.HOME,
dashboard = JSONObject(),
preferences = preferences,
shoppingLists = emptyList(),
rows = emptyList(),
)
return try {
signedIn.copy(dashboard = requireNotNull(client).request("/dashboard"))
} catch (error: Exception) {
if (error is NativeApiError && error.status == 401) throw NativeApiError(401, "session_not_accepted")
signedIn.copy(error = "Die Anmeldung war erfolgreich. Die Übersicht konnte noch nicht geladen werden.")
}
}
fun navigate(page: NativePage) = perform { previous -> load(previous.copy(page = page, listId = null, rows = emptyList())) }
fun refresh() = perform { previous -> load(previous) }
fun openList(id: Long, name: String) =
perform { previous -> load(previous.copy(page = NativePage.SHOPPING, listId = id, listName = name)) }
private fun load(previous: NativeState): NativeState {
val api = requireNotNull(client)
return when (previous.page) {
NativePage.HOME -> previous.copy(dashboard = api.request("/dashboard"))
NativePage.TASKS -> previous.copy(rows = api.request("/tasks").rows())
NativePage.NOTES -> previous.copy(rows = api.request("/notes").rows())
NativePage.SHOPPING -> {
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
}
}
fun saveTask(id: Long?, title: String, description: String, priority: String, date: String) = perform { previous ->
val payload = JSONObject().put("title", title.trim()).put("description", description).put("priority", priority).put(
"due_date",
date.ifBlank { null } ?: JSONObject.NULL,
)
requireNotNull(client).request(id?.let { "/tasks/$it" } ?: "/tasks", if (id == null) "POST" else "PUT", payload)
load(previous)
}
fun completeTask(task: JSONObject) = perform { previous ->
requireNotNull(client).request(
"/tasks/${task.getLong("id")}/status",
"PATCH",
JSONObject().put(
"status",
if (task.text("status") ==
"done"
) {
"open"
} else {
"done"
},
),
)
load(previous)
}
fun saveNote(id: Long?, title: String, content: String) = perform { previous ->
requireNotNull(client).request(
id?.let { "/notes/$it" } ?: "/notes",
if (id ==
null
) {
"POST"
} else {
"PUT"
},
JSONObject().put("title", title.trim()).put("content", content),
)
load(previous)
}
fun pinNote(note: JSONObject) = perform { previous ->
requireNotNull(client).request("/notes/${note.getLong("id")}/pin", "PATCH", JSONObject())
load(previous)
}
fun saveShopping(id: Long?, name: String, quantity: String) = perform { previous ->
val path = id?.let { "/shopping/items/$it" } ?: previous.listId?.let { "/shopping/$it/items" } ?: "/shopping"
requireNotNull(client).request(
path,
if (id ==
null
) {
"POST"
} else {
"PATCH"
},
JSONObject().put("name", name.trim()).apply {
if (previous.listId !=
null
) {
put("quantity", quantity)
}
},
)
load(previous)
}
fun createShoppingList(name: String) = perform { previous ->
val list = requireNotNull(client).request("/shopping", "POST", JSONObject().put("name", name.trim())).getJSONObject("data")
load(previous.copy(listId = list.getLong("id")))
}
fun saveDashboardVisibility(visible: Set<String>) = perform { previous ->
// A failed preference read must never overwrite unknown stored widgets.
check(previous.preferences.has("dashboard_widgets"))
val editable = pwaSupportedWidgets.filter { previous.moduleVisible(it) && previous.widget(it) }.toSet()
val widgets = pwaWidgetVisibility(previous.preferences, visible, editable)
requireNotNull(client).request("/preferences", "PUT", JSONObject().put("dashboard_widgets", widgets))
val preferences = requireNotNull(client).request("/preferences").getJSONObject("data")
load(previous.copy(preferences = preferences))
}
fun checkItem(item: JSONObject) = perform { previous ->
requireNotNull(client).request(
"/shopping/items/${item.getLong("id")}",
"PATCH",
JSONObject().put(
"is_checked",
if (item.optInt("is_checked") ==
1
) {
0
} else {
1
},
),
)
load(previous)
}
fun remove(path: String) = perform { previous ->
requireNotNull(client).request(path, "DELETE")
load(previous)
}
fun logout(forgetServer: Boolean = false) = perform(clearOnFailure = true) { previous ->
val revoked = runCatching {
requireNotNull(client).request("/auth/logout", "POST", JSONObject())
}.isSuccess
client?.clear()
vault?.clear()
if (forgetServer) {
preferences.checkedEdit { clear() }
client = null
vault = null
NativeState()
} else {
previous.copy(
user = null,
permissions = JSONObject(),
dashboard = JSONObject(),
preferences = JSONObject(),
shoppingLists = emptyList(),
rows = emptyList(),
listId = null,
page = NativePage.HOME,
twoFactor = false,
error = if (revoked) null else "Lokal abgemeldet. Die Abmeldung auf dem Server konnte nicht bestätigt werden.",
)
}
}
fun forgetLocalServer() = perform(clearOnFailure = true) {
client?.clear()
vault?.clear()
preferences.checkedEdit { clear() }
client = null
vault = null
NativeState()
}
override fun onCleared() {
generation++
worker.shutdownNow()
super.onCleared()
}
}
@@ -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,63 @@
package community.yuvomi.mobile
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/** Native cookies are encrypted with an OS-keystore key and bound to one origin. */
class NativeSession(context: Context, private val origin: String) {
private val preferences = context.getSharedPreferences("native-session", Context.MODE_PRIVATE)
private val alias = "yuvomi-native-session-v1"
private fun key(): SecretKey {
val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(store.getKey(alias, null) as? SecretKey)?.let { return it }
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply {
init(
KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build(),
)
}.generateKey()
}
fun load(): String? = runCatching {
val encoded = preferences.getString(origin, null) ?: return null
val bytes = Base64.decode(encoded, Base64.NO_WRAP)
require(bytes.size > 28)
Cipher.getInstance("AES/GCM/NoPadding").run {
init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, bytes.copyOfRange(0, 12)))
updateAAD(origin.toByteArray())
String(doFinal(bytes.copyOfRange(12, bytes.size)), Charsets.UTF_8)
}
}.getOrNull()
fun save(value: String) {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, key())
cipher.updateAAD(origin.toByteArray())
val encoded = Base64.encodeToString(cipher.iv + cipher.doFinal(value.toByteArray()), Base64.NO_WRAP)
preferences.checkedEdit { putString(origin, encoded) }
}
fun clear() {
preferences.checkedEdit { remove(origin) }
}
}
/** Unlike KTX edit(commit=true), this does not discard a failed persistence result. */
@SuppressLint("UseKtx")
fun SharedPreferences.checkedEdit(change: SharedPreferences.Editor.() -> Unit) {
val editor = edit()
editor.change()
check(editor.commit())
}
@@ -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"),
)
}
}
}
@@ -8,7 +8,8 @@ class ServerAddress private constructor(val origin: String) {
fun owns(value: String): Boolean = runCatching { fun owns(value: String): Boolean = runCatching {
val uri = URI(value) val uri = URI(value)
uri.rawUserInfo == null && uri.scheme.equals("https", true) && uri.rawUserInfo == null && uri.scheme.equals("https", true) &&
uri.host.equals(URI(origin).host, true) && effectivePort(uri) == effectivePort(URI(origin)) uri.host.equals(URI(origin).host, true) &&
effectivePort(uri) == effectivePort(URI(origin))
}.getOrDefault(false) }.getOrDefault(false)
companion object { companion object {
@@ -24,7 +25,9 @@ class ServerAddress private constructor(val origin: String) {
require(uri.rawPath.isNullOrEmpty() || uri.rawPath == "/") { require(uri.rawPath.isNullOrEmpty() || uri.rawPath == "/") {
"Yuvomi requires a dedicated hostname, not a subpath." "Yuvomi requires a dedicated hostname, not a subpath."
} }
require(uri.port == -1 || uri.port in 1..65535) { "The port must be between 1 and 65535." } require(uri.port == -1 || uri.port in 1..65535) {
"The port must be between 1 and 65535."
}
val port = if (uri.port == -1 || uri.port == 443) "" else ":${uri.port}" val port = if (uri.port == -1 || uri.port == 443) "" else ":${uri.port}"
return ServerAddress("https://${uri.host.lowercase(Locale.ROOT)}$port") return ServerAddress("https://${uri.host.lowercase(Locale.ROOT)}$port")
} }
@@ -1,16 +1,18 @@
package community.yuvomi.mobile package community.yuvomi.mobile
import org.json.JSONObject import java.io.ByteArrayOutputStream
import java.net.URI import java.net.URI
import javax.net.ssl.HttpsURLConnection import javax.net.ssl.HttpsURLConnection
import java.io.ByteArrayOutputStream import org.json.JSONObject
object ServerProbe { object ServerProbe {
/** No cookies, no automatic redirects, bounded response and deadlines. */ /** No cookies, no automatic redirects, bounded response and deadlines. */
fun check(server: ServerAddress) { fun check(server: ServerAddress) {
val version = json(server, "/api/v1/version") val version = json(server, "/api/v1/version")
require(version.opt("app_name") is String && version.opt("setup_required") is Boolean && require(
version.opt("password_reset_enabled") is Boolean) { version.opt("app_name") is String && version.opt("setup_required") is Boolean &&
version.opt("password_reset_enabled") is Boolean,
) {
"This server does not expose compatible Yuvomi bootstrap metadata." "This server does not expose compatible Yuvomi bootstrap metadata."
} }
val auth = json(server, "/api/v1/auth/oidc/config") val auth = json(server, "/api/v1/auth/oidc/config")
@@ -1,5 +1,3 @@
<resources> <resources>
<string name="app_name">Yuvomi Mobile</string> <string name="app_name">Yuvomi Mobile</string>
<string name="checking_server">Checking your server…</string>
<string name="probe_failed">Cannot verify a compatible Yuvomi server. Check the URL, TLS certificate, VPN, and reverse-proxy access.</string>
</resources> </resources>
@@ -0,0 +1,107 @@
package community.yuvomi.mobile
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class NativeCookiesTest {
private val server = ServerAddress.parse("https://home.example:8443")
@Test fun acceptsOnlyAllowListedSameHostCookies() {
val jar = NativeCookies(server)
jar.accept(listOf("yuvomi.sid=valid; Secure; HttpOnly; Path=/; Max-Age=60"))
assertEquals("yuvomi.sid=valid", jar.header())
jar.accept(listOf("other=unwanted; Secure", "yuvomi.sid=other-host; Domain=evil.example; Secure"))
assertEquals("yuvomi.sid=valid", jar.header())
}
@Test fun acceptsUpstreamDefaultCookieOnHttpsWithoutServerSecureAttribute() {
val jar = NativeCookies(server)
jar.accept(listOf("yuvomi.sid=default-cookie; HttpOnly; Path=/; Max-Age=60"))
assertEquals("yuvomi.sid=default-cookie", jar.header())
assertTrue(jar.hasSession())
val restored = NativeCookies(server, jar.serialized())
assertEquals("yuvomi.sid=default-cookie", restored.header())
assertTrue(restored.hasSession())
jar.accept(listOf("yuvomi.sid=; Path=/; Max-Age=0"))
assertFalse(jar.hasSession())
}
@Test fun emptyExpiredAndMissingCookiesAreNotAuthenticatedSessions() {
val jar = NativeCookies(server)
assertFalse(jar.hasSession())
jar.accept(listOf("yuvomi.sid=; Path=/; Max-Age=60"))
assertFalse(jar.hasSession())
val now = System.currentTimeMillis()
jar.accept(listOf("yuvomi.sid=short; Path=/; Max-Age=1"), now)
assertFalse(jar.hasSession(now + 2_000))
}
@Test fun authenticationErrorsDistinguishCredentialsCodeAndSessionHandoff() {
assertEquals(
"Benutzername oder Passwort wurden vom Server nicht akzeptiert.",
NativeApiError(401, authenticationStep = "/auth/login").userMessage(),
)
assertTrue(NativeApiError(401, authenticationStep = "/auth/2fa/verify").userMessage().contains("Bestätigungscode"))
assertTrue(NativeApiError(401, "session_cookie_missing").userMessage().contains("Sitzungscookie"))
assertTrue(NativeApiError(401, "session_not_accepted").userMessage().contains("akzeptiert die Sitzung nicht"))
assertEquals("Anmeldung oder Sitzung ist nicht mehr gültig.", NativeApiError(401).userMessage())
}
@Test fun absoluteExpiryDoesNotExtendWhenRestoring() {
val now = System.currentTimeMillis()
val jar = NativeCookies(server)
jar.accept(listOf("yuvomi.sid=short; Secure; Path=/; Max-Age=60"), now)
val restored = NativeCookies(server, jar.serialized(now))
assertEquals("yuvomi.sid=short", restored.header(now + 59_000))
assertEquals("", restored.header(now + 61_000))
}
@Test fun sessionOnlyCookieIsNotPersisted() {
val jar = NativeCookies(server)
jar.accept(listOf("yuvomi.sid=temporary; Secure; Path=/"))
assertEquals("yuvomi.sid=temporary", jar.header())
assertEquals("[]", jar.serialized())
}
@Test fun deletionAndClearRemoveSession() {
val jar = NativeCookies(server)
jar.accept(listOf("yuvomi.sid=valid; Secure; Max-Age=60"))
jar.accept(listOf("yuvomi.sid=deleted; Secure; Max-Age=0"))
assertEquals("", jar.header())
jar.accept(listOf("csrf-token=token; Secure; Max-Age=60"))
jar.clear()
assertEquals("", jar.header())
}
@Test fun rejectsMalformedAndHeaderInjectionPersistence() {
assertEquals("", NativeCookies(server, "not-json").header())
val rows = JSONArray().put(
JSONObject().put("name", "yuvomi.sid").put("value", "bad\r\nInjected: value").put(
"expires",
Long.MAX_VALUE - 1,
),
)
assertEquals("", NativeCookies(server, rows.toString()).header())
}
@Test fun permissionPayloadKeepsReadOnlyAndHiddenModulesDistinct() {
val state =
NativeState(
permissions = JSONObject("""{"admin":false,"modules":{"tasks":"read","notes":"none"},"widgets":{"shopping":"none"}}"""),
)
assertEquals("read", state.access("tasks"))
assertEquals("none", state.access("notes"))
assertEquals("write", state.access("shopping"))
assertFalse(state.widget("shopping"))
assertTrue(state.widget("notes"))
}
@Test fun unavailableCounterIsNotShownAsZero() {
assertEquals("—", JSONObject("""{"openTaskCount":null}""").countText("openTaskCount"))
assertEquals("0", JSONObject("""{"openTaskCount":0}""").countText("openTaskCount"))
}
}
@@ -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"))
}
}
@@ -1,20 +1,31 @@
package community.yuvomi.mobile package community.yuvomi.mobile
import org.junit.Assert.* import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class ServerAddressTest { class ServerAddressTest {
@Test fun canonicalizesAndSupportsPortsAndIPv6() { @Test fun canonicalizesAndSupportsPortsAndIPv6() {
assertEquals("https://family.example", ServerAddress.parse(" HTTPS://Family.Example:443/ ").origin) assertEquals(
assertEquals("https://family.example:8443", ServerAddress.parse("https://family.example:8443").origin) "https://family.example",
ServerAddress.parse(" HTTPS://Family.Example:443/ ").origin,
)
assertEquals(
"https://family.example:8443",
ServerAddress.parse("https://family.example:8443").origin,
)
assertEquals("https://[::1]:8443", ServerAddress.parse("https://[::1]:8443").origin) assertEquals("https://[::1]:8443", ServerAddress.parse("https://[::1]:8443").origin)
} }
@Test fun rejectsUnsafeConfiguration() { @Test fun rejectsUnsafeConfiguration() {
listOf("http://family.example", "javascript:alert(1)", "https://user:pass@family.example", listOf(
"http://family.example", "javascript:alert(1)", "https://user:pass@family.example",
"https://family.example/subpath", "https://family.example?token=secret", "https://family.example/subpath", "https://family.example?token=secret",
"https://family.example/#token", "https://family.example:0", "https://family.example:65536", "https://family.example/#token", "https://family.example:0", "https://family.example:65536",
"https://family.example/%2f", "https://family.example\\@evil.example", "https:///", "").forEach { "https://family.example/%2f", "https://family.example\\@evil.example", "https:///", "",
).forEach {
assertThrows(it, IllegalArgumentException::class.java) { ServerAddress.parse(it) } assertThrows(it, IllegalArgumentException::class.java) { ServerAddress.parse(it) }
} }
} }
@@ -22,9 +33,16 @@ class ServerAddressTest {
@Test fun navigationIsOriginBoundNotStringPrefixBound() { @Test fun navigationIsOriginBoundNotStringPrefixBound() {
val server = ServerAddress.parse("https://family.example") val server = ServerAddress.parse("https://family.example")
assertTrue(server.owns("https://FAMILY.example:443/tasks?id=1")) assertTrue(server.owns("https://FAMILY.example:443/tasks?id=1"))
listOf("https://family.example.evil.test/", "https://family.example@evil.test/", listOf(
"https://user@family.example/", "http://family.example/", "https://family.example:8443/", "https://family.example.evil.test/",
"file:///etc/passwd", "intent://family.example", "data:text/html,hello").forEach { "https://family.example@evil.test/",
"https://user@family.example/",
"http://family.example/",
"https://family.example:8443/",
"file:///etc/passwd",
"intent://family.example",
"data:text/html,hello",
).forEach {
assertFalse(it, server.owns(it)) assertFalse(it, server.owns(it))
} }
} }
+26 -2
View File
@@ -1,6 +1,30 @@
buildscript {
repositories {
google()
mavenCentral()
}
// Override AGP's bundled KGP to include CVE-2026-53914 remediation.
dependencies { classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.21") }
configurations.getByName("classpath").resolutionStrategy {
activateDependencyLocking()
eachDependency {
when (requested.group) {
"io.netty" -> useVersion("4.1.139.Final")
"com.google.protobuf" -> useVersion("3.25.9")
"com.google.crypto.tink" -> useVersion("1.15.0")
"org.bouncycastle" -> useVersion("1.86")
"org.apache.commons" -> if (requested.name == "commons-lang3") useVersion("3.21.0")
"org.apache.httpcomponents" -> if (requested.name == "httpclient") useVersion("4.5.14")
"org.bitbucket.b_c" -> if (requested.name == "jose4j") useVersion("0.9.6")
"org.jdom" -> if (requested.name == "jdom2") useVersion("2.0.6.1")
}
}
}
}
plugins { plugins {
id("com.android.application") version "8.13.2" apply false id("com.android.application") version "9.4.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.10" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.4.21" apply false
} }
// WSL mounted Windows volumes may reject Gradle's POSIX file metadata operations. // WSL mounted Windows volumes may reject Gradle's POSIX file metadata operations.
+118
View File
@@ -0,0 +1,118 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
# To regenerate this file, run: ./gradlew dependencies --write-locks
androidx.databinding:databinding-common:9.4.1=classpath
androidx.databinding:databinding-compiler-common:9.4.1=classpath
com.android.application:com.android.application.gradle.plugin:9.4.1=classpath
com.android.databinding:baseLibrary:9.4.1=classpath
com.android.tools.analytics-library:crash:32.4.1=classpath
com.android.tools.analytics-library:protos:32.4.1=classpath
com.android.tools.analytics-library:shared:32.4.1=classpath
com.android.tools.analytics-library:tracker:32.4.1=classpath
com.android.tools.build.jetifier:jetifier-core:1.0.0-beta10=classpath
com.android.tools.build.jetifier:jetifier-processor:1.0.0-beta10=classpath
com.android.tools.build:aapt2-proto:9.4.1-15978811=classpath
com.android.tools.build:aaptcompiler:9.4.1=classpath
com.android.tools.build:apksig:9.4.1=classpath
com.android.tools.build:apkzlib:9.4.1=classpath
com.android.tools.build:builder-model:9.4.1=classpath
com.android.tools.build:builder-test-api:9.4.1=classpath
com.android.tools.build:builder:9.4.1=classpath
com.android.tools.build:bundletool:1.18.3=classpath
com.android.tools.build:gradle-api:9.4.1=classpath
com.android.tools.build:gradle-common-api:9.4.1=classpath
com.android.tools.build:gradle-settings-api:9.4.1=classpath
com.android.tools.build:gradle:9.4.1=classpath
com.android.tools.build:manifest-merger:32.4.1=classpath
com.android.tools.ddms:ddmlib:32.4.1=classpath
com.android.tools.layoutlib:layoutlib-api:32.4.1=classpath
com.android.tools.lint:lint-model:32.4.1=classpath
com.android.tools.lint:lint-typedef-remover:32.4.1=classpath
com.android.tools.utp:gradle-work-action-api:32.4.1=classpath
com.android.tools:annotations:32.4.1=classpath
com.android.tools:common:32.4.1=classpath
com.android.tools:dvlib:32.4.1=classpath
com.android.tools:repository:32.4.1=classpath
com.android.tools:sdk-common:32.4.1=classpath
com.android.tools:sdklib:32.4.1=classpath
com.android:signflinger:9.4.1=classpath
com.android:zipflinger:9.4.1=classpath
com.google.auto.value:auto-value-annotations:1.6.2=classpath
com.google.code.findbugs:jsr305:3.0.2=classpath
com.google.code.gson:gson:2.11.0=classpath
com.google.crypto.tink:tink:1.15.0=classpath
com.google.dagger:dagger:2.28.3=classpath
com.google.errorprone:error_prone_annotations:2.36.0=classpath
com.google.flatbuffers:flatbuffers-java:1.12.0=classpath
com.google.guava:failureaccess:1.0.2=classpath
com.google.guava:guava:33.4.0-jre=classpath
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=classpath
com.google.j2objc:j2objc-annotations:3.0.0=classpath
com.google.jimfs:jimfs:1.1=classpath
com.google.protobuf:protobuf-java-util:3.25.9=classpath
com.google.protobuf:protobuf-java:3.25.9=classpath
com.googlecode.juniversalchardet:juniversalchardet:1.0.3=classpath
com.squareup:javapoet:1.13.0=classpath
com.squareup:javawriter:2.5.0=classpath
com.sun.istack:istack-commons-runtime:3.0.8=classpath
com.sun.xml.fastinfoset:FastInfoset:1.2.16=classpath
commons-codec:commons-codec:1.17.1=classpath
commons-io:commons-io:2.16.1=classpath
commons-logging:commons-logging:1.2=classpath
jakarta.activation:jakarta.activation-api:1.2.1=classpath
jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=classpath
javax.inject:javax.inject:1=classpath
net.java.dev.jna:jna-platform:5.6.0=classpath
net.java.dev.jna:jna:5.6.0=classpath
net.sf.jopt-simple:jopt-simple:4.9=classpath
net.sf.kxml:kxml2:2.3.0=classpath
org.apache.commons:commons-compress:1.27.1=classpath
org.apache.commons:commons-lang3:3.21.0=classpath
org.apache.httpcomponents:httpclient:4.5.14=classpath
org.apache.httpcomponents:httpcore:4.4.16=classpath
org.apache.httpcomponents:httpmime:4.5.6=classpath
org.bitbucket.b_c:jose4j:0.9.6=classpath
org.bouncycastle:bcpkix-jdk18on:1.86=classpath
org.bouncycastle:bcprov-jdk18on:1.86=classpath
org.bouncycastle:bcutil-jdk18on:1.86=classpath
org.checkerframework:checker-qual:3.43.0=classpath
org.glassfish.jaxb:jaxb-runtime:2.3.2=classpath
org.glassfish.jaxb:txw2:2.3.2=classpath
org.jdom:jdom2:2.0.6.1=classpath
org.jetbrains.kotlin.plugin.compose:org.jetbrains.kotlin.plugin.compose.gradle.plugin:2.4.21=classpath
org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.4.21=classpath
org.jetbrains.kotlin:fus-statistics-gradle-plugin:2.4.21=classpath
org.jetbrains.kotlin:kotlin-build-statistics:2.4.21=classpath
org.jetbrains.kotlin:kotlin-build-tools-api:2.4.21=classpath
org.jetbrains.kotlin:kotlin-compiler-runner:2.4.21=classpath
org.jetbrains.kotlin:kotlin-daemon-client:2.4.21=classpath
org.jetbrains.kotlin:kotlin-gradle-plugin-annotations:2.4.21=classpath
org.jetbrains.kotlin:kotlin-gradle-plugin-api:2.4.21=classpath
org.jetbrains.kotlin:kotlin-gradle-plugin-idea-proto:2.4.21=classpath
org.jetbrains.kotlin:kotlin-gradle-plugin-idea:2.4.21=classpath
org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.21=classpath
org.jetbrains.kotlin:kotlin-gradle-plugins-bom:2.4.21=classpath
org.jetbrains.kotlin:kotlin-klib-commonizer-api:2.4.21=classpath
org.jetbrains.kotlin:kotlin-native-utils:2.4.21=classpath
org.jetbrains.kotlin:kotlin-reflect:2.3.21=classpath
org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.10=classpath
org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.10=classpath
org.jetbrains.kotlin:kotlin-stdlib:2.3.21=classpath
org.jetbrains.kotlin:kotlin-tooling-core:2.4.21=classpath
org.jetbrains.kotlin:kotlin-util-io:2.4.21=classpath
org.jetbrains.kotlin:kotlin-util-klib-metadata:2.4.21=classpath
org.jetbrains.kotlin:kotlin-util-klib:2.4.21=classpath
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=classpath
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0=classpath
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0=classpath
org.jetbrains:annotations:13.0=classpath
org.jvnet.staxex:stax-ex:1.8.1=classpath
org.ow2.asm:asm-analysis:9.9=classpath
org.ow2.asm:asm-commons:9.9=classpath
org.ow2.asm:asm-tree:9.9=classpath
org.ow2.asm:asm-util:9.9=classpath
org.ow2.asm:asm:9.9=classpath
org.slf4j:slf4j-api:1.7.30=classpath
org.tensorflow:tensorflow-lite-metadata:0.2.0=classpath
empty=
+2
View File
@@ -1,4 +1,6 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
org.gradle.workers.max=2
kotlin.compiler.execution.strategy=in-process
android.useAndroidX=true android.useAndroidX=true
kotlin.code.style=official kotlin.code.style=official
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
Binary file not shown.
+4 -2
View File
@@ -1,8 +1,10 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip distributionSha256Sum=bbaeb2fef8710818cf0e261201dab964c572f92b942812df0c3620d62a529a01
distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78 distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
networkTimeout=10000 networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
+5 -8
View File
@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
# #
# Copyright © 2015-2021 the original authors. # Copyright © 2015 the original authors.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -20,7 +20,7 @@
############################################################################## ##############################################################################
# #
# Gradle start up script for POSIX generated by Gradle. # gradlew start up script for POSIX generated by Gradle.
# #
# Important for running: # Important for running:
# #
@@ -29,7 +29,7 @@
# bash, then to run this script, type that shell name before the whole # bash, then to run this script, type that shell name before the whole
# command line, like: # command line, like:
# #
# ksh Gradle # ksh gradlew
# #
# Busybox and similar reduced shells will NOT work, because this script # Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features: # requires all of these POSIX shell features:
@@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop. # Darwin, MinGW, and NonStop.
# #
# (3) This script is generated from the Groovy template # (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project. # within the Gradle project.
# #
# You can find Gradle at https://github.com/gradle/gradle/. # You can find Gradle at https://github.com/gradle/gradle/.
@@ -114,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;; NONSTOP* ) nonstop=true ;;
esac esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM. # Determine the Java command to use to start the JVM.
@@ -172,7 +171,6 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java # For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" ) JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -212,8 +210,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \ set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \ "-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
org.gradle.wrapper.GradleWrapperMain \
"$@" "$@"
# Stop when "xargs" is not available. # Stop when "xargs" is not available.
+12 -24
View File
@@ -19,12 +19,12 @@
@if "%DEBUG%"=="" @echo off @if "%DEBUG%"=="" @echo off
@rem ########################################################################## @rem ##########################################################################
@rem @rem
@rem Gradle startup script for Windows @rem gradlew startup script for Windows
@rem @rem
@rem ########################################################################## @rem ##########################################################################
@rem Set local scope for the variables with windows NT shell @rem Set local scope for the variables, and ensure extensions are enabled
if "%OS%"=="Windows_NT" setlocal setlocal EnableExtensions
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=. if "%DIRNAME%"=="" set DIRNAME=.
@@ -51,7 +51,7 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
goto fail "%COMSPEC%" /c exit 1
:findJavaFromJavaHome :findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=% set JAVA_HOME=%JAVA_HOME:"=%
@@ -65,30 +65,18 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
goto fail "%COMSPEC%" /c exit 1
:execute :execute
@rem Setup the command line @rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle @rem Execute gradlew
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* @rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:end :exitWithErrorLevel
@rem End local scope for the variables with windows NT shell @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
if %ERRORLEVEL% equ 0 goto mainEnd "%COMSPEC%" /c exit %ERRORLEVEL%
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+11 -2
View File
@@ -1,7 +1,16 @@
pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } } pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement { dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories { google(); mavenCentral() } repositories {
google()
mavenCentral()
}
} }
rootProject.name = "YuvomiMobile" rootProject.name = "YuvomiMobile"
include(":app") include(":app")
+57
View File
@@ -0,0 +1,57 @@
# Native client architecture
The requested product is a native reconstruction of Yuvomi's PWA, not a native
window around that website. Both shipping entry points now render native views:
| Responsibility | Android | iOS |
| --- | --- | --- |
| Screens and navigation | Jetpack Compose / Material 3 primitives, Yuvomi tokens | SwiftUI, Yuvomi tokens |
| Application state | AndroidViewModel + Compose state | MainActor ObservableObject |
| JSON API | HttpsURLConnection, serialized worker | URLSession actor, bounded streaming delegate |
| Session ownership | Explicit origin-bound HTTPS-only cookie jar | Private ephemeral URLSession cookie storage |
| Persistent cookies | AES-GCM, AndroidKeyStore key, origin as authenticated data | Keychain, WhenUnlockedThisDeviceOnly, origin-scoped account |
| Server logic | Existing unmodified Yuvomi API | Existing unmodified Yuvomi API |
There is no WebView/WKWebView in either shipped source tree and no browser
fallback for missing modules. The old implementation and browser tests are
archived in `legacy/`; their results are historical, not evidence for this client.
The first native slice contains HTTPS onboarding, setup/password/TOTP login,
session restoration, a basic dashboard, task/note/shopping interactions, read-only
permission handling and local sign-out/server removal. Exact feature and visual
parity is tracked in [native parity](native-parity.md), not inferred from a list of
navigation destinations. The other modules remain unfinished.
## Transport and state boundaries
- One configured root HTTPS origin/port; system certificate validation only.
- API calls never follow redirects. No cookie or Authorization state is replayed
to another host. Authentication proxies returning HTML are not treated as APIs.
- JSON reads have byte limits and timeouts. Writes are not queued for offline
replay. Only a server-declared `csrf_invalid` rejection permits one token refresh
and retry; ordinary authorization failures and transport failures do not.
- Server session cookies are persisted, not passwords or TOTP/recovery codes.
Session-only cookies are not converted to long-lived cookies.
- Native UI mutations wait for the server result. Permissions remain enforced
server-side; sparse module/widget permissions also control native affordances.
- Android's native jar enforces HTTPS itself rather than rejecting a valid server
cookie solely because its `Secure` attribute is absent. This supports upstream's
default session settings behind HTTPS proxies without permitting HTTP or
cross-origin cookie replay. Browser cookie configuration remains the server
operator's responsibility; `SESSION_SECURE=true` is recommended for HTTPS sites.
- Local removal clears the native cookie vault and in-memory records. It is not
remote revocation. Logout attempts server revocation and still erases the local
session if the server cannot be reached.
- A prior WebView prototype installation may retain its old OS website data.
This data is never imported by the native client. Clear app data/reinstall the
prototype before reviewing this native preview if complete legacy erasure is
required; no migration of private household records is performed.
## Explicit gaps
No native OIDC handoff has been implemented. A browser sign-in is not represented
as a successful native API session. Password reset/invitation flows, the remaining
modules, configurable dashboard, uploads/downloads, notification integrations,
offline cache, localization parity and visual baseline comparison remain open.
The signing setup was paused by the maintainer's native-rebuild correction; it
must not be used to label this incomplete client as a finished release.
+91
View File
@@ -0,0 +1,91 @@
# Android development
Requirements: JDK 17, Android SDK platform 37.0, build-tools 36.0.0, current
platform-tools, and accepted Android SDK licenses. Minimum Android 8 (API 26).
The application surface is Jetpack Compose, not Android System WebView. Target
API 37. On Android 17, choose "Zugriff auf private Netzwerke erlauben" for a LAN instance; public HTTPS servers
do not need this optional runtime permission. No discovery/scanning is performed.
```sh
export ANDROID_HOME=/path/to/android-sdk
bash android/gradlew -p android testDebugUnitTest lintDebug lintRelease assembleDebug assembleRelease bundleRelease
bash android/gradlew -p android connectedDebugAndroidTest
```
Gradle 9.6.0 is checksum-verified by the wrapper. AGP 9.4.1 uses built-in Kotlin
with KGP and Compose compiler plugin 2.4.21. Maven versions are locked. No dynamic
application dependencies or private Maven repository is required.
Ordinary outputs:
- `android/app/build/outputs/apk/debug/app-debug.apk` (development-signed)
- `android/app/build/outputs/apk/release/app-release-unsigned.apk`
- `android/app/build/outputs/bundle/release/app-release.aab` (unsigned unless signed later)
- `android/app/build/reports/` (lint, JVM and emulator results)
The Gitea build job uploads `android-native-debug-apk`, containing an installable
development-signed `app-debug.apk`. It is a native preview, not a signed stable
release or a claim of full PWA parity. See `native-parity.md` before testing it.
Native UI tests live in `src/androidTestNative/java` and the real-server native
scenario in `src/androidTestNativeIntegration/java`. The old browser tests and
renderer are archived under `legacy/` and are not compiled into the app/suites.
Immediately after building, collect and inspect the outputs:
```sh
python3 scripts/collect-android-artifacts.py
```
This writes ignored `artifacts/android/` copies, `SHA256SUMS`, source identity and
manifest/network-policy/signature evidence. It requires `ANDROID_HOME` and respects
`YUVOMI_BUILD_DIR`. Source identity records the checkout at collection time; always
rebuild immediately beforehand and use a clean worktree for a release candidate.
The unsigned release cannot be installed/published as-is. See release signing
instructions. `gradle-wrapper.jar` is the standard generated Gradle bootstrap
binary; it is intentionally versioned, unlike generated APKs or caches.
On WSL/Windows mounts that reject POSIX metadata operations, put outputs on the
Linux filesystem:
```sh
export YUVOMI_BUILD_DIR="$HOME/yuvomi-build"
bash android/gradlew -p android testDebugUnitTest assembleDebug
```
The application never permits HTTP, including debug builds. Use real HTTPS for
manual development. For a temporary local HTTPS integration environment:
```sh
git clone --filter=blob:none https://github.com/ulsklyc/yuvomi.git /path/to/upstream
git -C /path/to/upstream checkout 10bfef7686ed7303b4c74c7fcc55a5eb16356c9d
npm ci --omit=dev --prefix /path/to/upstream
python3 scripts/test-integration.py /path/to/upstream --android
python3 scripts/test-integration.py /path/to/upstream --android --cookie-without-secure
```
Requires Node 22.14+ and a running emulator. The harness creates and removes its
own CA/key/database, uses `adb reverse`, and builds `.integration` with explicit
test trust. It does not weaken production TLS. Test credentials are ephemeral.
Keep test reports private if you customize the harness with real data (not advised).
The native test calls the real API through native controls and saves a synthetic
dashboard screenshot under `app/build/outputs/native-screenshots/`. It does not
demonstrate native SSO merely because the protocol fixture also tests OIDC.
Both commands retain HTTPS and system/test-CA validation; the second reproduces
upstream's default `SESSION_SECURE=false` cookie attributes behind an HTTPS proxy.
The native jar must retain that session while continuing to forbid HTTP and
cross-origin replay. The scenario also rejects a wrong password, then retries
successfully without retaining the old error.
The test writes through `PlatformTestStorageRegistry`; AGP collects the file in
`outputs/connected_android_test_additional_output/integrationAndroidTest/` before
uninstalling the fixture. The harness rejects missing/ambiguous output and removes
only its known screenshot from previous runs. Do not extract app-private files
after Gradle returns: UTP has already uninstalled the `.integration` package.
Capture uses the native window and asserts nonuniform pixels; a valid PNG header
alone is not sufficient screenshot evidence.
For dependency updates run `:app:dependencies --write-locks`, the full pipeline,
and `python3 scripts/check-dependencies.py`. Review lockfile changes. The version
update lint recommendations are informational, not suppressed correctness errors;
OSV checks independently fail on known advisories.
+43
View File
@@ -0,0 +1,43 @@
# iOS development
Requires macOS, Xcode 16.4 or newer with an iOS simulator, and XcodeGen 2.44.1.
Minimum iOS/iPadOS 18.4 is retained for this rebuild. The application surface is
SwiftUI; the old WKWebView renderer is archived, not shipped. No CocoaPods or external
runtime package is required. For App Store submission use Apple's currently
required Xcode/iOS SDK, not merely the oldest compiler that can build the source.
```sh
bash scripts/install-xcodegen.sh "$HOME/.local/yuvomi-xcodegen"
"$HOME/.local/yuvomi-xcodegen/xcodegen/bin/xcodegen" generate --spec ios/project.yml
device=$(python3 scripts/select-simulator.py)
xcodebuild -project ios/Yuvomi.xcodeproj -scheme Yuvomi \
-destination "platform=iOS Simulator,id=$device" CODE_SIGNING_ALLOWED=NO test
xcodebuild -project ios/Yuvomi.xcodeproj -scheme Yuvomi \
-destination 'generic/platform=iOS' -archivePath artifacts/Yuvomi-unsigned.xcarchive \
CODE_SIGNING_ALLOWED=NO archive
```
`project.yml` is the source of truth. The Xcode project and generated Info.plist
are ignored. The XcodeGen download is version- and SHA256-pinned. The project
includes a privacy manifest, camera/photo/local-network usage strings, universal
app icon, orientation support, unit and UI test targets, and no ATS exception.
On macOS or Linux with Swift 6 installed:
```sh
swift test --package-path ios
swiftc -frontend -parse ios/Sources/*.swift ios/Tests/*.swift ios/UITests/*.swift
```
These portable tests compile the **production Foundation API transport, contracts
and URL/metadata policies**. Parsing does not typecheck SwiftUI or Security APIs. Neither command is
evidence that the iOS application builds. Actual Xcode testing is mandatory.
For a device build select your authorized development team in Xcode, use your own
registered bundle identifier, and let Xcode manage development provisioning.
Never commit generated profiles or certificates. See `release-process.md` for
distribution. A Linux environment cannot produce a validated iOS archive.
The Mac runner now bootstraps checksum-pinned native Node 22 before checkout,
because its JavaScript actions cannot start without Node in the service PATH.
This does not install/choose Xcode, accept its license or create a simulator.
+98
View File
@@ -0,0 +1,98 @@
# Gitea CI runners
Workflows live in `.gitea/workflows/` for `git.nesterovic.cc`. Enable repository
Actions in Gitea settings. Scheduling uses the labels supplied by the maintainer:
| Job | `runs-on` | Eligible runners |
| --- | --- | --- |
| Android builds, JVM/lint checks | `ubuntu-latest` | `ubuntu-runner-1` through `ubuntu-runner-6` |
| Android emulator matrix (API 35 / 37.0) | `ubuntu-latest` | Same Ubuntu pool, with KVM access |
| Manual unsigned Android release build | `ubuntu-latest` | Same Ubuntu pool |
| iOS simulator, XCTest and unsigned archive | `macos-arm64` | `macos-m4pro` |
Runner names are informational; Gitea matches labels. The Mac's additional
`macos` label is not needed. No `self-hosted` label is requested because it is not
present in the supplied configuration. No runner registration or host settings
are changed by these workflows.
## Ubuntu prerequisites
The job environment must be Linux x86_64 with Bash, Git (SHA-256 capable), curl,
unzip, Python 3 and the shared libraries needed by the Android SDK/emulator and
Node's native build tools. Use a maintained Ubuntu runner image with compiler,
make, SQLite build prerequisites and emulator runtime libraries available. The
pinned setup actions provision Temurin 17, Android SDK packages and Node 22 for
the upstream fixture. SDK installation accepts the Android SDK licenses.
Emulator jobs require virtualization and readable/writable `/dev/kvm` **inside
the job environment**. For container-backed runners, the administrator must pass
the device through (for example `--device=/dev/kvm`) and configure group access.
VM hosts need nested virtualization. The preflight fails with a clear message
instead of attempting privileged host changes or silently using slow emulation.
All runners sharing `ubuntu-latest` must meet these prerequisites. Run each
emulator job in its own container; if using host execution, do not share an ADB
server/AVD directory with simultaneous jobs on that host.
## Apple Silicon prerequisites
Map `macos-arm64` to native host execution on the M4 Pro (not a Linux container or
Rosetta shell). Use a dedicated unprivileged CI account with Bash, SHA-256-capable Git, curl,
unzip and Python 3 available to the runner service's PATH. Install/select full
Xcode 16.4+ (or a current release supporting this project), accept its license and
complete first-launch setup. Install an iOS 18.4+ simulator runtime and create an
available iPhone simulator; `select-simulator.py` chooses the newest eligible one.
Before the first JavaScript action, an inline shell bootstrap installs verified
native Node 22.22.1 into a versioned runner tool cache and exports its bin directory
through `GITHUB_PATH`. It needs no Homebrew/root access and remains available to
action post-cleanup. This addresses run #4's `Cannot find: node in PATH` failure;
native bootstrap and checkout were verified in Mac job 1062 (run 661/#6).
That job subsequently stalled in Apple-tool preflight after printing Xcode 27.1.
The preflight now prints each tool probe and bounds it to 60 seconds using Python
(`scripts/check-apple-tools.py`), so the next run identifies the unresponsive
command rather than hanging silently. Follow-up job 1066 (run 663/#8) verified the
diagnostic: Xcode and Swift lookup pass, but `xcrun simctl list devices available`
times out. This is a runner prerequisite failure, not an iOS application test.
If an Apple probe stalls, run the named command in the runner user's interactive
login session, check the selected full Xcode, finish its first-launch components
and confirm CoreSimulator/runtimes work for that user. CI does not accept licenses,
change `xcode-select`, install runtimes or kill host simulator services. The runner
administrator must repair those prerequisites before native Apple validation.
XcodeGen's pinned, checksum-verified binary includes native arm64 and x86_64
slices; Rosetta/Homebrew installation is not required by the workflow. Build,
archive and Swift package scratch outputs use `RUNNER_TEMP`. Scratch directories
and the upstream clone are uniquely allocated per job and removed after artifact
upload to avoid collisions and buildup on persistent hosts. No Apple signing
credentials are required or provisioned. Avoid concurrent native jobs on the same
Mac user/simulator; configure runner capacity accordingly.
## Forge compatibility and security
- External actions use explicit HTTPS hosts and full commit pins. The Node 20
Gitea-compatible artifact action uses the v3 protocol, not GitHub's v4 backend.
- Checkout sets `GIT_DEFAULT_HASH=sha256` for this repository and does not persist
its token. Upstream remains a separate, unmodified SHA-1 clone.
- Fork pull requests are skipped on these private runners. Same-repository PRs,
pushes and manual runs are trusted code: restrict repository write access,
require review, isolate runner accounts and keep unrelated credentials away.
Do not enable `pull_request_target` to bypass this restriction.
- Gitea ignores GitHub `environment` approval gates. Consequently the manual
release workflow only emits **unsigned** APK/AAB files and never loads signing
secrets or publishes. Signing remains an owner-controlled external operation.
Local checks for workflow changes:
```sh
python3 scripts/check-repository.py
python3 scripts/test-ci-runner.py
python3 scripts/test-verifier.py
python3 scripts/lint-workflows.py /path/to/actionlint
bash -n scripts/check-ci-runner.sh
```
The actionlint adapter normalizes Gitea's absolute action URLs only for parsing;
repository checks validate the actual hosts, pins, checkout settings and labels.
These checks do not prove a remote job ran. Inspect the first Gitea Actions runs
and their artifacts before claiming hosted Android or Apple-platform validation.
+214
View File
@@ -0,0 +1,214 @@
# Native rebuild continuation
## Current objective — do not restore the wrapper
The maintainer rejected the browser-wrapper architecture and requested the PWA's
appearance/functions implemented as genuine native screens. Shipping Android now
uses Compose and iOS SwiftUI, calling the existing API directly. `legacy/` holds
the superseded renderers/tests for reference only. No WebView fallback, raw-JSON
module placeholder or old browser-test pass may be represented as native parity.
Read [native parity](native-parity.md), [architecture](architecture.md), and
[current validation](validation.md) first. Initial auth/dashboard/tasks/notes/
shopping slices exist, but **no module has accepted full visual/functional parity**.
The remaining pages/settings/auth/file/offline/notification work is explicit.
### Android design follow-up: 0.2.2 native preview / code 4
The user confirmed the login fix and requested the PWA design 1:1. The Android
slice now uses PWA-derived native primitives in `PwaDesign.kt`, `PwaIcons.kt`,
`PwaShell.kt`, `NativePages.kt` and `NativeForms.kt`, instead of the first generic
Material layout. `PwaPresentation.kt` handles greeting/timezone/config filtering;
the controller reads personal preferences and auto-selects the first shopping list.
The confirmed cookie/session transport was not changed.
Read [design comparison](native-design.md) before making visual claims. External
Playwright 1.55.1/Chromium 140 tooling captures the actual unmodified PWA; the
emulator exports ten light/dark native views with viewport metadata. Match the
360 × 592 content viewport and Android variable Roboto, not the system-bar-inclusive
root. The earlier clipped tab labels were caused by per-tab circular clipping.
The apparent second white capsule segment occurred where subsequent dashboard
content remained visible beneath the translucent navigation; inspect actual
matched captures rather than treating any visible card edge as a second bar.
Reference generation uses `/tmp/opencode/yuvomi-visual-tools/node_modules/playwright`,
`/tmp/opencode/yuvomi-visual-tools/fonts.conf` and persistent browsers at
`/home/neste/yuvomi-tools/playwright-browsers`. The temporary font config points to
the emulator's variable `/system/fonts/Roboto-Regular.ttf`; it includes all weights.
Set `PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64` on this Ubuntu 26 host.
Reference output is ignored under `artifacts/visual/pwa-360x592`; native screenshots
are in the external build's `app/outputs/native-screenshots`. The side-by-side
compositor is tooling only; screenshots are evidence, not automated parity approval.
The committed source `7ac9882` also passed a separate clean-archive build in
`/tmp/opencode/yuvomi-design-clean-1fknxqpt`, outputs at
`/home/neste/yuvomi-design-clean-build-7ac9882`. All three inspected native packages
are collected in ignored `artifacts/android/`; the installable CI debug APK is a
separate artifact with its own signature/checksum, not assumed identical.
Gitea run 670/build job 1079 succeeded at `7ac9882`. The actual 0.2.2/code-4 CI APK
was downloaded, signature/version/contents checked, installed and cold-launched
on `emulator-5554`; link and SHA-256 are in `validation.md`. It is now the installed
local preview, with a certificate different from the local Gradle debug key.
Remove only that known synthetic preview/use a fresh AVD before local device tests
if an install mismatch occurs; zero tests must still fail verification.
Remote emulator jobs 1080/1081 still report inaccessible `/dev/kvm`. Signing remains
paused, and the complete Android run is not green despite its successful build job.
iOS is unchanged by this design pass. Later inspection of run 669/job 1078 at
`bd0e4cc` verified 16 portable tests, 16 simulator XCTest tests, one onboarding UI
test and an unsigned device archive. The historical CoreSimulator timeout is no
longer the current blocker; see `validation.md` and the retained external job log.
Run 671/job 1082 at `7ac9882` subsequently repeated those successful Apple checks;
the actual log was downloaded and its test counts/archive markers inspected.
This is not iOS native API/Keychain/device or visual-parity acceptance.
No module has accepted full functional/visual parity, the rest
of the inventory remains open, and signing stays paused. Continue refinement from
actual comparison images, including non-default font sizes/devices and native
module functionality; do not declare the whole user request finished from this
Android slice alone.
### Android login follow-up: 0.2.1 native preview / code 3
A user reported immediate HTTP-401 login failure although the same direct password
login works in the browser. Reproduced against unmodified upstream by setting its
existing `SESSION_SECURE=false` default behind the HTTPS fixture: the old Android
jar discarded the cookie and the next authenticated request failed. The fix keeps
all native cookies HTTPS/origin-bound independent of that server attribute;
certificate checks, redirect refusal, encryption and expiry are unchanged. Login
errors now distinguish credential/code rejection from session-cookie problems and
a successful retry clears the old error. Both secure/default-cookie test modes
are in CI; see `login-troubleshooting.md` and the latest validation section.
The fix is source `4b483c2`; local 13 JVM, both 7-test cookie modes, 6 ordinary
native UI tests, lint and all packages pass. Gitea build job 1071/run 666 (#11)
passed and published the actual 0.2.1/code-3 APK; downloaded, verified and installed/
launched on `emulator-5554`. Exact link/checksum are in `validation.md`.
That CI-signed debug APK is now installed on the local emulator. Its certificate
differs from the local Gradle debug key: before another local device-test run,
remove only this synthetic preview package or use a fresh AVD. An install failure
with zero tests must not be reported as a passing instrumentation run.
The owner-authorized signing key and four Gitea secrets were created before this
correction. Their private backup is `/home/neste/yuvomi-signing/initial-20261021`.
Do not print its contents. Uncommitted signing changes are preserved in Git stash
`wip: owner-authorized signing setup paused for true native rewrite`; do not apply
them blindly or resume signing/publishing the old wrapper.
Current local native outputs/logs:
- `/home/neste/yuvomi-native-build` (use `YUVOMI_BUILD_DIR` on WSL).
- `/home/neste/yuvomi-native-validation.log` and native integration logs.
- `/home/neste/yuvomi-native-swift` and `/home/neste/yuvomi-native-swift.log`.
- The existing persistent SDK, Swift, ktlint, actionlint and unmodified upstream
paths listed below remain usable. Set `ANDROID_SERIAL=emulator-5554`; do not
authorize or deploy to unrelated devices visible in `adb devices`.
Run native JVM/Compose UI tests, the real upstream fixture, portable Swift API/
contract tests, formatting, lint and OSV after changes. Apple UI/Security.framework
is not typechecked by Linux. Gitea's Mac job now bootstraps pinned Node before
checkout, but actual Xcode results still need inspection. Ubuntu emulator jobs
continue requiring accessible KVM; do not weaken or fake that evidence.
Initial 0.2.0 local native evidence: 10 JVM tests, 6 API-35 Compose tests, 7 real-server
instrumentation tests (including those same 6), both lint variants and native
debug/unsigned release APK/AAB builds pass. Sixteen portable Swift tests and strict
format/parsing pass. Screenshot export now uses AndroidX test storage/AGP output
collection before uninstall; the PNG was opened and confirmed nonblank. Five new
output-regression tests guard missing, invalid, ambiguous and stale screenshots.
See `validation.md` for logs and limitations. No Apple SDK result is implied.
Android build job 1059 in global run 660 (#5) succeeded; the actual native debug
artifact was downloaded, checked and installed/launched on `emulator-5554`.
Jobs 1060/1061 still fail KVM preflight. Mac job 1062 in run 661 (#6) passed native
Node bootstrap and checkout, printed Xcode 27.1, then stalled in Apple-tool
preflight for at least 20 minutes. The new `check-apple-tools.py` prints each
command and imposes a 60-second deadline; fourteen runner/probe regressions pass.
Follow-up Mac job 1066 in run 663 (#8) identifies the exact blocker:
`xcrun simctl list devices available` exceeds 60 seconds. Xcode version and Swift
lookup pass. An administrator must fix the runner user's CoreSimulator/login
session/Xcode prerequisites without weakening the check. App compilation/tests
and archive have not run. No remote host license/settings were changed.
Rebuild/collect from a clean checkout; do not present cached wrapper artifacts as
the native preview. Keep the signing stash parked.
Next work: stabilize/visually compare this slice, close auth/advanced CRUD gaps,
then implement the remaining real native modules against the parity inventory.
## Historical environment and original delivery context
This is a development delivery, not authorization to publish. Preserve the Git
history on `feat/yuvomi-mobile`; use Conventional Commits and normal pushes only.
The authorized community remote is `https://git.nesterovic.cc/nessi/Yuvomi-App`.
Do not push, submit issues or open pull requests against upstream on this task's
behalf. No production credentials or store accounts have been provided.
## Start with the evidence
Read [validation](validation.md), [security review](security-review.md), and
[release gates](release-process.md). Do not repeat completed implementation work
or equate portable Swift checks with an iOS app build. Inspect `git status` before
editing; preserve any newer maintainer changes.
The upstream reference is Yuvomi 2.78.0 at
`10bfef7686ed7303b4c74c7fcc55a5eb16356c9d`. Keep it unmodified. Subpath hosting,
browser-to-native OIDC handoff and native remote push are not implemented.
The extended API 35 HTTPS suite passes all seven tests, including the local-IdP
round trip, native transfers, recovery and back navigation. Portable Swift has
seven passing policy tests; the separate JUnit verifier has eight regressions.
Keep the fixture's scope separate from real-provider or Apple-platform validation.
## Persistent local environment (Linux/WSL)
- Repository: `/mnt/c/Users/neste/Documents/GIT/Yuvomi-App`.
- Upstream and installed dependencies: `/home/neste/yuvomi-tools/upstream`.
- SDK: `/home/neste/yuvomi-tools/android-toolchain/android-sdk`.
- Build outputs/logs: `/home/neste/yuvomi-build`.
- Swift 6.2.1: `/home/neste/yuvomi-tools/swift-6.2.1-RELEASE-ubuntu24.04/usr/bin`.
- Swift compatibility libraries: `/home/neste/yuvomi-tools/compat/usr/lib/x86_64-linux-gnu`.
- Swift scratch output: `/home/neste/yuvomi-swift-build`.
- ktlint and actionlint: `/home/neste/yuvomi-tools/ktlint.jar` and
`/home/neste/yuvomi-tools/actionlint`.
- Local collected artifacts/evidence: ignored `artifacts/android/`.
`/tmp/opencode` is temporary and was cleared by server restarts. Background
processes may also disappear. Keep durable logs under `/home/neste`; never persist
the integration fixture's generated private CA, keys or real household data.
```sh
export ANDROID_HOME=/home/neste/yuvomi-tools/android-toolchain/android-sdk
export YUVOMI_BUILD_DIR=/home/neste/yuvomi-build
"$ANDROID_HOME/platform-tools/adb" devices -l
"$ANDROID_HOME/platform-tools/adb" shell am get-started-user-state 0
# Require RUNNING_UNLOCKED, then:
python3 scripts/test-integration.py /home/neste/yuvomi-tools/upstream --android
bash android/gradlew -p android testDebugUnitTest lintDebug lintRelease assembleDebug assembleRelease bundleRelease
python3 scripts/collect-android-artifacts.py
```
The API 35 AVD is `yuvomi-test`. Its stable attempt uses KVM, `-gpu host`,
`-no-window -no-audio -no-boot-anim -no-snapshot -memory 2560 -cores 2`.
Launch under the SDK/AVD owner with access to the `kvm` group. Other GPU modes
previously crashed the host emulator with SIGSEGV. API 37 had broken services and
locked credential-encrypted storage; no API 37 runtime success is claimed.
For portable Swift checks add the compiler directory to `PATH` and set
`LD_LIBRARY_PATH` to the compatibility-library directory above, then use the
commands in [iOS development](build-ios.md). Apple application/XCTest/UI/archive
validation must happen on macOS with Xcode and real supported devices.
## Highest-priority external work
1. Extend the now-successful Apple SDK/simulator/archive validation beyond the
basic onboarding UI test to authenticated native API flows, Keychain runtime,
visual comparisons and physical devices. These did not run on Linux.
2. Run the API 37 permission grant/denial/revocation scenarios on a working image
or device, plus the minimum-supported Android and current WebView matrix.
3. Review iOS unknown-length download disk-pressure handling before release.
4. Validate real supported IdPs, TOTP after SSO, linking, cancellation, native
file/camera pickers, process death, accessibility and offline account isolation.
5. Establish private vulnerability reporting, owned identifiers, authorized CI,
human security review and owner-controlled signing before any distribution.
Gitea workflows in `.gitea/workflows/` target the maintainer's `ubuntu-latest`
pool and `macos-arm64` M4 Pro runner. Read [runner setup](ci-runners.md) before
changing their labels or signing policy. Hosted CI and store readiness still
require actual successful runs; runner registration alone is not test evidence.
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

+41
View File
@@ -0,0 +1,41 @@
# Historical architecture — superseded WebView prototype
Two independent native applications share a policy, not a cross-platform runtime:
| Responsibility | Android | iOS |
| --- | --- | --- |
| Native entry/configuration | ComponentActivity and platform widgets | SwiftUI |
| Web application | Android WebView | WKWebView |
| Server URL policy | ServerAddress | ServerAddress |
| Reachability/metadata | HttpsURLConnection, no redirects/cookies | Ephemeral URLSession, no redirects/cookies |
| Authentication | Upstream web UI and OS cookie jar | Upstream web UI and WKWebsiteDataStore |
| OIDC | Separate restricted web view after consent | Separate restricted web view after consent |
| Downloads | Origin-bound streaming / bounded blob capture and pull | WKDownload to temporary protected file, system export |
The client never reimplements a Yuvomi module or introduces a backend. The user
selects the origin. Bootstrap calls check the actual upstream unauthenticated
metadata contract before loading the server application. Configuration stores only
the origin and a query/fragment-free last path; auth/reset/invite routes are not
restored. No native password or API-token storage exists.
The current web view remains alive across background/resume and ordinary rotation.
Android handles orientation changes in the existing activity; process recreation
loads the remembered path and retains OS cookies, not unsaved web form memory.
iOS retains a StateObject-owned view; cold launch restores path and persistent
cookies. No resume reload is imposed. Native recovery offers Retry, Keep viewing,
and server management without hiding the route back to configuration.
Server erasure destroys the browsing context and clears all website storage before
another server is loaded. The local wipe does not invalidate the remote session;
logout is still a server-side operation. Web application history remains native
web history. External schemes, universal links and app links are not registered
because arbitrary self-hosted domains cannot be verified by this app in advance.
The optional integration build has a different Android package ID and adds trust
for a fresh test CA generated outside the repository. It uses the same production
code and the unmodified pinned upstream server. Neither normal debug nor release
resources include that CA. Never distribute an integration APK.
Read-only offline functionality belongs to upstream's service worker. The app
preserves it where the platform engine supports it, but does not promise offline
editing, synchronization, or the same PWA cache behavior on all OS versions.
+70
View File
@@ -0,0 +1,70 @@
# Historical server compatibility — superseded WebView prototype
Source reference: upstream 2.78.0 revision
`10bfef7686ed7303b4c74c7fcc55a5eb16356c9d`. No general version range has been
certified. Anonymous bootstrap metadata deliberately omits the exact version;
native probing verifies shape, not an invented minimum-version comparison.
Use `https://family.example.com` or `https://family.example.com:8443`, backed by a
root-hosted Yuvomi installation and a valid system-trusted certificate. Operators
should set `SESSION_SECURE=true`, correct `BASE_URL`, and correct proxy trust.
Private servers are supported through reachable HTTPS/VPN, not TLS bypasses.
International hostnames should be entered in ASCII/punycode for both platforms.
Root-relative assets, API endpoints, cookie paths, callback redirects and worker
registration mean `/yuvomi/` subpaths are not supported by the inspected source.
Use a dedicated hostname. Reverse proxies must pass `/api/v1/*`, cookies, HTML,
JavaScript and the service worker consistently. An external authentication proxy
that intercepts bootstrap calls is rejected with a connection error; it is not
silently identified as a Yuvomi server.
## Authentication
Password login, TOTP/recovery codes, password reset, invitations, server-side
session expiry/revocation and logout remain upstream web flows. No native code
stores the password, supplies a CSRF bypass, or extends an expired session.
Backgrounding does not clear cookies. Native server erasure does not revoke a
remote session; use Yuvomi logout first or revoke sessions server-side.
### OIDC: local fixture verified; real-provider validation pending
The initiating Yuvomi session contains OIDC state, nonce and PKCE verifier. When
the server redirects to an identity provider, native consent offers an isolated
web sign-in window sharing the OS website cookie store. HTTPS-only redirects
can return to the server callback; the final same-origin page returns to the main
view. No app-capability handlers or JS bridge are installed in that auth window.
Some providers (including configurations that prohibit embedded user agents)
require a system browser. **Those cannot sign the native view in with upstream's
current protocol.** Use Server settings -> Open server in browser, and sign in
there for an independent browser session. Opening only the external authorization
link in a browser does not transfer the initiating cookie. Browser cookies are
never copied into the app, and custom-scheme callbacks are not fabricated.
The API 35 integration suite completes a native isolated-window round trip with
a local test IdP, real RS256 ID tokens, nonce and S256 PKCE. No external
IdP/developer account was supplied. Validate the actual provider, including
cross-site cookies, cancellation, errors, TOTP and account linking, before relying
on that deployment. iOS authentication has not run against Apple SDKs. A native
browser handoff would require an independently reviewed upstream protocol; no
such upstream change is included or published.
Android generated exports require a current WebView supporting document-start
scripts and are capped at 25 MB. A bounded Blob registry preserves a generated
file even when upstream immediately revokes its object URL; it does not fetch
blob URLs or alter the server's CSP. HTTPS file downloads are capped at 100 MB.
Update the system WebView if generated exports are unavailable on an old engine.
## Notifications and offline
Upstream implements Web Push and optional ntfy/Gotify/webhook/email channels.
Embedded web views do not supply a portable native push subscription. This app
has **no native remote-push claim**, no Firebase/APNs secrets, no background
polling service, and no fake notification-registration UI. Configure an existing
optional upstream ntfy/Gotify channel and its client if desired. No centralized
service is required by this application.
Upstream service-worker caching is read-only, allow-listed, and versioned.
Mutations/authentication remain online. Platform-specific cache/resume behavior
and offline account switching still need device validation. Do not rely on offline
editing, synchronization, or closed-app push in this native development build.
+47
View File
@@ -0,0 +1,47 @@
# Historical security review — superseded WebView prototype
This is an engineering review, not an independent penetration test or certification.
Addressed during implementation:
- Canonical, HTTPS-only origin/port comparison; credential, fragment, query,
subpath, scheme, lookalike-host and TLS-downgrade regression cases.
- A main-frame POST can bypass Android's usual navigation callback. The request
interception gate now rejects **all** cross-origin requests, not only subresources.
The emulator regression was observed failing when the main-frame guard was
deliberately removed, then rerun with the fix restored.
- TLS failures cancel by default. Native download redirects are refused before
cookies can be replayed to another origin. Bootstrap requests use neither
cookies nor automatic redirects and have bounded metadata reads/timeouts.
- Files use explicit system selection/content URIs; no filesystem or storage
permission is granted to web content. Blob exports have no native JS bridge.
- Complete Android browsing-data deletion uses WebStorageCompat, with OS app
reset for engines without that capability. WebStorage.deleteAllData alone was
insufficient for service-worker CacheStorage/IndexedDB and is not relied upon.
- iOS server erase replaces the WKWebView, not only its URL, eliminating old
history/back-forward contexts as well as persistent website data.
- iOS metadata uses typed decoding, rejecting numeric values masquerading as booleans.
- Native picker results are bound to the browsing generation/request. Android
rejects stale upload/download selections, revokes camera URI grants, and clears
capture cache on erasure. iOS ignores export-cancellation callbacks from an old
download. Apple-specific callback regressions have not run without Xcode.
- KGP was upgraded after OSV identified CVE-2026-53914 in the original build tool.
The final OSV scan found no listed advisories across 213 locked/direct Maven
coordinates and build plugins. Rescan after resolution/lock updates.
- The inspected release APK is not debuggable, rejects cleartext, disables backup
and trusts system CAs only. AndroidX's exported profile installer receiver is
protected by the system DUMP permission; app providers are not exported.
Release gates still requiring evidence:
- Actual Xcode typecheck, simulator, archive, physical-device and accessibility review.
- End-to-end IdP flows, especially providers that prohibit embedded agents; no
browser-to-native session transfer is supported by the current upstream protocol.
- iOS unknown-length download disk-pressure behavior; a post-download size check
is not a streaming quota. Verify file picker/camera cancellation on real devices.
- Android file-picker/camera/rotation while a picker is outstanding; cancellation
on process death intentionally loses the selection, never silently retries it.
- Offline sign-out/account switching, stale-worker updates and server erasure on
current WebView/WKWebView versions. Upstream tests are not native device proof.
- Reverify final signed release manifests, signing, store ownership, private vulnerability contact
and applicable privacy disclosures. No claim that all vulnerabilities are absent.
+146
View File
@@ -0,0 +1,146 @@
# Historical validation — superseded WebView prototype
These results describe the old wrapper, not the current native implementation.
See [current native validation](validation.md).
**Partially verified development software, not production-ready.**
Local validation date: 2026-10-21. No unexecuted iOS application test, hosted CI
run, production signing, or store publication is represented as completed.
## Executed evidence
- Android debug APK and unsigned release APK/AAB built successfully; both
`lintDebug` and `lintRelease` passed. JVM security/picker-policy tests: **7 passed,
zero failures/skips**.
- Android API 35 regular emulator suite: **6 passed, zero failures/skips**.
This covers onboarding, invalid URLs, background/resume, hardened settings,
cross-origin POST/subresource isolation, opaque-page Blob rejection and activity
recreation. Its permission test checks that the API 37 permission UI is absent;
it is **not** evidence of API 37 permission grant/denial behavior.
- Unmodified pinned upstream ran behind a temporary HTTPS reverse proxy.
Bootstrap metadata, password login, Secure/HttpOnly/SameSite cookies, persistent
session, CSRF rejection, logout/revocation passed in the harness.
- Android's extended real-server scenario exercises login, HttpOnly cookies,
document upload via upstream fetch, native FileTransfer output, bounded Blob
export, background/resume, recreation, CSRF-protected logout, revocation,
recovery, isolated OIDC return and back navigation. It does not automate the OS
camera/document destination picker or prove every upstream module works.
Two consecutive API 35 runs have **7 passed, zero failures/skips each**, including the
six regular security/onboarding cases and this real-server scenario.
- The cross-origin POST regression was deliberately observed failing without its
guard. The production guard was restored and the API 35 regression passes.
- Portable Swift production-policy tests: 7 passed (URL and typed metadata).
All Swift sources passed format lint and parsing with Swift 6.2.1 on Linux.
The portable package excludes BrowserModelTests. This does not build iOS.
- Selected upstream suites ran successfully: cookie-secure-flag (4),
session-sliding (8), two-factor (22), password-reset (24), sw-api-cache (42).
Upstream's plain OIDC script also exited successfully. These are upstream tests,
not evidence of a provider round-trip or native push support.
- The HTTPS harness independently passed upstream OIDC discovery, RS256 ID-token
signature/nonce validation, S256 PKCE exchange and session creation with a local
test IdP. The fixture checks single-use codes, exact redirect URIs and OAuth
Basic client authentication. Loopback hosts/ports and automatic fixture consent
are not representative of every provider's cross-site/embedded-agent policies.
- OSV: no listed advisories for **213** locked/direct Maven coordinates and build
plugins at scan time. This is not a guarantee that no vulnerabilities exist.
- ktlint 1.7.1, actionlint 1.7.7, repository invariants, Python compilation,
Node syntax and shell syntax checks passed. JUnit-verifier regression suite:
**8 passed**, including rejection of absent, empty, malformed, insufficient,
failed, errored and skipped evidence.
- Release APK inspection confirms API 26 minimum / API 37 target, no debuggable
flag, backups and cleartext disabled, system-only certificate trust. Exported
entry points are the launcher and AndroidX ProfileInstallReceiver protected by
the system DUMP permission; file/startup providers are not exported. Debug APK
signature verifies; release APK/AAB are unsigned. Collected archives contain no
fixture CA/key or signing-key files.
## Emulator limitations and retry evidence
Earlier extended-suite attempts crashed the host emulator with SIGSEGV under
software/off GPU modes. Host-GPU mode remained running and produced actionable
JUnit failures: an OIDC-return synchronization timeout, followed by an outage
assertion that incorrectly assumed the upstream worker could not serve its cached
app shell, then a document-readiness race after recreation. Tests now verify the
loaded JavaScript origin, wait for the sign-in window to close, retry only a probe
lost to document replacement and exercise recovery through uncached metadata.
The completed retry passes all seven tests without disabling the worker or
weakening production TLS/CSP/origin policies.
API 37 attempts encountered broken package/activity services and locked
credential-encrypted storage. **No API 37 runtime pass is claimed.**
Local logs and reports live in `/home/neste/yuvomi-build/`; test XML is under
`app/outputs/androidTest-results/connected/`. Earlier failing JUnit snapshots are
preserved as `integration-host-gpu-oidc-race.xml` and
`integration-host-gpu-sw-cache.xml`, plus
`integration-host-gpu-document-ready.xml`. Passing evidence is preserved in
`integration-api35-first-pass.xml` and `integration-api35-repeat-pass.xml`; logs are
`upstream-integration-final-retry.log` and `upstream-integration-repeat.log`.
These files are local evidence, not published
artifacts or a hosted CI result.
Test environment: JDK 17.0.20.1, Node 22.22.1, Python 3.14.4, Android Emulator
37.2.12, API 35 x86_64 Google APIs image, WebView 124.0.6367.219. The old bundled
WebView is useful compatibility evidence, not a recommendation to use an unpatched
engine; a current engine/device run remains mandatory.
## Artifacts
Ignored `artifacts/android/` contains the development-signed debug APK and unsigned
release APK/AAB, `SHA256SUMS`, `build-info.json` and manifest/policy/signature
inspection evidence. No integration APK is collected. No IPA or Apple archive
exists. See [Android build](build-android.md) for repeatable collection commands.
## Commands
```sh
bash android/gradlew -p android testDebugUnitTest lintDebug lintRelease assembleDebug assembleRelease bundleRelease connectedDebugAndroidTest
python3 scripts/test-integration.py /path/to/pinned-upstream --android
swift test --package-path ios
swiftc -frontend -parse ios/Sources/*.swift ios/Tests/*.swift ios/UITests/*.swift
python3 scripts/check-repository.py
python3 scripts/check-dependencies.py
python3 scripts/test-verifier.py
python3 scripts/collect-android-artifacts.py
git diff --check
```
This environment is Linux/WSL. Android SDK and a KVM emulator were installed for
real execution. Build output relocation was needed because the Windows mount
rejects some Gradle POSIX metadata operations. Swift's Linux toolchain was
installed for portable tests. No macOS, Xcode, Apple simulator or physical device
is available; no iOS build/archive/UI result is claimed.
## Mandatory remaining evidence
- Run Xcode build, XCTest/UI tests and unsigned archive on macOS; fix any compiler
or runtime issues. The app has **not** yet been typechecked against Apple SDKs.
- Validate real IdPs: cancellation, account linking, TOTP after OIDC, and embedded
provider policy. Browser-only providers have no native-session handoff upstream.
- Real-device camera/document picker cancellation, file MIME cases, rotation with
an outstanding picker, network loss/reconnect, process kill/memory pressure,
session expiry/revocation, OS backup, and offline account isolation.
- Android 17 private-network permission grant/denial/revocation on a matching OS.
Also run minimum-supported API 26 and a current OS/WebView device matrix.
- VoiceOver/TalkBack, large text, contrast, keyboard/safe areas, reduced motion,
light/dark appearance and responsive web modules on actual supported devices.
- Successful CI runs on the configured Gitea runners, private security
reporting contact, human security review, store ownership and production signing.
No Firebase/APNs registration, closed-app push, offline editing, App Store approval,
signing, publication or physical-device validation is claimed. See `SECURITY.md`
and `docs/security-review.md` for residual limitations.
Resume work using [maintainer continuation](continuation.md).
## Gitea runner adaptation
Workflows now live in `.gitea/workflows/` and target the supplied `ubuntu-latest`
pool and native `macos-arm64` M4 Pro runner. Local workflow grammar lint (Gitea URL
syntax normalized), repository invariants, shell/Python syntax, ten mocked runner
preflight regressions and eight evidence-verifier regressions pass. SHA-256 Git
initialization/fetch was exercised locally; the pinned XcodeGen archive's checksum
and native arm64 slice were verified. These are not remote runner execution or
Apple SDK results. See [runner requirements](ci-runners.md); the local emulator
preflight currently rejects unavailable/inaccessible KVM as designed. Original
application build evidence above remains historical and is not a rebuild of the
workflow-only changes.
+56
View File
@@ -0,0 +1,56 @@
# Android native login: 0.2.1 preview fix
## Reproduced compatibility defect
The Android 0.2.0 native preview could show “Anmeldung oder Sitzung ist nicht mehr
gültig” even when the identical direct username/password login worked in the PWA.
Upstream defaults to `SESSION_SECURE=false`; an HTTPS reverse proxy does not by
itself change this cookie attribute. The old native jar rejected the session
cookie without `Secure`, so the successful login was followed by an unauthenticated
API request. This was an app compatibility defect, not evidence of a bad password.
Android 0.2.1 native preview (version code 3) keeps the cookie in its existing
HTTPS-only, origin/port-bound jar. It still never makes HTTP requests, follows
redirects or shares cookies with another server. System TLS validation,
AndroidKeyStore encryption and absolute expiry remain unchanged. The native client
does not depend on weakening the server or changing its configuration. For browser
security, server operators should still use `SESSION_SECURE=true` on HTTPS sites.
[Download the fixed 0.2.1 preview APK ZIP](https://git.nesterovic.cc/nessi/Yuvomi-App/actions/runs/666/artifacts/android-native-debug-apk)
(verified Gitea build job 1071; repository access required, 14-day retention).
Extract `app-debug.apk` and install it. The downloaded artifact's version and
signature were checked and installation/launch tested on the authorized emulator.
## Error distinctions
- Rejected username/password: the server rejected `/auth/login` with HTTP 401.
- Invalid/expired second-factor step: the verification endpoint rejected the code
or its pending session. Restart login if necessary.
- Login confirmed but no usable cookie: inspect server/reverse-proxy cookie
configuration; no password-reset claim is made.
- Login confirmed but session rejected: inspect server/proxy session handling.
The app never displays raw cookie values, credentials or arbitrary server response
bodies as diagnostics. A failed attempt's message is cleared after successful retry.
If the updated app still fails, report the exact new message, app/server version,
whether the direct browser password login works and whether 2FA is enabled.
**Do not send passwords, session cookies, recovery codes or authentication tokens.**
## Reproduction and regression evidence
```sh
python3 scripts/test-integration.py /path/to/pinned-upstream --android --cookie-without-secure
python3 scripts/test-integration.py /path/to/pinned-upstream --android
```
The first mode fails with the old native jar despite successful protocol-level
browser login. After the fix, it passes native login, a wrong-password/correct-retry
sequence, dashboard/recreation, task/note/shopping operations and logout. The normal
Secure-cookie mode remains required too. These use synthetic temporary records and
the unmodified pinned upstream, not the user's server or credentials.
Development APKs from different CI jobs can have different debug signing keys.
If Android rejects an update as signature-incompatible, uninstall the old preview
before installing the new one. This clears local app/session data and requires a
fresh login; server household records are not deleted. Production signing remains
paused and full native PWA parity is not implied by this fix.
+90
View File
@@ -0,0 +1,90 @@
# YUVOMI MOBILE – FINAL DELIVERY REPORT
**Delivery status: incomplete native preview, not final product acceptance.**
This report closes the initial native rebuild slice, not the requested complete
1:1 replacement. No module has accepted functional and visual parity.
**Follow-up:** Android 0.2.1 native preview (code 3) fixes a reproduced session-cookie
login compatibility defect. See [login troubleshooting](login-troubleshooting.md)
and the newer [validation evidence](validation.md); the original 0.2.0 results
below remain historical evidence, not a replacement for the fixed APK.
## 1. Outcome
The rejected embedded-browser application has been replaced by genuine native
shipping entry points. Missing modules do not fall back to a WebView.
## 2. Upstream reference
Unmodified Yuvomi 2.78.0, commit
`10bfef7686ed7303b4c74c7fcc55a5eb16356c9d`. No upstream change or push.
## 3. Android
Kotlin/Compose onboarding, setup/password/TOTP interfaces, basic dashboard,
tasks/notes/shopping and session management. Version `0.2.0-native-preview`,
code 2. This is a tested initial slice, not full module coverage.
## 4. iOS
SwiftUI equivalents, native API models/transport and Keychain storage implemented.
Sixteen portable tests pass; Apple SDK application/UI/Keychain validation remains
unverified locally. No installable IPA or App Store-ready archive is claimed.
## 5. Design parity
Upstream colors, spacing/radii and brand geometry guide native controls. One real
synthetic Android dashboard screenshot was inspected. No side-by-side full PWA
visual acceptance, complete adaptive-layout or accessibility approval yet.
## 6. API and architecture
Direct same-origin HTTPS/JSON, native controls and state; server-owned business
logic and authorization. Superseded browser code/tests are preserved in `legacy/`
but excluded from shipping builds and native test counts.
## 7. Security
System TLS, redirect refusal, bounded reads, upstream CSRF and protected native
cookies. AndroidKeyStore/AES-GCM and iOS Keychain/private URLSession storage; no
stored passwords, test CA in ordinary builds or hidden browser session import.
Human security review and additional device/role/error-path coverage remain open.
## 8. Executed validation
10 JVM tests, 6 native API-35 UI tests, the 7-test real-server suite (including
those same 6), both Android lint variants and package builds pass. Swift has 16
portable passes plus strict formatting/parsing. 27 script regressions, repository
checks, workflow lint and the 266-coordinate OSV scan pass. See
[validation](validation.md) for evidence, failures corrected and exact limitations.
## 9. Artifacts
The local collector replaces old wrapper outputs in ignored `artifacts/android/`
with native debug APK and unsigned release APK/AAB, checksums and manifest/network/
signature inspection. `build-info.json` records exact source identity and dirty
state. Only the development-signed debug APK is installable as provided.
## 10. Gitea CI
Android build job 1059 succeeded and uploaded `android-native-debug-apk`. The actual
artifact was downloaded, signature/version checked, installed and launched on the
authorized local emulator. [Download the verified artifact](https://git.nesterovic.cc/nessi/Yuvomi-App/actions/runs/660/artifacts/android-native-debug-apk).
Mac Node bootstrap, checkout and Swift lookup now succeed, but
`xcrun simctl list devices available` times out after 60 seconds in follow-up job
1066. The runner's CoreSimulator availability needs administrator repair. The remote emulator matrix
still fails missing-KVM preflight, not native tests. No green whole-pipeline or
Apple application validation is claimed. See [validation](validation.md).
## 11. Signing and publication
Initial owner-authorized signing material/secrets are preserved privately; signing
workflow changes remain parked in the named stash. No production-signed native
release, store submission or claim of completed product delivery.
## 12. Remaining work
Native SSO/recovery/invitations, advanced first-slice controls, the remaining
modules/settings, files, offline, notifications, Apple/device validation and 1:1
visual/behavior acceptance remain substantial work. The complete page inventory
and acceptance criteria are in [native parity](native-parity.md).
+93
View File
@@ -0,0 +1,93 @@
# Native PWA design comparison
The target is the actual unmodified Yuvomi 2.78.0 PWA, revision
`10bfef7686ed7303b4c74c7fcc55a5eb16356c9d`, not a new Material reinterpretation.
Android 0.2.2/code 4 is a design pass on the existing native slice. **No page has
accepted 1:1 visual/functional parity.** iOS has not received this Android pass.
## Implementation
`PwaDesign.kt` maps measured palette/type roles, field shapes and module seals.
`PwaIcons.kt` draws upstream module geometry on the native canvas.
`PwaShell.kt` provides the capsule/FAB and native sheets; API 31+ uses an in-memory
recorded native drawing layer for the blurred backdrop, with opaque older-API
fallback. No HTML, JavaScript or browser surface is shipped.
`NativePages.kt` / `NativeForms.kt` compose the supported native screens.
Personal preferences and shopping lists are loaded from the real API. Widget
visibility writes retain other stored widget entries/options; they are personal,
not household default edits. Entries hidden/denied to this native UI are retained;
new supported selections can be added. Saving is disabled if preference loading
did not provide a known configuration, rather than overwriting unknown data.
## Reproducible comparison
The fixture has synthetic account/data only: one task, pinned note, list/item,
four widgets (`tasks`, `calendar`, `shopping`, `notes`) and three supported mobile
favorites (`tasks`, `kitchen`, `notes`). This deliberate **supported-slice** setup
is not the PWA's complete/default dashboard or a full navigation comparison.
Native integration exports `login`, `dashboard`, `tasks`, `notes`, `shopping`, each
in light/dark, plus the compatible `native-dashboard.png` and `viewport.json`.
Capture clears `FLAG_SECURE` only in that temporary-fixture test and restores it in
`finally`. It waits for IME/window settling and crops `native-screen`, excluding
system bars. Ordinary builds remain screenshot-protected.
The collector requires the whole allow-listed gallery from exactly one device,
rejects missing/invalid/ambiguous output and removes only known stale evidence.
Observed API-35 content viewport: **720 × 1184 px, density 2, fontScale 1**, hence
360 × 592 CSS pixels for the browser reference. Root bounds are
`[0,48,720,1232]` on the 720 × 1280 emulator. Do not compare a full native root
image against a 360 × 640 PWA content viewport.
```sh
ANDROID_SERIAL=emulator-5554 python3 scripts/test-integration.py /path/to/pinned-upstream --android --cookie-without-secure
PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64 \
PLAYWRIGHT_BROWSERS_PATH=/path/to/external/browsers \
FONTCONFIG_FILE=/path/to/isolated/android-roboto/fonts.conf \
python3 scripts/capture-pwa-reference.py /path/to/pinned-upstream \
--playwright-module /path/to/external/node_modules/playwright \
--width 360 --height 592 --output artifacts/visual/pwa-360x592
node scripts/compare-native-pwa.mjs /path/to/external/node_modules/playwright \
/path/to/build/app/outputs/native-screenshots artifacts/visual/pwa-360x592 \
artifacts/visual/comparisons
```
Playwright/Chromium and the emulator's variable Roboto are external tooling, not
application dependencies. The reference recorder captures real browser PNGs and
computed styles, without editing upstream files. It uses a temporary loopback-only
HTTPS fixture, blocks external browser requests and does not export credentials,
cookies, generated certificates or keys. Dates/greetings and initial account colors
can vary; compare those separately from geometry. Native integration keeps OIDC
enabled for the redirect-refusal regression, whereas the password-login reference
disables it; their extra SSO controls are not a valid exact login comparison.
The comparison compositor refuses mismatched viewport/image sizes or font scale,
places the actual captures side by side and records their SHA-256 hashes. It does
not replace either screenshot, normalize away UI differences or assert acceptance.
## Explicit remaining differences
- Full calendar navigation and its widget action are absent, not fake buttons.
- Native SSO/recovery/invites and most modules/settings remain unimplemented.
- Advanced task categories/assignment/statuses, note rendering/categories and
shopping quick-add/details do not yet match all PWA interactions.
- Card internals, utility icons, shadows/glass and form geometry still require
visual refinement and broader device/font-size/accessibility testing.
- The three supported favorites stand in for unavailable native destinations;
this is not full preference/navigation parity.
- The unchanged iOS slice now has a verified Apple simulator build/test/archive
run, but no new design comparison or native real-server parity acceptance;
see [validation](validation.md).
Screenshot existence and passing CRUD tests are evidence, **not visual acceptance**.
See [native parity](native-parity.md) for the complete scope.
## Reviewed examples from this pass
Left is the real PWA, right the actual native capture. Remaining differences are
deliberately visible, including the absent calendar action, widget spacing,
utility icon/control differences and upstream's dashboard-customization hint.
- [Dashboard, light](images/dashboard-light-comparison.png)
- [Dashboard, dark](images/dashboard-dark-comparison.png)
- [Tasks, light](images/tasks-light-comparison.png)
- [Notes, dark](images/notes-dark-comparison.png)
+95
View File
@@ -0,0 +1,95 @@
# Native parity tracker
## Acceptance contract
**Native means native screens, forms, lists, navigation and interaction.** Android
uses Kotlin/Compose; iOS uses SwiftUI. PWA HTML/JavaScript is not embedded as the
application surface. The PWA is the design/behavior reference, not the renderer.
Reference: unmodified upstream 2.78.0,
`10bfef7686ed7303b4c74c7fcc55a5eb16356c9d`. The scope includes its thirty page
modules, settings leaves, permissions, dashboard customization and cross-module
workflows. Third-party web extensions need an explicit native integration design;
arbitrary server-installed HTML cannot silently become a native module.
Use these states honestly:
- **Slice**: real native UI/API interaction exists, with the listed limitations.
- **Pending**: not implemented. No fake screen, raw-JSON editor or WebView fallback.
- **Parity accepted**: requires functional and visual evidence on both platforms.
**No module currently has this status.**
Android 0.2.2 has a PWA-derived design pass on the supported slice, with real
reference/native light/dark captures. See [design comparison](native-design.md).
This does not change the acceptance status or certify the unchanged iOS UI.
## Page inventory
| Upstream page | Native status | Work still required before parity |
| --- | --- | --- |
| login | Slice: password + TOTP/recovery step | OIDC/provider policy, recovery/error UX, localization, visual comparison |
| setup | Slice: initial household creation | Full onboarding and configuration workflow |
| forgot-password | Pending | Native recovery request and server-capability UX |
| reset-password | Pending | Native reset token/confirmation flow |
| join | Pending | Invitations, account setup, validation |
| dashboard | Slice: task/calendar/shopping/note previews | All remaining widgets, user layout/options, timezone-aware presentation, quick add, wall mode |
| tasks | Slice: list/search/status filter/create/edit/complete/delete | Category/assignee/tag/visibility controls, recurrence, subtasks, attachments, comments, archive/history, lock UX, bulk/sync controls |
| shopping | Slice: lists/items/create/edit/check/delete | List rename/duplicate, categories, ordering, suggestions, live versions, prices/stores, imports/send/undo |
| notes | Slice: list/search/create/edit/pin/delete | Markdown/checklists, categories, note colors, ordering and concurrent edit UX |
| calendar | Dashboard preview only; module pending | Agenda/week/month, editing, recurrence/scopes, source calendars, timezone behavior, attachments/sync |
| schedule | Pending | Shift grid, roster, creation and settings |
| birthdays | Pending | Date/age privacy, contacts, reminders/import |
| contacts | Pending | Address book, editing, links/import/export |
| documents | Pending | Browse/search, metadata, native upload/camera/export and lifecycle tests |
| inventory | Pending | Items, locations, guarantees, attachments and reminders |
| meals | Pending | Meal plan, ingredients, household options and shopping transfer |
| recipes | Pending | Search/detail/editor, source import, images, pantry/shopping cross-links |
| pantry | Pending | Stock, batches/expiry, locations, quantity controls and shopping transfer |
| budget | Pending | Accounts, entries, categories, currency/amount handling and attachments |
| budget-plans | Pending | Plan editor, period/category matching and forecasts |
| budget-stats | Pending | Native charts and filters matching upstream calculations |
| subscriptions | Pending | Lifecycle, billing, recurrence and linked budget behavior |
| split-expenses | Pending | Groups, guests, balances, settlement and permission distinctions |
| housekeeping | Pending | Visits, tasks, time and role-gated payment data |
| waste | Pending | Schedules, pickup display/import and reminders |
| rewards | Pending | Catalog, ledger, goals, approvals and pocket-money precision |
| health | Pending | Medication, cycle, nutrition and capability-gated private views |
| health-fasting | Pending | Clock, history, capabilities and lifecycle behavior |
| settings | Slice: native logout/server removal only | Every relevant personal/admin settings leaf and native permissions |
| pair-display | Pending | Pairing protocol, display role and shared-surface privacy |
## Design reference
Native primitives reuse the upstream warm palette (`#F5F3ED` / `#191816`), opaque
surfaces (`#FFFFFF` / `#2B2825`), violet accent, 16-point cards, 26-point auth panel,
pill navigation/buttons and the exact three-circle brand geometry. These are
initial design-token mappings, **not proof of 1:1 visuals**. Material/SF symbols,
typography, sheets, adaptive layouts and module details still need comparison.
Every accepted module needs side-by-side screenshots from matching server data,
light/dark and small/large text coverage, keyboard/safe-area/back behavior,
read-only/denied permission cases and real API success/error tests. Android's
integration test exports a synthetic-fixture native dashboard screenshot; it is
not a production screenshot or a visual-parity approval.
## Implementation order
1. Stabilize the current native authentication/dashboard/tasks/notes/shopping
slice, including Apple SDK compilation and actual native tests.
2. Close auth/recovery/SSO and first-slice feature gaps, then accept those screens
against PWA visual/behavior baselines rather than adding empty menu entries.
3. Implement calendar/family/contacts, kitchen/pantry, documents/inventory, then
finance, household, rewards and health as separately tested native slices.
4. Complete settings, extensions/cross-module flows, accessibility, offline and
notifications. Audit every upstream route/settings leaf before claiming parity.
## Signing work retained, not released
The owner-authorized initial signing key and four Gitea repository secrets were
created before the native correction. The private backup remains outside Git at
`/home/neste/yuvomi-signing/initial-20261021`. Never print/commit its contents.
The uncommitted signing-workflow changes were preserved in Git stash
`wip: owner-authorized signing setup paused for true native rewrite`; do not apply
it blindly to the new workflows. No signing-secret values belong in screenshots,
test fixtures, documentation, commits or artifacts. Native signing/release work
remains paused until this preview's scope is explicitly accepted.
+72
View File
@@ -0,0 +1,72 @@
# Release process
No release is authorized by the existence of these scripts. No workflow publishes
to either store. A debug APK or unsigned archive is not a production release.
1. Complete every outstanding gate in `validation.md` and `security-review.md`.
Obtain independent human review of authentication, navigation, file handlers,
local erasure and privacy disclosures. Run actual supported device/OS tests.
2. Resolve bundle/application identifier ownership. `community.yuvomi.mobile` is
a provisional community identifier, not proof of registry/store ownership.
Confirm use of the Yuvomi name, required attribution and independent status.
3. Bump version/name/code in Android and the iOS project spec; update CHANGELOG.
4. Run native JVM/Compose/emulator/real-upstream HTTPS tests, portable Swift and Xcode simulator
tests, static checks, OSV scan, release builds and artifact inspection. Record
upstream revision, app SHA, OS/SDK/Xcode versions, results and checksums.
5. Produce signed artifacts only using an authorized maintainer's credentials.
6. Review store privacy/Data Safety answers for the app AND selected-server model,
permission rationale, encryption/export compliance, screenshots, support URL,
vulnerability contact, and device demonstrations. An incomplete native preview
is not a finished PWA replacement or App Store approval.
7. Create a release/tag and publish artifacts/store submissions manually only
after explicit maintainer authorization. Never force-push or rewrite history.
## Android
The manual `.gitea/workflows/release.yml` workflow runs on `ubuntu-latest` and
emits **unsigned** artifacts only. Gitea ignores GitHub environment approval gates,
so this workflow does not load signing secrets or offer a signing toggle.
An authorized maintainer must separately sign the verified APK/AAB using their
own keystore, alias and passwords with `zipalign`, `apksigner`, and `jarsigner`.
Keep private material in a protected signing environment, never on the general
validation runners. Securely back up the real upload key and use Play App Signing
according to the owner's policy. An initial owner-authorized key and repository
secrets already exist outside Git; signing workflow changes are parked during
the native rebuild. Preserve the key, do not generate a replacement or apply the
old signing stash without review. See `native-parity.md`.
Manually verify APK/AAB signatures and inspect
the final manifest: no debuggable flag, HTTP/test CA or unintended exported component.
Never distribute an `.integration` package or include its generated test resources.
Run `python3 scripts/collect-android-artifacts.py` immediately after the clean-source
build to inspect unsigned outputs and record local hashes/source identity. This
collector intentionally rejects signed release outputs; collect before signing.
## iOS
The CI archive is unsigned and cannot be uploaded to App Store Connect. An owner
needs Apple Developer Program membership, a registered app ID, distribution
certificate/private key, provisioning profile and App Store Connect permissions.
Generate/open the project, choose the authorized team, set the owned bundle ID,
select a physical/generic iOS destination, and use Product -> Archive. Validate
the archive in Organizer and export using the owner's distribution method. For
CLI export use a **locally supplied** ExportOptions.plist with the correct team,
method and provisioning mappings:
```sh
xcodebuild -exportArchive -archivePath artifacts/Yuvomi.xcarchive \
-exportPath artifacts/ios -exportOptionsPlist /secure/path/ExportOptions.plist
```
Do not add distribution certificates, profiles, App Store keys or passwords to the
repository. Device camera, Files, VoiceOver, memory pressure and app-switcher
privacy need physical-device validation beyond simulator tests.
## CI hosting
Gitea Actions workflows are provided in `.gitea/workflows/`, using the supplied
`ubuntu-latest` and `macos-arm64` runner labels. See [runner setup](ci-runners.md)
for SDK, KVM, Xcode, simulator and security requirements. Repository Actions must
be enabled. Inspect actual runs before claiming CI is operational; no GitHub
mirror or store destination is created. CI is not an application dependency.
+36
View File
@@ -0,0 +1,36 @@
# Native security review record
This is an engineering checklist, not an independent audit or release approval.
The wrapper review is retained separately as
[historical evidence](legacy-wrapper-security-review.md).
## Implemented controls to exercise
- Origin-bound HTTPS API transports, no redirects, no production test-CA bypass.
- Server-owned session and CSRF semantics, no persistent password storage.
- Origin-bound AES-GCM/AndroidKeyStore cookies; iOS Keychain protected
WhenUnlockedThisDeviceOnly with a private ephemeral URLSession jar.
- Bounded JSON reads, deadlines and no offline/ambiguous-write retry queue.
- Native permission-aware controls, destructive-action confirmation and server
validation; partial update payloads preserve fields the native editor cannot edit.
- No WebView/WKWebView in either shipping source tree. Legacy tests do not prove
this client's behavior; explicit native tests and source invariants are required.
## Remaining gates
1. Run Apple SDK typechecking, simulator/XCTest/UI and device checks; portable
Foundation transport compilation is not SwiftUI/Security.framework validation.
2. Review persistent-cookie expiry/removal, TLS failures, redirects, CSRF refresh,
expired sessions, background/recreation and stale-response isolation on devices.
3. Review migrations from the prototype: browser sessions are never imported and
OS website data may remain until app-data reset/reinstall.
4. Complete missing auth/provider protocols and permission/role scenarios before
claiming native authentication parity, particularly SSO-only deployments.
5. Implement/review files, notifications, offline storage and additional modules
as actual native capabilities; do not restore the wrapper as a shortcut.
6. Independently review the new UI/state/transport code, actual signed artifacts,
accessibility, supported OS matrix and runner trust before distribution.
The owner-authorized signing configuration exists privately but its workflow
changes are intentionally parked. A signed incomplete client is not a completed
native product. Refer to [native parity](native-parity.md) before release work.
+41
View File
@@ -0,0 +1,41 @@
# Native server compatibility
Reference: Yuvomi 2.78.0 at
`10bfef7686ed7303b4c74c7fcc55a5eb16356c9d`. No general server-version range or
complete native feature compatibility is certified.
Enter a root HTTPS hostname, optionally with a custom port. HTTP, embedded
credentials, query/fragment configuration and subpath deployments are rejected.
Use a valid system-trusted TLS certificate and reachable VPN/LAN connectivity.
No certificate bypass or proprietary mobile service is introduced.
The native client probes the real public `/api/v1/version` and
`/api/v1/auth/oidc/config` shapes. Anonymous metadata does not disclose the server
version. An authentication proxy returning redirects/HTML instead of those JSON
contracts is unsupported, not silently treated as a valid Yuvomi API.
## Authentication
The implemented native slice uses setup/password login, the existing two-factor
verification endpoint, `/auth/me`, session cookies, CSRF headers and logout.
Passwords are not stored. Native session persistence is separate from browser
storage and protected by the platform keystore/keychain.
**Native SSO is not implemented yet.** The old wrapper's local-IdP WebView result
does not apply to this client. The existing server protocol does not provide a
generic browser-to-native session exchange for arbitrary self-hosted hosts; a
reviewed solution is required. SSO-only deployments cannot currently use this
native preview. Reset and invitation workflows also remain pending.
Only initial dashboard/tasks/notes/shopping capabilities are implemented; see the
[parity tracker](native-parity.md) for precise gaps. The complete server UI is not
embedded to fill those gaps. Read-only and denied module permissions affect
native controls; the server remains the enforcement point.
## Notifications, files and offline
Native remote push, uploads/camera/download/export and offline caching/editing
are not current native capabilities. The old wrapper's file handlers and the
PWA service worker are not part of the native renderer. No fake registration or
automatic offline mutation replay is supplied. Optional existing server-side
ntfy/Gotify/email integrations remain independently operated upstream features.
+233
View File
@@ -0,0 +1,233 @@
# Native validation
**Incomplete native rebuild; not full PWA parity and not production-ready.**
The old browser results are [historical only](legacy-wrapper-validation.md).
The new shipping surfaces are Android Compose and iOS SwiftUI. Current native
tests are in `androidTestNative` / `androidTestNativeIntegration`, JVM native policy
tests, and Swift production contracts/transport tests. Archived WebView tests are
not included in the native suites or counted toward native completion.
## 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 |
| --- | --- |
| Android JVM tests | 10 passed; zero failed/skipped |
| Native Compose UI/security suite, API 35 emulator | 6 passed; zero failed/skipped |
| Real-upstream native integration suite | 7 passed; zero failed/skipped (the same 6 onboarding tests plus 1 multi-step real-server scenario) |
| Native scenario | Login/dashboard, activity recreation, task/note/list/item creation and API readback, redirect refusal, logout and vault erasure |
| Integration screenshot export | Successful AGP test-output collection; native dashboard PNG opened and visually confirmed nonblank, synthetic data only |
| Android lint | Debug and release passed; newer-version recommendations remain informational |
| Android packages | Debug APK and unsigned release APK/AAB built successfully |
| Swift portable suite | 16 passed; actual Foundation transport/contracts and URL/metadata policy, not Apple UI/Keychain execution |
| Swift formatting/parsing | Strict format lint and parsing passed, including Apple-only sources |
| Script regressions | 14 runner-preflight/Apple-probe, 8 JUnit-evidence and 5 screenshot-output tests passed |
| Repository/workflows | Source invariants, Python compilation, diff whitespace and actionlint for all 3 Gitea workflows passed |
| OSV | No listed advisories for 266 locked/direct Maven coordinates and plugins; not a security audit |
Environment: Linux/WSL, OpenJDK 17.0.20.1, Gradle 9.6.0, AGP 9.4.1,
Kotlin/Compose compiler 2.4.21, API 35 `sdk_gphone64_x86_64` emulator, Swift 6.2.1.
The host-only Tink upgrade to 1.15.0 removes the observed obsolete generated
protobuf warning; the final Android validation log has no such warning.
The first integration attempt missed the scrolled-off login button. Later,
post-uninstall screenshot retrieval failed, and the first compositor capture was
black. The final test scrolls the real button, uses native-window capture with a
nonblank-pixel assertion and writes through `PlatformTestStorageRegistry` for AGP
collection. The latest complete harness exits successfully, not just its JUnit
subprocess. Ordinary builds retain screenshot protection.
The unmodified pinned upstream HTTPS/OIDC protocol fixture also passed TLS trust,
metadata, cookies, CSRF and remote revocation checks. Its IdP exchange is **not
native SSO**: the native client refuses the redirect and SSO remains pending.
Evidence paths in the local external build directory:
- `app/test-results/testDebugUnitTest/`
- `app/outputs/androidTest-results/connected/{debug,integration}/`
- `app/outputs/native-screenshots/native-dashboard.png`
- `app/reports/lint-results-{debug,release}.html`
- Logs: `/home/neste/yuvomi-native-validation.log`,
`/home/neste/yuvomi-native-integration-final.log`,
`/home/neste/yuvomi-native-swift.log`.
## Observed Gitea results for source `40a7962`
- Android run #5 (global run 660), build job 1059: **success**, including packages,
JVM/lint, formatting/repository/script checks, OSV and both artifact uploads.
- The actual `android-native-debug-apk` ZIP was downloaded. It contains exactly
`app-debug.apk`, version `0.2.0-native-preview`/code 2; APK v2 signature verified,
no fixture certificate/private-key files, installed and launched on the local
authorized API-35 emulator. SHA-256 of the downloaded APK:
`48d58ba5663b2e4fe563064efc567732e8ea35588cb610660f60406e82ccf610`.
Its ephemeral CI debug certificate differs from the local development key, so
its checksum intentionally differs from the locally collected APK.
- [Download the verified native debug artifact](https://git.nesterovic.cc/nessi/Yuvomi-App/actions/runs/660/artifacts/android-native-debug-apk)
(repository access required; normal 14-day artifact retention applies).
- Remote jobs 1060/1061 (API 35/37.0): **failed preflight** because `/dev/kvm` was
not exposed/writable. They executed no emulator tests. The matrix is not green.
- iOS run #6 (global 661), job 1062: verified native Node bootstrap and checkout
**succeeded**. Xcode 27.1/build 27A9275 printed, but the subsequent Apple-tool
preflight did not return during a 20-minute observation. No SwiftUI compile or
simulator/XCTest/Keychain/device success is claimed. Bounded, named tool probes
now fail with actionable diagnostics instead of silently hanging for the job's
full hour.
- Follow-up iOS run #8 (global 663), job 1066 at `92dbd0e`: Node, checkout,
`xcodebuild -version` and `xcrun --find swift` passed. The exact blocker is
**`xcrun simctl list devices available`**, which exceeded its 60-second deadline.
Preflight correctly failed; application builds/tests/archive were skipped, not
passed. The runner administrator must repair CoreSimulator availability for the
runner user before Apple application validation can proceed.
At that point no Apple SDK application build or simulator UI result was available
from the Linux runs or stalled Mac job. The later verified run 669 above supersedes
that limitation for compilation/basic simulator tests/archive only. No physical-
device result is claimed. Runner prerequisites were not weakened, and this agent
did not change host license/settings.
## Commands
```sh
bash android/gradlew -p android testDebugUnitTest lintDebug lintRelease assembleDebug assembleRelease bundleRelease connectedDebugAndroidTest
python3 scripts/test-integration.py /path/to/pinned-upstream --android
swift test --package-path ios
swiftc -frontend -parse ios/Sources/*.swift ios/Tests/*.swift ios/UITests/*.swift
python3 scripts/check-repository.py
python3 scripts/check-dependencies.py
python3 scripts/lint-workflows.py /path/to/actionlint
```
On the local WSL checkout use `YUVOMI_BUILD_DIR=/home/neste/yuvomi-native-build`
and the SDK paths from [continuation](continuation.md). Select only the authorized
test emulator with `ANDROID_SERIAL=emulator-5554`; unrelated/unauthorized attached
devices are not test targets.
## External gates
Broad Apple UI/runtime coverage, real supported devices, Android API 37, native
SSO/reset/invite protocols, permission/role cases, files/offline/notifications,
complete module and settings parity, visual baselines, accessibility and human
security review remain open. Missing modules are listed in
[native parity](native-parity.md), not silently represented as working.
+18
View File
@@ -0,0 +1,18 @@
// swift-tools-version: 6.0
import PackageDescription
// Portable tests for the exact production URL policy. This is not an iOS build.
let package = Package(
name: "YuvomiPolicy",
products: [.library(name: "Yuvomi", targets: ["Yuvomi"])],
targets: [
.target(
name: "Yuvomi", path: "Sources",
exclude: [
"NativeStore.swift", "NativeRootView.swift", "NativeSessionVault.swift",
"ServerProbe.swift", "YuvomiApp.swift", "PrivacyInfo.xcprivacy", "Assets.xcassets",
], sources: ["ServerAddress.swift", "ServerMetadata.swift", "NativeContracts.swift", "NativeAPI.swift"]),
.testTarget(
name: "YuvomiPolicyTests", dependencies: ["Yuvomi"], path: "Tests"),
]
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

@@ -0,0 +1,14 @@
{
"images": [
{
"filename": "AppIcon.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
}
],
"info": {
"author": "Yuvomi Mobile contributors",
"version": 1
}
}
+151
View File
@@ -0,0 +1,151 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
private struct NativeSavedCookie: Codable {
let name: String
let value: String
let expires: Date
}
/// Bounded streaming requests. Redirects never replay native session cookies.
private final class NativeTransport: NSObject, URLSessionDataDelegate, @unchecked Sendable {
private struct Pending {
let continuation: CheckedContinuation<(Data, HTTPURLResponse), Error>
var data = Data()
var response: HTTPURLResponse?
}
private let lock = NSLock()
private var requests: [Int: Pending] = [:]
func register(_ task: URLSessionDataTask, continuation: CheckedContinuation<(Data, HTTPURLResponse), Error>) {
lock.lock()
requests[task.taskIdentifier] = Pending(continuation: continuation)
lock.unlock()
}
func urlSession(
_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void
) {
completionHandler(nil)
}
func urlSession(
_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse,
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
) {
guard let http = response as? HTTPURLResponse, response.expectedContentLength <= 4 * 1024 * 1024 else {
completionHandler(.cancel)
return
}
lock.lock()
requests[dataTask.taskIdentifier]?.response = http
lock.unlock()
completionHandler(.allow)
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
lock.lock()
let allowed = (requests[dataTask.taskIdentifier]?.data.count ?? 0) + data.count <= 4 * 1024 * 1024
if allowed { requests[dataTask.taskIdentifier]?.data.append(data) }
lock.unlock()
if !allowed { dataTask.cancel() }
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
lock.lock()
let pending = requests.removeValue(forKey: task.taskIdentifier)
lock.unlock()
guard let pending else { return }
if error != nil {
pending.continuation.resume(throwing: NativeAPIError.unavailable)
} else if let response = pending.response {
pending.continuation.resume(returning: (pending.data, response))
} else {
pending.continuation.resume(throwing: NativeAPIError.invalidResponse)
}
}
}
actor NativeAPI {
let server: ServerAddress
private let transport: NativeTransport
private let storage: HTTPCookieStorage
private let persist: @Sendable (Data) throws -> Void
private let session: URLSession
private var csrf = ""
private var closed = false
init(server: ServerAddress, saved: Data?, persist: @escaping @Sendable (Data) throws -> Void) throws {
self.server = server
self.persist = persist
let transport = NativeTransport()
self.transport = transport
let configuration = URLSessionConfiguration.ephemeral
guard let storage = configuration.httpCookieStorage else { throw NativeAPIError.unavailable }
self.storage = storage
configuration.timeoutIntervalForRequest = 15
configuration.timeoutIntervalForResource = 30
configuration.urlCache = nil
if let saved, let cookies = try? JSONDecoder().decode([NativeSavedCookie].self, from: saved) {
for cookie in cookies where cookie.expires > Date() && ["yuvomi.sid", "csrf-token"].contains(cookie.name) {
if let restored = HTTPCookie(properties: [
.domain: server.url.host ?? "", .path: "/", .secure: "TRUE", .name: cookie.name,
.value: cookie.value, .expires: cookie.expires,
]) {
storage.setCookie(restored)
}
}
}
session = URLSession(configuration: configuration, delegate: transport, delegateQueue: nil)
}
func close() {
closed = true
csrf = ""
session.invalidateAndCancel()
for cookie in storage.cookies ?? [] { storage.deleteCookie(cookie) }
}
func request<Value: Decodable & Sendable>(
_ path: String, method: String = "GET", body: [String: NativeValue]? = nil,
as type: Value.Type, retry: Bool = true
) async throws -> Value {
guard !closed else { throw NativeAPIError.closed }
guard path.hasPrefix("/"), !path.hasPrefix("//"), !path.contains("#"), !path.contains("\\"),
let url = URL(string: server.url.absoluteString + "/api/v1" + path), server.owns(url)
else { throw NativeAPIError.invalidPath }
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Accept")
let mutation = ["POST", "PUT", "PATCH", "DELETE"].contains(method)
if mutation {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(csrf, forHTTPHeaderField: "X-CSRF-Token")
request.httpBody = try JSONEncoder().encode(body ?? [:])
}
let (data, response) = try await withCheckedThrowingContinuation { continuation in
let task = session.dataTask(with: request)
transport.register(task, continuation: continuation)
task.resume()
}
guard !closed else { throw NativeAPIError.closed }
guard !(300...399).contains(response.statusCode) else { throw NativeAPIError.response(response.statusCode) }
let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
if let token = response.value(forHTTPHeaderField: "X-CSRF-Token") ?? root?["csrfToken"] as? String {
csrf = token
}
let cookies = (storage.cookies(for: server.url) ?? []).filter {
$0.isSecure && ["yuvomi.sid", "csrf-token"].contains($0.name) && $0.path == "/" && $0.expiresDate != nil
}.map { NativeSavedCookie(name: $0.name, value: $0.value, expires: $0.expiresDate!) }
try persist(JSONEncoder().encode(cookies))
if response.statusCode == 403 && mutation && retry && root?["reason"] as? String == "csrf_invalid" {
let _: NativeIdentity = try await self.request("/auth/me", as: NativeIdentity.self, retry: false)
return try await self.request(path, method: method, body: body, as: type, retry: false)
}
guard (200...299).contains(response.statusCode) else { throw NativeAPIError.response(response.statusCode) }
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
do { return try decoder.decode(type, from: data.isEmpty ? Data("{}".utf8) : data) } catch {
throw NativeAPIError.invalidResponse
}
}
}
+147
View File
@@ -0,0 +1,147 @@
import Foundation
enum NativePage: String, CaseIterable, Identifiable, Sendable {
case home, tasks, shopping, notes, more
var id: String { rawValue }
var title: String {
switch self {
case .home: "Übersicht"
case .tasks: "Aufgaben"
case .shopping: "Einkauf"
case .notes: "Notizen"
case .more: "Mehr"
}
}
var symbol: String {
switch self {
case .home: "house"
case .tasks: "checkmark.square"
case .shopping: "cart"
case .notes: "note.text"
case .more: "ellipsis"
}
}
}
struct NativeBootstrap: Decodable, Sendable {
let appName: String
let setupRequired: Bool
let passwordResetEnabled: Bool
}
struct NativeAuthentication: Decodable, Sendable {
let enabled: Bool
let passwordLoginEnabled: Bool
let guestPasswordLoginEnabled: Bool?
}
struct NativeUser: Decodable, Sendable {
let id: Int
let username: String
let displayName: String
}
struct NativePermissions: Decodable, Sendable {
let admin: Bool
let modules: [String: String]
let widgets: [String: String]
func access(_ module: String) -> String { admin ? "write" : modules[module] ?? "write" }
func showsWidget(_ widget: String) -> Bool { admin || widgets[widget] != "none" }
}
struct NativeIdentity: Decodable, Sendable {
let user: NativeUser?
let permissions: NativePermissions?
let csrfToken: String?
let twoFactorRequired: Bool?
}
struct NativeEnvelope<Value: Decodable & Sendable>: Decodable, Sendable { let data: Value }
struct NativeEmpty: Decodable, Sendable {}
struct NativeTask: Decodable, Identifiable, Sendable {
let id: Int
let title: String
let description: String?
let status: String
let priority: String
let dueDate: String?
let category: String?
}
struct NativeNote: Decodable, Identifiable, Sendable {
let id: Int
let title: String?
let content: String
let pinned: Int
}
struct NativeShoppingList: Decodable, Identifiable, Sendable {
let id: Int
let name: String
let itemTotal: Int?
let itemChecked: Int?
let openCount: Int?
}
struct NativeShoppingItem: Decodable, Identifiable, Sendable {
let id: Int
let name: String
let quantity: String?
let category: String?
let isChecked: Int
}
struct NativeEventPreview: Decodable, Identifiable, Sendable {
let id: Int
let title: String
let startDatetime: String
}
struct NativeTaskPreview: Decodable, Identifiable, Sendable {
let id: Int
let title: String
let dueDate: String?
}
struct NativeDashboard: Decodable, Sendable {
let openTaskCount: Int?
let shoppingOpenCount: Int?
let urgentTasks: [NativeTaskPreview]
let upcomingEvents: [NativeEventPreview]
let shoppingLists: [NativeShoppingList]
let pinnedNotes: [NativeNote]
}
enum NativeValue: Encodable, Sendable {
case string(String)
case number(Int)
case bool(Bool)
case null
func encode(to encoder: Encoder) throws {
var value = encoder.singleValueContainer()
switch self {
case .string(let text): try value.encode(text)
case .number(let number): try value.encode(number)
case .bool(let flag): try value.encode(flag)
case .null: try value.encodeNil()
}
}
}
enum NativeAPIError: LocalizedError {
case response(Int)
case invalidResponse, invalidPath, unavailable, closed
var errorDescription: String? {
switch self {
case .response(401): "Anmeldung oder Sitzung ist nicht mehr gültig."
case .response(403): "Diese Aktion ist nicht erlaubt. Bitte prüfe deine Berechtigungen."
case .response(409): "Der Eintrag wurde inzwischen geändert. Bitte neu laden."
case .response(429): "Zu viele Versuche. Bitte warte einen Moment."
case .response(let status): "Die Anfrage konnte nicht abgeschlossen werden (HTTP \(status))."
case .invalidPath, .invalidResponse: "Der Server hat keine gültige API-Antwort geliefert."
case .unavailable: "Verbindung fehlgeschlagen. Bitte Server, Zertifikat und Netzwerk prüfen."
case .closed: "Diese Sitzung wurde geschlossen."
}
}
}
+555
View File
@@ -0,0 +1,555 @@
import SwiftUI
private enum NativeDesign {
static let accent = Color(red: 108 / 255, green: 58 / 255, blue: 237 / 255)
static func tint(_ dark: Bool) -> Color {
dark ? Color(red: 167 / 255, green: 139 / 255, blue: 250 / 255) : accent
}
static func button(_ dark: Bool) -> Color {
dark ? Color(red: 124 / 255, green: 58 / 255, blue: 237 / 255) : accent
}
static func background(_ dark: Bool) -> Color {
dark
? Color(red: 25 / 255, green: 24 / 255, blue: 22 / 255)
: Color(red: 245 / 255, green: 243 / 255, blue: 237 / 255)
}
static func surface(_ dark: Bool) -> Color {
dark ? Color(red: 43 / 255, green: 40 / 255, blue: 37 / 255) : .white
}
}
private struct NativeDraft: Identifiable {
let id = UUID()
let recordID: Int?
let page: NativePage
var title: String
var detail: String
var priority = "none"
var date = ""
}
struct NativeRootView: View {
@StateObject private var store = NativeStore()
@Environment(\.colorScheme) private var scheme
@Environment(\.scenePhase) private var phase
@State private var draft: NativeDraft?
@State private var forget = false
@State private var deletePath: String?
var body: some View {
ZStack {
NativeDesign.background(scheme == .dark).ignoresSafeArea()
if store.user == nil { authentication } else { application }
if phase != .active {
NativeDesign.background(scheme == .dark).ignoresSafeArea()
NativeBrandMark().frame(width: 64, height: 64)
}
}
.tint(NativeDesign.tint(scheme == .dark))
.task { await store.restore() }
.onChange(of: store.user?.id) {
draft = nil
deletePath = nil
search = ""
}
.onChange(of: store.server) {
draft = nil
deletePath = nil
search = ""
}
.sheet(item: $draft) { entry in
NativeEditor(draft: entry, busy: store.busy, error: store.error) { body in
let path: String
let method: String
switch entry.page {
case .tasks:
path = entry.recordID.map { "/tasks/\($0)" } ?? "/tasks"
method = entry.recordID == nil ? "POST" : "PUT"
case .notes:
path = entry.recordID.map { "/notes/\($0)" } ?? "/notes"
method = entry.recordID == nil ? "POST" : "PUT"
case .shopping:
path =
entry.recordID.map { "/shopping/items/\($0)" } ?? store.selectedList.map {
"/shopping/\($0.id)/items"
} ?? "/shopping"
method = entry.recordID == nil ? "POST" : "PATCH"
default: return false
}
return await store.mutate(path, method: method, body: body)
}
}
.alert("Server entfernen?", isPresented: $forget) {
Button("Lokal entfernen", role: .destructive) { Task { await store.forget() } }
Button("Abbrechen", role: .cancel) {}
} message: {
Text(
"Die native Sitzung und geladene Inhalte werden lokal gelöscht. Melde dich möglichst zuerst ab, um die Sitzung auch auf dem Server zu widerrufen."
)
}
.alert(
"Eintrag löschen?", isPresented: Binding(get: { deletePath != nil }, set: { if !$0 { deletePath = nil } })
) {
Button("Löschen", role: .destructive) {
if let path = deletePath { Task { _ = await store.mutate(path, method: "DELETE") } }
deletePath = nil
}
Button("Abbrechen", role: .cancel) { deletePath = nil }
} message: {
Text("Der Eintrag wird auf deinem Server gelöscht.")
}
}
private var authentication: some View {
NativeAuthenticationScreen(store: store, forget: { forget = true })
}
private var application: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if store.busy { ProgressView().frame(maxWidth: .infinity) }
if let error = store.error {
Text(error).foregroundStyle(.red).accessibilityIdentifier("native-error")
}
switch store.page {
case .home: dashboard
case .tasks: taskList
case .notes: noteList
case .shopping: shopping
case .more: more
}
}
.padding(16)
.frame(maxWidth: 760)
.frame(maxWidth: .infinity)
}
.background(NativeDesign.background(scheme == .dark))
.navigationTitle(store.selectedList?.name ?? store.page.title)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Aktualisieren", systemImage: "arrow.clockwise") { Task { await store.refresh() } }.disabled(
store.busy)
}
if [.tasks, .notes, .shopping].contains(store.page),
store.permissions?.access(store.page.rawValue) == "write"
{
ToolbarItem(placement: .topBarTrailing) {
Button("Hinzufügen", systemImage: "plus") {
draft = NativeDraft(recordID: nil, page: store.page, title: "", detail: "")
}.disabled(store.busy)
}
}
}
.safeAreaInset(edge: .bottom) {
HStack(spacing: 0) {
ForEach(
NativePage.allCases.filter {
[.home, .more].contains($0) || store.permissions?.access($0.rawValue) != "none"
}
) { page in
Button {
Task { await store.navigate(page) }
} label: {
VStack(spacing: 4) {
Image(systemName: page.symbol).font(.system(size: 21))
Text(page.title).font(.system(size: 11))
}
.foregroundStyle(store.page == page ? NativeDesign.tint(scheme == .dark) : .secondary)
.frame(maxWidth: .infinity).padding(.vertical, 10)
}
.disabled(store.busy).accessibilityIdentifier("nav-\(page.rawValue)")
}
}
.padding(6).background(.regularMaterial, in: Capsule()).shadow(
color: .black.opacity(0.08), radius: 12, y: 4
).padding(.horizontal, 12).padding(.bottom, 8)
}
}
}
@ViewBuilder private var dashboard: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Hallo, \(store.user?.displayName ?? "")").font(.title2.bold())
Text(Date.now, format: .dateTime.weekday(.wide).day().month(.wide)).foregroundStyle(.secondary)
}
if let data = store.dashboard {
if store.permissions?.access("tasks") != "none", store.permissions?.showsWidget("tasks") == true {
NativeCard("Aufgaben", subtitle: "\(data.openTaskCount.map(String.init) ?? "—") offen") {
if data.urgentTasks.isEmpty { empty("Keine dringenden Aufgaben") }
ForEach(data.urgentTasks) { item in row(item.title, detail: item.dueDate ?? "") }
Button("Alle Aufgaben") { Task { await store.navigate(.tasks) } }
}
}
if store.permissions?.access("calendar") != "none", store.permissions?.showsWidget("calendar") == true {
NativeCard("Kalender", subtitle: "Nächste Termine") {
if data.upcomingEvents.isEmpty { empty("Keine anstehenden Termine") }
ForEach(data.upcomingEvents) { item in
row(
item.title,
detail: String(item.startDatetime.replacingOccurrences(of: "T", with: " ").prefix(16)))
}
}
}
if store.permissions?.access("shopping") != "none", store.permissions?.showsWidget("shopping") == true {
NativeCard("Einkauf", subtitle: "\(data.shoppingOpenCount.map(String.init) ?? "—") offene Artikel") {
if data.shoppingLists.isEmpty { empty("Alles eingekauft") }
ForEach(data.shoppingLists) { item in row(item.name, detail: "\(item.openCount ?? 0) offen") }
Button("Alle Listen") { Task { await store.navigate(.shopping) } }
}
}
if store.permissions?.access("notes") != "none", store.permissions?.showsWidget("notes") == true {
NativeCard("Notizen", subtitle: "Angeheftet") {
if data.pinnedNotes.isEmpty { empty("Noch keine Notizen") }
ForEach(data.pinnedNotes) { item in
row(item.title ?? "Notiz", detail: String(item.content.prefix(140)))
}
Button("Alle Notizen") { Task { await store.navigate(.notes) } }
}
}
}
}
@State private var search = ""
@State private var taskStatus = "open"
private var canWrite: Bool { !store.busy && store.permissions?.access(store.page.rawValue) == "write" }
@ViewBuilder private var taskList: some View {
TextField("Suchen", text: $search).textFieldStyle(.roundedBorder)
Picker("Status", selection: $taskStatus) {
Text("Offen").tag("open")
Text("Erledigt").tag("done")
Text("Alle").tag("all")
}.pickerStyle(.segmented)
let filtered = store.tasks.filter {
(search.isEmpty || ($0.title + ($0.description ?? "")).localizedCaseInsensitiveContains(search))
&& (taskStatus == "all" || (taskStatus == "done" ? $0.status == "done" : $0.status != "done"))
}
if filtered.isEmpty && !store.busy { empty("Hier gibt es noch keine passenden Aufgaben.") }
ForEach(filtered) { item in
NativeCard {
HStack {
Button {
Task {
_ = await store.mutate(
"/tasks/\(item.id)/status", method: "PATCH",
body: ["status": .string(item.status == "done" ? "open" : "done")])
}
} label: {
Image(systemName: item.status == "done" ? "checkmark.circle.fill" : "circle").font(.title2)
}.disabled(!canWrite).accessibilityLabel(item.status == "done" ? "Wieder öffnen" : "Erledigen")
Button {
draft = NativeDraft(
recordID: item.id, page: .tasks, title: item.title, detail: item.description ?? "",
priority: item.priority, date: item.dueDate ?? "")
} label: {
row(
item.title,
detail: [item.category, item.dueDate].compactMap { $0 }.joined(separator: " · "))
}.buttonStyle(.plain).disabled(!canWrite)
Spacer()
deleteButton("/tasks/\(item.id)")
}
}
}
}
@ViewBuilder private var noteList: some View {
TextField("Suchen", text: $search).textFieldStyle(.roundedBorder)
let filtered = store.notes.filter {
search.isEmpty || (($0.title ?? "") + $0.content).localizedCaseInsensitiveContains(search)
}
if filtered.isEmpty && !store.busy { empty("Hier gibt es noch keine passenden Notizen.") }
ForEach(filtered) { item in
NativeCard {
Button {
draft = NativeDraft(recordID: item.id, page: .notes, title: item.title ?? "", detail: item.content)
} label: {
row(item.title ?? "Notiz", detail: item.content)
}.buttonStyle(.plain).disabled(!canWrite)
HStack {
Button(item.pinned == 1 ? "Lösen" : "Anheften") {
Task { _ = await store.mutate("/notes/\(item.id)/pin", method: "PATCH") }
}.disabled(!canWrite)
Spacer()
deleteButton("/notes/\(item.id)")
}
}
}
}
@ViewBuilder private var shopping: some View {
if store.selectedList != nil {
Button("Alle Listen") { Task { await store.navigate(.shopping) } }.disabled(store.busy)
if store.items.isEmpty && !store.busy { empty("Diese Liste ist noch leer.") }
ForEach(store.items) { item in
NativeCard {
HStack {
Button {
Task {
_ = await store.mutate(
"/shopping/items/\(item.id)", method: "PATCH",
body: ["is_checked": .number(item.isChecked == 1 ? 0 : 1)])
}
} label: {
Image(systemName: item.isChecked == 1 ? "checkmark.circle.fill" : "circle").font(.title2)
}.disabled(!canWrite).accessibilityLabel(item.isChecked == 1 ? "Wieder öffnen" : "Abhaken")
Button {
draft = NativeDraft(
recordID: item.id, page: .shopping, title: item.name, detail: item.quantity ?? "")
} label: {
row(item.name, detail: item.quantity ?? "")
}.buttonStyle(.plain).disabled(!canWrite)
Spacer()
deleteButton("/shopping/items/\(item.id)")
}
}
}
} else {
if store.lists.isEmpty && !store.busy { empty("Hier gibt es noch keine Einkaufslisten.") }
ForEach(store.lists) { list in
NativeCard {
HStack {
Button {
Task { await store.open(list) }
} label: {
row(list.name, detail: "\(list.itemTotal ?? 0) Artikel")
}.buttonStyle(.plain).disabled(store.busy)
Spacer()
deleteButton("/shopping/\(list.id)")
}
}
}
}
}
private var more: some View {
VStack(spacing: 16) {
NativeCard("Dein Haushalt", subtitle: store.appName) {
Text(store.user?.displayName ?? "")
Text(store.server).foregroundStyle(.secondary)
}
NativeCard("Konto") {
Button("Abmelden") { Task { await store.logout() } }.disabled(store.busy)
Button("Server und lokale Sitzung entfernen", role: .destructive) { forget = true }.disabled(store.busy)
}
NativeCard("Nativer Neubau", subtitle: "Noch keine vollständige PWA-Parität") {
Text(
"Die weiteren Module und Einstellungen werden separat nativ nachgebaut. Es gibt keinen Browser-Fallback."
)
}
}
}
private func deleteButton(_ path: String) -> some View {
Button("Löschen", systemImage: "trash", role: .destructive) { deletePath = path }.labelStyle(.iconOnly)
.disabled(!canWrite)
}
private func row(_ title: String, detail: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title).fontWeight(.semibold)
if !detail.isEmpty { Text(detail).font(.subheadline).foregroundStyle(.secondary) }
}.frame(maxWidth: .infinity, alignment: .leading)
}
private func empty(_ text: String) -> some View {
Text(text).foregroundStyle(.secondary).padding(.vertical, 16).frame(maxWidth: .infinity)
}
}
private struct NativeAuthenticationScreen: View {
@Environment(\.colorScheme) private var scheme
@ObservedObject var store: NativeStore
let forget: () -> Void
@State private var address = ""
@State private var username = ""
@State private var password = ""
@State private var name = ""
@State private var code = ""
@State private var invalidAddress = false
@State private var sso = false
var body: some View {
ScrollView {
VStack(spacing: 16) {
NativeCard {
VStack(spacing: 16) {
NativeBrandMark().frame(width: 64, height: 64).accessibilityHidden(true)
Text(store.appName).font(.system(size: 34, weight: .bold))
Text(
store.server.isEmpty ? "Dein Zuhause. Dein Server." : "Alles für euren Alltag an einem Ort."
).foregroundStyle(.secondary).multilineTextAlignment(.center)
if store.server.isEmpty {
TextField("Server-Adresse", text: $address).keyboardType(.URL).textContentType(.URL)
.textInputAutocapitalization(.never).autocorrectionDisabled().textFieldStyle(
.roundedBorder
).accessibilityIdentifier("server-url")
if invalidAddress {
Text("Bitte eine gültige HTTPS-Adresse ohne Unterpfad eingeben.").foregroundStyle(.red)
.accessibilityIdentifier("invalid-server")
}
Text("HTTPS · eigener Server · keine zentrale Cloud").font(.caption).foregroundStyle(
.secondary)
Button("Verbinden") {
guard (try? ServerAddress(address)) != nil else {
invalidAddress = true
return
}
invalidAddress = false
Task { await store.connect(address) }
}.buttonStyle(.borderedProminent).tint(NativeDesign.button(scheme == .dark)).disabled(
store.busy || address.isEmpty
)
.accessibilityIdentifier("connect")
} else if store.twoFactor {
TextField("Code oder Wiederherstellungscode", text: $code).textContentType(.oneTimeCode)
.textFieldStyle(.roundedBorder)
Button("Bestätigen") {
let value = code
code = ""
Task { await store.verify(value) }
}.buttonStyle(.borderedProminent).tint(NativeDesign.button(scheme == .dark)).disabled(
store.busy || code.isEmpty)
Button("Zurück zur Anmeldung") { store.cancelTwoFactor() }.disabled(store.busy)
} else {
if store.setup { TextField("Anzeigename", text: $name).textFieldStyle(.roundedBorder) }
if store.passwordAllowed || store.setup {
TextField("Benutzername", text: $username).textContentType(.username)
.textInputAutocapitalization(.never).autocorrectionDisabled().textFieldStyle(
.roundedBorder
).accessibilityIdentifier("username")
SecureField("Passwort", text: $password).textContentType(.password).textFieldStyle(
.roundedBorder
).accessibilityIdentifier("password")
Button(store.setup ? "Haushalt einrichten" : "Anmelden") {
let submitted = password
password = ""
Task {
await store.login(username: username, password: submitted, displayName: name)
}
}.buttonStyle(.borderedProminent).tint(NativeDesign.button(scheme == .dark)).disabled(
store.busy || username.isEmpty || password.isEmpty
).accessibilityIdentifier("login")
}
if store.oidc { Button("SSO – noch in Umsetzung") { sso = true } }
Button("Anderen Server verwenden", action: forget).disabled(store.busy)
}
if store.busy { ProgressView() }
if let error = store.error {
Text(error).foregroundStyle(.red).accessibilityIdentifier("auth-error")
}
}
.padding(8)
}
.frame(maxWidth: 412)
}
.padding(16).frame(maxWidth: .infinity)
.containerRelativeFrame(.vertical) { height, _ in max(height, 500) }
}
.alert("Native Anmeldung", isPresented: $sso) {
Button("Verstanden", role: .cancel) {}
} message: {
Text(
"SSO wird für den nativen API-Client noch nicht unterstützt. Es wird keine Browser-Sitzung als native Anmeldung vorgetäuscht."
)
}
}
}
private struct NativeEditor: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.scenePhase) private var phase
@Environment(\.colorScheme) private var scheme
@State var draft: NativeDraft
let busy: Bool
let error: String?
let save: ([String: NativeValue]) async -> Bool
var body: some View {
NavigationStack {
Form {
TextField(draft.page == .shopping ? "Name" : "Titel", text: $draft.title)
TextField(draft.page == .shopping ? "Menge" : "Inhalt", text: $draft.detail, axis: .vertical).lineLimit(
3...10)
if draft.page == .tasks {
Picker("Priorität", selection: $draft.priority) {
Text("Keine").tag("none")
Text("Niedrig").tag("low")
Text("Mittel").tag("medium")
Text("Hoch").tag("high")
Text("Dringend").tag("urgent")
}
TextField("Fällig am (JJJJ-MM-TT)", text: $draft.date)
}
if let error { Text(error).foregroundStyle(.red) }
}
.navigationTitle(draft.recordID == nil ? "Hinzufügen" : "Bearbeiten").navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() }.disabled(busy) }
ToolbarItem(placement: .confirmationAction) {
Button("Speichern") {
var body: [String: NativeValue] = [
draft.page == .shopping ? "name" : "title": .string(draft.title)
]
if draft.page == .notes { body["content"] = .string(draft.detail) }
if draft.page == .tasks {
body["description"] = .string(draft.detail)
body["priority"] = .string(draft.priority)
body["due_date"] = draft.date.isEmpty ? .null : .string(draft.date)
}
if draft.page == .shopping { body["quantity"] = .string(draft.detail) }
Task { if await save(body) { dismiss() } }
}.disabled(
busy || (draft.title.isEmpty && draft.page != .notes)
|| (draft.page == .notes && draft.detail.isEmpty))
}
}
}.interactiveDismissDisabled(busy)
.overlay {
if phase != .active {
ZStack {
NativeDesign.background(scheme == .dark).ignoresSafeArea()
NativeBrandMark().frame(width: 64, height: 64)
}
}
}
}
}
private struct NativeCard<Content: View>: View {
@Environment(\.colorScheme) private var scheme
let title: String?
let subtitle: String?
let content: Content
init(_ title: String? = nil, subtitle: String? = nil, @ViewBuilder content: () -> Content) {
self.title = title
self.subtitle = subtitle
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
if let title { Text(title).font(.title3.weight(.semibold)) }
if let subtitle { Text(subtitle).font(.subheadline).foregroundStyle(.secondary) }
content
}.frame(maxWidth: .infinity, alignment: .leading).padding(16).background(
NativeDesign.surface(scheme == .dark), in: RoundedRectangle(cornerRadius: 16))
}
}
private struct NativeBrandMark: View {
var body: some View {
Canvas { context, size in
let shape = Path(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: size.width * 0.225)
context.fill(
shape,
with: .linearGradient(
Gradient(colors: [Color(red: 139 / 255, green: 92 / 255, blue: 246 / 255), NativeDesign.accent]),
startPoint: .zero, endPoint: CGPoint(x: size.width, y: size.height)))
for (x, y, radius) in [(64.0, 72.0, 27.0), (100.0, 78.0, 25.0), (80.0, 106.0, 24.0)] {
let rect = CGRect(
x: (x - radius) * size.width / 160, y: (y - radius) * size.height / 160,
width: radius * 2 * size.width / 160, height: radius * 2 * size.height / 160)
context.fill(Path(ellipseIn: rect), with: .color(.white.opacity(0.82)))
}
}
}
}
+40
View File
@@ -0,0 +1,40 @@
import Foundation
import Security
/// Native cookies, separate from WebKit and scoped to one configured origin.
struct NativeSessionVault: Sendable {
let origin: String
private var query: [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "community.yuvomi.mobile.native-session",
kSecAttrAccount as String: origin,
]
}
func load() -> Data? {
var request = query
request[kSecReturnData as String] = true
request[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
guard SecItemCopyMatching(request as CFDictionary, &result) == errSecSuccess else { return nil }
return result as? Data
}
func save(_ data: Data) throws {
let attributes: [String: Any] = [
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
]
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
if status == errSecItemNotFound {
var request = query
for (key, value) in attributes { request[key] = value }
guard SecItemAdd(request as CFDictionary, nil) == errSecSuccess else { throw NativeAPIError.unavailable }
} else if status != errSecSuccess {
throw NativeAPIError.unavailable
}
}
func clear() throws {
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else { throw NativeAPIError.unavailable }
}
}
+221
View File
@@ -0,0 +1,221 @@
import SwiftUI
@MainActor
final class NativeStore: ObservableObject {
@Published private(set) var server = ""
@Published private(set) var appName = "Yuvomi"
@Published private(set) var user: NativeUser?
@Published private(set) var permissions: NativePermissions?
@Published private(set) var setup = false
@Published private(set) var passwordAllowed = true
@Published private(set) var oidc = false
@Published private(set) var twoFactor = false
@Published private(set) var busy = false
@Published var error: String?
@Published private(set) var page = NativePage.home
@Published private(set) var dashboard: NativeDashboard?
@Published private(set) var tasks: [NativeTask] = []
@Published private(set) var notes: [NativeNote] = []
@Published private(set) var lists: [NativeShoppingList] = []
@Published private(set) var items: [NativeShoppingItem] = []
@Published private(set) var selectedList: NativeShoppingList?
private var api: NativeAPI?
private var vault: NativeSessionVault?
private var generation = 0
func restore() async {
guard server.isEmpty, let saved = UserDefaults.standard.string(forKey: "native-origin") else { return }
await connect(saved)
}
@discardableResult
private func run(_ operation: () async throws -> Void) async -> Bool {
guard !busy else { return false }
let current = generation
busy = true
error = nil
defer { if generation == current { busy = false } }
do {
try await operation()
return true
} catch {
guard current == generation else { return false }
if case NativeAPIError.response(401) = error, user != nil {
clearLoadedData()
self.error = "Deine Sitzung ist abgelaufen. Bitte erneut anmelden."
} else {
self.error = (error as? NativeAPIError)?.errorDescription ?? NativeAPIError.unavailable.errorDescription
}
return false
}
}
func connect(_ input: String) async {
await run {
let address = try ServerAddress(input)
let storage = NativeSessionVault(origin: address.url.absoluteString)
let client = try NativeAPI(server: address, saved: storage.load(), persist: storage.save)
let metadata = try await client.request("/version", as: NativeBootstrap.self)
let authentication = try await client.request("/auth/oidc/config", as: NativeAuthentication.self)
api = client
vault = storage
server = address.url.absoluteString
appName = metadata.appName
setup = metadata.setupRequired
oidc = authentication.enabled
passwordAllowed = authentication.passwordLoginEnabled || authentication.guestPasswordLoginEnabled == true
UserDefaults.standard.set(server, forKey: "native-origin")
do { try await accept(client.request("/auth/me", as: NativeIdentity.self)) } catch NativeAPIError.response(
401)
{ return }
}
}
func login(username: String, password: String, displayName: String) async {
await run {
guard let api else { throw NativeAPIError.closed }
if setup {
let _: NativeEmpty = try await api.request(
"/auth/setup", method: "POST",
body: [
"username": .string(username.trimmingCharacters(in: .whitespaces)),
"password": .string(password),
"display_name": .string(displayName.isEmpty ? username : displayName),
], as: NativeEmpty.self)
}
let response = try await api.request(
"/auth/login", method: "POST",
body: [
"username": .string(username.trimmingCharacters(in: .whitespaces)), "password": .string(password),
], as: NativeIdentity.self)
if response.twoFactorRequired == true {
twoFactor = true
setup = false
} else {
try await accept(response)
}
}
}
func verify(_ code: String) async {
await run {
guard let api else { throw NativeAPIError.closed }
try await accept(
api.request("/auth/2fa/verify", method: "POST", body: ["code": .string(code)], as: NativeIdentity.self))
}
}
func cancelTwoFactor() {
if !busy {
twoFactor = false
error = nil
}
}
private func accept(_ identity: NativeIdentity) async throws {
guard let user = identity.user, let permissions = identity.permissions else {
throw NativeAPIError.invalidResponse
}
self.user = user
self.permissions = permissions
setup = false
twoFactor = false
page = .home
try await reload()
}
func navigate(_ selected: NativePage) async {
await run {
page = selected
selectedList = nil
try await reload()
}
}
func open(_ list: NativeShoppingList) async {
await run {
page = .shopping
selectedList = list
try await reload()
}
}
func refresh() async { await run { try await reload() } }
private func reload() async throws {
guard let api else { throw NativeAPIError.closed }
switch page {
case .home: dashboard = try await api.request("/dashboard", as: NativeDashboard.self)
case .tasks: tasks = try await api.request("/tasks", as: NativeEnvelope<[NativeTask]>.self).data
case .notes: notes = try await api.request("/notes", as: NativeEnvelope<[NativeNote]>.self).data
case .shopping:
if let selectedList {
items = try await api.request(
"/shopping/\(selectedList.id)/items", as: NativeEnvelope<[NativeShoppingItem]>.self
).data
} else {
lists = try await api.request("/shopping", as: NativeEnvelope<[NativeShoppingList]>.self).data
}
case .more: break
}
}
func mutate(_ path: String, method: String, body: [String: NativeValue] = [:]) async -> Bool {
await run {
guard let api else { throw NativeAPIError.closed }
let _: NativeEmpty = try await api.request(path, method: method, body: body, as: NativeEmpty.self)
try await reload()
}
}
func logout() async {
await run {
let revoked: Bool
do {
if let api {
let _: NativeEmpty = try await api.request("/auth/logout", method: "POST", as: NativeEmpty.self)
}
revoked = true
} catch { revoked = false }
await api?.close()
clearLoadedData()
try vault?.clear()
if let address = try? ServerAddress(server), let storage = vault {
api = try NativeAPI(server: address, saved: nil, persist: storage.save)
}
if !revoked { error = "Lokal abgemeldet. Die Abmeldung auf dem Server konnte nicht bestätigt werden." }
}
}
func forget() async {
guard !busy else { return }
busy = true
defer { busy = false }
generation += 1
await api?.close()
clearLoadedData()
do { try vault?.clear() } catch {
self.error = NativeAPIError.unavailable.errorDescription
return
}
api = nil
vault = nil
server = ""
appName = "Yuvomi"
setup = false
passwordAllowed = true
oidc = false
error = nil
UserDefaults.standard.removeObject(forKey: "native-origin")
}
private func clearLoadedData() {
user = nil
permissions = nil
dashboard = nil
tasks = []
notes = []
lists = []
items = []
selectedList = nil
page = .home
twoFactor = false
}
}
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>NSPrivacyTracking</key><false/>
<key>NSPrivacyTrackingDomains</key><array/>
<key>NSPrivacyCollectedDataTypes</key><array/>
<key>NSPrivacyAccessedAPITypes</key><array>
<dict><key>NSPrivacyAccessedAPIType</key><string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key><array><string>CA92.1</string></array></dict>
</array>
</dict></plist>
+57
View File
@@ -0,0 +1,57 @@
import Foundation
struct ServerAddress: Equatable, Sendable {
let url: URL
init(_ input: String) throws {
let value = input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.contains("\\"), !value.contains(where: { $0.isWhitespace }),
var parts = URLComponents(string: value),
parts.scheme?.lowercased() == "https",
let host = parts.host, !host.isEmpty,
parts.user == nil, parts.password == nil,
parts.query == nil, parts.fragment == nil,
parts.percentEncodedPath.isEmpty || parts.percentEncodedPath == "/",
parts.port == nil || (1...65535).contains(parts.port!)
else {
throw ConnectionError.invalidAddress
}
parts.scheme = "https"
parts.host = host.lowercased()
parts.path = ""
if parts.port == 443 { parts.port = nil }
guard let url = parts.url else { throw ConnectionError.invalidAddress }
self.url = url
}
func owns(_ candidate: URL) -> Bool {
guard let parts = URLComponents(url: candidate, resolvingAgainstBaseURL: false),
let own = URLComponents(url: url, resolvingAgainstBaseURL: false)
else { return false }
return parts.scheme?.lowercased() == "https" && parts.user == nil && parts.password == nil
&& parts.host?.lowercased() == own.host?.lowercased()
&& (parts.port ?? 443) == (own.port ?? 443)
}
func ownsDownload(_ candidate: URL) -> Bool {
if owns(candidate) { return true }
guard candidate.scheme == "blob",
let inner = URL(string: String(candidate.absoluteString.dropFirst(5)))
else { return false }
return owns(inner)
}
}
enum ConnectionError: LocalizedError {
case invalidAddress, incompatible, unavailable
var errorDescription: String? {
switch self {
case .invalidAddress:
return "Use a root HTTPS URL with a valid host and port. Remove credentials, subpaths, query and fragment."
case .incompatible:
return "This server does not expose compatible Yuvomi metadata."
case .unavailable:
return "Cannot verify the server. Check its certificate, VPN, network and reverse proxy."
}
}
}
+19
View File
@@ -0,0 +1,19 @@
import Foundation
enum ServerMetadata {
private struct Bootstrap: Decodable {
let appName: String
let setupRequired: Bool
let passwordResetEnabled: Bool
}
private struct Authentication: Decodable {
let enabled: Bool
let passwordLoginEnabled: Bool
}
static func validate(bootstrap: Data, authentication: Data) throws {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
_ = try decoder.decode(Bootstrap.self, from: bootstrap)
_ = try decoder.decode(Authentication.self, from: authentication)
}
}
+40
View File
@@ -0,0 +1,40 @@
import Foundation
final class NoRedirect: NSObject, URLSessionTaskDelegate {
func urlSession(
_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void
) {
completionHandler(nil)
}
}
enum ServerProbe {
static func check(_ server: ServerAddress) async throws {
let configuration = URLSessionConfiguration.ephemeral
configuration.httpCookieStorage = nil
configuration.urlCache = nil
configuration.timeoutIntervalForRequest = 10
configuration.timeoutIntervalForResource = 20
let session = URLSession(configuration: configuration, delegate: NoRedirect(), delegateQueue: nil)
defer { session.invalidateAndCancel() }
let version = try await json(session, server.url.appendingPathComponent("api/v1/version"))
let auth = try await json(session, server.url.appendingPathComponent("api/v1/auth/oidc/config"))
try ServerMetadata.validate(bootstrap: version, authentication: auth)
}
private static func json(_ session: URLSession, _ url: URL) async throws -> Data {
var request = URLRequest(url: url)
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (stream, response) = try await session.bytes(for: request)
guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw ConnectionError.unavailable }
var data = Data()
for try await byte in stream {
guard data.count < 65_536 else { throw ConnectionError.incompatible }
data.append(byte)
}
return data
}
}
+8
View File
@@ -0,0 +1,8 @@
import SwiftUI
@main
struct YuvomiApp: App {
var body: some Scene {
WindowGroup { NativeRootView() }
}
}
+88
View File
@@ -0,0 +1,88 @@
import XCTest
@testable import Yuvomi
final class NativeContractsTests: XCTestCase {
private func decode<T: Decodable>(_ type: T.Type, _ json: String) throws -> T {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try decoder.decode(type, from: Data(json.utf8))
}
func testSparsePermissionsPreserveReadAndHiddenStates() throws {
let permissions = try decode(
NativePermissions.self,
#"{"admin":false,"modules":{"tasks":"read","notes":"none"},"widgets":{"shopping":"none"}}"#)
XCTAssertEqual(permissions.access("tasks"), "read")
XCTAssertEqual(permissions.access("notes"), "none")
XCTAssertEqual(permissions.access("shopping"), "write")
XCTAssertFalse(permissions.showsWidget("shopping"))
}
func testTwoFactorResponseDoesNotInventSignedInUser() throws {
let identity = try decode(NativeIdentity.self, #"{"twoFactorRequired":true,"recoveryAvailable":true}"#)
XCTAssertEqual(identity.twoFactorRequired, true)
XCTAssertNil(identity.user)
XCTAssertNil(identity.permissions)
}
func testTaskFieldsMatchActualServerContract() throws {
let task = try decode(
NativeTask.self,
#"{"id":17,"title":"Native task","description":null,"status":"open","priority":"high","due_date":"2026-10-21","category":"general","recurrence_rule":"FREQ=DAILY"}"#
)
XCTAssertEqual(task.id, 17)
XCTAssertEqual(task.dueDate, "2026-10-21")
XCTAssertNil(task.description)
}
func testShoppingUsesServerCounterNames() throws {
let list = try decode(NativeShoppingList.self, #"{"id":3,"name":"Einkauf","item_total":8,"item_checked":2}"#)
XCTAssertEqual(list.itemTotal, 8)
XCTAssertEqual(list.itemChecked, 2)
XCTAssertNil(list.openCount)
}
func testDashboardUnavailableCountsStayUnknown() throws {
let dashboard = try decode(
NativeDashboard.self,
#"{"openTaskCount":null,"shoppingOpenCount":0,"urgentTasks":[],"upcomingEvents":[],"shoppingLists":[],"pinnedNotes":[]}"#
)
XCTAssertNil(dashboard.openTaskCount)
XCTAssertEqual(dashboard.shoppingOpenCount, 0)
}
func testMutationEncodingPreservesNullAndNumericFlags() throws {
let body: [String: NativeValue] = ["due_date": .null, "is_checked": .number(1), "title": .string("Native")]
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(body)) as? [String: Any])
XCTAssertTrue(json["due_date"] is NSNull)
XCTAssertEqual(json["is_checked"] as? Int, 1)
XCTAssertEqual(json["title"] as? String, "Native")
}
func testRejectsBooleanWhereServerUsesIntegerState() {
XCTAssertThrowsError(try decode(NativeShoppingItem.self, #"{"id":1,"name":"Apfel","is_checked":true}"#))
}
@MainActor
func testNativeTransportRejectsNonAPIPathsBeforeNetwork() async throws {
let api = try NativeAPI(server: ServerAddress("https://example.invalid"), saved: nil, persist: { _ in })
for path in ["https://evil.invalid", "//evil.invalid", "/path#fragment", "/path\\escape"] {
do {
let _: NativeEmpty = try await api.request(path, as: NativeEmpty.self)
XCTFail("Invalid path accepted")
} catch NativeAPIError.invalidPath {}
}
await api.close()
}
@MainActor
func testClosedTransportCannotReuseAnOldSession() async throws {
let api = try NativeAPI(server: ServerAddress("https://example.invalid"), saved: nil, persist: { _ in })
await api.close()
do {
let _: NativeEmpty = try await api.request("/auth/me", as: NativeEmpty.self)
XCTFail("Closed session accepted a request")
} catch NativeAPIError.closed {}
}
}
+40
View File
@@ -0,0 +1,40 @@
import XCTest
@testable import Yuvomi
final class ServerAddressTests: XCTestCase {
func testCanonicalizationAndPorts() throws {
XCTAssertEqual(try ServerAddress(" HTTPS://Family.Example:443/ ").url.absoluteString, "https://family.example")
XCTAssertEqual(try ServerAddress("https://family.example:8443").url.port, 8443)
XCTAssertEqual(try ServerAddress("https://[::1]:8443").url.port, 8443)
}
func testUnsafeConfigurations() {
for value in [
"http://family.example", "javascript:alert(1)", "https://u:p@family.example",
"https://family.example/subpath", "https://family.example?token=secret",
"https://family.example/#token", "https://family.example:0",
"https://family.example:65536", "https://family.example/%2f",
"https://family.example\\@evil.test", "https:///", "",
] {
XCTAssertThrowsError(try ServerAddress(value), value)
}
}
func testOriginIsolation() throws {
let server = try ServerAddress("https://family.example")
XCTAssertTrue(server.owns(URL(string: "https://FAMILY.example:443/tasks?id=1")!))
for value in [
"https://family.example.evil.test", "https://family.example@evil.test",
"https://user@family.example", "http://family.example", "https://family.example:8443",
"file:///etc/passwd", "data:text/html,hi",
] {
XCTAssertFalse(server.owns(URL(string: value)!), value)
}
}
func testBlobExportsAreOriginBound() throws {
let server = try ServerAddress("https://family.example")
XCTAssertTrue(server.ownsDownload(URL(string: "blob:https://family.example/unique-id")!))
XCTAssertFalse(server.ownsDownload(URL(string: "blob:https://evil.example/unique-id")!))
XCTAssertFalse(server.ownsDownload(URL(string: "blob:http://family.example/unique-id")!))
XCTAssertFalse(server.ownsDownload(URL(string: "file:///tmp/export")!))
}
}
+21
View File
@@ -0,0 +1,21 @@
import XCTest
@testable import Yuvomi
final class ServerMetadataTests: XCTestCase {
private let bootstrap = Data(#"{"app_name":"Yuvomi","setup_required":false,"password_reset_enabled":true}"#.utf8)
private let authentication = Data(#"{"enabled":false,"password_login_enabled":true}"#.utf8)
func testAcceptsActualAnonymousMetadataWithoutVersion() throws {
try ServerMetadata.validate(bootstrap: bootstrap, authentication: authentication)
}
func testRejectsNumericBooleanAndMissingAuthenticationMetadata() {
let numeric = Data(#"{"enabled":1,"password_login_enabled":true}"#.utf8)
XCTAssertThrowsError(try ServerMetadata.validate(bootstrap: bootstrap, authentication: numeric))
XCTAssertThrowsError(try ServerMetadata.validate(bootstrap: bootstrap, authentication: Data("{}".utf8)))
}
func testRejectsProxyHtmlAndInvalidJson() {
XCTAssertThrowsError(
try ServerMetadata.validate(bootstrap: Data("<html>SSO proxy</html>".utf8), authentication: authentication))
XCTAssertThrowsError(try ServerMetadata.validate(bootstrap: Data("null".utf8), authentication: authentication))
}
}
+16
View File
@@ -0,0 +1,16 @@
import XCTest
final class OnboardingTests: XCTestCase {
func testRejectsCleartextWithoutNetwork() {
let app = XCUIApplication()
app.launch()
let field = app.textFields["server-url"]
XCTAssertTrue(field.waitForExistence(timeout: 5))
field.tap()
field.typeText("http://family.example")
app.buttons["connect"].tap()
XCTAssertTrue(app.staticTexts["invalid-server"].waitForExistence(timeout: 3))
XCTAssertTrue(field.exists)
XCTAssertEqual(app.webViews.count, 0)
}
}
+73
View File
@@ -0,0 +1,73 @@
name: Yuvomi
options:
bundleIdPrefix: community.yuvomi
deploymentTarget:
iOS: "18.4"
settings:
base:
SWIFT_VERSION: "5.0"
MARKETING_VERSION: "0.2.0"
CURRENT_PROJECT_VERSION: 2
CODE_SIGN_STYLE: Automatic
DEVELOPMENT_TEAM: ""
ENABLE_USER_SCRIPT_SANDBOXING: YES
SWIFT_TREAT_WARNINGS_AS_ERRORS: YES
GCC_TREAT_WARNINGS_AS_ERRORS: YES
targets:
Yuvomi:
type: application
platform: iOS
sources: [Sources]
info:
path: Info.plist
properties:
CFBundleDisplayName: Yuvomi Mobile
UILaunchScreen: {}
NSCameraUsageDescription: Take a photo to upload to your Yuvomi server.
NSPhotoLibraryUsageDescription: Choose photos to upload to your Yuvomi server.
NSLocalNetworkUsageDescription: Connect to the private Yuvomi server you configure.
ITSAppUsesNonExemptEncryption: false
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
UISupportedInterfaceOrientations~ipad:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: community.yuvomi.mobile
TARGETED_DEVICE_FAMILY: "1,2"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
dependencies:
- sdk: Security.framework
YuvomiTests:
type: bundle.unit-test
platform: iOS
sources: [Tests]
dependencies:
- target: Yuvomi
settings:
base:
GENERATE_INFOPLIST_FILE: YES
YuvomiUITests:
type: bundle.ui-testing
platform: iOS
sources: [UITests]
dependencies:
- target: Yuvomi
settings:
base:
GENERATE_INFOPLIST_FILE: YES
schemes:
Yuvomi:
build:
targets:
Yuvomi: all
test:
targets: [YuvomiTests, YuvomiUITests]
gatherCoverageData: true
archive:
config: Release
+8
View File
@@ -0,0 +1,8 @@
# Superseded WebView prototype
These files are retained as historical reference, not shipped application code.
The Android browser tests in `legacy/android/tests` and Apple BrowserModel tests
describe this prototype, not native feature parity.
Android now selects the explicitly named native test source directories instead.
Do not restore a WebView as the application surface or count old browser tests as
native-client validation.
+16
View File
@@ -0,0 +1,16 @@
package community.yuvomi.mobile
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class BlobOriginsTest {
@Test fun blobDownloadsCannotCrossOriginsOrDowngradeTls() {
val server = ServerAddress.parse("https://family.example")
assertTrue(BlobTransfer.owns(server, "blob:https://family.example/unique-id"))
listOf(
"blob:https://evil.example/id", "blob:http://family.example/id",
"blob:https://family.example.evil.test/id", "data:text/plain,hi",
).forEach { assertFalse(it, BlobTransfer.owns(server, it)) }
}
}
+165
View File
@@ -0,0 +1,165 @@
package community.yuvomi.mobile
import android.content.ContentResolver
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Base64
import android.webkit.WebView
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import java.io.OutputStream
import java.util.UUID
import java.util.WeakHashMap
import java.util.concurrent.Executor
import org.json.JSONObject
/** Same-origin, user-selected blob exports. No JS bridge or CSP/network bypass. */
object BlobTransfer {
private val stores = WeakHashMap<WebView, String>()
fun owns(server: ServerAddress, value: String): Boolean = value.startsWith("blob:") && server.owns(value.substring(5))
fun install(web: WebView, server: ServerAddress) {
if (!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) return
val key = "__yuvomi_blobs_${UUID.randomUUID().toString().replace("-", "") }"
stores[web] = key
val script = """
(function(){if(window.top!==window)return;
const entries=new Map();let total=0;const limit=25*1024*1024;
const original=URL.createObjectURL.bind(URL);
URL.createObjectURL=function(blob){const url=original(blob);
if(blob instanceof Blob && blob.size<=limit){
while(entries.size && (total+blob.size>limit || entries.size>=8)){
const first=entries.keys().next().value;total-=entries.get(first).size;entries.delete(first);
}
entries.set(url,blob);total+=blob.size;
}return url;};
Object.defineProperty(window,${JSONObject.quote(key)},{value:{take:function(url){
const blob=entries.get(url);if(blob){total-=blob.size;entries.delete(url);}return blob;
}}});
})();
""".trimIndent()
// Retain a bounded Blob reference even if upstream revokes the object URL
// immediately after clicking its download anchor. Never fetch a blob URL:
// upstream's CSP correctly refuses that operation in a WebView.
WebViewCompat.addDocumentStartJavaScript(web, script, setOf(server.origin))
}
fun forget(web: WebView) {
stores.remove(web)
}
fun save(
server: ServerAddress,
web: WebView,
value: String,
resolver: ContentResolver,
destination: Uri,
worker: Executor,
authorized: () -> Boolean,
completed: (Boolean) -> Unit,
) {
val store = stores[web]
if (store == null || !authorized() || !server.owns(web.url.orEmpty()) || !owns(server, value)) {
completed(false)
return
}
val handler = Handler(Looper.getMainLooper())
val key = JSONObject.quote("__yuvomi_export_${UUID.randomUUID().toString().replace("-", "")}")
val maxBytes = 25 * 1024 * 1024
var output: OutputStream? = null
var offset = 0
var finished = false
lateinit var expiry: Runnable
fun allowed() = authorized() && server.owns(web.url.orEmpty())
fun finish(success: Boolean) {
if (finished) return
finished = true
handler.removeCallbacks(expiry)
if (runCatching { allowed() }.getOrDefault(false)) {
runCatching { web.evaluateJavascript("delete window[$key]", null) }
}
val close = Runnable {
val closed = runCatching { output?.close() }.isSuccess
handler.post { completed(success && closed) }
}
runCatching { worker.execute(close) }.onFailure { close.run() }
}
expiry = Runnable { finish(false) }
lateinit var pull: Runnable
pull = Runnable {
if (!runCatching { allowed() }.getOrDefault(false)) {
finish(false)
return@Runnable
}
val script = """
(function(){const x=window[$key];
if(!x || x.state!=='ready')return {state:x?x.state:'failed'};
const b=x.bytes;if(b.length>$maxBytes)return {state:'failed'};
const end=Math.min($offset+32768,b.length);
return {state:'ready',data:btoa(String.fromCharCode.apply(null,b.subarray($offset,end))),done:end===b.length};})()
""".trimIndent()
runCatching {
web.evaluateJavascript(script) { result ->
if (finished) return@evaluateJavascript
val data = runCatching {
require(result.length < 50_000)
JSONObject(result)
}.getOrNull()
when (data?.optString("state")) {
"loading" -> handler.postDelayed(pull, 50)
"ready" -> {
val chunk = runCatching { Base64.decode(data.getString("data"), Base64.NO_WRAP) }.getOrNull()
if (chunk == null || chunk.size > 32768 || offset + chunk.size > maxBytes) {
finish(false)
} else {
offset += chunk.size
runCatching {
worker.execute {
val success = runCatching { requireNotNull(output).write(chunk) }.isSuccess
handler.post {
if (!success) {
finish(false)
} else if (data.optBoolean("done")) {
finish(true)
} else {
handler.post(pull)
}
}
}
}.onFailure { finish(false) }
}
}
else -> finish(false)
}
}
}.onFailure { finish(false) }
}
handler.postDelayed(expiry, 45_000)
runCatching {
worker.execute {
output = runCatching { resolver.openOutputStream(destination, "w") }.getOrNull()
handler.post {
if (finished) {
runCatching { output?.close() }
return@post
}
if (output == null || !allowed()) {
finish(false)
return@post
}
val script = """
(function(){window[$key]={state:'loading'};
try{const b=window[${JSONObject.quote(store)}].take(${JSONObject.quote(value)});
if(!b || b.size>$maxBytes)throw new Error('size');
b.arrayBuffer().then(a=>window[$key]={state:'ready',bytes:new Uint8Array(a)})
.catch(()=>window[$key]={state:'failed'});
}catch(e){window[$key]={state:'failed'};}})()
""".trimIndent()
runCatching { web.evaluateJavascript(script) { handler.post(pull) } }.onFailure { finish(false) }
}
}
}.onFailure { finish(false) }
}
}
@@ -12,7 +12,8 @@ object FileTransfer {
val connection = URI(url).toURL().openConnection() as HttpsURLConnection val connection = URI(url).toURL().openConnection() as HttpsURLConnection
try { try {
connection.instanceFollowRedirects = false connection.instanceFollowRedirects = false
connection.connectTimeout = 15_000; connection.readTimeout = 20_000 connection.connectTimeout = 15_000
connection.readTimeout = 20_000
cookie?.let { connection.setRequestProperty("Cookie", it) } cookie?.let { connection.setRequestProperty("Cookie", it) }
require(connection.responseCode == 200) { "Download refused" } require(connection.responseCode == 200) { "Download refused" }
require(connection.contentLengthLong <= 100L * 1024 * 1024) { "Download too large" } require(connection.contentLengthLong <= 100L * 1024 * 1024) { "Download too large" }
@@ -31,6 +32,8 @@ object FileTransfer {
} }
} }
} }
} finally { connection.disconnect() } } finally {
connection.disconnect()
}
} }
} }
+689
View File
@@ -0,0 +1,689 @@
package community.yuvomi.mobile
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.net.http.SslError
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import android.text.InputType
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.webkit.CookieManager
import android.webkit.GeolocationPermissions
import android.webkit.PermissionRequest
import android.webkit.RenderProcessGoneDetail
import android.webkit.SslErrorHandler
import android.webkit.URLUtil
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebStorage
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Button
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.ScrollView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.FileProvider
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.webkit.WebStorageCompat
import androidx.webkit.WebViewFeature
import java.io.ByteArrayInputStream
import java.io.File
import java.net.URI
import java.util.concurrent.Executors
class MainActivity : ComponentActivity() {
private var worker = Executors.newSingleThreadExecutor()
private val handler = Handler(Looper.getMainLooper())
private val preferences by lazy { getSharedPreferences("server", MODE_PRIVATE) }
private var server: ServerAddress? = null
private var web: WebView? = null
private var authDialog: AlertDialog? = null
private lateinit var root: LinearLayout
private lateinit var content: FrameLayout
private lateinit var progress: ProgressBar
private var fileCallback: ValueCallback<Array<Uri>>? = null
private var capture: Uri? = null
private var pendingDownload: String? = null
private var generation = 0
private val uploadSelection = SelectionGate()
private val downloadSelection = SelectionGate()
private val loadTimeout =
Runnable { showError("The server is taking too long. Check your connection or try again.") }
private val localNetworkPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) {
toast("Private-network access allowed. Retry your server connection.")
} else {
toast("Private-network access was not granted. Public HTTPS servers can still be used.")
}
}
private val picker =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val accepted = uploadSelection.consume(generation)
val callback = fileCallback
fileCallback = null
val uris = if (!accepted || result.resultCode != RESULT_OK) {
null
} else {
val data = result.data
data?.clipData?.let { clip ->
Array(clip.itemCount.coerceAtMost(50)) { clip.getItemAt(it).uri }
}
?: data?.data?.let { arrayOf(it) } ?: capture?.let { arrayOf(it) }
}
callback?.onReceiveValue(uris?.filter { it.scheme == "content" }?.toTypedArray())
revokeCapture()
capture = null
}
private val saveDocument =
registerForActivityResult(
ActivityResultContracts.CreateDocument("application/octet-stream"),
) { destination ->
val accepted = downloadSelection.consume(generation)
val url = pendingDownload
pendingDownload = null
if (!accepted) return@registerForActivityResult
val current = server
if (destination != null && url != null && current != null &&
BlobTransfer.owns(current, url)
) {
val view = web ?: return@registerForActivityResult
val epoch = generation
BlobTransfer.save(
current,
view,
url,
contentResolver,
destination,
worker,
{ !isDestroyed && epoch == generation },
) { success ->
if (!success) runCatching { contentResolver.delete(destination, null, null) }
if (!isDestroyed &&
epoch == generation
) {
toast(
if (success) "Export saved" else "Export failed or exceeded the 25 MB limit.",
)
}
}
} else if (destination != null && url != null && current != null && current.owns(url)) {
val cookies = CookieManager.getInstance().getCookie(url)
val epoch = generation
worker.execute {
val success = runCatching {
FileTransfer.save(current, url, cookies, contentResolver, destination)
}.isSuccess
if (!success) runCatching { contentResolver.delete(destination, null, null) }
runOnUiThread {
if (!isDestroyed && epoch == generation) {
if (success) {
toast("Download saved")
} else {
toast(
"Download failed. Check your connection and sign-in, then retry.",
)
}
}
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState?.getBoolean("uploadOutstanding") == true) uploadSelection.restoreOutstanding()
if (savedInstanceState?.getBoolean("downloadOutstanding") == true) downloadSelection.restoreOutstanding()
// Household data is hidden from screenshots and the recent-apps preview.
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
WebView.setWebContentsDebuggingEnabled(false)
root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
val bars = insets.getInsets(
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime(),
)
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
insets
}
setContentView(root)
onBackPressedDispatcher.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (web?.canGoBack() == true) {
web?.goBack()
} else if (web != null) {
moveTaskToBack(true)
} else {
isEnabled = false
onBackPressedDispatcher.onBackPressed()
}
}
},
)
File(cacheDir, "captures").listFiles()?.filter {
it.lastModified() <
System.currentTimeMillis() - 86_400_000
}
?.forEach { it.delete() }
server =
preferences.getString("origin", null)?.let {
runCatching { ServerAddress.parse(it) }.getOrNull()
}
if (server == null) onboarding() else browser()
}
private fun column(): LinearLayout = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(24), dp(20), dp(24), dp(20))
}
private fun text(value: String, size: Float = 17f) = TextView(this).apply {
text = value
textSize = size
setPadding(0, dp(8), 0, dp(8))
}
private fun button(label: String, action: () -> Unit) = Button(this).apply {
text = label
isAllCaps = false
minHeight = dp(48)
setOnClickListener { action() }
}
private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt()
private fun onboarding() {
root.removeAllViews()
val form = column()
form.addView(text("Your home, connected", 30f))
form.addView(
text("Connect to your own Yuvomi server. Your data stays with your household."),
)
val address = EditText(this).apply {
hint = "https://family.example.com"
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI
setSingleLine(true)
contentDescription = "Yuvomi server URL"
setText(server?.origin.orEmpty())
id = View.generateViewId()
}
val label =
text("HTTPS required. Custom ports supported. Use a dedicated hostname, not a subpath.")
label.accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE
lateinit var connect: Button
connect = button("Connect securely") {
val candidate = runCatching { ServerAddress.parse(address.text.toString()) }.getOrElse {
address.error = it.message
return@button
}
connect.isEnabled = false
address.isEnabled = false
label.setText(R.string.checking_server)
val epoch = generation
worker.execute {
val result = runCatching { ServerProbe.check(candidate) }
runOnUiThread {
if (isDestroyed || epoch != generation) return@runOnUiThread
connect.isEnabled = true
address.isEnabled = true
if (result.isSuccess) {
server = candidate
preferences.edit {
putString("origin", candidate.origin)
remove("path")
}
browser()
} else {
// Exception details may contain URL parameters; never expose them.
label.setText(R.string.probe_failed)
}
}
}
}
form.addView(address)
form.addView(label)
localNetworkButton()?.let { form.addView(it) }
form.addView(connect)
form.addView(
text("Independent community app. No telemetry or mandatory cloud service.", 14f),
)
root.addView(ScrollView(this).apply { addView(form) })
}
private fun browser() {
val current = server ?: return onboarding()
root.removeAllViews()
val toolbar = LinearLayout(this)
toolbar.addView(button("Server settings") { settings() })
toolbar.addView(
button("Share server") {
startActivity(
Intent.createChooser(
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, current.origin)
},
"Share server address",
),
)
},
)
root.addView(toolbar)
progress = ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply {
contentDescription = "Loading Yuvomi"
max = 100
}
root.addView(progress, LinearLayout.LayoutParams(-1, dp(4)))
content = FrameLayout(this)
root.addView(content, LinearLayout.LayoutParams(-1, 0, 1f))
val view = WebView(this)
web = view
secure(view)
BlobTransfer.install(view, current)
CookieManager.getInstance().setAcceptCookie(true)
CookieManager.getInstance().setAcceptThirdPartyCookies(view, false)
view.webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(v: WebView, request: WebResourceRequest): WebResourceResponse? {
if (!current.owns(request.url.toString())) {
return WebResourceResponse(
"text/plain",
"UTF-8",
403,
"Blocked",
emptyMap(),
ByteArrayInputStream(ByteArray(0)),
)
}
return null
}
override fun shouldOverrideUrlLoading(v: WebView, request: WebResourceRequest): Boolean {
val url = request.url.toString()
if (current.owns(url)) return false
if (request.isForMainFrame) external(url)
return true
}
override fun onPageStarted(v: WebView, url: String, icon: Bitmap?) {
if (!current.owns(url)) {
v.stopLoading()
external(url)
return
}
progress.visibility = View.VISIBLE
handler.removeCallbacks(loadTimeout)
handler.postDelayed(loadTimeout, 30_000)
}
override fun onPageFinished(v: WebView, url: String) {
handler.removeCallbacks(loadTimeout)
progress.visibility = View.GONE
CookieManager.getInstance().flush()
if (current.owns(url)) {
val path = URI(url).rawPath.orEmpty()
if (!listOf("auth", "reset", "invite", "login").any {
path.contains(it, true)
}
) {
preferences.edit { putString("path", path) }
}
}
}
override fun onReceivedError(v: WebView, r: WebResourceRequest, e: WebResourceError) {
if (r.isForMainFrame) {
showError(
"Unable to connect. Cached content may still be available. Check your network or VPN.",
)
}
}
override fun onReceivedHttpError(v: WebView, r: WebResourceRequest, e: WebResourceResponse) {
if (r.isForMainFrame &&
e.statusCode >= 400
) {
showError("The server is unavailable (HTTP ${e.statusCode}).")
}
}
override fun onReceivedSslError(v: WebView, h: SslErrorHandler, e: SslError) {
h.cancel()
showError(
"The server certificate could not be verified. Contact your server administrator.",
)
}
override fun onRenderProcessGone(v: WebView, detail: RenderProcessGoneDetail): Boolean {
releaseWeb()
browser()
toast("Yuvomi recovered after a browser interruption.")
return true
}
}
view.webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(v: WebView, value: Int) {
progress.progress = value
}
override fun onPermissionRequest(request: PermissionRequest) {
request.deny()
}
override fun onGeolocationPermissionsShowPrompt(origin: String, callback: GeolocationPermissions.Callback) {
callback.invoke(origin, false, false)
}
override fun onShowFileChooser(v: WebView, callback: ValueCallback<Array<Uri>>, params: FileChooserParams): Boolean {
if (!current.owns(v.url.orEmpty())) return false
if (!uploadSelection.begin(generation)) {
callback.onReceiveValue(null)
toast("Finish or cancel the existing system file picker first.")
return true
}
fileCallback?.onReceiveValue(null)
fileCallback = callback
runCatching {
val types = params.acceptTypes.filter {
it.matches(Regex("[a-zA-Z0-9.+*-]+/[a-zA-Z0-9.+*-]+"))
}
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = types.singleOrNull() ?: "*/*"
if (types.size > 1) putExtra(Intent.EXTRA_MIME_TYPES, types.toTypedArray())
putExtra(
Intent.EXTRA_ALLOW_MULTIPLE,
params.mode == FileChooserParams.MODE_OPEN_MULTIPLE,
)
}
val chooser = Intent.createChooser(intent, "Select a file")
if (types.any { it.startsWith("image/") }) {
val directory = File(cacheDir, "captures").apply { mkdirs() }
capture =
FileProvider.getUriForFile(
this@MainActivity,
"$packageName.files",
File.createTempFile("capture-", ".jpg", directory),
)
chooser.putExtra(
Intent.EXTRA_INITIAL_INTENTS,
arrayOf(
Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
putExtra(MediaStore.EXTRA_OUTPUT, capture)
clipData =
android.content.ClipData.newRawUri("Yuvomi photo", capture)
addFlags(
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_READ_URI_PERMISSION,
)
},
),
)
}
picker.launch(chooser)
}.onFailure {
uploadSelection.consume(generation)
fileCallback?.onReceiveValue(null)
fileCallback = null
toast("No compatible file picker is available.")
}
return true
}
}
view.setDownloadListener { url, _, disposition, mime, _ ->
if (!current.owns(url) &&
!BlobTransfer.owns(current, url)
) {
toast("Only downloads from your server are supported.")
} else if (pendingDownload == null && downloadSelection.begin(generation)) {
pendingDownload = url
val name = URLUtil.guessFileName(
url,
disposition,
mime,
).replace(Regex("[^a-zA-Z0-9._ -]"), "_").take(120)
runCatching { saveDocument.launch(name.ifBlank { "download" }) }.onFailure {
downloadSelection.consume(generation)
pendingDownload = null
toast("No compatible document provider is available.")
}
}
}
content.addView(view, FrameLayout.LayoutParams(-1, -1))
val path = preferences.getString("path", "/").orEmpty()
view.loadUrl(
current.origin + if (path.startsWith("/") && !path.startsWith("//")) path else "/",
)
}
@SuppressLint("SetJavaScriptEnabled")
private fun secure(view: WebView) {
view.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
allowFileAccess = false
allowContentAccess = false
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
javaScriptCanOpenWindowsAutomatically = false
setSupportMultipleWindows(false)
mediaPlaybackRequiresUserGesture = true
safeBrowsingEnabled = true
setGeolocationEnabled(false)
}
}
private fun external(value: String) {
handler.removeCallbacks(loadTimeout)
progress.visibility = View.GONE
val uri = runCatching { URI(value) }.getOrNull() ?: return
if (uri.scheme != "https" || uri.host.isNullOrBlank() || uri.rawUserInfo != null) {
toast("This link type is blocked for your security.")
return
}
AlertDialog.Builder(this).setTitle("Leave your Yuvomi server?")
.setMessage(
"Destination: ${uri.host}\n\nOpen normal links in your browser. For SSO, use the isolated sign-in window only if you trust this provider. Providers that require a system browser cannot transfer a session back to this app.",
)
.setPositiveButton("Browser") { _, _ -> openBrowser(value) }
.setNeutralButton("Sign-in window") { _, _ -> authentication(value) }
.setNegativeButton("Cancel", null).show()
}
private fun authentication(value: String) {
if (authDialog != null) return
val auth = WebView(this)
secure(auth)
CookieManager.getInstance().setAcceptThirdPartyCookies(auth, false)
auth.webChromeClient = object : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest) {
request.deny()
}
}
auth.webViewClient = object : WebViewClient() {
override fun onPageStarted(v: WebView, url: String, favicon: Bitmap?) {
authDialog?.setTitle(
"Sign in: ${runCatching {
URI(url).host
}.getOrNull().orEmpty()}",
)
}
override fun shouldOverrideUrlLoading(v: WebView, request: WebResourceRequest): Boolean {
val uri = request.url
return uri.scheme != "https" || !uri.userInfo.isNullOrEmpty()
}
override fun onReceivedSslError(v: WebView, h: SslErrorHandler, e: SslError) {
h.cancel()
}
override fun onPageFinished(v: WebView, url: String) {
if (server?.owns(url) == true && !URI(url).path.startsWith("/api/")) {
CookieManager.getInstance().flush()
authDialog?.dismiss()
web?.loadUrl(url)
}
}
}
authDialog = AlertDialog.Builder(this).setTitle("Isolated web sign-in")
.setView(auth).setNegativeButton("Close", null).create().also { dialog ->
dialog.setOnDismissListener {
auth.stopLoading()
auth.removeAllViews()
auth.destroy()
authDialog = null
}
dialog.show()
dialog.window?.setLayout(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
}
auth.loadUrl(value)
}
private fun openBrowser(value: String) {
runCatching {
startActivity(
Intent(Intent.ACTION_VIEW, value.toUri()).addCategory(Intent.CATEGORY_BROWSABLE),
)
}
.onFailure { toast("No browser is available.") }
}
private fun showError(message: String) {
if (isDestroyed || web == null) return
handler.removeCallbacks(loadTimeout)
progress.visibility = View.GONE
if (content.childCount > 1) return
val panel = column().apply {
addView(text("Connection interrupted", 25f))
addView(text(message))
addView(
button("Retry") {
content.removeView(this)
web?.reload()
},
)
addView(button("Keep viewing") { content.removeView(this) })
addView(button("Server settings") { settings() })
localNetworkButton()?.let { addView(it) }
}
val dark =
resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK ==
android.content.res.Configuration.UI_MODE_NIGHT_YES
panel.setBackgroundColor(if (dark) 0xff191816.toInt() else 0xfff5f3ed.toInt())
content.addView(panel, FrameLayout.LayoutParams(-1, -1))
panel.accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE
ViewCompat.setAccessibilityPaneTitle(panel, "Connection interrupted")
}
private fun settings() {
AlertDialog.Builder(this).setTitle("Server settings")
.setMessage(
"${server?.origin.orEmpty()}\n\nSwitching servers erases all app cookies, storage and cached household data. Downloads saved outside the app remain. This does not revoke the server session; sign out in Yuvomi first where possible.",
)
.setPositiveButton("Erase and change server") { _, _ ->
generation++
worker.shutdownNow()
worker = Executors.newSingleThreadExecutor()
authDialog?.dismiss()
releaseWeb()
val captures = File(cacheDir, "captures")
val capturesErased = !captures.exists() || captures.listFiles()?.all { !it.exists() || it.delete() } == true
if (capturesErased && WebViewFeature.isFeatureSupported(WebViewFeature.DELETE_BROWSING_DATA)) {
WebStorageCompat.deleteBrowsingData(WebStorage.getInstance()) {
CookieManager.getInstance().removeAllCookies {
CookieManager.getInstance().flush()
preferences.edit { clear() }
server = null
onboarding()
}
}
} else {
// Older engines cannot reliably erase CacheStorage/IndexedDB.
// OS reset is complete and scoped to this application, but closes it.
val reset = getSystemService(
android.app.ActivityManager::class.java,
).clearApplicationUserData()
if (!reset) {
toast("Local data could not be erased. Clear this app's storage in Android settings before switching servers.")
finish()
}
}
}
.setNeutralButton("Open server in browser") { _, _ ->
server?.let { openBrowser(it.origin) }
}
.setNegativeButton("Continue", null).show()
}
private fun localNetworkButton(): Button? {
if (android.os.Build.VERSION.SDK_INT < 37 ||
checkSelfPermission(android.Manifest.permission.ACCESS_LOCAL_NETWORK) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
) {
return null
}
return button("Allow private-network servers") {
AlertDialog.Builder(this).setTitle("Private server access")
.setMessage(
"Android requires permission to connect to a Yuvomi server on your local network. The app connects only to your configured server; it does not scan for devices. Public internet servers do not need this permission.",
)
.setPositiveButton("Continue") { _, _ ->
localNetworkPermission.launch(android.Manifest.permission.ACCESS_LOCAL_NETWORK)
}
.setNegativeButton("Cancel", null).show()
}
}
private fun releaseWeb() {
handler.removeCallbacks(loadTimeout)
fileCallback?.onReceiveValue(null)
fileCallback = null
pendingDownload = null
revokeCapture()
capture = null
web?.let { view ->
BlobTransfer.forget(view)
view.stopLoading()
(view.parent as? ViewGroup)?.removeView(view)
view.removeAllViews()
view.destroy()
}
web = null
}
private fun revokeCapture() {
capture?.let {
revokeUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
}
}
override fun onSaveInstanceState(outState: Bundle) {
outState.putBoolean("uploadOutstanding", uploadSelection.outstanding)
outState.putBoolean("downloadOutstanding", downloadSelection.outstanding)
super.onSaveInstanceState(outState)
}
private fun toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_LONG).show()
override fun onPause() {
web?.onPause()
CookieManager.getInstance().flush()
super.onPause()
}
override fun onResume() {
super.onResume()
web?.onResume()
}
override fun onDestroy() {
generation++
authDialog?.dismiss()
releaseWeb()
worker.shutdownNow()
super.onDestroy()
}
}
+23
View File
@@ -0,0 +1,23 @@
package community.yuvomi.mobile
/** Never delivers a late system-picker result into a different server/web context. */
class SelectionGate {
private var owner: Int? = null
val outstanding: Boolean get() = owner != null
fun begin(generation: Int): Boolean {
if (outstanding) return false
owner = generation
return true
}
fun consume(generation: Int): Boolean {
val previous = owner
owner = null
return previous == generation
}
fun restoreOutstanding() {
owner = -1
}
}
+31
View File
@@ -0,0 +1,31 @@
package community.yuvomi.mobile
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SelectionGateTest {
@Test fun acceptsOnlyTheInitiatingContextOnce() {
val gate = SelectionGate()
assertTrue(gate.begin(0))
assertTrue(gate.consume(0))
assertFalse(gate.consume(0))
}
@Test fun serverSwitchCannotDeliverAnOldSelectionToTheNewServer() {
val gate = SelectionGate()
assertTrue(gate.begin(0))
assertFalse(gate.begin(1))
assertFalse(gate.consume(1))
assertTrue(gate.begin(1))
assertTrue(gate.consume(1))
}
@Test fun processRecreationCancelsSelectionWithoutReusingItsCallback() {
val gate = SelectionGate()
gate.restoreOutstanding()
assertFalse(gate.begin(0))
assertFalse(gate.consume(0))
assertTrue(gate.begin(0))
}
}
+5
View File
@@ -0,0 +1,5 @@
<resources>
<string name="app_name">Yuvomi Mobile</string>
<string name="checking_server">Checking your server…</string>
<string name="probe_failed">Cannot verify a compatible Yuvomi server. Check the URL, TLS certificate, VPN, and reverse-proxy access.</string>
</resources>
+111
View File
@@ -0,0 +1,111 @@
package community.yuvomi.mobile
import android.content.Context
import android.webkit.WebView
import androidx.core.content.FileProvider
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import org.json.JSONArray
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class BlobExportTest {
@Test fun rejectsBlobExportFromOpaqueNativePageEvenWithClaimedOrigin() {
val context = ApplicationProvider.getApplicationContext<Context>()
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit()
.putString("origin", "https://family.example").commit()
val file = File(File(context.cacheDir, "captures").apply { mkdirs() }, "blob-test.csv")
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
val worker = Executors.newSingleThreadExecutor()
try {
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
lateinit var web: WebView
val created = CountDownLatch(1)
var blob = ""
scenario.onActivity {
// A real WebView with a controlled document, not a network mock.
// The HTTPS upstream suite separately exercises the app's client.
web = WebView(it)
web.settings.javaScriptEnabled = true
web.loadDataWithBaseURL(
"https://family.example/",
"<!doctype html><title>Export test</title>",
"text/html",
"UTF-8",
null,
)
}
val deadline = System.nanoTime() + 10_000_000_000L
var ready = false
while (!ready && System.nanoTime() < deadline) {
val checked = CountDownLatch(1)
scenario.onActivity {
web.evaluateJavascript("location.origin") {
ready =
it == "\"https://family.example\""
checked.countDown()
}
}
assertTrue(checked.await(2, TimeUnit.SECONDS))
if (!ready) Thread.sleep(50)
}
assertTrue("Trusted document loaded", ready)
scenario.onActivity {
web.evaluateJavascript(
"URL.createObjectURL(new Blob(['name,value\\nYuvomi,Grüße\\n'],{type:'text/csv'}))",
) {
blob = JSONArray("[$it]").getString(0)
created.countDown()
}
}
assertTrue(created.await(10, TimeUnit.SECONDS))
assertTrue(
"Blob inherits trusted origin: $blob",
BlobTransfer.owns(ServerAddress.parse("https://family.example"), blob),
)
assertNotNull(
context.contentResolver.openOutputStream(uri, "w")?.also {
it.close()
},
)
val saved = CountDownLatch(1)
var success = false
scenario.onActivity {
assertFalse(
"Native page URL remains opaque despite loadDataWithBaseURL",
ServerAddress.parse("https://family.example").owns(web.url.orEmpty()),
)
BlobTransfer.save(
ServerAddress.parse("https://family.example"),
web,
blob,
context.contentResolver,
uri,
worker,
{ true },
) {
success = it
saved.countDown()
}
}
assertTrue(saved.await(50, TimeUnit.SECONDS))
assertFalse(success)
assertEquals(0L, file.length())
scenario.onActivity { web.destroy() }
}
} finally {
worker.shutdownNow()
file.delete()
}
}
}
@@ -0,0 +1,66 @@
package community.yuvomi.mobile
import android.content.Context
import android.net.Uri
import android.view.View
import android.view.ViewGroup
import android.webkit.WebResourceRequest
import android.webkit.WebView
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class BrowserSecurityTest {
@Test fun blocksCrossOriginPostAndSubresourceBeforeNetwork() {
val context = ApplicationProvider.getApplicationContext<Context>()
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit()
.putString("origin", "https://family.example").commit()
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
lateinit var web: WebView
lateinit var client: android.webkit.WebViewClient
scenario.onActivity {
web = requireNotNull(findWeb(it.window.decorView))
client = web.webViewClient
}
// Interception runs off the UI thread in WebView. POST navigations do
// not reliably call shouldOverrideUrlLoading, so this is a separate gate.
for (mainFrame in listOf(true, false)) {
val response = client.shouldInterceptRequest(
web,
Request("https://evil.example/collect", mainFrame),
)
assertNotNull(response)
assertEquals(403, response!!.statusCode)
}
assertNull(
client.shouldInterceptRequest(web, Request("https://family.example/tasks", true)),
)
}
}
private fun findWeb(view: View): WebView? {
if (view is WebView) return view
if (view is ViewGroup) {
for (i in 0 until view.childCount) {
findWeb(view.getChildAt(i))?.let {
return it
}
}
}
return null
}
private class Request(private val value: String, private val mainFrame: Boolean) : WebResourceRequest {
override fun getUrl(): Uri = Uri.parse(value)
override fun isForMainFrame() = mainFrame
override fun isRedirect() = false
override fun hasGesture() = true
override fun getMethod() = "POST"
override fun getRequestHeaders(): MutableMap<String, String> = mutableMapOf()
}
}
@@ -0,0 +1,71 @@
package community.yuvomi.mobile
import android.Manifest
import android.accessibilityservice.AccessibilityServiceInfo
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.view.accessibility.AccessibilityNodeInfo
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class LocalNetworkPermissionTest {
@Test fun permissionIsOptionalAndHonorsSystemDenialAndGrant() {
val context = ApplicationProvider.getApplicationContext<Context>()
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit().clear().commit()
ActivityScenario.launch(MainActivity::class.java).use {
if (Build.VERSION.SDK_INT < 37 ||
context.checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) == PackageManager.PERMISSION_GRANTED
) {
onView(withText("Allow private-network servers")).check(doesNotExist())
return@use
}
val automation = InstrumentationRegistry.getInstrumentation().uiAutomation
automation.serviceInfo = automation.serviceInfo.apply { flags = flags or AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS }
onView(withText("Allow private-network servers")).perform(click())
onView(withText("Cancel")).perform(click())
assertEquals(PackageManager.PERMISSION_DENIED, context.checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK))
onView(withText("Allow private-network servers")).perform(click())
onView(withText("Continue")).perform(click())
chooseSystemButton("permission_deny_button")
await { context.checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) == PackageManager.PERMISSION_DENIED }
onView(withText("Allow private-network servers")).perform(click())
onView(withText("Continue")).perform(click())
chooseSystemButton("permission_allow_button")
await { context.checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) == PackageManager.PERMISSION_GRANTED }
}
}
private fun chooseSystemButton(id: String) {
val automation = InstrumentationRegistry.getInstrumentation().uiAutomation
await {
val node = find(automation.rootInActiveWindow, id) ?: return@await false
node.performAction(AccessibilityNodeInfo.ACTION_CLICK)
}
}
private fun find(node: AccessibilityNodeInfo?, id: String): AccessibilityNodeInfo? {
if (node == null) return null
if (node.viewIdResourceName?.endsWith("/$id") == true) return node
for (index in 0 until node.childCount) find(node.getChild(index), id)?.let { return it }
return null
}
private fun await(condition: () -> Boolean) {
val deadline = System.nanoTime() + 10_000_000_000L
while (System.nanoTime() < deadline) {
if (condition()) return
Thread.sleep(100)
}
assertTrue("System permission response timed out", condition())
}
}
+93
View File
@@ -0,0 +1,93 @@
package community.yuvomi.mobile
import android.content.Context
import android.webkit.WebView
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.closeSoftKeyboard
import androidx.test.espresso.action.ViewActions.replaceText
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.hasErrorText
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class OnboardingTest {
private val context: Context get() = ApplicationProvider.getApplicationContext()
@Before fun clearConfiguration() {
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit().clear().commit()
}
@Test fun rejectsHttpAndSubpathsLocally() {
ActivityScenario.launch(MainActivity::class.java).use {
onView(
withContentDescription("Yuvomi server URL"),
).perform(replaceText("http://family.example"), closeSoftKeyboard())
onView(withText("Connect securely")).perform(click())
onView(
withContentDescription("Yuvomi server URL"),
).check(matches(hasErrorText("Use HTTPS with a valid server hostname.")))
onView(
withContentDescription("Yuvomi server URL"),
).perform(replaceText("https://family.example/subpath"), closeSoftKeyboard())
onView(withText("Connect securely")).perform(click())
onView(
withContentDescription("Yuvomi server URL"),
).check(matches(hasErrorText("Yuvomi requires a dedicated hostname, not a subpath.")))
}
}
@Test fun survivesRecreationWithoutLosingOnboarding() {
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
scenario.recreate()
onView(withText("Connect securely")).check(matches(isDisplayed()))
onView(withContentDescription("Yuvomi server URL")).check(matches(isEnabled()))
}
}
@Test fun webViewIsHardenedAndBackgroundingPreservesInstance() {
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit()
.putString("origin", "https://family.example").commit()
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
var initial: WebView? = null
scenario.onActivity { activity ->
fun find(view: android.view.View): WebView? {
if (view is WebView) return view
if (view is android.view.ViewGroup) {
for (i in 0 until view.childCount) {
find(view.getChildAt(i))?.let {
return it
}
}
}
return null
}
initial = find(activity.window.decorView)
val web = requireNotNull(initial)
assertFalse(web.settings.allowFileAccess)
assertFalse(web.settings.allowContentAccess)
assertEquals(
android.webkit.WebSettings.MIXED_CONTENT_NEVER_ALLOW,
web.settings.mixedContentMode,
)
assertTrue(web.settings.javaScriptEnabled)
}
scenario.moveToState(androidx.lifecycle.Lifecycle.State.CREATED)
scenario.moveToState(androidx.lifecycle.Lifecycle.State.RESUMED)
scenario.onActivity { assertNotNull(initial?.parent) }
}
}
}
@@ -0,0 +1,333 @@
package community.yuvomi.mobile
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.WebView
import androidx.core.content.FileProvider
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.closeSoftKeyboard
import androidx.test.espresso.action.ViewActions.replaceText
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import java.io.File
import java.net.URI
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import javax.net.ssl.HttpsURLConnection
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class UpstreamIntegrationTest {
@Test fun actualUpstreamOnboardingAuthenticationLifecycleAndLogout() {
val arguments = InstrumentationRegistry.getArguments()
val origin = requireNotNull(arguments.getString("server"))
val password = requireNotNull(arguments.getString("password"))
val context = ApplicationProvider.getApplicationContext<Context>()
if (android.os.Build.VERSION.SDK_INT >= 37) {
InstrumentationRegistry.getInstrumentation().uiAutomation.grantRuntimePermission(
context.packageName,
android.Manifest.permission.ACCESS_LOCAL_NETWORK,
)
}
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit().clear().commit()
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
onView(
withContentDescription("Yuvomi server URL"),
).perform(replaceText(origin), closeSoftKeyboard())
onView(withText("Connect securely")).perform(click())
var web: WebView? = null
await("native onboarding completed") {
scenario.onActivity { web = findWeb(it.window.decorView) }
web != null
}
await("actual upstream application loaded") {
documentLoaded(scenario, requireNotNull(web), origin)
}
// Run in the real WebView origin, preserving real Set-Cookie and CSRF.
val credentials = JSONObject().put(
"username",
"mobiletest",
).put("password", password).toString()
evaluate(
scenario,
requireNotNull(web),
"""
window.__mobileResult = null;
fetch('/api/v1/auth/login', {method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify($credentials)}).then(async r => {
const data = await r.json(); window.__mobileCsrf=data.csrfToken;
window.__mobileResult=r.status;
});
""".trimIndent(),
)
await("upstream password login") {
evaluate(scenario, requireNotNull(web), "window.__mobileResult") ==
"200"
}
assertEquals(
"false",
evaluate(scenario, requireNotNull(web), "document.cookie.includes('yuvomi.sid=')"),
)
evaluate(
scenario,
requireNotNull(web),
"""
window.__mobileResult=null;
fetch('/api/v1/documents',{method:'POST',headers:{'Content-Type':'application/json',
'X-CSRF-Token':window.__mobileCsrf},body:JSON.stringify({name:'Mobile upload',
original_name:'mobile.txt',content_data:'data:text/plain;base64,WXV2b21pIGZpbGUgdGVzdAo='})})
.then(async r=>{const d=await r.json();window.__mobileDocument=d.data?.id;window.__mobileResult=r.status});
""".trimIndent(),
)
await("real upstream file upload") {
evaluate(scenario, requireNotNull(web), "window.__mobileResult") ==
"201"
}
val documentId = evaluate(
scenario,
requireNotNull(web),
"window.__mobileDocument",
).toLong()
val downloadURL = "$origin/api/v1/documents/$documentId/download"
var cookie = ""
scenario.onActivity { cookie = CookieManager.getInstance().getCookie(downloadURL) }
val file =
File(File(context.cacheDir, "captures").apply { mkdirs() }, "upstream-download.txt")
val destination = FileProvider.getUriForFile(
context,
"${context.packageName}.files",
file,
)
try {
FileTransfer.save(
ServerAddress.parse(origin),
downloadURL,
cookie,
context.contentResolver,
destination,
)
assertEquals("Yuvomi file test\n", file.readText())
} finally {
file.delete()
}
val blobResult =
evaluate(
scenario,
requireNotNull(web),
"URL.createObjectURL(new Blob(['Yuvomi export'],{type:'text/plain'}))",
)
val blobURL = JSONArray("[$blobResult]").getString(0)
val exported = CountDownLatch(1)
val exportWorker = Executors.newSingleThreadExecutor()
var exportSuccess = false
try {
scenario.onActivity {
BlobTransfer.save(
ServerAddress.parse(origin),
requireNotNull(web),
blobURL,
context.contentResolver,
destination,
exportWorker,
{ true },
) {
exportSuccess = it
exported.countDown()
}
}
assertTrue(exported.await(50, TimeUnit.SECONDS))
assertTrue("Blob export in hardened application WebView", exportSuccess)
assertEquals("Yuvomi export", file.readText())
} finally {
exportWorker.shutdownNow()
file.delete()
}
scenario.moveToState(androidx.lifecycle.Lifecycle.State.CREATED)
scenario.moveToState(androidx.lifecycle.Lifecycle.State.RESUMED)
evaluate(
scenario,
requireNotNull(web),
"window.__mobileResult=null;fetch('/api/v1/auth/me').then(r=>window.__mobileResult=r.status)",
)
await("session survives backgrounding") {
evaluate(scenario, requireNotNull(web), "window.__mobileResult") ==
"200"
}
scenario.recreate()
await("restored WebView") {
scenario.onActivity { web = findWeb(it.window.decorView) }
web != null && documentLoaded(scenario, requireNotNull(web), origin)
}
await("persistent session after activity recreation") {
evaluate(
scenario,
requireNotNull(web),
"""
if(!window.__mobileSessionProbe){window.__mobileSessionProbe=true;
fetch('/api/v1/auth/me').then(async r=>{const d=await r.json();
window.__mobileCsrf=d.csrfToken;window.__mobileResult=r.status})}
window.__mobileResult
""".trimIndent(),
) ==
"200"
}
evaluate(
scenario,
requireNotNull(web),
"""
window.__mobileResult=null;
fetch('/api/v1/auth/logout',{method:'POST',headers:{'Content-Type':'application/json',
'X-CSRF-Token':window.__mobileCsrf},body:'{}'}).then(r=>window.__mobileResult=r.status);
""".trimIndent(),
)
await("CSRF-protected logout") {
evaluate(scenario, requireNotNull(web), "window.__mobileResult") ==
"200"
}
evaluate(
scenario,
requireNotNull(web),
"window.__mobileResult=null;fetch('/api/v1/auth/me').then(r=>window.__mobileResult=r.status)",
)
await("session revoked") {
evaluate(scenario, requireNotNull(web), "window.__mobileResult") ==
"401"
}
// The upstream worker may legitimately serve the cached app shell
// during an outage. Use non-cacheable metadata to exercise native
// main-frame network recovery without disabling that worker.
evaluate(
scenario,
requireNotNull(web),
"fetch('/__mobile_test/network/off',{method:'POST'}).then(()=>location.assign('/api/v1/version'))",
)
await("native network error recovery panel") {
var visible = false
scenario.onActivity { visible = hasText(it.window.decorView, "Connection interrupted") }
visible
}
val recovery = URI("$origin/__mobile_test/network/on").toURL().openConnection() as HttpsURLConnection
try {
recovery.requestMethod = "POST"
recovery.connectTimeout = 5_000
recovery.readTimeout = 5_000
assertEquals(200, recovery.responseCode)
} finally {
recovery.disconnect()
}
onView(withText("Retry")).perform(click())
await("reconnected to real upstream metadata") {
evaluate(scenario, requireNotNull(web), "document.body?.innerText.includes('app_name')") == "true"
}
scenario.onActivity { requireNotNull(web).loadUrl("$origin/") }
await("application restored after outage") {
var restored = false
scenario.onActivity { restored = requireNotNull(web).url == "$origin/" }
restored && documentLoaded(scenario, requireNotNull(web), origin)
}
scenario.onActivity { requireNotNull(web).loadUrl("$origin/api/v1/auth/oidc/start") }
await("native consent before leaving Yuvomi for OIDC") {
runCatching { onView(withText("Sign-in window")).check(matches(isDisplayed())) }.isSuccess
}
onView(withText("Sign-in window")).perform(click())
await("OIDC callback returns to main application") {
var returned = false
scenario.onActivity { returned = requireNotNull(web).url == "$origin/" }
val signInClosed = runCatching {
onView(withText("Close")).check(doesNotExist())
}.isSuccess
signInClosed && returned &&
documentLoaded(scenario, requireNotNull(web), origin)
}
evaluate(
scenario,
requireNotNull(web),
"window.__mobileSubject=null;fetch('/api/v1/auth/me').then(r=>r.json()).then(d=>window.__mobileSubject=d.user?.username)",
)
await("isolated OIDC view shares the initiating server session") {
evaluate(scenario, requireNotNull(web), "window.__mobileSubject") == "\"mobile-oidc\""
}
var previous = ""
scenario.onActivity {
previous = requireNotNull(web).url.orEmpty()
requireNotNull(web).loadUrl("$origin/tasks")
}
await("internal module navigation") {
var loaded = false
scenario.onActivity { loaded = requireNotNull(web).url == "$origin/tasks" && requireNotNull(web).canGoBack() }
loaded && documentLoaded(scenario, requireNotNull(web), origin)
}
scenario.onActivity { it.onBackPressedDispatcher.onBackPressed() }
await("native back restores WebView history") {
var restored = false
scenario.onActivity { restored = requireNotNull(web).url == previous }
restored
}
}
}
private fun documentLoaded(scenario: ActivityScenario<MainActivity>, web: WebView, origin: String): Boolean = evaluate(
scenario,
web,
"location.origin===${JSONObject.quote(origin)} && document.readyState==='complete'",
) == "true"
private fun evaluate(scenario: ActivityScenario<MainActivity>, web: WebView, javascript: String): String {
val completed = CountDownLatch(1)
var result = ""
scenario.onActivity {
web.evaluateJavascript(javascript) {
result = it
completed.countDown()
}
}
assertTrue("JavaScript evaluation timed out", completed.await(10, TimeUnit.SECONDS))
return result
}
private fun await(label: String, condition: () -> Boolean) {
val deadline = System.nanoTime() + 30_000_000_000L
while (System.nanoTime() < deadline) {
if (condition()) return
Thread.sleep(100)
}
fail("Timed out: $label")
}
private fun findWeb(view: View): WebView? {
if (view is WebView) return view
if (view is ViewGroup) {
for (i in 0 until view.childCount) {
findWeb(view.getChildAt(i))?.let {
return it
}
}
}
return null
}
private fun hasText(view: View, text: String): Boolean {
if (view is android.widget.TextView && view.text.toString() == text) return true
if (view is ViewGroup) {
for (index in 0 until view.childCount) {
if (hasText(view.getChildAt(index), text)) return true
}
}
return false
}
}
+77
View File
@@ -0,0 +1,77 @@
import SwiftUI
import WebKit
struct AuthenticationView: View {
let destination: URL
@ObservedObject var model: BrowserModel
var body: some View {
NavigationStack {
IsolatedAuthentication(url: destination, model: model)
.navigationTitle(model.authenticationHost)
.navigationBarTitleDisplayMode(.inline)
.toolbar { Button("Close") { model.authentication = nil } }
}
}
}
struct IsolatedAuthentication: UIViewRepresentable {
let url: URL
let model: BrowserModel
func makeCoordinator() -> Coordinator { Coordinator(model) }
func makeUIView(context: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
// Same cookie store as the initiating Yuvomi handshake; no manual cookie export.
configuration.websiteDataStore = model.web.configuration.websiteDataStore
configuration.preferences.javaScriptCanOpenWindowsAutomatically = false
configuration.mediaTypesRequiringUserActionForPlayback = .all
let view = WKWebView(frame: .zero, configuration: configuration)
view.navigationDelegate = context.coordinator
view.uiDelegate = context.coordinator
if #available(iOS 16.4, *) { view.isInspectable = false }
view.load(URLRequest(url: url))
return view
}
func updateUIView(_ uiView: WKWebView, context: Context) {}
static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) {
uiView.stopLoading()
uiView.navigationDelegate = nil
uiView.uiDelegate = nil
}
final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate {
let model: BrowserModel
init(_ model: BrowserModel) { self.model = model }
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
model.authenticationHost = webView.url?.host ?? "Sign in"
}
func webView(
_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
guard let url = navigationAction.request.url, url.scheme == "https",
url.user == nil, url.password == nil
else {
decisionHandler(.cancel)
return
}
decisionHandler(.allow)
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
if let url = webView.url, model.server?.owns(url) == true, !url.path.hasPrefix("/api/") {
model.authentication = nil
model.web.load(URLRequest(url: url))
}
}
func webView(
_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType,
decisionHandler: @escaping (WKPermissionDecision) -> Void
) { decisionHandler(.deny) }
func webView(
_ webView: WKWebView, runOpenPanelWith parameters: WKOpenPanelParameters,
initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping @MainActor @Sendable ([URL]?) -> Void
) {
completionHandler(nil)
}
}
}
+310
View File
@@ -0,0 +1,310 @@
import SwiftUI
import WebKit
struct WebDestination: Identifiable {
let id = UUID()
let url: URL
}
@MainActor
final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUIDelegate, WKDownloadDelegate {
@Published var loading = false
@Published var canGoBack = false
@Published var error: String?
@Published var externalURL: URL?
@Published var authentication: WebDestination?
@Published var authenticationHost = "Sign in"
@Published var download: WebDestination?
private(set) var server: ServerAddress?
private(set) var web: WKWebView
private var observations: [NSKeyValueObservation] = []
private var deadline: Timer?
private var activeDownload: WKDownload?
private var downloadLocation: URL?
override init() {
web = Self.makeWebView()
super.init()
configureWebView()
// Only app-owned temporary exports are removed, never user-saved documents.
let folder = Self.exportDirectory
try? FileManager.default.removeItem(at: folder)
}
private static func makeWebView() -> WKWebView {
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .default()
configuration.preferences.javaScriptCanOpenWindowsAutomatically = false
configuration.allowsInlineMediaPlayback = true
configuration.mediaTypesRequiringUserActionForPlayback = .all
return WKWebView(frame: .zero, configuration: configuration)
}
private func configureWebView() {
web.navigationDelegate = self
web.uiDelegate = self
web.allowsBackForwardNavigationGestures = true
web.isOpaque = false
if #available(iOS 16.4, *) { web.isInspectable = false }
observations.append(
web.observe(\.canGoBack, options: [.new]) { [weak self] view, _ in
self?.canGoBack = view.canGoBack
})
}
static var exportDirectory: URL {
FileManager.default.temporaryDirectory.appendingPathComponent("YuvomiExports", isDirectory: true)
}
func open(_ server: ServerAddress) {
self.server = server
let path = UserDefaults.standard.string(forKey: "path") ?? "/"
let restored = URL(string: path, relativeTo: server.url)?.absoluteURL
let url = restored.flatMap { server.owns($0) ? $0 : nil } ?? server.url
web.load(URLRequest(url: url))
}
func rememberPath() {
guard let url = web.url, server?.owns(url) == true else { return }
let path = url.path
guard !["auth", "reset", "invite", "login"].contains(where: { path.localizedCaseInsensitiveContains($0) })
else { return }
UserDefaults.standard.set(path, forKey: "path")
}
func erase(completion: @escaping () -> Void) {
deadline?.invalidate()
web.stopLoading()
activeDownload?.cancel { _ in }
activeDownload = nil
authentication = nil
removeDownload()
server = nil
web.navigationDelegate = nil
web.uiDelegate = nil
observations.removeAll()
WKWebsiteDataStore.default().removeData(
ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(),
modifiedSince: .distantPast
) { [weak self] in
guard let self else { return }
self.web = Self.makeWebView()
self.configureWebView()
self.canGoBack = false
self.loading = false
self.error = nil
completion()
}
}
func startAuthentication(_ url: URL) {
guard url.scheme == "https", url.user == nil, url.password == nil else { return }
authenticationHost = url.host ?? "Sign in"
authentication = WebDestination(url: url)
}
func removeDownload(expected: URL? = nil) {
if let expected, download?.url != expected, downloadLocation != expected { return }
download = nil
if let location = downloadLocation {
try? FileManager.default.removeItem(at: location.deletingLastPathComponent())
}
downloadLocation = nil
}
func webView(
_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
guard let url = navigationAction.request.url else {
decisionHandler(.cancel)
return
}
if server?.owns(url) == true || (navigationAction.shouldPerformDownload && server?.ownsDownload(url) == true) {
decisionHandler(navigationAction.shouldPerformDownload ? .download : .allow)
} else {
decisionHandler(.cancel)
if navigationAction.targetFrame?.isMainFrame != false {
if url.scheme == "https", url.host != nil, url.user == nil, url.password == nil {
externalURL = url
} else {
error = "This link type is blocked for your security."
}
}
}
}
func webView(
_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse,
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void
) {
if let response = navigationResponse.response as? HTTPURLResponse,
navigationResponse.isForMainFrame, response.statusCode >= 400
{
error = "The server is unavailable (HTTP \(response.statusCode))."
}
guard let url = navigationResponse.response.url, server?.ownsDownload(url) == true else {
decisionHandler(.cancel)
return
}
if !navigationResponse.canShowMIMEType {
guard navigationResponse.response.expectedContentLength <= 100 * 1024 * 1024 else {
error = "This file exceeds the 100 MB download limit."
decisionHandler(.cancel)
return
}
decisionHandler(.download)
} else {
decisionHandler(.allow)
}
}
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
loading = true
deadline?.invalidate()
deadline = Timer.scheduledTimer(withTimeInterval: 30, repeats: false) { [weak self] _ in
Task { @MainActor in
self?.loading = false
self?.error = "The server is taking too long. Check your connection or try again."
}
}
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
loading = false
deadline?.invalidate()
rememberPath()
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { failed(error) }
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
failed(error)
}
private func failed(_ failure: Error) {
loading = false
deadline?.invalidate()
guard (failure as NSError).code != NSURLErrorCancelled else { return }
error =
"Unable to connect securely. Check your network, VPN and server certificate. Cached pages may still be available."
}
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
error = "Yuvomi's browser was interrupted. Tap Retry to restore the page."
loading = false
}
func webView(
_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures
) -> WKWebView? {
if let url = navigationAction.request.url {
if server?.owns(url) == true {
web.load(navigationAction.request)
} else if url.scheme == "https", url.user == nil, url.password == nil {
externalURL = url
}
}
return nil
}
func webView(
_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType,
decisionHandler: @escaping (WKPermissionDecision) -> Void
) {
// File input capture uses Apple's picker. Live camera/microphone access is not required by upstream.
decisionHandler(.deny)
}
func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
attach(download)
}
func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) {
attach(download)
}
private func attach(_ download: WKDownload) {
guard activeDownload == nil, self.download == nil else {
download.cancel { _ in }
error = "Finish the current download before starting another."
return
}
activeDownload = download
download.delegate = self
}
func download(
_ download: WKDownload, decideDestinationUsing response: URLResponse,
suggestedFilename: String, completionHandler: @escaping (URL?) -> Void
) {
guard let url = response.url, server?.ownsDownload(url) == true,
response.expectedContentLength <= 100 * 1024 * 1024
else {
completionHandler(nil)
return
}
do {
let folder = Self.exportDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(
at: folder, withIntermediateDirectories: true,
attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication])
var values = URLResourceValues()
values.isExcludedFromBackup = true
var directory = folder
try directory.setResourceValues(values)
let name = String(
suggestedFilename.map { $0.isLetter || $0.isNumber || "._ -".contains($0) ? $0 : "_" }.prefix(120))
let safeName = name.isEmpty || name == "." || name == ".." ? "download" : name
downloadLocation = folder.appendingPathComponent(safeName)
completionHandler(downloadLocation)
} catch {
completionHandler(nil)
self.error = "Could not prepare the download."
}
}
func download(
_ download: WKDownload, willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest, decisionHandler: @escaping (WKDownload.RedirectPolicy) -> Void
) {
// Do not replay session credentials to a redirect target.
decisionHandler(.cancel)
}
func downloadDidFinish(_ download: WKDownload) {
guard activeDownload === download else { return }
activeDownload = nil
guard let location = downloadLocation,
let size = try? location.resourceValues(forKeys: [.fileSizeKey]).fileSize,
size <= 100 * 1024 * 1024
else {
removeDownload()
error = "Download exceeded the size limit."
return
}
self.download = WebDestination(url: location)
}
func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) {
guard activeDownload === download else { return }
activeDownload = nil
removeDownload()
self.error = "Download failed. Check your connection and sign-in, then retry."
}
}
struct BrowserView: UIViewRepresentable {
@ObservedObject var model: BrowserModel
func makeUIView(context: Context) -> WKWebView { model.web }
func updateUIView(_ uiView: WKWebView, context: Context) {}
}
struct ExportView: UIViewControllerRepresentable {
let url: URL
let completion: () -> Void
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
let picker = UIDocumentPickerViewController(forExporting: [url], asCopy: true)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
func makeCoordinator() -> Coordinator { Coordinator(completion) }
final class Coordinator: NSObject, UIDocumentPickerDelegate {
let completion: () -> Void
init(_ completion: @escaping () -> Void) { self.completion = completion }
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { completion() }
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
completion()
}
}
}
+40
View File
@@ -0,0 +1,40 @@
import WebKit
import XCTest
@testable import Yuvomi
final class BrowserModelTests: XCTestCase {
@MainActor
func testMainViewHasNoNativeBridgeOrAutomaticPopupAccess() {
let model = BrowserModel()
XCTAssertFalse(model.web.configuration.preferences.javaScriptCanOpenWindowsAutomatically)
XCTAssertTrue(model.web.configuration.userContentController.userScripts.isEmpty)
XCTAssertTrue(model.web.configuration.websiteDataStore.isPersistent)
XCTAssertFalse(model.web.isInspectable)
}
@MainActor
func testLateExportCancellationDoesNotEraseAnotherServersDownload() {
let model = BrowserModel()
let previous = URL(fileURLWithPath: "/old-selection.txt")
let current = URL(fileURLWithPath: "/new-selection.txt")
model.download = WebDestination(url: current)
model.removeDownload(expected: previous)
XCTAssertEqual(model.download?.url, current)
model.removeDownload(expected: current)
XCTAssertNil(model.download)
}
@MainActor
func testServerErasureReplacesHistoryContext() async {
let model = BrowserModel()
let previousView = model.web
await withCheckedContinuation { continuation in
model.erase { continuation.resume() }
}
XCTAssertFalse(previousView === model.web)
XCTAssertNil(model.server)
XCTAssertFalse(model.canGoBack)
XCTAssertNil(model.error)
}
}
+178
View File
@@ -0,0 +1,178 @@
import SwiftUI
import WebKit
struct ContentView: View {
@StateObject private var browser = BrowserModel()
@State private var input = UserDefaults.standard.string(forKey: "origin") ?? ""
@State private var server: ServerAddress?
@State private var connecting = false
@State private var message: String?
@State private var settings = false
@State private var erasing = false
@Environment(\.scenePhase) private var scenePhase
@Environment(\.colorScheme) private var colorScheme
var body: some View {
NavigationStack {
Group {
if server != nil {
ZStack {
BrowserView(model: browser)
if let error = browser.error {
VStack(spacing: 18) {
Image(systemName: "wifi.exclamationmark").font(.largeTitle)
Text("Connection interrupted").font(.title2).bold()
Text(error)
Button("Retry") {
browser.error = nil
browser.web.reload()
}
Button("Keep viewing") { browser.error = nil }
Button("Server settings") { settings = true }
}
.padding(24).frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(uiColor: .systemGroupedBackground))
.accessibilityElement(children: .contain)
}
if browser.loading {
ProgressView("Loading Yuvomi").padding().background(.regularMaterial).cornerRadius(12)
}
}
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
browser.web.goBack()
} label: {
Image(systemName: "chevron.backward")
}
.disabled(!browser.canGoBack).accessibilityLabel("Back")
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Server settings") { settings = true }
}
}
} else {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Image(systemName: "house.fill").font(.system(size: 48)).foregroundStyle(.tint)
.accessibilityHidden(true)
Text("Your home, connected").font(.largeTitle).bold()
Text("Connect to your own Yuvomi server. Your data stays with your household.")
TextField("https://family.example.com", text: $input)
.keyboardType(.URL).textContentType(.URL)
.textInputAutocapitalization(.never).autocorrectionDisabled()
.textFieldStyle(.roundedBorder).accessibilityLabel("Yuvomi server URL")
Text("HTTPS required. Custom ports supported. Use a dedicated hostname, not a subpath.")
.font(.callout).foregroundStyle(.secondary)
if let message { Text(message).accessibilityIdentifier("connectionError") }
if connecting { ProgressView("Checking your server") }
Button("Connect securely") { connect() }
.buttonStyle(.borderedProminent).disabled(connecting || erasing)
.frame(minHeight: 48)
Text("Independent community app. No telemetry or mandatory cloud service.")
.font(.footnote).foregroundStyle(.secondary)
}.padding(24)
}.background(Color(uiColor: .systemGroupedBackground))
}
}
.navigationTitle("Yuvomi Mobile").navigationBarTitleDisplayMode(.inline)
.tint(
colorScheme == .dark
? Color(red: 167 / 255, green: 139 / 255, blue: 250 / 255)
: Color(red: 108 / 255, green: 58 / 255, blue: 237 / 255))
}
.task {
if server == nil, let existing = try? ServerAddress(input) {
server = existing
browser.open(existing)
}
}
.onChange(of: scenePhase) { _, phase in
if phase == .background { browser.rememberPath() }
}
.overlay {
if scenePhase != .active {
Color(uiColor: .systemBackground).ignoresSafeArea()
.overlay(Text("Yuvomi Mobile").font(.title).bold())
}
}
.sheet(isPresented: $settings) {
NavigationStack {
Form {
Section("Your server") {
Text(server?.url.absoluteString ?? "")
if let url = server?.url {
ShareLink("Share server address", item: url)
Link("Open server in system browser", destination: url)
}
}
Section {
Text(
"Changing servers erases cookies, storage and cached household data. Exported files remain. Sign out in Yuvomi first to revoke the server session."
)
Button("Erase local data and change server", role: .destructive) {
settings = false
erasing = true
browser.erase {
UserDefaults.standard.removeObject(forKey: "origin")
UserDefaults.standard.removeObject(forKey: "path")
server = nil
input = ""
erasing = false
}
}
}
Section("About") {
Text(
"Independent community contribution. MIT licensed. Not endorsed by upstream Yuvomi. No mobile telemetry. Your server's own privacy settings still apply."
)
}
}.navigationTitle("Server settings")
.toolbar { Button("Done") { settings = false } }
}
}
.alert(
"External destination",
isPresented: Binding(
get: { browser.externalURL != nil }, set: { if !$0 { browser.externalURL = nil } }
), presenting: browser.externalURL
) { url in
Button("Open in browser") {
UIApplication.shared.open(url)
browser.externalURL = nil
}
Button("Isolated sign-in window") {
browser.startAuthentication(url)
browser.externalURL = nil
}
Button("Cancel", role: .cancel) { browser.externalURL = nil }
} message: { url in
Text(
"Destination: \(url.host ?? "")\nFor SSO, use the isolated window only if you trust this provider. Browser sessions do not transfer into the app."
)
}
.sheet(item: $browser.authentication) { destination in
AuthenticationView(destination: destination.url, model: browser)
}
.sheet(item: $browser.download) { file in
ExportView(url: file.url) { browser.removeDownload(expected: file.url) }
}
}
private func connect() {
do {
let candidate = try ServerAddress(input)
connecting = true
message = nil
Task { @MainActor in
defer { connecting = false }
do {
try await ServerProbe.check(candidate)
UserDefaults.standard.set(candidate.url.absoluteString, forKey: "origin")
server = candidate
browser.open(candidate)
} catch { message = ConnectionError.unavailable.localizedDescription }
}
} catch { message = ConnectionError.invalidAddress.localizedDescription }
}
}
+105
View File
@@ -0,0 +1,105 @@
// Browser automation is a reference-measurement tool, never an application renderer.
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
const [playwrightModule, destination] = process.argv.slice(2);
const require = createRequire(import.meta.url);
const { chromium } = require(playwrightModule);
const origin = process.env.YUVOMI_TEST_URL;
if (!/^https:\/\/127\.0\.0\.1:\d+$/.test(origin)) throw new Error('Only the temporary loopback fixture is allowed');
const browser = await chromium.launch({ headless: true, args: process.getuid?.() === 0 ? ['--no-sandbox'] : [] });
const width = Number(process.env.YUVOMI_VISUAL_WIDTH || 360);
const height = Number(process.env.YUVOMI_VISUAL_HEIGHT || 640);
const measurements = {
upstreamRevision: '10bfef7686ed7303b4c74c7fcc55a5eb16356c9d', browser: browser.version(),
androidFontConfiguration: Boolean(process.env.FONTCONFIG_FILE),
fixture: { widgets: ['tasks', 'calendar', 'shopping', 'notes'], mobileFavorites: ['tasks', 'kitchen', 'notes'] }, screens: [],
};
try {
const context = await browser.newContext({
viewport: { width, height }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, reducedMotion: 'reduce',
locale: 'de-DE', timezoneId: 'Europe/Berlin', ignoreHTTPSErrors: true,
userAgent: 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36',
serviceWorkers: 'block',
});
await context.route('**/*', route => new URL(route.request().url()).origin === origin ? route.continue() : route.abort());
const api = context.request;
const password = process.env.YUVOMI_TEST_PASSWORD;
let csrf = '';
async function request(route, data) {
const response = await api.fetch(origin + '/api/v1' + route, {
method: data === undefined ? 'GET' : (route === '/preferences' ? 'PUT' : 'POST'),
data, headers: { 'X-CSRF-Token': csrf },
});
if (!response.ok()) throw new Error(`Fixture API failed at ${route}: HTTP ${response.status()}`);
const result = await response.json();
if (result.csrfToken) csrf = result.csrfToken;
return result;
}
await request('/auth/setup', { username: 'mobiletest', display_name: 'Mobile Test', password });
await request('/auth/login', { username: 'mobiletest', password });
await request('/auth/onboarding-seen', {});
await request('/auth/changelog-seen', {});
const today = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' }).format(new Date());
await request('/tasks', { title: 'Gemeinsam den Alltag planen', priority: 'high', due_date: today });
await request('/notes', { title: 'Willkommen zuhause', content: 'Native Oberfläche, eigener Server.', pinned: 1 });
const list = await request('/shopping', { name: 'Wochenmarkt' });
await request(`/shopping/${list.data.id}/items`, { name: 'Äpfel', quantity: '4' });
// Match the implemented native slice, not a fabricated full-module acceptance.
await request('/preferences', {
dashboard_widgets: ['tasks', 'calendar', 'shopping', 'notes'].map((id, order) => ({ id, visible: true, order, size: '1x1' })),
dashboard_today_glance: false, mobile_nav_order: ['tasks', 'kitchen', 'notes'],
timezone: 'Europe/Berlin', timezone_hint_dismissed: true,
});
await request('/auth/logout', {});
const page = await context.newPage();
await page.addInitScript(() => {
localStorage.setItem('yuvomi-language', 'de');
localStorage.setItem('yuvomi-lang', 'de');
});
const selectors = ['.auth-page', '.auth-hero', '.auth-hero__mark', '.auth-hero__title', '.auth-hero__tagline', '.label', '.input', '.btn--primary', '.dashboard-overview__date',
'.dashboard-overview__title', '.widget', '.widget__header', '.widget__title', '.widget__body', '.nav-bottom__items',
'.nav-bottom .nav-item', '.page-toolbar', '.page-toolbar__title', '.task-card', '.task-card__title', '.note-card', '.list-row'];
async function capture(name) {
await page.evaluate(() => document.fonts.ready);
// Compare neutral states, not a keyboard focus ring left by fixture navigation.
await page.evaluate(() => document.activeElement?.blur?.());
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await page.screenshot({ path: path.join(destination, `${name}.png`), animations: 'disabled' });
measurements.screens.push({ name, viewport: page.viewportSize(), elements: await page.evaluate(selectors =>
selectors.flatMap(selector => [...document.querySelectorAll(selector)].slice(0, 5).map(element => {
const bounds = element.getBoundingClientRect(); const style = getComputedStyle(element);
return { selector, text: element.matches('input') ? '' : element.textContent.trim().slice(0, 80),
x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height,
font: style.fontFamily, fontSize: style.fontSize, fontWeight: style.fontWeight, lineHeight: style.lineHeight,
letterSpacing: style.letterSpacing, color: style.color, background: style.backgroundColor,
radius: style.borderRadius, padding: style.padding };
})), selectors) });
}
for (const theme of ['light', 'dark']) {
await page.emulateMedia({ colorScheme: theme, reducedMotion: 'reduce' });
await page.goto(origin + '/login');
await page.evaluate(value => localStorage.setItem('yuvomi-theme', value), theme);
await page.reload();
await page.locator('#auth-btn').waitFor();
await capture(`login-${theme}-${width}`);
await page.locator('#username').fill('mobiletest');
await page.locator('#password').fill(password);
await page.locator('#auth-btn').click();
await page.locator('.dashboard-overview__title').waitFor();
await page.locator('.widget--tasks .widget__body').waitFor();
await capture(`dashboard-${theme}-${width}`);
for (const [route, selector, name] of [['/tasks', '.task-card', 'tasks'], ['/notes', '.note-card', 'notes'], ['/shopping', '.shopping-item', 'shopping']]) {
await page.goto(origin + route);
await page.locator(selector).first().waitFor();
await capture(`${name}-${theme}-${width}`);
}
await request('/auth/me');
await request('/auth/logout', {});
}
await context.close();
} finally {
fs.writeFileSync(path.join(destination, 'measurements.json'), JSON.stringify(measurements, null, 2) + '\n');
await browser.close();
}
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more