SHA256
Compare commits
21
Commits
No files matched your search
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: gradle
|
||||||
|
directory: /android
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
@@ -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/
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"indentation": { "spaces": 4 },
|
||||||
|
"lineLength": 120,
|
||||||
|
"respectsExistingLineBreaks": true
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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.
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
+90
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+304
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
requestLocalNetwork = { if (Build.VERSION.SDK_INT >= 37) localNetwork.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
setContentView(root)
|
|
||||||
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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=
|
||||||
@@ -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
|
||||||
BIN
Binary file not shown.
+4
-2
@@ -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
|
||||||
Vendored
+5
-8
@@ -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.
|
||||||
|
|||||||
Vendored
+12
-24
@@ -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
|
|
||||||
@@ -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")
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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 |
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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)
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct YuvomiApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup { NativeRootView() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")!))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-2
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
Reference in new issue
Block a user