SHA256
Compare commits
12
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
e0938df256 | ||
|
|
7ac9882307 | ||
|
|
bd0e4cc4fb | ||
|
|
4b483c20e3 | ||
|
|
129eac675c | ||
|
|
92dbd0e06d | ||
|
|
40a7962307 | ||
|
|
1433e1670a | ||
|
|
8c480ddca3 | ||
|
|
81973c3c45 | ||
|
|
97570b7fdf | ||
|
|
11bf4ca620 |
No files matched your search
@@ -16,3 +16,4 @@ max_line_length = 140
|
|||||||
ktlint_code_style = android_studio
|
ktlint_code_style = android_studio
|
||||||
ij_kotlin_allow_trailing_comma = true
|
ij_kotlin_allow_trailing_comma = true
|
||||||
ij_kotlin_allow_trailing_comma_on_call_site = 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
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
name: Android validation
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
concurrency:
|
|
||||||
group: android-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 30
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
|
||||||
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: '17'
|
|
||||||
- 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
|
|
||||||
- name: Check dependencies against OSV
|
|
||||||
run: python3 scripts/check-dependencies.py
|
|
||||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
|
||||||
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:
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
api: ['35', '37.0']
|
|
||||||
timeout-minutes: 35
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
|
||||||
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: '17'
|
|
||||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
|
||||||
with:
|
|
||||||
node-version: '22.22.1'
|
|
||||||
- name: Prepare pinned, unmodified upstream
|
|
||||||
run: |
|
|
||||||
git clone --filter=blob:none https://github.com/ulsklyc/yuvomi.git "$RUNNER_TEMP/upstream"
|
|
||||||
git -C "$RUNNER_TEMP/upstream" checkout 10bfef7686ed7303b4c74c7fcc55a5eb16356c9d
|
|
||||||
npm ci --omit=dev --prefix "$RUNNER_TEMP/upstream"
|
|
||||||
- name: Enable KVM for the emulator
|
|
||||||
run: |
|
|
||||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
|
||||||
sudo udevadm control --reload-rules
|
|
||||||
sudo udevadm trigger --name-match=kvm
|
|
||||||
- uses: 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 6
|
|
||||||
python3 scripts/test-integration.py "$RUNNER_TEMP/upstream" --android
|
|
||||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
name: android-device-test-reports-${{ matrix.api }}
|
|
||||||
path: android/app/build/reports/androidTests/
|
|
||||||
retention-days: 14
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
name: iOS validation
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
concurrency:
|
|
||||||
group: ios-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
jobs:
|
|
||||||
simulator:
|
|
||||||
runs-on: macos-15
|
|
||||||
timeout-minutes: 35
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
|
||||||
- name: Install verified XcodeGen and generate project
|
|
||||||
run: |
|
|
||||||
bash scripts/install-xcodegen.sh "$RUNNER_TEMP/xcodegen"
|
|
||||||
"$RUNNER_TEMP/xcodegen/xcodegen/bin/xcodegen" generate --spec ios/project.yml
|
|
||||||
xcodebuild -version
|
|
||||||
- name: Portable URL policy tests and Swift syntax
|
|
||||||
run: |
|
|
||||||
swift test --package-path ios
|
|
||||||
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 "$RUNNER_TEMP/DerivedData" -resultBundlePath "$RUNNER_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' -archivePath "$RUNNER_TEMP/Yuvomi-unsigned.xcarchive" CODE_SIGNING_ALLOWED=NO archive
|
|
||||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
name: ios-unsigned-archive-and-test-results
|
|
||||||
path: |
|
|
||||||
${{ runner.temp }}/Simulator.xcresult
|
|
||||||
${{ runner.temp }}/Yuvomi-unsigned.xcarchive
|
|
||||||
retention-days: 14
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
name: Maintainer release build (never publishes)
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
sign_android:
|
|
||||||
description: Sign APK and AAB using the protected release environment
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
jobs:
|
|
||||||
android:
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
environment: release
|
|
||||||
timeout-minutes: 30
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
|
||||||
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: '17'
|
|
||||||
- run: bash android/gradlew -p android testDebugUnitTest lintRelease assembleRelease bundleRelease
|
|
||||||
- name: Sign with maintainer-supplied credentials
|
|
||||||
if: inputs.sign_android
|
|
||||||
env:
|
|
||||||
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
|
||||||
STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
|
|
||||||
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
|
||||||
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
umask 077
|
|
||||||
test -n "$KEYSTORE_BASE64" && test -n "$STORE_PASSWORD" && test -n "$KEY_PASSWORD" && test -n "$KEY_ALIAS"
|
|
||||||
trap 'rm -f "$RUNNER_TEMP/release.jks"' EXIT
|
|
||||||
printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/release.jks"
|
|
||||||
tools="$ANDROID_HOME/build-tools/36.0.0"
|
|
||||||
"$tools/zipalign" -p -f 4 android/app/build/outputs/apk/release/app-release-unsigned.apk "$RUNNER_TEMP/aligned.apk"
|
|
||||||
"$tools/apksigner" sign --ks "$RUNNER_TEMP/release.jks" --ks-key-alias "$KEY_ALIAS" --ks-pass env:STORE_PASSWORD --key-pass env:KEY_PASSWORD --out android/app/build/outputs/apk/release/yuvomi-release-signed.apk "$RUNNER_TEMP/aligned.apk"
|
|
||||||
"$tools/apksigner" verify --verbose android/app/build/outputs/apk/release/yuvomi-release-signed.apk
|
|
||||||
jarsigner -keystore "$RUNNER_TEMP/release.jks" -storepass:env STORE_PASSWORD -keypass:env KEY_PASSWORD android/app/build/outputs/bundle/release/app-release.aab "$KEY_ALIAS"
|
|
||||||
jarsigner -verify android/app/build/outputs/bundle/release/app-release.aab
|
|
||||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
|
||||||
with:
|
|
||||||
name: maintainer-android-release-builds
|
|
||||||
path: |
|
|
||||||
android/app/build/outputs/apk/release/*.apk
|
|
||||||
android/app/build/outputs/bundle/release/*.aab
|
|
||||||
retention-days: 7
|
|
||||||
+35
-1
@@ -2,12 +2,46 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Android 0.2.2 native preview (code 4)
|
||||||
|
|
||||||
|
- Replace the initial Material-style slice with native PWA-derived typography,
|
||||||
|
light/dark colors, module icons/seals, compact cards, list rows, capsule
|
||||||
|
navigation and bottom-sheet editors. Remove the duplicate overview heading.
|
||||||
|
- Read personal dashboard/favorite preferences, select the first shopping list,
|
||||||
|
and save supported widget visibility without deleting unsupported config.
|
||||||
|
- Add real-PWA reference capture and a native ten-view light/dark screenshot
|
||||||
|
gallery with measured content viewport; browser automation is tooling only.
|
||||||
|
- Preserve the confirmed HTTPS login/session fix. This is an Android design pass,
|
||||||
|
**not accepted 1:1 parity**; iOS and remaining modules are unchanged/incomplete.
|
||||||
|
|
||||||
|
### Android 0.2.1 native preview (code 3)
|
||||||
|
|
||||||
|
- Fix login against upstream-default session cookies without `Secure`: Android
|
||||||
|
now retains them in its origin-bound HTTPS-only jar instead of losing the
|
||||||
|
authenticated session. HTTP, cross-origin redirects and invalid TLS remain denied.
|
||||||
|
- Distinguish credential/code rejection from a missing/rejected native session;
|
||||||
|
clear a previous login error after a successful retry.
|
||||||
|
- Exercise both upstream cookie configurations over real HTTPS, including a wrong
|
||||||
|
password followed by a successful native login. See `docs/login-troubleshooting.md`.
|
||||||
|
|
||||||
|
### Initial native preview
|
||||||
|
|
||||||
|
- Begin the genuine native rebuild: Compose/SwiftUI screens, direct HTTPS API,
|
||||||
|
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,
|
- Native Android HTTPS onboarding, origin-bound browsing, session persistence,
|
||||||
recovery, server erasure, camera/document selection, downloads and blob exports.
|
recovery, server erasure, camera/document selection, downloads and blob exports.
|
||||||
- SwiftUI/WKWebView iOS implementation, isolated web sign-in, native settings,
|
- SwiftUI/WKWebView iOS implementation, isolated web sign-in, native settings,
|
||||||
recovery, file export, and strict URL/metadata policies; Xcode validation pending.
|
recovery, file export, and strict URL/metadata policies; Xcode validation pending.
|
||||||
- JVM, Android emulator, portable Swift, and real pinned-upstream HTTPS tests.
|
- JVM, Android emulator, portable Swift, and real pinned-upstream HTTPS tests.
|
||||||
- Reproducible build configuration, CI and maintainer-controlled signing workflow.
|
- Reproducible build configuration, CI and maintainer-controlled release procedures.
|
||||||
- MIT licensing, upstream audit, security model and release documentation.
|
- MIT licensing, upstream audit, security model and release documentation.
|
||||||
|
|
||||||
No stable release has been published. No native push or browser-to-app session
|
No stable release has been published. No native push or browser-to-app session
|
||||||
|
|||||||
+3
-1
@@ -11,7 +11,9 @@ Add regression tests with security fixes, and demonstrate that the test fails
|
|||||||
without the fix. Do not commit build outputs, credentials, private signing keys,
|
without the fix. Do not commit build outputs, credentials, private signing keys,
|
||||||
local servers, generated fixture certificates, or production household data.
|
local servers, generated fixture certificates, or production household data.
|
||||||
|
|
||||||
Keep platform integrations native and dependencies minimal. The server owns
|
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
|
business logic, authentication, CSRF, and authorization. Never solve compatibility
|
||||||
by disabling TLS, accepting every origin, or copying a browser's session cookies.
|
by disabling TLS, accepting every origin, or copying a browser's session cookies.
|
||||||
Upstream prohibits frontend frameworks, bundlers, and transpilers in its own
|
Upstream prohibits frontend frameworks, bundlers, and transpilers in its own
|
||||||
|
|||||||
@@ -1,23 +1,31 @@
|
|||||||
# 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)
|
- [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`.
|
||||||
|
|||||||
+56
-1
@@ -1,4 +1,59 @@
|
|||||||
# Security policy
|
# 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 software: no production-readiness claim.** Only the current
|
||||||
development branch is maintained; no stable mobile release exists yet.
|
development branch is maintained; no stable mobile release exists yet.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -9,8 +10,8 @@ android {
|
|||||||
applicationId = "community.yuvomi.mobile"
|
applicationId = "community.yuvomi.mobile"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 37
|
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 {
|
||||||
@@ -33,6 +34,11 @@ android {
|
|||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
buildFeatures { compose = true }
|
||||||
|
sourceSets.getByName("androidTest").kotlin.directories.apply {
|
||||||
|
clear()
|
||||||
|
add("src/androidTestNative/java")
|
||||||
|
}
|
||||||
lint {
|
lint {
|
||||||
abortOnError = true
|
abortOnError = true
|
||||||
warningsAsErrors = true
|
warningsAsErrors = true
|
||||||
@@ -41,7 +47,7 @@ android {
|
|||||||
testBuildType = "integration"
|
testBuildType = "integration"
|
||||||
sourceSets.getByName(
|
sourceSets.getByName(
|
||||||
"androidTest",
|
"androidTest",
|
||||||
).kotlin.directories.add("src/androidTestIntegration/java")
|
).kotlin.directories.add("src/androidTestNativeIntegration/java")
|
||||||
val testResources = providers.environmentVariable("YUVOMI_TEST_RES").get()
|
val testResources = providers.environmentVariable("YUVOMI_TEST_RES").get()
|
||||||
sourceSets.getByName("integration").res.directories.add(testResources)
|
sourceSets.getByName("integration").res.directories.add(testResources)
|
||||||
defaultConfig.testInstrumentationRunnerArguments["server"] =
|
defaultConfig.testInstrumentationRunnerArguments["server"] =
|
||||||
@@ -89,9 +95,13 @@ configurations.configureEach {
|
|||||||
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")
|
||||||
}
|
}
|
||||||
+99
-48
@@ -2,33 +2,86 @@
|
|||||||
# Manual edits can break the build and are not advised.
|
# Manual edits can break the build and are not advised.
|
||||||
# This file is expected to be part of source control.
|
# This file is expected to be part of source control.
|
||||||
# To regenerate this file, run: ./gradlew :app:dependencies --write-locks
|
# To regenerate this file, run: ./gradlew :app:dependencies --write-locks
|
||||||
androidx.activity:activity-ktx:1.10.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
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:1.10.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,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.annotation:annotation-experimental:1.4.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,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-jvm:1.8.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:1.8.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.arch.core:core-common:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,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-runtime:2.2.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,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.collection:collection-jvm:1.4.2=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
androidx.arch.core:core-runtime:2.2.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
androidx.collection:collection:1.0.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,integrationCompileClasspath,releaseCompileClasspath
|
androidx.autofill:autofill:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
androidx.collection:collection:1.4.2=debugAndroidTestLintChecksClasspath,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-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.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,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,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,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,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.interpolator:interpolator:1.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
androidx.customview:customview-poolingcontainer:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
androidx.lifecycle:lifecycle-common:2.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
androidx.emoji2:emoji2:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
androidx.lifecycle:lifecycle-livedata-core:2.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
androidx.graphics:graphics-path:1.0.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
androidx.lifecycle:lifecycle-runtime-ktx:2.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
androidx.interpolator:interpolator:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
androidx.lifecycle:lifecycle-runtime:2.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
androidx.lifecycle:lifecycle-common-java8:2.9.4=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,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-viewmodel-savedstate:2.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,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-viewmodel:2.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,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.profileinstaller:profileinstaller:1.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
androidx.lifecycle:lifecycle-process:2.9.4=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
androidx.savedstate:savedstate-ktx:1.2.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,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.savedstate:savedstate:1.2.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,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.startup:startup-runtime:1.1.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,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-core:3.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
androidx.test.espresso:espresso-idling-resource: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.ext:junit:1.2.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
@@ -38,8 +91,7 @@ androidx.test:monitor:1.7.2=debugAndroidTestCompileClasspath,debugAndroidTestLin
|
|||||||
androidx.test:runner:1.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
androidx.test:runner:1.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
androidx.tracing:tracing:1.1.0=debugAndroidTestCompileClasspath
|
androidx.tracing:tracing:1.1.0=debugAndroidTestCompileClasspath
|
||||||
androidx.tracing:tracing:1.2.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
androidx.tracing:tracing:1.2.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
androidx.versionedparcelable:versionedparcelable:1.1.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
androidx.versionedparcelable:versionedparcelable:1.1.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
androidx.webkit:webkit:1.14.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,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:protos:32.4.1=androidLintTool
|
||||||
com.android.tools.analytics-library:shared: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.analytics-library:tracker:32.4.1=androidLintTool
|
||||||
@@ -107,6 +159,7 @@ org.glassfish.jaxb:jaxb-runtime:2.3.2=androidLintTool
|
|||||||
org.glassfish.jaxb:txw2: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-core:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
org.hamcrest:hamcrest-library:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
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.0=kotlinAbiValidationCompatClasspath
|
||||||
org.jetbrains.kotlin:kotlin-build-tools-api:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
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-compat:2.4.21=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
@@ -118,6 +171,7 @@ org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.0=kotlinAbiValidationCompatC
|
|||||||
org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
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.0=kotlinAbiValidationCompatClasspath
|
||||||
org.jetbrains.kotlin:kotlin-compiler-runner:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
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.0=kotlinAbiValidationCompatClasspath
|
||||||
org.jetbrains.kotlin:kotlin-daemon-client:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
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.0=kotlinAbiValidationCompatClasspath
|
||||||
@@ -126,36 +180,31 @@ org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinAbiValidationCompatClasspath,ko
|
|||||||
org.jetbrains.kotlin:kotlin-reflect:2.2.10=androidLintTool
|
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.0=kotlinAbiValidationCompatClasspath
|
||||||
org.jetbrains.kotlin:kotlin-script-runtime:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
org.jetbrains.kotlin:kotlin-script-runtime:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
org.jetbrains.kotlin:kotlin-stdlib-common:2.4.21=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
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:1.8.0=debugCompileClasspath,debugUnitTestCompileClasspath,integrationCompileClasspath,releaseCompileClasspath
|
|
||||||
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.20=debugAndroidTestCompileClasspath,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-jdk7:2.2.10=androidLintTool,unified-test-platform-gradle-work-action
|
||||||
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0=debugCompileClasspath,debugUnitTestCompileClasspath,integrationCompileClasspath,releaseCompileClasspath
|
|
||||||
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.20=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
|
||||||
org.jetbrains.kotlin:kotlin-stdlib-jdk8: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.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.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-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.kotlin:kotlin-tooling-core:2.4.21=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4=debugCompileClasspath,debugUnitTestCompileClasspath,integrationCompileClasspath,releaseCompileClasspath
|
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-android:1.7.1=debugAndroidTestCompileClasspath
|
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-android:1.7.3=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
|
||||||
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.6.4=debugCompileClasspath,debugUnitTestCompileClasspath,integrationCompileClasspath,releaseCompileClasspath
|
|
||||||
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.1=debugAndroidTestCompileClasspath
|
|
||||||
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
|
||||||
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=androidLintTool,unified-test-platform-gradle-work-action
|
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=androidLintTool,unified-test-platform-gradle-work-action
|
||||||
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.6.4=debugCompileClasspath,debugUnitTestCompileClasspath,integrationCompileClasspath,releaseCompileClasspath
|
|
||||||
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.1=debugAndroidTestCompileClasspath
|
|
||||||
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
|
||||||
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
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-jvm:1.9.0=androidLintTool,unified-test-platform-gradle-work-action
|
||||||
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4=debugCompileClasspath,debugUnitTestCompileClasspath,integrationCompileClasspath,releaseCompileClasspath
|
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.7.1=debugAndroidTestCompileClasspath
|
|
||||||
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
|
||||||
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0=androidLintTool,unified-test-platform-gradle-work-action
|
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0=androidLintTool,unified-test-platform-gradle-work-action
|
||||||
org.jetbrains:annotations:13.0=debugCompileClasspath,debugUnitTestCompileClasspath,integrationCompileClasspath,kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,releaseCompileClasspath
|
org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
org.jetbrains:annotations:23.0.0=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-gradle-work-action
|
org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
org.jspecify:jspecify:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
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-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-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.platform:junit-platform-launcher:1.13.3=unified-test-platform-gradle-work-action
|
||||||
@@ -165,5 +214,7 @@ org.opentest4j:opentest4j:1.3.0=unified-test-platform-gradle-work-action
|
|||||||
org.ow2.asm:asm-analysis:9.9=androidLintTool
|
org.ow2.asm:asm-analysis:9.9=androidLintTool
|
||||||
org.ow2.asm:asm-commons: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=androidLintTool
|
||||||
|
org.ow2.asm:asm-tree:9.9.1=composeMappingProducerClasspath
|
||||||
org.ow2.asm:asm:9.9=androidLintTool
|
org.ow2.asm:asm:9.9=androidLintTool
|
||||||
empty=androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugReverseMetadataValues,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,integrationAnnotationProcessorClasspath,integrationImplementationDependenciesMetadata,integrationReverseMetadataValues,kotlinCompilerPluginClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathIntegration,kotlinCompilerPluginClasspathRelease,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata,releaseReverseMetadataValues
|
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,689 +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.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.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 var 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 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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
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)
|
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(
|
controller,
|
||||||
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime(),
|
needsLocalNetwork =
|
||||||
)
|
Build.VERSION.SDK_INT >= 37 &&
|
||||||
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
|
checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) != PackageManager.PERMISSION_GRANTED,
|
||||||
insets
|
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) {
|
|
||||||
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,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"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,17 +46,4 @@ class ServerAddressTest {
|
|||||||
assertFalse(it, server.owns(it))
|
assertFalse(it, server.owns(it))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,7 @@ buildscript {
|
|||||||
when (requested.group) {
|
when (requested.group) {
|
||||||
"io.netty" -> useVersion("4.1.139.Final")
|
"io.netty" -> useVersion("4.1.139.Final")
|
||||||
"com.google.protobuf" -> useVersion("3.25.9")
|
"com.google.protobuf" -> useVersion("3.25.9")
|
||||||
|
"com.google.crypto.tink" -> useVersion("1.15.0")
|
||||||
"org.bouncycastle" -> useVersion("1.86")
|
"org.bouncycastle" -> useVersion("1.86")
|
||||||
"org.apache.commons" -> if (requested.name == "commons-lang3") useVersion("3.21.0")
|
"org.apache.commons" -> if (requested.name == "commons-lang3") useVersion("3.21.0")
|
||||||
"org.apache.httpcomponents" -> if (requested.name == "httpclient") useVersion("4.5.14")
|
"org.apache.httpcomponents" -> if (requested.name == "httpclient") useVersion("4.5.14")
|
||||||
@@ -21,7 +22,10 @@ buildscript {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
plugins { id("com.android.application") version "9.4.1" apply false }
|
plugins {
|
||||||
|
id("com.android.application") version "9.4.1" 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.
|
||||||
// Opt-in external output root; ordinary developer/CI builds retain standard paths.
|
// Opt-in external output root; ordinary developer/CI builds retain standard paths.
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ com.android:zipflinger:9.4.1=classpath
|
|||||||
com.google.auto.value:auto-value-annotations:1.6.2=classpath
|
com.google.auto.value:auto-value-annotations:1.6.2=classpath
|
||||||
com.google.code.findbugs:jsr305:3.0.2=classpath
|
com.google.code.findbugs:jsr305:3.0.2=classpath
|
||||||
com.google.code.gson:gson:2.11.0=classpath
|
com.google.code.gson:gson:2.11.0=classpath
|
||||||
com.google.crypto.tink:tink:1.7.0=classpath
|
com.google.crypto.tink:tink:1.15.0=classpath
|
||||||
com.google.dagger:dagger:2.28.3=classpath
|
com.google.dagger:dagger:2.28.3=classpath
|
||||||
com.google.errorprone:error_prone_annotations:2.36.0=classpath
|
com.google.errorprone:error_prone_annotations:2.36.0=classpath
|
||||||
com.google.flatbuffers:flatbuffers-java:1.12.0=classpath
|
com.google.flatbuffers:flatbuffers-java:1.12.0=classpath
|
||||||
@@ -80,6 +80,8 @@ org.checkerframework:checker-qual:3.43.0=classpath
|
|||||||
org.glassfish.jaxb:jaxb-runtime:2.3.2=classpath
|
org.glassfish.jaxb:jaxb-runtime:2.3.2=classpath
|
||||||
org.glassfish.jaxb:txw2:2.3.2=classpath
|
org.glassfish.jaxb:txw2:2.3.2=classpath
|
||||||
org.jdom:jdom2:2.0.6.1=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:fus-statistics-gradle-plugin:2.4.21=classpath
|
||||||
org.jetbrains.kotlin:kotlin-build-statistics: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-build-tools-api:2.4.21=classpath
|
||||||
|
|||||||
+48
-32
@@ -1,41 +1,57 @@
|
|||||||
# Architecture
|
# Native client architecture
|
||||||
|
|
||||||
Two independent native applications share a policy, not a cross-platform runtime:
|
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 |
|
| Responsibility | Android | iOS |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Native entry/configuration | ComponentActivity and platform widgets | SwiftUI |
|
| Screens and navigation | Jetpack Compose / Material 3 primitives, Yuvomi tokens | SwiftUI, Yuvomi tokens |
|
||||||
| Web application | Android WebView | WKWebView |
|
| Application state | AndroidViewModel + Compose state | MainActor ObservableObject |
|
||||||
| Server URL policy | ServerAddress | ServerAddress |
|
| JSON API | HttpsURLConnection, serialized worker | URLSession actor, bounded streaming delegate |
|
||||||
| Reachability/metadata | HttpsURLConnection, no redirects/cookies | Ephemeral URLSession, no redirects/cookies |
|
| Session ownership | Explicit origin-bound HTTPS-only cookie jar | Private ephemeral URLSession cookie storage |
|
||||||
| Authentication | Upstream web UI and OS cookie jar | Upstream web UI and WKWebsiteDataStore |
|
| Persistent cookies | AES-GCM, AndroidKeyStore key, origin as authenticated data | Keychain, WhenUnlockedThisDeviceOnly, origin-scoped account |
|
||||||
| OIDC | Separate restricted web view after consent | Separate restricted web view after consent |
|
| Server logic | Existing unmodified Yuvomi API | Existing unmodified Yuvomi API |
|
||||||
| 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
|
There is no WebView/WKWebView in either shipped source tree and no browser
|
||||||
selects the origin. Bootstrap calls check the actual upstream unauthenticated
|
fallback for missing modules. The old implementation and browser tests are
|
||||||
metadata contract before loading the server application. Configuration stores only
|
archived in `legacy/`; their results are historical, not evidence for this client.
|
||||||
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.
|
The first native slice contains HTTPS onboarding, setup/password/TOTP login,
|
||||||
Android handles orientation changes in the existing activity; process recreation
|
session restoration, a basic dashboard, task/note/shopping interactions, read-only
|
||||||
loads the remembered path and retains OS cookies, not unsaved web form memory.
|
permission handling and local sign-out/server removal. Exact feature and visual
|
||||||
iOS retains a StateObject-owned view; cold launch restores path and persistent
|
parity is tracked in [native parity](native-parity.md), not inferred from a list of
|
||||||
cookies. No resume reload is imposed. Native recovery offers Retry, Keep viewing,
|
navigation destinations. The other modules remain unfinished.
|
||||||
and server management without hiding the route back to configuration.
|
|
||||||
|
|
||||||
Server erasure destroys the browsing context and clears all website storage before
|
## Transport and state boundaries
|
||||||
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
|
- One configured root HTTPS origin/port; system certificate validation only.
|
||||||
for a fresh test CA generated outside the repository. It uses the same production
|
- API calls never follow redirects. No cookie or Authorization state is replayed
|
||||||
code and the unmodified pinned upstream server. Neither normal debug nor release
|
to another host. Authentication proxies returning HTML are not treated as APIs.
|
||||||
resources include that CA. Never distribute an integration APK.
|
- 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.
|
||||||
|
|
||||||
Read-only offline functionality belongs to upstream's service worker. The app
|
## Explicit gaps
|
||||||
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.
|
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.
|
||||||
+27
-3
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
Requirements: JDK 17, Android SDK platform 37.0, build-tools 36.0.0, current
|
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).
|
platform-tools, and accepted Android SDK licenses. Minimum Android 8 (API 26).
|
||||||
Use a maintained Android System WebView. Target API 37. On Android 17, choose
|
The application surface is Jetpack Compose, not Android System WebView. Target
|
||||||
"Allow private-network servers" when using a LAN instance; public HTTPS servers
|
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.
|
do not need this optional runtime permission. No discovery/scanning is performed.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -13,7 +13,7 @@ bash android/gradlew -p android connectedDebugAndroidTest
|
|||||||
```
|
```
|
||||||
|
|
||||||
Gradle 9.6.0 is checksum-verified by the wrapper. AGP 9.4.1 uses built-in Kotlin
|
Gradle 9.6.0 is checksum-verified by the wrapper. AGP 9.4.1 uses built-in Kotlin
|
||||||
with the build-cache fix in KGP 2.4.21. Maven versions are locked. No dynamic
|
with KGP and Compose compiler plugin 2.4.21. Maven versions are locked. No dynamic
|
||||||
application dependencies or private Maven repository is required.
|
application dependencies or private Maven repository is required.
|
||||||
|
|
||||||
Ordinary outputs:
|
Ordinary outputs:
|
||||||
@@ -23,6 +23,14 @@ Ordinary outputs:
|
|||||||
- `android/app/build/outputs/bundle/release/app-release.aab` (unsigned unless signed later)
|
- `android/app/build/outputs/bundle/release/app-release.aab` (unsigned unless signed later)
|
||||||
- `android/app/build/reports/` (lint, JVM and emulator results)
|
- `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:
|
Immediately after building, collect and inspect the outputs:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -54,12 +62,28 @@ git clone --filter=blob:none https://github.com/ulsklyc/yuvomi.git /path/to/upst
|
|||||||
git -C /path/to/upstream checkout 10bfef7686ed7303b4c74c7fcc55a5eb16356c9d
|
git -C /path/to/upstream checkout 10bfef7686ed7303b4c74c7fcc55a5eb16356c9d
|
||||||
npm ci --omit=dev --prefix /path/to/upstream
|
npm ci --omit=dev --prefix /path/to/upstream
|
||||||
python3 scripts/test-integration.py /path/to/upstream --android
|
python3 scripts/test-integration.py /path/to/upstream --android
|
||||||
|
python3 scripts/test-integration.py /path/to/upstream --android --cookie-without-secure
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires Node 22.14+ and a running emulator. The harness creates and removes its
|
Requires Node 22.14+ and a running emulator. The harness creates and removes its
|
||||||
own CA/key/database, uses `adb reverse`, and builds `.integration` with explicit
|
own CA/key/database, uses `adb reverse`, and builds `.integration` with explicit
|
||||||
test trust. It does not weaken production TLS. Test credentials are ephemeral.
|
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).
|
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,
|
For dependency updates run `:app:dependencies --write-locks`, the full pipeline,
|
||||||
and `python3 scripts/check-dependencies.py`. Review lockfile changes. The version
|
and `python3 scripts/check-dependencies.py`. Review lockfile changes. The version
|
||||||
|
|||||||
+8
-4
@@ -1,8 +1,8 @@
|
|||||||
# iOS development
|
# iOS development
|
||||||
|
|
||||||
Requires macOS, Xcode 16.4 or newer with an iOS simulator, and XcodeGen 2.44.1.
|
Requires macOS, Xcode 16.4 or newer with an iOS simulator, and XcodeGen 2.44.1.
|
||||||
Minimum iOS/iPadOS 18.4: the auth window must be able to deny native file-panel
|
Minimum iOS/iPadOS 18.4 is retained for this rebuild. The application surface is
|
||||||
requests, which earlier WKWebView delegates cannot do. No CocoaPods or external
|
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
|
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.
|
required Xcode/iOS SDK, not merely the oldest compiler that can build the source.
|
||||||
|
|
||||||
@@ -29,11 +29,15 @@ swift test --package-path ios
|
|||||||
swiftc -frontend -parse ios/Sources/*.swift ios/Tests/*.swift ios/UITests/*.swift
|
swiftc -frontend -parse ios/Sources/*.swift ios/Tests/*.swift ios/UITests/*.swift
|
||||||
```
|
```
|
||||||
|
|
||||||
These portable tests compile the **production URL/metadata policies only**.
|
These portable tests compile the **production Foundation API transport, contracts
|
||||||
Parsing does not typecheck UIKit, SwiftUI or WebKit APIs. Neither command is
|
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.
|
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
|
For a device build select your authorized development team in Xcode, use your own
|
||||||
registered bundle identifier, and let Xcode manage development provisioning.
|
registered bundle identifier, and let Xcode manage development provisioning.
|
||||||
Never commit generated profiles or certificates. See `release-process.md` for
|
Never commit generated profiles or certificates. See `release-process.md` for
|
||||||
distribution. A Linux environment cannot produce a validated iOS archive.
|
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.
|
||||||
+142
-5
@@ -1,4 +1,138 @@
|
|||||||
# Maintainer continuation
|
# 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
|
This is a development delivery, not authorization to publish. Preserve the Git
|
||||||
history on `feat/yuvomi-mobile`; use Conventional Commits and normal pushes only.
|
history on `feat/yuvomi-mobile`; use Conventional Commits and normal pushes only.
|
||||||
@@ -63,8 +197,9 @@ validation must happen on macOS with Xcode and real supported devices.
|
|||||||
|
|
||||||
## Highest-priority external work
|
## Highest-priority external work
|
||||||
|
|
||||||
1. Typecheck/build the iOS app against Apple SDKs, run its actual XCTest/UI tests,
|
1. Extend the now-successful Apple SDK/simulator/archive validation beyond the
|
||||||
and produce an unsigned archive. Fix any failures; none of these ran locally.
|
basic onboarding UI test to authenticated native API flows, Keychain runtime,
|
||||||
|
visual comparisons and physical devices. These did not run on Linux.
|
||||||
2. Run the API 37 permission grant/denial/revocation scenarios on a working image
|
2. Run the API 37 permission grant/denial/revocation scenarios on a working image
|
||||||
or device, plus the minimum-supported Android and current WebView matrix.
|
or device, plus the minimum-supported Android and current WebView matrix.
|
||||||
3. Review iOS unknown-length download disk-pressure handling before release.
|
3. Review iOS unknown-length download disk-pressure handling before release.
|
||||||
@@ -73,5 +208,7 @@ validation must happen on macOS with Xcode and real supported devices.
|
|||||||
5. Establish private vulnerability reporting, owned identifiers, authorized CI,
|
5. Establish private vulnerability reporting, owned identifiers, authorized CI,
|
||||||
human security review and owner-controlled signing before any distribution.
|
human security review and owner-controlled signing before any distribution.
|
||||||
|
|
||||||
The provided GitHub Actions workflows do not automatically execute on the current
|
Gitea workflows in `.gitea/workflows/` target the maintainer's `ubuntu-latest`
|
||||||
non-GitHub forge. Do not claim hosted CI or store readiness without actual runs.
|
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.
|
||||||
+21
-21
@@ -10,32 +10,32 @@ to either store. A debug APK or unsigned archive is not a production release.
|
|||||||
a provisional community identifier, not proof of registry/store ownership.
|
a provisional community identifier, not proof of registry/store ownership.
|
||||||
Confirm use of the Yuvomi name, required attribution and independent status.
|
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.
|
3. Bump version/name/code in Android and the iOS project spec; update CHANGELOG.
|
||||||
4. Run JVM/emulator/real-upstream HTTPS tests, portable Swift and Xcode simulator
|
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
|
tests, static checks, OSV scan, release builds and artifact inspection. Record
|
||||||
upstream revision, app SHA, OS/WebView/Xcode versions, results and checksums.
|
upstream revision, app SHA, OS/SDK/Xcode versions, results and checksums.
|
||||||
5. Produce signed artifacts only using an authorized maintainer's credentials.
|
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,
|
6. Review store privacy/Data Safety answers for the app AND selected-server model,
|
||||||
permission rationale, encryption/export compliance, screenshots, support URL,
|
permission rationale, encryption/export compliance, screenshots, support URL,
|
||||||
vulnerability contact, and device demonstrations. A functional web companion
|
vulnerability contact, and device demonstrations. An incomplete native preview
|
||||||
is not guaranteed to pass Apple's minimum-functionality review.
|
is not a finished PWA replacement or App Store approval.
|
||||||
7. Create a release/tag and publish artifacts/store submissions manually only
|
7. Create a release/tag and publish artifacts/store submissions manually only
|
||||||
after explicit maintainer authorization. Never force-push or rewrite history.
|
after explicit maintainer authorization. Never force-push or rewrite history.
|
||||||
|
|
||||||
## Android
|
## Android
|
||||||
|
|
||||||
The manual `release.yml` workflow uses a protected GitHub environment named
|
The manual `.gitea/workflows/release.yml` workflow runs on `ubuntu-latest` and
|
||||||
`release`. Configure branch restrictions and required reviewers before adding
|
emits **unsigned** artifacts only. Gitea ignores GitHub environment approval gates,
|
||||||
secrets. With `sign_android=false` it emits unsigned artifacts. Signing requires:
|
so this workflow does not load signing secrets or offer a signing toggle.
|
||||||
|
|
||||||
- `ANDROID_KEYSTORE_BASE64`
|
An authorized maintainer must separately sign the verified APK/AAB using their
|
||||||
- `ANDROID_STORE_PASSWORD`
|
own keystore, alias and passwords with `zipalign`, `apksigner`, and `jarsigner`.
|
||||||
- `ANDROID_KEY_PASSWORD`
|
Keep private material in a protected signing environment, never on the general
|
||||||
- `ANDROID_KEY_ALIAS`
|
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
|
||||||
The workflow uses `zipalign`, `apksigner`, and `jarsigner`; private material exists
|
secrets already exist outside Git; signing workflow changes are parked during
|
||||||
only in a restrictive runner-temporary file removed on exit. Securely back up the
|
the native rebuild. Preserve the key, do not generate a replacement or apply the
|
||||||
real upload key and use Play App Signing according to the owner's policy. No
|
old signing stash without review. See `native-parity.md`.
|
||||||
production key is generated here. Manually verify APK/AAB signatures and inspect
|
Manually verify APK/AAB signatures and inspect
|
||||||
the final manifest: no debuggable flag, HTTP/test CA or unintended exported component.
|
the final manifest: no debuggable flag, HTTP/test CA or unintended exported component.
|
||||||
Never distribute an `.integration` package or include its generated test resources.
|
Never distribute an `.integration` package or include its generated test resources.
|
||||||
Run `python3 scripts/collect-android-artifacts.py` immediately after the clean-source
|
Run `python3 scripts/collect-android-artifacts.py` immediately after the clean-source
|
||||||
@@ -65,8 +65,8 @@ privacy need physical-device validation beyond simulator tests.
|
|||||||
|
|
||||||
## CI hosting
|
## CI hosting
|
||||||
|
|
||||||
GitHub Actions files are provided, but the configured community remote is hosted
|
Gitea Actions workflows are provided in `.gitea/workflows/`, using the supplied
|
||||||
on a different forge. GitHub workflows will not automatically run there. Enable
|
`ubuntu-latest` and `macos-arm64` runner labels. See [runner setup](ci-runners.md)
|
||||||
an authorized GitHub mirror/repository (or port the jobs to the forge's runner)
|
for SDK, KVM, Xcode, simulator and security requirements. Repository Actions must
|
||||||
and inspect real runs before claiming CI is operational. No destination is
|
be enabled. Inspect actual runs before claiming CI is operational; no GitHub
|
||||||
created automatically. CI service availability is not an application dependency.
|
mirror or store destination is created. CI is not an application dependency.
|
||||||
+31
-42
@@ -1,47 +1,36 @@
|
|||||||
# Security review record
|
# Native security review record
|
||||||
|
|
||||||
This is an engineering review, not an independent penetration test or certification.
|
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).
|
||||||
|
|
||||||
Addressed during implementation:
|
## Implemented controls to exercise
|
||||||
|
|
||||||
- Canonical, HTTPS-only origin/port comparison; credential, fragment, query,
|
- Origin-bound HTTPS API transports, no redirects, no production test-CA bypass.
|
||||||
subpath, scheme, lookalike-host and TLS-downgrade regression cases.
|
- Server-owned session and CSRF semantics, no persistent password storage.
|
||||||
- A main-frame POST can bypass Android's usual navigation callback. The request
|
- Origin-bound AES-GCM/AndroidKeyStore cookies; iOS Keychain protected
|
||||||
interception gate now rejects **all** cross-origin requests, not only subresources.
|
WhenUnlockedThisDeviceOnly with a private ephemeral URLSession jar.
|
||||||
The emulator regression was observed failing when the main-frame guard was
|
- Bounded JSON reads, deadlines and no offline/ambiguous-write retry queue.
|
||||||
deliberately removed, then rerun with the fix restored.
|
- Native permission-aware controls, destructive-action confirmation and server
|
||||||
- TLS failures cancel by default. Native download redirects are refused before
|
validation; partial update payloads preserve fields the native editor cannot edit.
|
||||||
cookies can be replayed to another origin. Bootstrap requests use neither
|
- No WebView/WKWebView in either shipping source tree. Legacy tests do not prove
|
||||||
cookies nor automatic redirects and have bounded metadata reads/timeouts.
|
this client's behavior; explicit native tests and source invariants are required.
|
||||||
- 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:
|
## Remaining gates
|
||||||
|
|
||||||
- Actual Xcode typecheck, simulator, archive, physical-device and accessibility review.
|
1. Run Apple SDK typechecking, simulator/XCTest/UI and device checks; portable
|
||||||
- End-to-end IdP flows, especially providers that prohibit embedded agents; no
|
Foundation transport compilation is not SwiftUI/Security.framework validation.
|
||||||
browser-to-native session transfer is supported by the current upstream protocol.
|
2. Review persistent-cookie expiry/removal, TLS failures, redirects, CSRF refresh,
|
||||||
- iOS unknown-length download disk-pressure behavior; a post-download size check
|
expired sessions, background/recreation and stale-response isolation on devices.
|
||||||
is not a streaming quota. Verify file picker/camera cancellation on real devices.
|
3. Review migrations from the prototype: browser sessions are never imported and
|
||||||
- Android file-picker/camera/rotation while a picker is outstanding; cancellation
|
OS website data may remain until app-data reset/reinstall.
|
||||||
on process death intentionally loses the selection, never silently retries it.
|
4. Complete missing auth/provider protocols and permission/role scenarios before
|
||||||
- Offline sign-out/account switching, stale-worker updates and server erasure on
|
claiming native authentication parity, particularly SSO-only deployments.
|
||||||
current WebView/WKWebView versions. Upstream tests are not native device proof.
|
5. Implement/review files, notifications, offline storage and additional modules
|
||||||
- Reverify final signed release manifests, signing, store ownership, private vulnerability contact
|
as actual native capabilities; do not restore the wrapper as a shortcut.
|
||||||
and applicable privacy disclosures. No claim that all vulnerabilities are absent.
|
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.
|
||||||
@@ -1,70 +1,41 @@
|
|||||||
# Server compatibility and authentication
|
# Native server compatibility
|
||||||
|
|
||||||
Source reference: upstream 2.78.0 revision
|
Reference: Yuvomi 2.78.0 at
|
||||||
`10bfef7686ed7303b4c74c7fcc55a5eb16356c9d`. No general version range has been
|
`10bfef7686ed7303b4c74c7fcc55a5eb16356c9d`. No general server-version range or
|
||||||
certified. Anonymous bootstrap metadata deliberately omits the exact version;
|
complete native feature compatibility is certified.
|
||||||
native probing verifies shape, not an invented minimum-version comparison.
|
|
||||||
|
|
||||||
Use `https://family.example.com` or `https://family.example.com:8443`, backed by a
|
Enter a root HTTPS hostname, optionally with a custom port. HTTP, embedded
|
||||||
root-hosted Yuvomi installation and a valid system-trusted certificate. Operators
|
credentials, query/fragment configuration and subpath deployments are rejected.
|
||||||
should set `SESSION_SECURE=true`, correct `BASE_URL`, and correct proxy trust.
|
Use a valid system-trusted TLS certificate and reachable VPN/LAN connectivity.
|
||||||
Private servers are supported through reachable HTTPS/VPN, not TLS bypasses.
|
No certificate bypass or proprietary mobile service is introduced.
|
||||||
International hostnames should be entered in ASCII/punycode for both platforms.
|
|
||||||
|
|
||||||
Root-relative assets, API endpoints, cookie paths, callback redirects and worker
|
The native client probes the real public `/api/v1/version` and
|
||||||
registration mean `/yuvomi/` subpaths are not supported by the inspected source.
|
`/api/v1/auth/oidc/config` shapes. Anonymous metadata does not disclose the server
|
||||||
Use a dedicated hostname. Reverse proxies must pass `/api/v1/*`, cookies, HTML,
|
version. An authentication proxy returning redirects/HTML instead of those JSON
|
||||||
JavaScript and the service worker consistently. An external authentication proxy
|
contracts is unsupported, not silently treated as a valid Yuvomi API.
|
||||||
that intercepts bootstrap calls is rejected with a connection error; it is not
|
|
||||||
silently identified as a Yuvomi server.
|
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
Password login, TOTP/recovery codes, password reset, invitations, server-side
|
The implemented native slice uses setup/password login, the existing two-factor
|
||||||
session expiry/revocation and logout remain upstream web flows. No native code
|
verification endpoint, `/auth/me`, session cookies, CSRF headers and logout.
|
||||||
stores the password, supplies a CSRF bypass, or extends an expired session.
|
Passwords are not stored. Native session persistence is separate from browser
|
||||||
Backgrounding does not clear cookies. Native server erasure does not revoke a
|
storage and protected by the platform keystore/keychain.
|
||||||
remote session; use Yuvomi logout first or revoke sessions server-side.
|
|
||||||
|
|
||||||
### OIDC: local fixture verified; real-provider validation pending
|
**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.
|
||||||
|
|
||||||
The initiating Yuvomi session contains OIDC state, nonce and PKCE verifier. When
|
Only initial dashboard/tasks/notes/shopping capabilities are implemented; see the
|
||||||
the server redirects to an identity provider, native consent offers an isolated
|
[parity tracker](native-parity.md) for precise gaps. The complete server UI is not
|
||||||
web sign-in window sharing the OS website cookie store. HTTPS-only redirects
|
embedded to fill those gaps. Read-only and denied module permissions affect
|
||||||
can return to the server callback; the final same-origin page returns to the main
|
native controls; the server remains the enforcement point.
|
||||||
view. No app-capability handlers or JS bridge are installed in that auth window.
|
|
||||||
|
|
||||||
Some providers (including configurations that prohibit embedded user agents)
|
## Notifications, files and offline
|
||||||
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
|
Native remote push, uploads/camera/download/export and offline caching/editing
|
||||||
a local test IdP, real RS256 ID tokens, nonce and S256 PKCE. No external
|
are not current native capabilities. The old wrapper's file handlers and the
|
||||||
IdP/developer account was supplied. Validate the actual provider, including
|
PWA service worker are not part of the native renderer. No fake registration or
|
||||||
cross-site cookies, cancellation, errors, TOTP and account linking, before relying
|
automatic offline mutation replay is supplied. Optional existing server-side
|
||||||
on that deployment. iOS authentication has not run against Apple SDKs. A native
|
ntfy/Gotify/email integrations remain independently operated upstream features.
|
||||||
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.
|
|
||||||
+209
-106
@@ -1,92 +1,211 @@
|
|||||||
# Validation status
|
# Native validation
|
||||||
|
|
||||||
**Partially verified development software, not production-ready.**
|
**Incomplete native rebuild; not full PWA parity and not production-ready.**
|
||||||
Local validation date: 2026-10-21. No unexecuted iOS application test, hosted CI
|
The old browser results are [historical only](legacy-wrapper-validation.md).
|
||||||
run, production signing, or store publication is represented as completed.
|
|
||||||
|
|
||||||
## Executed evidence
|
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 debug APK and unsigned release APK/AAB built successfully; both
|
## Android 0.2.2 / code 4: PWA-derived design pass
|
||||||
`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
|
Local API-35 validation after the design pass:
|
||||||
|
|
||||||
Earlier extended-suite attempts crashed the host emulator with SIGSEGV under
|
| Check | Observed result |
|
||||||
software/off GPU modes. Host-GPU mode remained running and produced actionable
|
| --- | --- |
|
||||||
JUnit failures: an OIDC-return synchronization timeout, followed by an outage
|
| JVM policy/presentation tests | 23 passed, zero failures/errors/skips |
|
||||||
assertion that incorrectly assumed the upstream worker could not serve its cached
|
| Native Compose UI/security/presentation tests | 9 passed, zero failures/errors/skips |
|
||||||
app shell, then a document-readiness race after recreation. Tests now verify the
|
| Real upstream, default non-Secure cookie over HTTPS | 10 passed, zero failures/errors/skips |
|
||||||
loaded JavaScript origin, wait for the sign-in window to close, retry only a probe
|
| Real upstream, Secure cookie | 10 passed, zero failures/errors/skips |
|
||||||
lost to document replacement and exercise recovery through uncached metadata.
|
| Android lint and packages | Both lint variants; debug and unsigned release APK/AAB passed |
|
||||||
The completed retry passes all seven tests without disabling the worker or
|
| Screenshot evidence | Ten light/dark native views, viewport metadata, ten actual-PWA references and ten matched-size side-by-side images |
|
||||||
weakening production TLS/CSP/origin policies.
|
| 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 |
|
||||||
|
|
||||||
API 37 attempts encountered broken package/activity services and locked
|
The first ordinary device-test attempt ran **zero tests** because a leftover
|
||||||
credential-encrypted storage. **No API 37 runtime pass is claimed.**
|
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.
|
||||||
|
|
||||||
Local logs and reports live in `/home/neste/yuvomi-build/`; test XML is under
|
Logs: `/home/neste/yuvomi-design-build-final.log`,
|
||||||
`app/outputs/androidTest-results/connected/`. Earlier failing JUnit snapshots are
|
`/home/neste/yuvomi-design-cookie-final.log`,
|
||||||
preserved as `integration-host-gpu-oidc-race.xml` and
|
`/home/neste/yuvomi-design-secure-final.log`.
|
||||||
`integration-host-gpu-sw-cache.xml`, plus
|
The [design comparison](native-design.md) includes reviewed synthetic images and
|
||||||
`integration-host-gpu-document-ready.xml`. Passing evidence is preserved in
|
documents the exact 360 × 592 content viewport and intentional fixture scope.
|
||||||
`integration-api35-first-pass.xml` and `integration-api35-repeat-pass.xml`; logs are
|
**This is not accepted 1:1 parity.** iOS was not changed in this design pass.
|
||||||
`upstream-integration-final-retry.log` and `upstream-integration-repeat.log`.
|
Remote Android KVM prerequisites and production signing remain open. The earlier
|
||||||
These files are local evidence, not published
|
Mac blocker has since cleared, as verified below.
|
||||||
artifacts or a hosted CI result.
|
|
||||||
|
|
||||||
Test environment: JDK 17.0.20.1, Node 22.22.1, Python 3.14.4, Android Emulator
|
A separate `git archive` checkout of committed source `7ac9882` was rebuilt at
|
||||||
37.2.12, API 35 x86_64 Google APIs image, WebView 124.0.6367.219. The old bundled
|
`/tmp/opencode/yuvomi-design-clean-1fknxqpt`, with outputs under
|
||||||
WebView is useful compatibility evidence, not a recommendation to use an unpatched
|
`/home/neste/yuvomi-design-clean-build-7ac9882`. JVM tests, both lint variants and
|
||||||
engine; a current engine/device run remains mandatory.
|
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.
|
||||||
|
|
||||||
## Artifacts
|
### Downloaded and installed CI artifact
|
||||||
|
|
||||||
Ignored `artifacts/android/` contains the development-signed debug APK and unsigned
|
Gitea Android run **670**, build job **1079**, source `7ac9882`: the build/test/lint,
|
||||||
release APK/AAB, `SHA256SUMS`, `build-info.json` and manifest/policy/signature
|
repository/script/format/dependency checks and artifact upload succeeded. The
|
||||||
inspection evidence. No integration APK is collected. No IPA or Apple archive
|
actual ZIP was downloaded; its sole `app-debug.apk` is
|
||||||
exists. See [Android build](build-android.md) for repeatable collection commands.
|
`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
|
## Commands
|
||||||
|
|
||||||
@@ -97,34 +216,18 @@ swift test --package-path ios
|
|||||||
swiftc -frontend -parse ios/Sources/*.swift ios/Tests/*.swift ios/UITests/*.swift
|
swiftc -frontend -parse ios/Sources/*.swift ios/Tests/*.swift ios/UITests/*.swift
|
||||||
python3 scripts/check-repository.py
|
python3 scripts/check-repository.py
|
||||||
python3 scripts/check-dependencies.py
|
python3 scripts/check-dependencies.py
|
||||||
python3 scripts/test-verifier.py
|
python3 scripts/lint-workflows.py /path/to/actionlint
|
||||||
python3 scripts/collect-android-artifacts.py
|
|
||||||
git diff --check
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This environment is Linux/WSL. Android SDK and a KVM emulator were installed for
|
On the local WSL checkout use `YUVOMI_BUILD_DIR=/home/neste/yuvomi-native-build`
|
||||||
real execution. Build output relocation was needed because the Windows mount
|
and the SDK paths from [continuation](continuation.md). Select only the authorized
|
||||||
rejects some Gradle POSIX metadata operations. Swift's Linux toolchain was
|
test emulator with `ANDROID_SERIAL=emulator-5554`; unrelated/unauthorized attached
|
||||||
installed for portable tests. No macOS, Xcode, Apple simulator or physical device
|
devices are not test targets.
|
||||||
is available; no iOS build/archive/UI result is claimed.
|
|
||||||
|
|
||||||
## Mandatory remaining evidence
|
## External gates
|
||||||
|
|
||||||
- Run Xcode build, XCTest/UI tests and unsigned archive on macOS; fix any compiler
|
Broad Apple UI/runtime coverage, real supported devices, Android API 37, native
|
||||||
or runtime issues. The app has **not** yet been typechecked against Apple SDKs.
|
SSO/reset/invite protocols, permission/role cases, files/offline/notifications,
|
||||||
- Validate real IdPs: cancellation, account linking, TOTP after OIDC, and embedded
|
complete module and settings parity, visual baselines, accessibility and human
|
||||||
provider policy. Browser-only providers have no native-session handoff upstream.
|
security review remain open. Missing modules are listed in
|
||||||
- Real-device camera/document picker cancellation, file MIME cases, rotation with
|
[native parity](native-parity.md), not silently represented as working.
|
||||||
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.
|
|
||||||
- Operational CI runs on an authorized GitHub repository/runner, 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).
|
|
||||||
+3
-3
@@ -9,10 +9,10 @@ let package = Package(
|
|||||||
.target(
|
.target(
|
||||||
name: "Yuvomi", path: "Sources",
|
name: "Yuvomi", path: "Sources",
|
||||||
exclude: [
|
exclude: [
|
||||||
"AuthenticationView.swift", "BrowserModel.swift", "ContentView.swift",
|
"NativeStore.swift", "NativeRootView.swift", "NativeSessionVault.swift",
|
||||||
"ServerProbe.swift", "YuvomiApp.swift", "PrivacyInfo.xcprivacy", "Assets.xcassets",
|
"ServerProbe.swift", "YuvomiApp.swift", "PrivacyInfo.xcprivacy", "Assets.xcassets",
|
||||||
], sources: ["ServerAddress.swift", "ServerMetadata.swift"]),
|
], sources: ["ServerAddress.swift", "ServerMetadata.swift", "NativeContracts.swift", "NativeAPI.swift"]),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "YuvomiPolicyTests", dependencies: ["Yuvomi"], path: "Tests", exclude: ["BrowserModelTests.swift"]),
|
name: "YuvomiPolicyTests", dependencies: ["Yuvomi"], path: "Tests"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct ServerAddress: Equatable {
|
struct ServerAddress: Equatable, Sendable {
|
||||||
let url: URL
|
let url: URL
|
||||||
|
|
||||||
init(_ input: String) throws {
|
init(_ input: String) throws {
|
||||||
|
|||||||
@@ -3,6 +3,6 @@ import SwiftUI
|
|||||||
@main
|
@main
|
||||||
struct YuvomiApp: App {
|
struct YuvomiApp: App {
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup { ContentView() }
|
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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,13 @@ final class OnboardingTests: XCTestCase {
|
|||||||
func testRejectsCleartextWithoutNetwork() {
|
func testRejectsCleartextWithoutNetwork() {
|
||||||
let app = XCUIApplication()
|
let app = XCUIApplication()
|
||||||
app.launch()
|
app.launch()
|
||||||
let field = app.textFields["Yuvomi server URL"]
|
let field = app.textFields["server-url"]
|
||||||
XCTAssertTrue(field.waitForExistence(timeout: 5))
|
XCTAssertTrue(field.waitForExistence(timeout: 5))
|
||||||
field.tap()
|
field.tap()
|
||||||
field.typeText("http://family.example")
|
field.typeText("http://family.example")
|
||||||
app.buttons["Connect securely"].tap()
|
app.buttons["connect"].tap()
|
||||||
XCTAssertTrue(app.staticTexts["connectionError"].waitForExistence(timeout: 3))
|
XCTAssertTrue(app.staticTexts["invalid-server"].waitForExistence(timeout: 3))
|
||||||
XCTAssertTrue(field.exists)
|
XCTAssertTrue(field.exists)
|
||||||
|
XCTAssertEqual(app.webViews.count, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+3
-3
@@ -6,8 +6,8 @@ options:
|
|||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
SWIFT_VERSION: "5.0"
|
SWIFT_VERSION: "5.0"
|
||||||
MARKETING_VERSION: "0.1.0"
|
MARKETING_VERSION: "0.2.0"
|
||||||
CURRENT_PROJECT_VERSION: 1
|
CURRENT_PROJECT_VERSION: 2
|
||||||
CODE_SIGN_STYLE: Automatic
|
CODE_SIGN_STYLE: Automatic
|
||||||
DEVELOPMENT_TEAM: ""
|
DEVELOPMENT_TEAM: ""
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING: YES
|
ENABLE_USER_SCRIPT_SANDBOXING: YES
|
||||||
@@ -42,7 +42,7 @@ targets:
|
|||||||
TARGETED_DEVICE_FAMILY: "1,2"
|
TARGETED_DEVICE_FAMILY: "1,2"
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
dependencies:
|
dependencies:
|
||||||
- sdk: WebKit.framework
|
- sdk: Security.framework
|
||||||
YuvomiTests:
|
YuvomiTests:
|
||||||
type: bundle.unit-test
|
type: bundle.unit-test
|
||||||
platform: iOS
|
platform: iOS
|
||||||
|
|||||||
@@ -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)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
File renamed without changes.
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
File renamed without changes.
@@ -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>
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,105 @@
|
|||||||
|
// Browser automation is a reference-measurement tool, never an application renderer.
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
|
||||||
|
const [playwrightModule, destination] = process.argv.slice(2);
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { chromium } = require(playwrightModule);
|
||||||
|
const origin = process.env.YUVOMI_TEST_URL;
|
||||||
|
if (!/^https:\/\/127\.0\.0\.1:\d+$/.test(origin)) throw new Error('Only the temporary loopback fixture is allowed');
|
||||||
|
const browser = await chromium.launch({ headless: true, args: process.getuid?.() === 0 ? ['--no-sandbox'] : [] });
|
||||||
|
const width = Number(process.env.YUVOMI_VISUAL_WIDTH || 360);
|
||||||
|
const height = Number(process.env.YUVOMI_VISUAL_HEIGHT || 640);
|
||||||
|
const measurements = {
|
||||||
|
upstreamRevision: '10bfef7686ed7303b4c74c7fcc55a5eb16356c9d', browser: browser.version(),
|
||||||
|
androidFontConfiguration: Boolean(process.env.FONTCONFIG_FILE),
|
||||||
|
fixture: { widgets: ['tasks', 'calendar', 'shopping', 'notes'], mobileFavorites: ['tasks', 'kitchen', 'notes'] }, screens: [],
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width, height }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, reducedMotion: 'reduce',
|
||||||
|
locale: 'de-DE', timezoneId: 'Europe/Berlin', ignoreHTTPSErrors: true,
|
||||||
|
userAgent: 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36',
|
||||||
|
serviceWorkers: 'block',
|
||||||
|
});
|
||||||
|
await context.route('**/*', route => new URL(route.request().url()).origin === origin ? route.continue() : route.abort());
|
||||||
|
const api = context.request;
|
||||||
|
const password = process.env.YUVOMI_TEST_PASSWORD;
|
||||||
|
let csrf = '';
|
||||||
|
async function request(route, data) {
|
||||||
|
const response = await api.fetch(origin + '/api/v1' + route, {
|
||||||
|
method: data === undefined ? 'GET' : (route === '/preferences' ? 'PUT' : 'POST'),
|
||||||
|
data, headers: { 'X-CSRF-Token': csrf },
|
||||||
|
});
|
||||||
|
if (!response.ok()) throw new Error(`Fixture API failed at ${route}: HTTP ${response.status()}`);
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.csrfToken) csrf = result.csrfToken;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
await request('/auth/setup', { username: 'mobiletest', display_name: 'Mobile Test', password });
|
||||||
|
await request('/auth/login', { username: 'mobiletest', password });
|
||||||
|
await request('/auth/onboarding-seen', {});
|
||||||
|
await request('/auth/changelog-seen', {});
|
||||||
|
const today = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' }).format(new Date());
|
||||||
|
await request('/tasks', { title: 'Gemeinsam den Alltag planen', priority: 'high', due_date: today });
|
||||||
|
await request('/notes', { title: 'Willkommen zuhause', content: 'Native Oberfläche, eigener Server.', pinned: 1 });
|
||||||
|
const list = await request('/shopping', { name: 'Wochenmarkt' });
|
||||||
|
await request(`/shopping/${list.data.id}/items`, { name: 'Äpfel', quantity: '4' });
|
||||||
|
// Match the implemented native slice, not a fabricated full-module acceptance.
|
||||||
|
await request('/preferences', {
|
||||||
|
dashboard_widgets: ['tasks', 'calendar', 'shopping', 'notes'].map((id, order) => ({ id, visible: true, order, size: '1x1' })),
|
||||||
|
dashboard_today_glance: false, mobile_nav_order: ['tasks', 'kitchen', 'notes'],
|
||||||
|
timezone: 'Europe/Berlin', timezone_hint_dismissed: true,
|
||||||
|
});
|
||||||
|
await request('/auth/logout', {});
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('yuvomi-language', 'de');
|
||||||
|
localStorage.setItem('yuvomi-lang', 'de');
|
||||||
|
});
|
||||||
|
const selectors = ['.auth-page', '.auth-hero', '.auth-hero__mark', '.auth-hero__title', '.auth-hero__tagline', '.label', '.input', '.btn--primary', '.dashboard-overview__date',
|
||||||
|
'.dashboard-overview__title', '.widget', '.widget__header', '.widget__title', '.widget__body', '.nav-bottom__items',
|
||||||
|
'.nav-bottom .nav-item', '.page-toolbar', '.page-toolbar__title', '.task-card', '.task-card__title', '.note-card', '.list-row'];
|
||||||
|
async function capture(name) {
|
||||||
|
await page.evaluate(() => document.fonts.ready);
|
||||||
|
// Compare neutral states, not a keyboard focus ring left by fixture navigation.
|
||||||
|
await page.evaluate(() => document.activeElement?.blur?.());
|
||||||
|
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||||
|
await page.screenshot({ path: path.join(destination, `${name}.png`), animations: 'disabled' });
|
||||||
|
measurements.screens.push({ name, viewport: page.viewportSize(), elements: await page.evaluate(selectors =>
|
||||||
|
selectors.flatMap(selector => [...document.querySelectorAll(selector)].slice(0, 5).map(element => {
|
||||||
|
const bounds = element.getBoundingClientRect(); const style = getComputedStyle(element);
|
||||||
|
return { selector, text: element.matches('input') ? '' : element.textContent.trim().slice(0, 80),
|
||||||
|
x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height,
|
||||||
|
font: style.fontFamily, fontSize: style.fontSize, fontWeight: style.fontWeight, lineHeight: style.lineHeight,
|
||||||
|
letterSpacing: style.letterSpacing, color: style.color, background: style.backgroundColor,
|
||||||
|
radius: style.borderRadius, padding: style.padding };
|
||||||
|
})), selectors) });
|
||||||
|
}
|
||||||
|
for (const theme of ['light', 'dark']) {
|
||||||
|
await page.emulateMedia({ colorScheme: theme, reducedMotion: 'reduce' });
|
||||||
|
await page.goto(origin + '/login');
|
||||||
|
await page.evaluate(value => localStorage.setItem('yuvomi-theme', value), theme);
|
||||||
|
await page.reload();
|
||||||
|
await page.locator('#auth-btn').waitFor();
|
||||||
|
await capture(`login-${theme}-${width}`);
|
||||||
|
await page.locator('#username').fill('mobiletest');
|
||||||
|
await page.locator('#password').fill(password);
|
||||||
|
await page.locator('#auth-btn').click();
|
||||||
|
await page.locator('.dashboard-overview__title').waitFor();
|
||||||
|
await page.locator('.widget--tasks .widget__body').waitFor();
|
||||||
|
await capture(`dashboard-${theme}-${width}`);
|
||||||
|
for (const [route, selector, name] of [['/tasks', '.task-card', 'tasks'], ['/notes', '.note-card', 'notes'], ['/shopping', '.shopping-item', 'shopping']]) {
|
||||||
|
await page.goto(origin + route);
|
||||||
|
await page.locator(selector).first().waitFor();
|
||||||
|
await capture(`${name}-${theme}-${width}`);
|
||||||
|
}
|
||||||
|
await request('/auth/me');
|
||||||
|
await request('/auth/logout', {});
|
||||||
|
}
|
||||||
|
await context.close();
|
||||||
|
} finally {
|
||||||
|
fs.writeFileSync(path.join(destination, 'measurements.json'), JSON.stringify(measurements, null, 2) + '\n');
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Capture the unmodified pinned PWA with synthetic data, never a user's server."""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import secrets
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
REVISION = "10bfef7686ed7303b4c74c7fcc55a5eb16356c9d"
|
||||||
|
|
||||||
|
|
||||||
|
def run(upstream, playwright_module, destination, width=360, height=640):
|
||||||
|
revision = subprocess.check_output(["git", "-C", str(upstream), "rev-parse", "HEAD"], text=True).strip()
|
||||||
|
assert revision == REVISION, "Visual reference must use pinned unmodified upstream"
|
||||||
|
assert not subprocess.check_output(["git", "-C", str(upstream), "diff", "HEAD", "--name-only"], text=True).strip()
|
||||||
|
destination.mkdir(parents=True, exist_ok=True)
|
||||||
|
base = "/tmp/opencode" if Path("/tmp/opencode").exists() else None
|
||||||
|
with tempfile.TemporaryDirectory(prefix="yuvomi-visual-", dir=base) as temporary:
|
||||||
|
directory = Path(temporary)
|
||||||
|
subprocess.run([
|
||||||
|
"openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1",
|
||||||
|
"-keyout", str(directory / "key.pem"), "-out", str(directory / "cert.pem"),
|
||||||
|
"-subj", "/CN=Yuvomi visual fixture",
|
||||||
|
"-addext", "subjectAltName=IP:127.0.0.1,DNS:localhost",
|
||||||
|
], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
env = {**os.environ, "NODE_EXTRA_CA_CERTS": str(directory / "cert.pem"),
|
||||||
|
"YUVOMI_TEST_DISABLE_OIDC": "true", "YUVOMI_TEST_PASSWORD": secrets.token_urlsafe(24),
|
||||||
|
"YUVOMI_VISUAL_WIDTH": str(width), "YUVOMI_VISUAL_HEIGHT": str(height)}
|
||||||
|
with (directory / "server.log").open("w") as log:
|
||||||
|
server = subprocess.Popen(["node", str(ROOT / "scripts/integration-server.mjs"), str(upstream), temporary],
|
||||||
|
cwd=upstream, env=env, stdout=log, stderr=log)
|
||||||
|
try:
|
||||||
|
for _ in range(200):
|
||||||
|
if (directory / "ready.json").exists():
|
||||||
|
break
|
||||||
|
if server.poll() is not None:
|
||||||
|
raise RuntimeError("Visual fixture failed to start; no private logs exported")
|
||||||
|
time.sleep(0.1)
|
||||||
|
ready = json.loads((directory / "ready.json").read_text())
|
||||||
|
env["YUVOMI_TEST_URL"] = f"https://127.0.0.1:{ready['port']}"
|
||||||
|
subprocess.run(["node", str(ROOT / "scripts/capture-pwa-reference.mjs"),
|
||||||
|
str(playwright_module), str(destination)], env=env, check=True)
|
||||||
|
finally:
|
||||||
|
server.terminate()
|
||||||
|
try:
|
||||||
|
server.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
server.kill()
|
||||||
|
server.wait()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("upstream", type=Path)
|
||||||
|
parser.add_argument("--playwright-module", type=Path, required=True)
|
||||||
|
parser.add_argument("--output", type=Path, default=ROOT / "artifacts/visual/pwa")
|
||||||
|
parser.add_argument("--width", type=int, default=360)
|
||||||
|
parser.add_argument("--height", type=int, default=640)
|
||||||
|
args = parser.parse_args()
|
||||||
|
assert 320 <= args.width <= 1600 and 320 <= args.height <= 1800, "Invalid reference viewport"
|
||||||
|
run(args.upstream.resolve(), args.playwright_module.resolve(), args.output.resolve(), args.width, args.height)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Bounded Apple toolchain probes; never install components or change the host."""
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def probe(command, timeout=60):
|
||||||
|
label = " ".join(command)
|
||||||
|
print(f"Runner prerequisite: checking {label} (timeout {timeout}s)", flush=True)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(command, capture_output=True, text=True, timeout=timeout, check=False)
|
||||||
|
except subprocess.TimeoutExpired as error:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{label} did not return within {timeout}s. Check selected Xcode, first-launch components "
|
||||||
|
"and CoreSimulator in the runner user's interactive login session; run this command there. "
|
||||||
|
"CI has not accepted licenses or changed host settings."
|
||||||
|
) from error
|
||||||
|
except OSError as error:
|
||||||
|
raise RuntimeError(f"Cannot start {label}: {error}") from error
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError(f"{label} exited {result.returncode}: {(result.stderr or result.stdout).strip()}")
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
print(probe(["xcodebuild", "-version"]).strip(), flush=True)
|
||||||
|
probe(["xcrun", "--find", "swift"])
|
||||||
|
probe(["xcrun", "simctl", "list", "devices", "available"])
|
||||||
|
except RuntimeError as error:
|
||||||
|
print(f"Runner prerequisite: {error}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Validate prerequisites without installing packages or modifying the runner host.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
fail() { printf 'Runner prerequisite: %s\n' "$*" >&2; exit 1; }
|
||||||
|
for tool in git curl unzip python3; do
|
||||||
|
command -v "$tool" >/dev/null || fail "install $tool in the job environment"
|
||||||
|
done
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
android|emulator)
|
||||||
|
[[ "$(uname -s)" == Linux && "$(uname -m)" == x86_64 ]] || fail 'ubuntu-latest must provide Linux x86_64'
|
||||||
|
if [[ "$1" == emulator ]]; then
|
||||||
|
[[ -c /dev/kvm && -r /dev/kvm && -w /dev/kvm ]] || fail 'expose /dev/kvm to the job and grant the runner user read/write access (nested virtualization required on VMs)'
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
ios)
|
||||||
|
[[ "$(uname -s)" == Darwin && "$(uname -m)" == arm64 ]] || fail 'macos-arm64 must execute natively on the Apple Silicon host'
|
||||||
|
command -v xcodebuild >/dev/null || fail 'install full Xcode and select it with xcode-select'
|
||||||
|
python3 "${BASH_SOURCE[0]%/*}/check-apple-tools.py"
|
||||||
|
;;
|
||||||
|
*) fail 'usage: bash scripts/check-ci-runner.sh android|emulator|ios' ;;
|
||||||
|
esac
|
||||||
@@ -47,8 +47,23 @@ network = ET.parse(ROOT / "android/app/src/main/res/xml/network_security_config.
|
|||||||
assert network.find("base-config").get("cleartextTrafficPermitted") == "false"
|
assert network.find("base-config").get("cleartextTrafficPermitted") == "false"
|
||||||
assert [node.get("src") for node in network.findall(".//certificates")] == ["system"]
|
assert [node.get("src") for node in network.findall(".//certificates")] == ["system"]
|
||||||
assert "distributionSha256Sum=" in (ROOT / "android/gradle/wrapper/gradle-wrapper.properties").read_text()
|
assert "distributionSha256Sum=" in (ROOT / "android/gradle/wrapper/gradle-wrapper.properties").read_text()
|
||||||
for workflow in (ROOT / ".github/workflows").glob("*.yml"):
|
workflows = sorted((ROOT / ".gitea/workflows").glob("*.yml"))
|
||||||
|
assert {path.name for path in workflows} == {"android.yml", "ios.yml", "release.yml"}, "Missing Gitea workflows"
|
||||||
|
for workflow in workflows:
|
||||||
for action in re.findall(r"uses:\s*([^\s#]+)", workflow.read_text()):
|
for action in re.findall(r"uses:\s*([^\s#]+)", workflow.read_text()):
|
||||||
assert re.fullmatch(r"[\w.-]+/[\w./-]+@[0-9a-f]{40}", action), f"Unpinned action: {action}"
|
assert re.fullmatch(r"https://(?:github.com|gitea.com)/[\w.-]+/[\w./-]+@[0-9a-f]{40}", action), f"Unpinned action: {action}"
|
||||||
|
expected_runner = "macos-arm64" if workflow.name == "ios.yml" else "ubuntu-latest"
|
||||||
|
labels = re.findall(r"runs-on:\s*([^\s#]+)", workflow.read_text())
|
||||||
|
assert labels and all(label == expected_runner for label in labels), f"Wrong runner in {workflow.name}"
|
||||||
|
checkouts = workflow.read_text().count("https://github.com/actions/checkout@")
|
||||||
|
assert checkouts == (2 if workflow.name == "android.yml" else 1), "Each job needs checkout"
|
||||||
|
assert workflow.read_text().count("GIT_DEFAULT_HASH: sha256") == checkouts, "SHA-256 checkout must be explicit"
|
||||||
|
assert workflow.read_text().count("persist-credentials: false") == checkouts, "Do not retain CI checkout tokens"
|
||||||
|
release = (ROOT / ".gitea/workflows/release.yml").read_text()
|
||||||
|
assert "secrets." not in release and "environment:" not in release, "Gitea signing needs a separately reviewed approval mechanism"
|
||||||
assert "NSAllowsArbitraryLoads" not in (ROOT / "ios/project.yml").read_text()
|
assert "NSAllowsArbitraryLoads" not in (ROOT / "ios/project.yml").read_text()
|
||||||
|
for path in list((ROOT / "android/app/src/main/java").rglob("*.kt")) + list((ROOT / "ios/Sources").rglob("*.swift")):
|
||||||
|
assert not re.search(r"android\.webkit\.|import WebKit|\b(?:WKWebView|WebView)\s*\(", path.read_text()), f"Web wrapper in native shipping source: {path}"
|
||||||
|
assert "NativeRootView()" in (ROOT / "ios/Sources/YuvomiApp.swift").read_text()
|
||||||
|
assert "setContent" in (ROOT / "android/app/src/main/java/community/yuvomi/mobile/MainActivity.kt").read_text()
|
||||||
print(f"Repository invariants: {len(paths)} source/documentation paths checked.")
|
print(f"Repository invariants: {len(paths)} source/documentation paths checked.")
|
||||||
@@ -23,6 +23,10 @@ def tool(name, *arguments):
|
|||||||
|
|
||||||
|
|
||||||
release = APP / "outputs/apk/release/app-release-unsigned.apk"
|
release = APP / "outputs/apk/release/app-release-unsigned.apk"
|
||||||
|
configuration = (ROOT / "android/app/build.gradle.kts").read_text()
|
||||||
|
version_name = re.search(r'versionName = "([^"]+)"', configuration).group(1)
|
||||||
|
version_code = re.search(r'versionCode = (\d+)', configuration).group(1)
|
||||||
|
assert "native" in version_name, "Collector expects the native preview, not the archived wrapper"
|
||||||
manifest = tool("aapt2", "dump", "xmltree", release, "--file", "AndroidManifest.xml")
|
manifest = tool("aapt2", "dump", "xmltree", release, "--file", "AndroidManifest.xml")
|
||||||
assert 'package="community.yuvomi.mobile"' in manifest
|
assert 'package="community.yuvomi.mobile"' in manifest
|
||||||
assert re.search(r":allowBackup\([^\n]*=false", manifest)
|
assert re.search(r":allowBackup\([^\n]*=false", manifest)
|
||||||
@@ -38,6 +42,9 @@ assert policy.count("E: certificates") == 1 and 'src="system"' in policy
|
|||||||
(DESTINATION / "release-network-policy.txt").write_text(policy)
|
(DESTINATION / "release-network-policy.txt").write_text(policy)
|
||||||
|
|
||||||
debug = APP / "outputs/apk/debug/app-debug.apk"
|
debug = APP / "outputs/apk/debug/app-debug.apk"
|
||||||
|
for apk in [debug, release]:
|
||||||
|
identity = tool("aapt2", "dump", "badging", apk).splitlines()[0]
|
||||||
|
assert f"versionCode='{version_code}'" in identity and f"versionName='{version_name}'" in identity, identity
|
||||||
(DESTINATION / "debug-signature.txt").write_text(tool("apksigner", "verify", "--verbose", debug))
|
(DESTINATION / "debug-signature.txt").write_text(tool("apksigner", "verify", "--verbose", debug))
|
||||||
unsigned = subprocess.run([str(TOOLS / "apksigner"), "verify", str(release)], capture_output=True, text=True)
|
unsigned = subprocess.run([str(TOOLS / "apksigner"), "verify", str(release)], capture_output=True, text=True)
|
||||||
assert unsigned.returncode != 0 and "Missing META-INF/MANIFEST.MF" in unsigned.stderr, unsigned.stderr
|
assert unsigned.returncode != 0 and "Missing META-INF/MANIFEST.MF" in unsigned.stderr, unsigned.stderr
|
||||||
@@ -61,7 +68,9 @@ dirty = bool(subprocess.check_output(["git", "status", "--porcelain"], cwd=ROOT,
|
|||||||
(DESTINATION / "build-info.json").write_text(json.dumps({
|
(DESTINATION / "build-info.json").write_text(json.dumps({
|
||||||
"source_revision": revision, "worktree_dirty_at_collection": dirty,
|
"source_revision": revision, "worktree_dirty_at_collection": dirty,
|
||||||
"upstream_revision": "10bfef7686ed7303b4c74c7fcc55a5eb16356c9d",
|
"upstream_revision": "10bfef7686ed7303b4c74c7fcc55a5eb16356c9d",
|
||||||
"status": "Development debug APK; unsigned release APK/AAB. Not production-ready.",
|
"version_name": version_name, "version_code": int(version_code),
|
||||||
|
"renderer": "native Jetpack Compose",
|
||||||
|
"status": "Incomplete native preview: development debug APK; unsigned release APK/AAB. No full PWA parity or production approval.",
|
||||||
"artifacts": entries,
|
"artifacts": entries,
|
||||||
}, indent=2) + "\n")
|
}, indent=2) + "\n")
|
||||||
print(f"Inspected and collected {len(entries)} artifacts in {DESTINATION}")
|
print(f"Inspected and collected {len(entries)} artifacts in {DESTINATION}")
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Development-only side-by-side evidence. This deliberately asserts no pixel parity.
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
const [modulePath, nativeDirectory, pwaDirectory, destination] = process.argv.slice(2);
|
||||||
|
if (!destination) throw new Error('Usage: node compare-native-pwa.mjs PLAYWRIGHT_MODULE NATIVE_DIR PWA_DIR OUTPUT_DIR');
|
||||||
|
const { chromium } = createRequire(import.meta.url)(path.resolve(modulePath));
|
||||||
|
const viewport = JSON.parse(fs.readFileSync(path.join(nativeDirectory, 'viewport.json')));
|
||||||
|
if (viewport.fontScale !== 1) throw new Error('This CSS reference comparison requires fontScale 1');
|
||||||
|
const cssWidth = viewport.widthPx / viewport.density;
|
||||||
|
const cssHeight = viewport.heightPx / viewport.density;
|
||||||
|
const reference = JSON.parse(fs.readFileSync(path.join(pwaDirectory, 'measurements.json')));
|
||||||
|
if (!reference.screens.every(screen => screen.viewport.width === cssWidth && screen.viewport.height === cssHeight)) {
|
||||||
|
throw new Error('PWA and native content viewports do not match');
|
||||||
|
}
|
||||||
|
function image(directory, name) {
|
||||||
|
const bytes = fs.readFileSync(path.join(directory, name));
|
||||||
|
if (bytes.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a' ||
|
||||||
|
bytes.readUInt32BE(16) !== viewport.widthPx || bytes.readUInt32BE(20) !== viewport.heightPx) {
|
||||||
|
throw new Error(`Invalid or mismatched PNG: ${name}`);
|
||||||
|
}
|
||||||
|
return { uri: `data:image/png;base64,${bytes.toString('base64')}`, sha256: createHash('sha256').update(bytes).digest('hex') };
|
||||||
|
}
|
||||||
|
fs.mkdirSync(destination, { recursive: true });
|
||||||
|
const browser = await chromium.launch({ headless: true, args: process.getuid?.() === 0 ? ['--no-sandbox'] : [] });
|
||||||
|
const records = [];
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage({ viewport: { width: viewport.widthPx * 2, height: viewport.heightPx + 48 }, deviceScaleFactor: 1 });
|
||||||
|
await page.route('**/*', route => route.abort());
|
||||||
|
for (const name of ['login', 'dashboard', 'tasks', 'notes', 'shopping']) {
|
||||||
|
for (const theme of ['light', 'dark']) {
|
||||||
|
const native = image(nativeDirectory, `${name}-${theme}.png`);
|
||||||
|
const pwa = image(pwaDirectory, `${name}-${theme}-${cssWidth}.png`);
|
||||||
|
await page.setContent(`<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'">
|
||||||
|
<style>body{margin:0;background:#eee;font:20px sans-serif}main{display:flex}section{width:${viewport.widthPx}px}
|
||||||
|
header{height:48px;display:grid;place-items:center}img{display:block;width:100%}</style>
|
||||||
|
<main><section><header>PWA 2.78.0 · ${name} · ${theme}</header><img src="${pwa.uri}"></section>
|
||||||
|
<section><header>Android nativ · ${name} · ${theme} · keine 1:1-Abnahme</header><img src="${native.uri}"></section></main>`);
|
||||||
|
await page.locator('img').evaluateAll(images => Promise.all(images.map(image => image.decode())));
|
||||||
|
const file = `${name}-${theme}-comparison.png`;
|
||||||
|
await page.screenshot({ path: path.join(destination, file) });
|
||||||
|
records.push({ file, nativeSha256: native.sha256, pwaSha256: pwa.sha256 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
fs.writeFileSync(path.join(destination, 'comparison.json'), JSON.stringify({
|
||||||
|
status: 'Side-by-side evidence only; no visual acceptance', viewport, upstreamRevision: reference.upstreamRevision, records,
|
||||||
|
}, null, 2) + '\n');
|
||||||
|
console.log(`Wrote ${records.length} matched-viewport comparisons; no parity assertion.`);
|
||||||
@@ -13,15 +13,24 @@ process.env.PORT = '0';
|
|||||||
process.env.BIND_ADDRESS = '127.0.0.1';
|
process.env.BIND_ADDRESS = '127.0.0.1';
|
||||||
process.env.DB_PATH = path.join(directory, 'fixture.db');
|
process.env.DB_PATH = path.join(directory, 'fixture.db');
|
||||||
process.env.SESSION_SECRET = randomBytes(48).toString('hex');
|
process.env.SESSION_SECRET = randomBytes(48).toString('hex');
|
||||||
process.env.SESSION_SECURE = 'true';
|
// Exercise both explicit Secure cookies and upstream's default behind HTTPS.
|
||||||
|
const sessionSecure = process.env.YUVOMI_TEST_SESSION_SECURE ?? 'true';
|
||||||
|
if (!['true', 'false'].includes(sessionSecure)) throw new Error('Invalid fixture cookie mode');
|
||||||
|
process.env.SESSION_SECURE = sessionSecure;
|
||||||
process.env.DB_ENCRYPTION_KEY = '';
|
process.env.DB_ENCRYPTION_KEY = '';
|
||||||
process.env.NODE_ENV = 'test';
|
process.env.NODE_ENV = 'test';
|
||||||
process.env.LOG_LEVEL = 'warn';
|
process.env.LOG_LEVEL = 'warn';
|
||||||
const tls = { key: fs.readFileSync(path.join(directory, 'key.pem')), cert: fs.readFileSync(path.join(directory, 'cert.pem')) };
|
const tls = { key: fs.readFileSync(path.join(directory, 'key.pem')), cert: fs.readFileSync(path.join(directory, 'cert.pem')) };
|
||||||
const idp = await startIdentityProvider(tls);
|
const idp = process.env.YUVOMI_TEST_DISABLE_OIDC === 'true' ? null : await startIdentityProvider(tls);
|
||||||
process.env.OIDC_ISSUER = idp.issuer;
|
if (idp) {
|
||||||
process.env.OIDC_CLIENT_ID = idp.clientId;
|
process.env.OIDC_ISSUER = idp.issuer;
|
||||||
process.env.OIDC_CLIENT_SECRET = idp.secret;
|
process.env.OIDC_CLIENT_ID = idp.clientId;
|
||||||
|
process.env.OIDC_CLIENT_SECRET = idp.secret;
|
||||||
|
} else {
|
||||||
|
delete process.env.OIDC_ISSUER;
|
||||||
|
delete process.env.OIDC_CLIENT_ID;
|
||||||
|
delete process.env.OIDC_CLIENT_SECRET;
|
||||||
|
}
|
||||||
const { server } = await import(pathToFileURL(path.join(upstream, 'server/index.js')));
|
const { server } = await import(pathToFileURL(path.join(upstream, 'server/index.js')));
|
||||||
if (!server.listening) await once(server, 'listening');
|
if (!server.listening) await once(server, 'listening');
|
||||||
let networkAvailable = true;
|
let networkAvailable = true;
|
||||||
@@ -47,7 +56,9 @@ const proxy = https.createServer(tls, (request, response) => {
|
|||||||
});
|
});
|
||||||
proxy.listen(0, '127.0.0.1');
|
proxy.listen(0, '127.0.0.1');
|
||||||
await once(proxy, 'listening');
|
await once(proxy, 'listening');
|
||||||
process.env.OIDC_REDIRECT_URI = `https://127.0.0.1:${proxy.address().port}/api/v1/auth/oidc/callback`;
|
if (idp) {
|
||||||
idp.setRedirect(process.env.OIDC_REDIRECT_URI);
|
process.env.OIDC_REDIRECT_URI = `https://127.0.0.1:${proxy.address().port}/api/v1/auth/oidc/callback`;
|
||||||
fs.writeFileSync(path.join(directory, 'ready.json'), JSON.stringify({ port: proxy.address().port, idpPort: idp.port }));
|
idp.setRedirect(process.env.OIDC_REDIRECT_URI);
|
||||||
process.on('SIGTERM', () => { idp.close(); proxy.close(); server.close(); process.exit(0); });
|
}
|
||||||
|
fs.writeFileSync(path.join(directory, 'ready.json'), JSON.stringify({ port: proxy.address().port, idpPort: idp?.port ?? null }));
|
||||||
|
process.on('SIGTERM', () => { idp?.close(); proxy.close(); server.close(); process.exit(0); });
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Lint Gitea workflows using actionlint's GitHub-compatible grammar.
|
||||||
|
|
||||||
|
Only the forge-specific absolute action URL syntax is normalized for actionlint;
|
||||||
|
the real URLs/pins and runner assignments are checked by check-repository.py.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
actionlint = sys.argv[1] if len(sys.argv) > 1 else "actionlint"
|
||||||
|
base = Path("/tmp/opencode") if Path("/tmp/opencode").exists() else None
|
||||||
|
with tempfile.TemporaryDirectory(prefix="yuvomi-workflow-lint-", dir=base) as temporary:
|
||||||
|
config = Path(temporary) / "actionlint.yaml"
|
||||||
|
config.write_text("self-hosted-runner:\n labels: [macos-arm64]\n")
|
||||||
|
workflows = sorted((ROOT / ".gitea/workflows").glob("*.yml"))
|
||||||
|
if not workflows:
|
||||||
|
raise SystemExit("No Gitea workflows found")
|
||||||
|
for path in workflows:
|
||||||
|
normalized = re.sub(r"(uses:\s*)https://(?:github.com|gitea.com)/", r"\1", path.read_text())
|
||||||
|
subprocess.run([actionlint, "-config-file", str(config), "-stdin-filename", str(path), "-"],
|
||||||
|
input=normalized, text=True, check=True, cwd=ROOT)
|
||||||
|
print(f"actionlint: {len(workflows)} Gitea workflows passed (action URL syntax normalized).")
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Test runner prerequisite decisions with fake tools, not actual Apple SDKs."""
|
||||||
|
import os
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
SCRIPT = Path(__file__).with_name("check-ci-runner.sh")
|
||||||
|
SPEC = importlib.util.spec_from_file_location("apple_tools", SCRIPT.with_name("check-apple-tools.py"))
|
||||||
|
APPLE_TOOLS = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(APPLE_TOOLS)
|
||||||
|
|
||||||
|
|
||||||
|
class RunnerPrerequisiteTests(unittest.TestCase):
|
||||||
|
def run_check(self, mode, system="Linux", architecture="x86_64", missing=None, broken_xcode=False, broken_xcrun=False):
|
||||||
|
base = "/tmp/opencode" if Path("/tmp/opencode").exists() else None
|
||||||
|
with tempfile.TemporaryDirectory(prefix="yuvomi-runner-test-", dir=base) as temporary:
|
||||||
|
directory = Path(temporary)
|
||||||
|
for name in ["git", "curl", "unzip", "python3", "xcodebuild", "xcrun"]:
|
||||||
|
if name == missing:
|
||||||
|
continue
|
||||||
|
path = directory / name
|
||||||
|
status = 1 if (name == "xcodebuild" and broken_xcode or name == "xcrun" and broken_xcrun) else 0
|
||||||
|
path.write_text(f"#!/bin/sh\nexec {shlex.quote(sys.executable)} \"$@\"\n" if name == "python3"
|
||||||
|
else f"#!/bin/sh\nexit {status}\n")
|
||||||
|
path.chmod(0o700)
|
||||||
|
uname = directory / "uname"
|
||||||
|
uname.write_text('#!/bin/sh\ncase "$1" in -s) echo "$TEST_SYSTEM";; -m) echo "$TEST_ARCH";; esac\n')
|
||||||
|
uname.chmod(0o700)
|
||||||
|
return subprocess.run(["/bin/bash", str(SCRIPT), mode], capture_output=True, text=True,
|
||||||
|
env={**os.environ, "PATH": temporary, "TEST_SYSTEM": system,
|
||||||
|
"TEST_ARCH": architecture})
|
||||||
|
|
||||||
|
def test_android_accepts_linux_x64(self):
|
||||||
|
self.assertEqual(self.run_check("android").returncode, 0)
|
||||||
|
|
||||||
|
def test_android_rejects_arm(self):
|
||||||
|
self.assertNotEqual(self.run_check("android", architecture="aarch64").returncode, 0)
|
||||||
|
|
||||||
|
def test_android_rejects_macos(self):
|
||||||
|
self.assertNotEqual(self.run_check("android", system="Darwin").returncode, 0)
|
||||||
|
|
||||||
|
def test_ios_accepts_native_arm_with_tools(self):
|
||||||
|
self.assertEqual(self.run_check("ios", system="Darwin", architecture="arm64").returncode, 0)
|
||||||
|
|
||||||
|
def test_ios_rejects_rosetta_or_intel(self):
|
||||||
|
self.assertNotEqual(self.run_check("ios", system="Darwin").returncode, 0)
|
||||||
|
|
||||||
|
def test_ios_rejects_linux(self):
|
||||||
|
self.assertNotEqual(self.run_check("ios", architecture="arm64").returncode, 0)
|
||||||
|
|
||||||
|
def test_missing_python_is_actionable(self):
|
||||||
|
result = self.run_check("android", missing="python3")
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("install python3", result.stderr)
|
||||||
|
|
||||||
|
def test_missing_full_xcode_fails(self):
|
||||||
|
result = self.run_check("ios", system="Darwin", architecture="arm64", missing="xcodebuild")
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("full Xcode", result.stderr)
|
||||||
|
|
||||||
|
def test_broken_xcode_fails(self):
|
||||||
|
self.assertNotEqual(self.run_check("ios", system="Darwin", architecture="arm64",
|
||||||
|
broken_xcode=True).returncode, 0)
|
||||||
|
|
||||||
|
def test_unknown_mode_fails(self):
|
||||||
|
self.assertNotEqual(self.run_check("other").returncode, 0)
|
||||||
|
|
||||||
|
def test_missing_xcrun_is_actionable(self):
|
||||||
|
result = self.run_check("ios", system="Darwin", architecture="arm64", missing="xcrun")
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("Cannot start xcrun", result.stderr)
|
||||||
|
|
||||||
|
def test_broken_xcrun_is_not_a_pass(self):
|
||||||
|
result = self.run_check("ios", system="Darwin", architecture="arm64", broken_xcrun=True)
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("xcrun --find swift exited 1", result.stderr)
|
||||||
|
|
||||||
|
def test_unresponsive_apple_probe_times_out_with_guidance(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "interactive login session"):
|
||||||
|
APPLE_TOOLS.probe([sys.executable, "-c", "import time; time.sleep(30)"], timeout=0.05)
|
||||||
|
|
||||||
|
def test_apple_probe_preserves_tool_failure_diagnostic(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "fixture tool failure"):
|
||||||
|
APPLE_TOOLS.probe([sys.executable, "-c", "import sys; sys.exit('fixture tool failure')"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Regressions for AGP screenshot output collection, not visual parity tests."""
|
||||||
|
import base64
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
SPEC = importlib.util.spec_from_file_location("integration", Path(__file__).with_name("test-integration.py"))
|
||||||
|
HARNESS = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(HARNESS)
|
||||||
|
PNG = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jJ1sAAAAASUVORK5CYII=")
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenshotEvidenceTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
base = "/tmp/opencode" if Path("/tmp/opencode").exists() else None
|
||||||
|
self.temporary = tempfile.TemporaryDirectory(prefix="yuvomi-evidence-", dir=base)
|
||||||
|
self.addCleanup(self.temporary.cleanup)
|
||||||
|
self.output = Path(self.temporary.name)
|
||||||
|
self.additional, self.screenshots = HARNESS.prepare_android_screenshots(self.output)
|
||||||
|
|
||||||
|
def fixture(self, device="fixture(AVD) - 15", contents=PNG):
|
||||||
|
image = self.additional / "connected" / device / "native-dashboard.png"
|
||||||
|
image.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
image.write_bytes(contents)
|
||||||
|
return image
|
||||||
|
|
||||||
|
def test_collects_actual_agp_variant_path_without_adb_or_installed_app(self):
|
||||||
|
image = self.fixture()
|
||||||
|
HARNESS.collect_native_screenshot(self.additional, self.screenshots)
|
||||||
|
self.assertEqual((self.screenshots / image.name).read_bytes(), PNG)
|
||||||
|
self.assertEqual(self.additional.name, "integrationAndroidTest")
|
||||||
|
|
||||||
|
def test_missing_screenshot_is_not_success(self):
|
||||||
|
with self.assertRaisesRegex(AssertionError, "Missing or ambiguous"):
|
||||||
|
HARNESS.collect_native_screenshot(self.additional, self.screenshots)
|
||||||
|
|
||||||
|
def test_rejects_run_as_error_even_when_command_reported_zero_exit(self):
|
||||||
|
self.fixture(contents=b"run-as: unknown package: community.yuvomi.mobile.integration\n")
|
||||||
|
with self.assertRaisesRegex(AssertionError, "Invalid native"):
|
||||||
|
HARNESS.collect_native_screenshot(self.additional, self.screenshots)
|
||||||
|
|
||||||
|
def test_rejects_ambiguous_device_evidence(self):
|
||||||
|
self.fixture("first")
|
||||||
|
self.fixture("second")
|
||||||
|
with self.assertRaisesRegex(AssertionError, "ambiguous"):
|
||||||
|
HARNESS.collect_native_screenshot(self.additional, self.screenshots)
|
||||||
|
|
||||||
|
def test_preparation_removes_only_known_stale_capture(self):
|
||||||
|
image = self.fixture()
|
||||||
|
HARNESS.collect_native_screenshot(self.additional, self.screenshots)
|
||||||
|
unrelated = image.with_name("other-output.txt")
|
||||||
|
unrelated.write_text("preserve")
|
||||||
|
HARNESS.prepare_android_screenshots(self.output)
|
||||||
|
self.assertFalse(image.exists())
|
||||||
|
self.assertFalse((self.screenshots / image.name).exists())
|
||||||
|
self.assertEqual(unrelated.read_text(), "preserve")
|
||||||
|
|
||||||
|
def gallery(self):
|
||||||
|
parent = self.fixture().parent
|
||||||
|
for name in HARNESS.SCREENSHOTS:
|
||||||
|
(parent / name).write_bytes(PNG)
|
||||||
|
(parent / "viewport.json").write_text(json.dumps({"widthPx": 720, "heightPx": 1184, "density": 2}))
|
||||||
|
return parent
|
||||||
|
|
||||||
|
def test_collects_complete_allowlisted_gallery(self):
|
||||||
|
parent = self.gallery()
|
||||||
|
(parent / "private.txt").write_text("not exported")
|
||||||
|
HARNESS.collect_native_screenshot(self.additional, self.screenshots, gallery=True)
|
||||||
|
self.assertEqual({p.name for p in self.screenshots.iterdir()}, set(HARNESS.VISUAL_OUTPUTS))
|
||||||
|
|
||||||
|
def test_partial_gallery_is_not_exported(self):
|
||||||
|
parent = self.gallery()
|
||||||
|
(parent / "notes-dark.png").unlink()
|
||||||
|
with self.assertRaisesRegex(AssertionError, "Missing or ambiguous"):
|
||||||
|
HARNESS.collect_native_screenshot(self.additional, self.screenshots, gallery=True)
|
||||||
|
self.assertFalse(self.screenshots.exists())
|
||||||
|
|
||||||
|
def test_clears_every_known_gallery_file_but_preserves_unrelated(self):
|
||||||
|
parent = self.gallery()
|
||||||
|
HARNESS.collect_native_screenshot(self.additional, self.screenshots, gallery=True)
|
||||||
|
(parent / "other.png").write_bytes(PNG)
|
||||||
|
HARNESS.prepare_android_screenshots(self.output)
|
||||||
|
self.assertEqual([p.name for p in parent.iterdir()], ["other.png"])
|
||||||
|
self.assertEqual(list(self.screenshots.iterdir()), [])
|
||||||
|
|
||||||
|
def test_rejects_gallery_split_across_devices(self):
|
||||||
|
parent = self.gallery()
|
||||||
|
other = parent.with_name("second")
|
||||||
|
other.mkdir()
|
||||||
|
(parent / "notes-dark.png").rename(other / "notes-dark.png")
|
||||||
|
with self.assertRaisesRegex(AssertionError, "Mixed-device"):
|
||||||
|
HARNESS.collect_native_screenshot(self.additional, self.screenshots, gallery=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+50
-10
@@ -20,9 +20,44 @@ import urllib.request
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
REVISION = "10bfef7686ed7303b4c74c7fcc55a5eb16356c9d"
|
REVISION = "10bfef7686ed7303b4c74c7fcc55a5eb16356c9d"
|
||||||
|
SCREENSHOTS = ("native-dashboard.png",) + tuple(
|
||||||
|
f"{page}-{theme}.png" for page in ("login", "dashboard", "tasks", "notes", "shopping") for theme in ("light", "dark"))
|
||||||
|
VISUAL_OUTPUTS = SCREENSHOTS + ("viewport.json",)
|
||||||
|
|
||||||
|
|
||||||
def run(upstream, android):
|
def prepare_android_screenshots(app_output):
|
||||||
|
additional = app_output / "connected_android_test_additional_output/integrationAndroidTest"
|
||||||
|
screenshots = app_output / "native-screenshots"
|
||||||
|
# Never accept a previous run's screenshot as current evidence.
|
||||||
|
for name in VISUAL_OUTPUTS:
|
||||||
|
for stale in additional.rglob(name):
|
||||||
|
stale.unlink()
|
||||||
|
(screenshots / name).unlink(missing_ok=True)
|
||||||
|
return additional, screenshots
|
||||||
|
|
||||||
|
|
||||||
|
def collect_native_screenshot(additional, screenshots, gallery=False):
|
||||||
|
names = VISUAL_OUTPUTS if gallery else ("native-dashboard.png",)
|
||||||
|
validated = {}
|
||||||
|
parents = set()
|
||||||
|
for name in names:
|
||||||
|
captures = list(additional.rglob(name))
|
||||||
|
assert len(captures) == 1, f"Missing or ambiguous native fixture screenshot: {name}"
|
||||||
|
capture = captures[0].read_bytes()
|
||||||
|
if name.endswith(".png"):
|
||||||
|
assert capture.startswith(b"\x89PNG\r\n\x1a\n"), "Invalid native fixture screenshot"
|
||||||
|
else:
|
||||||
|
viewport = json.loads(capture)
|
||||||
|
assert viewport["widthPx"] > 0 and viewport["heightPx"] > 0 and viewport["density"] > 0
|
||||||
|
parents.add(captures[0].parent)
|
||||||
|
validated[name] = capture
|
||||||
|
assert len(parents) == 1, "Mixed-device native gallery evidence"
|
||||||
|
screenshots.mkdir(parents=True, exist_ok=True)
|
||||||
|
for name, capture in validated.items():
|
||||||
|
(screenshots / name).write_bytes(capture)
|
||||||
|
|
||||||
|
|
||||||
|
def run(upstream, android, cookie_without_secure=False):
|
||||||
revision = subprocess.check_output(["git", "-C", str(upstream), "rev-parse", "HEAD"], text=True).strip()
|
revision = subprocess.check_output(["git", "-C", str(upstream), "rev-parse", "HEAD"], text=True).strip()
|
||||||
assert revision == REVISION, f"Expected pinned upstream {REVISION}, got {revision}"
|
assert revision == REVISION, f"Expected pinned upstream {REVISION}, got {revision}"
|
||||||
base_temp = Path("/tmp/opencode") if Path("/tmp/opencode").exists() else Path(tempfile.gettempdir())
|
base_temp = Path("/tmp/opencode") if Path("/tmp/opencode").exists() else Path(tempfile.gettempdir())
|
||||||
@@ -37,7 +72,8 @@ def run(upstream, android):
|
|||||||
with (directory / "server.log").open("w") as log:
|
with (directory / "server.log").open("w") as log:
|
||||||
process = subprocess.Popen(["node", str(ROOT / "scripts/integration-server.mjs"), str(upstream), temporary],
|
process = subprocess.Popen(["node", str(ROOT / "scripts/integration-server.mjs"), str(upstream), temporary],
|
||||||
cwd=upstream, stdout=log, stderr=log,
|
cwd=upstream, stdout=log, stderr=log,
|
||||||
env={**os.environ, "NODE_EXTRA_CA_CERTS": str(directory / "cert.pem")})
|
env={**os.environ, "NODE_EXTRA_CA_CERTS": str(directory / "cert.pem"),
|
||||||
|
"YUVOMI_TEST_SESSION_SECURE": "false" if cookie_without_secure else "true"})
|
||||||
try:
|
try:
|
||||||
for _ in range(200):
|
for _ in range(200):
|
||||||
if (directory / "ready.json").exists():
|
if (directory / "ready.json").exists():
|
||||||
@@ -83,7 +119,7 @@ def run(upstream, android):
|
|||||||
status, body, headers = request("/api/v1/auth/login", {"username": "mobiletest", "password": password})
|
status, body, headers = request("/api/v1/auth/login", {"username": "mobiletest", "password": password})
|
||||||
assert status == 200
|
assert status == 200
|
||||||
session = [cookie for cookie in cookies if cookie.name == "yuvomi.sid"][0]
|
session = [cookie for cookie in cookies if cookie.name == "yuvomi.sid"][0]
|
||||||
assert session.secure and session.has_nonstandard_attr("HttpOnly")
|
assert session.secure == (not cookie_without_secure) and session.has_nonstandard_attr("HttpOnly")
|
||||||
assert "SameSite=Lax" in " ".join(headers.get_all("Set-Cookie"))
|
assert "SameSite=Lax" in " ".join(headers.get_all("Set-Cookie"))
|
||||||
csrf = json.loads(body)["csrfToken"]
|
csrf = json.loads(body)["csrfToken"]
|
||||||
assert request("/api/v1/auth/me")[0] == 200
|
assert request("/api/v1/auth/me")[0] == 200
|
||||||
@@ -91,7 +127,8 @@ def run(upstream, android):
|
|||||||
assert request("/api/v1/auth/logout", {})[0] == 403
|
assert request("/api/v1/auth/logout", {})[0] == 403
|
||||||
assert request("/api/v1/auth/logout", {}, {"X-CSRF-Token": csrf})[0] == 200
|
assert request("/api/v1/auth/logout", {}, {"X-CSRF-Token": csrf})[0] == 200
|
||||||
assert request("/api/v1/auth/me")[0] == 401
|
assert request("/api/v1/auth/me")[0] == 401
|
||||||
print("PASS: login, Secure/HttpOnly/SameSite cookies, persistence, CSRF rejection and logout/revocation")
|
cookie_mode = "HTTPS with upstream-default non-Secure session cookie" if cookie_without_secure else "Secure session cookie"
|
||||||
|
print(f"PASS: login, {cookie_mode}, HttpOnly/SameSite, persistence, CSRF rejection and logout/revocation")
|
||||||
assert request("/api/v1/auth/oidc/start")[0] == 200
|
assert request("/api/v1/auth/oidc/start")[0] == 200
|
||||||
status, body, _ = request("/api/v1/auth/me")
|
status, body, _ = request("/api/v1/auth/me")
|
||||||
identity = json.loads(body)
|
identity = json.loads(body)
|
||||||
@@ -120,18 +157,19 @@ def run(upstream, android):
|
|||||||
subprocess.run([str(adb), "reverse", f"tcp:{port}", f"tcp:{port}"], check=True)
|
subprocess.run([str(adb), "reverse", f"tcp:{port}", f"tcp:{port}"], check=True)
|
||||||
subprocess.run([str(adb), "reverse", f"tcp:{ready['idpPort']}", f"tcp:{ready['idpPort']}"], check=True)
|
subprocess.run([str(adb), "reverse", f"tcp:{ready['idpPort']}", f"tcp:{ready['idpPort']}"], check=True)
|
||||||
try:
|
try:
|
||||||
|
app_output = (Path(env["YUVOMI_BUILD_DIR"]) / "app" if "YUVOMI_BUILD_DIR" in env
|
||||||
|
else ROOT / "android/app/build") / "outputs"
|
||||||
|
additional, screenshots = prepare_android_screenshots(app_output)
|
||||||
# The ephemeral password is passed through the local test runner only,
|
# The ephemeral password is passed through the local test runner only,
|
||||||
# never written into a source file or printed by this harness.
|
# never written into a source file or printed by this harness.
|
||||||
result = subprocess.run(["bash", str(ROOT / "android/gradlew"), "-p", str(ROOT / "android"),
|
result = subprocess.run(["bash", str(ROOT / "android/gradlew"), "-p", str(ROOT / "android"),
|
||||||
"-Pintegration", "connectedIntegrationAndroidTest"], env=env, check=False)
|
"-Pintegration", "connectedIntegrationAndroidTest"], env=env, check=False)
|
||||||
if result.returncode:
|
if result.returncode:
|
||||||
raise RuntimeError("Android integration tests failed. Inspect the Gradle test report.")
|
raise RuntimeError("Android integration tests failed. Inspect the Gradle test report.")
|
||||||
output = Path(env.get("YUVOMI_BUILD_DIR", ROOT / "android"))
|
reports = app_output / "androidTest-results/connected/integration"
|
||||||
reports = output / "app/outputs/androidTest-results/connected/integration"
|
|
||||||
if "YUVOMI_BUILD_DIR" not in env:
|
|
||||||
reports = ROOT / "android/app/build/outputs/androidTest-results/connected/integration"
|
|
||||||
subprocess.run(["python3", str(ROOT / "scripts/verify-test-results.py"),
|
subprocess.run(["python3", str(ROOT / "scripts/verify-test-results.py"),
|
||||||
str(reports), "--minimum", "7"], check=True)
|
str(reports), "--minimum", "10"], check=True)
|
||||||
|
collect_native_screenshot(additional, screenshots, gallery=True)
|
||||||
finally:
|
finally:
|
||||||
subprocess.run([str(adb), "reverse", "--remove", f"tcp:{port}"], check=False)
|
subprocess.run([str(adb), "reverse", "--remove", f"tcp:{port}"], check=False)
|
||||||
subprocess.run([str(adb), "reverse", "--remove", f"tcp:{ready['idpPort']}"], check=False)
|
subprocess.run([str(adb), "reverse", "--remove", f"tcp:{ready['idpPort']}"], check=False)
|
||||||
@@ -148,5 +186,7 @@ if __name__ == "__main__":
|
|||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("upstream", type=Path)
|
parser.add_argument("upstream", type=Path)
|
||||||
parser.add_argument("--android", action="store_true")
|
parser.add_argument("--android", action="store_true")
|
||||||
|
parser.add_argument("--cookie-without-secure", action="store_true",
|
||||||
|
help="Test upstream's default session-cookie attributes over HTTPS; never enables HTTP")
|
||||||
arguments = parser.parse_args()
|
arguments = parser.parse_args()
|
||||||
run(arguments.upstream.resolve(), arguments.android)
|
run(arguments.upstream.resolve(), arguments.android, arguments.cookie_without_secure)
|
||||||
Reference in new issue
Block a user