SHA256
Compare commits
9
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
2d12491516 | ||
|
|
9ca11e4fb5 | ||
|
|
f7b9dcd91e | ||
|
|
9088ee7e6a | ||
|
|
d70c5cc2de | ||
|
|
2b9996ff3f | ||
|
|
f776c853c6 | ||
|
|
69c48169e2 | ||
|
|
74c2c9e6e8 |
No files matched your search
@@ -0,0 +1,18 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.{yml,yaml,json,xml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.{kt,kts}]
|
||||||
|
max_line_length = 140
|
||||||
|
ktlint_code_style = android_studio
|
||||||
|
ij_kotlin_allow_trailing_comma = true
|
||||||
|
ij_kotlin_allow_trailing_comma_on_call_site = true
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: gradle
|
||||||
|
directory: /android
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.idea/
|
.idea/
|
||||||
.gradle/
|
.gradle/
|
||||||
|
.kotlin/
|
||||||
**/build/
|
**/build/
|
||||||
local.properties
|
local.properties
|
||||||
*.keystore
|
*.keystore
|
||||||
@@ -13,6 +14,7 @@ DerivedData/
|
|||||||
*.xcuserstate
|
*.xcuserstate
|
||||||
xcuserdata/
|
xcuserdata/
|
||||||
ios/Yuvomi.xcodeproj/
|
ios/Yuvomi.xcodeproj/
|
||||||
|
ios/Info.plist
|
||||||
.swiftpm/
|
.swiftpm/
|
||||||
.build/
|
.build/
|
||||||
artifacts/
|
artifacts/
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"indentation": { "spaces": 4 },
|
||||||
|
"lineLength": 120,
|
||||||
|
"respectsExistingLineBreaks": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
- Native Android HTTPS onboarding, origin-bound browsing, session persistence,
|
||||||
|
recovery, server erasure, camera/document selection, downloads and blob exports.
|
||||||
|
- SwiftUI/WKWebView iOS implementation, isolated web sign-in, native settings,
|
||||||
|
recovery, file export, and strict URL/metadata policies; Xcode validation pending.
|
||||||
|
- JVM, Android emulator, portable Swift, and real pinned-upstream HTTPS tests.
|
||||||
|
- Reproducible build configuration, CI and maintainer-controlled signing workflow.
|
||||||
|
- MIT licensing, upstream audit, security model and release documentation.
|
||||||
|
|
||||||
|
No stable release has been published. No native push or browser-to-app session
|
||||||
|
handoff is claimed. See validation records for what has actually been executed.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
This is an independent mobile repository, not the upstream Yuvomi project.
|
||||||
|
Discuss substantial changes with the community maintainer before submitting a PR.
|
||||||
|
Do not open upstream issues or submit upstream patches on somebody else's behalf.
|
||||||
|
|
||||||
|
Use `feat/`, `fix/`, or `docs/` branches and Conventional Commits. Keep commits
|
||||||
|
reviewable and scoped. Preserve existing work. Run the relevant tests, lint,
|
||||||
|
`python3 scripts/check-repository.py`, and `git diff --check` before committing.
|
||||||
|
Add regression tests with security fixes, and demonstrate that the test fails
|
||||||
|
without the fix. Do not commit build outputs, credentials, private signing keys,
|
||||||
|
local servers, generated fixture certificates, or production household data.
|
||||||
|
|
||||||
|
Keep platform integrations native and dependencies minimal. The server owns
|
||||||
|
business logic, authentication, CSRF, and authorization. Never solve compatibility
|
||||||
|
by disabling TLS, accepting every origin, or copying a browser's session cookies.
|
||||||
|
Upstream prohibits frontend frameworks, bundlers, and transpilers in its own
|
||||||
|
codebase; we make no changes to it. Read its CONTRIBUTING, SECURITY, and DESIGN
|
||||||
|
before proposing an upstream integration.
|
||||||
|
|
||||||
|
This code was drafted with OpenAI through OpenCode. Disclose AI assistance in PRs
|
||||||
|
(one sentence is sufficient), as upstream requests. Generated code and automated
|
||||||
|
reviews do not replace human review. No merge, release, public issue, or store
|
||||||
|
publication happens automatically. Security-sensitive code needs human review.
|
||||||
|
|
||||||
|
See `docs/build-android.md`, `docs/build-ios.md`, and `docs/validation.md`.
|
||||||
|
The portable Swift package checks shared production policy, not the iOS app.
|
||||||
@@ -18,6 +18,7 @@ reconnect or erase local browsing data and switch installations.
|
|||||||
- [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)
|
||||||
|
- [Maintainer continuation](docs/continuation.md)
|
||||||
|
|
||||||
MIT licensed. Upstream notices are preserved in `THIRD_PARTY_NOTICES.md`.
|
MIT licensed. Upstream notices are preserved in `THIRD_PARTY_NOTICES.md`.
|
||||||
This implementation is AI-assisted (OpenAI via OpenCode) and requires human security
|
This implementation is AI-assisted (OpenAI via OpenCode) and requires human security
|
||||||
|
|||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
# Security policy
|
||||||
|
|
||||||
|
**Development software: no production-readiness claim.** Only the current
|
||||||
|
development branch is maintained; no stable mobile release exists yet.
|
||||||
|
|
||||||
|
Do not post vulnerabilities, cookies, private URLs, household records or keys in a
|
||||||
|
public issue. Contact the community repository's owner through an established
|
||||||
|
private channel or enable the host's private vulnerability-reporting mechanism
|
||||||
|
before public distribution. No private reporting mailbox has been supplied; we
|
||||||
|
will not invent one. If none is available, request a private contact without
|
||||||
|
including exploit details. Upstream Yuvomi vulnerabilities belong in
|
||||||
|
[upstream's private reporting channel](https://github.com/ulsklyc/yuvomi/security/advisories/new),
|
||||||
|
not its public issue tracker. This project cannot promise upstream response times.
|
||||||
|
|
||||||
|
Include affected app/OS/WebView/server versions, reproduction steps, impact,
|
||||||
|
and a minimal sanitized demonstration. Never include real credentials.
|
||||||
|
|
||||||
|
## Security model
|
||||||
|
|
||||||
|
- The user selects one HTTPS origin, including its port. A valid TLS chain is
|
||||||
|
required. No certificate-error bypass, HTTP production mode, or custom scheme
|
||||||
|
handler exists. Use a VPN for private-network access.
|
||||||
|
- Main application navigation is origin-bound. Android also blocks cross-origin
|
||||||
|
subresource and POST requests before the network. External links need a native
|
||||||
|
choice; dangerous URI schemes are rejected. No exported deep-link receiver exists.
|
||||||
|
- No `addJavascriptInterface` bridge or native credential store. Android blob
|
||||||
|
exports capture at most 25 MB / eight same-origin Blob references using an
|
||||||
|
origin-restricted document-start script, then pull bounded chunks with
|
||||||
|
`evaluateJavascript` only after a user chooses a destination. Every pull
|
||||||
|
rechecks origin and lifecycle. No blob fetch or CSP relaxation is used.
|
||||||
|
- WebView/WKWebView owns persistent HttpOnly session cookies. Native code neither
|
||||||
|
handles passwords nor bypasses the server's CSRF rules. Native Android download
|
||||||
|
code obtains a cookie snapshot only for the configured origin and refuses redirects.
|
||||||
|
- Live microphone, geolocation and media-capture requests are denied. The trusted
|
||||||
|
app's file input uses explicit system pickers; Android image capture delegates
|
||||||
|
to the camera app with a narrowly scoped content URI and no broad storage access.
|
||||||
|
- External SSO uses a separate capability-restricted web view and the same OS
|
||||||
|
website cookie store. It does not gain app navigation/file handlers. iOS 18.4+
|
||||||
|
is required so uploads can be explicitly denied in that untrusted auth window.
|
||||||
|
- Android backups/device transfer are excluded and screenshots are blocked.
|
||||||
|
iOS hides content when inactive and excludes temporary exports from backups.
|
||||||
|
OS-managed WKWebView storage remains governed by iOS device/backup protection;
|
||||||
|
do not assume the app can exclude every WebKit-managed cookie from an OS backup.
|
||||||
|
- Server changes stop and destroy the old web context and erase website data.
|
||||||
|
Older Android engines that lack complete browsing-data deletion use an OS
|
||||||
|
app-data reset, which closes the app. A local wipe is not remote session revocation.
|
||||||
|
- No app logs contain passwords, tokens, cookie headers or raw network exceptions.
|
||||||
|
System/browser diagnostics are outside the app's complete control.
|
||||||
|
|
||||||
|
## Limitations and release gates
|
||||||
|
|
||||||
|
The configured server and any server-installed extension are trusted same-origin
|
||||||
|
code. An XSS or malicious administrator remains able to act as the signed-in user;
|
||||||
|
native origin checks cannot fix compromised application content. Structural metadata
|
||||||
|
probing does not authenticate the Yuvomi product or expose an anonymous version number.
|
||||||
|
Embedded SSO providers that reject web views need a browser session instead; no
|
||||||
|
secure native-session handoff exists upstream. A future handoff needs upstream
|
||||||
|
review and single-use, short-lived, session-bound exchange semantics.
|
||||||
|
|
||||||
|
Downloaded files saved by the user remain outside app erasure. Android network
|
||||||
|
downloads are capped at 100 MB / 120 seconds; generated blob exports at 25 MB /
|
||||||
|
45 seconds. iOS checks declared size before downloads and actual size before
|
||||||
|
export (100 MB); an unknown/chunked size may consume disk before rejection.
|
||||||
|
This resource-limit gap requires review and device testing before release.
|
||||||
|
|
||||||
|
Android WebView Safe Browsing remains enabled. The installed engine/OS may perform
|
||||||
|
its own security checks; this app does not implement or log those network services.
|
||||||
|
Keep the OS and WebView patched. OSV advisory checks are build-time, never runtime,
|
||||||
|
and do not prove the absence of vulnerabilities. See `docs/security-review.md`.
|
||||||
@@ -31,3 +31,23 @@ SOFTWARE.
|
|||||||
Android build dependencies retain their own licenses (Android SDK/platform APIs,
|
Android build dependencies retain their own licenses (Android SDK/platform APIs,
|
||||||
AndroidX: Apache-2.0; Kotlin: Apache-2.0; Gradle: Apache-2.0).
|
AndroidX: Apache-2.0; Kotlin: Apache-2.0; Gradle: Apache-2.0).
|
||||||
Apple system frameworks are provided by the operating system, not vendored here.
|
Apple system frameworks are provided by the operating system, not vendored here.
|
||||||
|
|
||||||
|
## Gradle wrapper notice
|
||||||
|
|
||||||
|
The unmodified official Gradle wrapper JAR retains `META-INF/LICENSE` (Apache-2.0).
|
||||||
|
The corresponding Gradle distribution NOTICE is reproduced below:
|
||||||
|
|
||||||
|
This product includes software developed by
|
||||||
|
The Apache Software Foundation (http://www.apache.org/).
|
||||||
|
|
||||||
|
It includes the following other software:
|
||||||
|
|
||||||
|
Groovy (http://groovy-lang.org)
|
||||||
|
SLF4J (http://www.slf4j.org)
|
||||||
|
JUnit (http://www.junit.org)
|
||||||
|
JCIFS (http://jcifs.samba.org)
|
||||||
|
HttpClient (https://hc.apache.org/httpcomponents-client-4.5.x/)
|
||||||
|
|
||||||
|
For licenses, see the LICENSE file supplied in the Gradle distribution.
|
||||||
|
If any software distributed with Gradle does not have an Apache 2 License, its
|
||||||
|
license is explicitly listed in that distribution's LICENSE file.
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "community.yuvomi.mobile"
|
namespace = "community.yuvomi.mobile"
|
||||||
compileSdk = 36
|
compileSdk { version = release(37) { minorApiLevel = 0 } }
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "community.yuvomi.mobile"
|
applicationId = "community.yuvomi.mobile"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 37
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.1.0"
|
versionName = "0.1.0"
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
@@ -18,19 +17,75 @@ android {
|
|||||||
release {
|
release {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro",
|
||||||
|
)
|
||||||
// Signing is deliberately supplied by the authorized release maintainer.
|
// Signing is deliberately supplied by the authorized release maintainer.
|
||||||
}
|
}
|
||||||
|
create("integration") {
|
||||||
|
initWith(getByName("debug"))
|
||||||
|
applicationIdSuffix = ".integration"
|
||||||
|
matchingFallbacks += "debug"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
lint { abortOnError = true; warningsAsErrors = true }
|
lint {
|
||||||
|
abortOnError = true
|
||||||
|
warningsAsErrors = true
|
||||||
|
}
|
||||||
|
if (providers.gradleProperty("integration").isPresent) {
|
||||||
|
testBuildType = "integration"
|
||||||
|
sourceSets.getByName(
|
||||||
|
"androidTest",
|
||||||
|
).kotlin.directories.add("src/androidTestIntegration/java")
|
||||||
|
val testResources = providers.environmentVariable("YUVOMI_TEST_RES").get()
|
||||||
|
sourceSets.getByName("integration").res.directories.add(testResources)
|
||||||
|
defaultConfig.testInstrumentationRunnerArguments["server"] =
|
||||||
|
providers.environmentVariable("YUVOMI_TEST_URL").get()
|
||||||
|
defaultConfig.testInstrumentationRunnerArguments["password"] =
|
||||||
|
providers.environmentVariable("YUVOMI_TEST_PASSWORD").get()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } }
|
kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } }
|
||||||
|
|
||||||
|
dependencyLocking { lockAllConfigurations() }
|
||||||
|
|
||||||
|
// AGP's device-test tools still request vulnerable older wire libraries. These
|
||||||
|
// are host-only, not APK dependencies; stay within binary-compatible minor lines.
|
||||||
|
configurations.configureEach {
|
||||||
|
if (name.startsWith("_internal-unified-test-platform") || name == "androidLintTool" ||
|
||||||
|
name == "unified-test-platform-gradle-work-action"
|
||||||
|
) {
|
||||||
|
resolutionStrategy.eachDependency {
|
||||||
|
when (requested.group) {
|
||||||
|
"io.netty" -> {
|
||||||
|
useVersion("4.1.139.Final")
|
||||||
|
because("Remediate published host-test HTTP/TLS advisories")
|
||||||
|
}
|
||||||
|
"com.google.protobuf" -> {
|
||||||
|
useVersion("3.25.9")
|
||||||
|
because("Remediate protobuf recursion/DoS advisories")
|
||||||
|
}
|
||||||
|
"org.bouncycastle" -> {
|
||||||
|
useVersion("1.86")
|
||||||
|
because("Remediate published build-tool cryptography advisories")
|
||||||
|
}
|
||||||
|
"org.apache.commons" -> if (requested.name == "commons-lang3") useVersion("3.21.0")
|
||||||
|
"org.apache.httpcomponents" -> if (requested.name ==
|
||||||
|
"httpclient"
|
||||||
|
) {
|
||||||
|
useVersion("4.5.14")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("androidx.activity:activity-ktx:1.10.1")
|
implementation("androidx.activity:activity-ktx:1.10.1")
|
||||||
implementation("androidx.core:core-ktx:1.16.0")
|
implementation("androidx.core:core-ktx:1.16.0")
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# This is a Gradle generated file for dependency locking.
|
||||||
|
# Manual edits can break the build and are not advised.
|
||||||
|
# This file is expected to be part of source control.
|
||||||
|
# To regenerate this file, run: ./gradlew :app:dependencies --write-locks
|
||||||
|
androidx.activity:activity-ktx:1.10.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,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.annotation:annotation-experimental:1.4.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,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:1.8.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.arch.core:core-runtime:2.2.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.collection:collection-jvm:1.4.2=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.collection:collection:1.0.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,integrationCompileClasspath,releaseCompileClasspath
|
||||||
|
androidx.collection:collection:1.4.2=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.concurrent:concurrent-futures-ktx:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
androidx.concurrent:concurrent-futures:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.core:core-ktx:1.16.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,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:1.16.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,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.lifecycle:lifecycle-common:2.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,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.lifecycle:lifecycle-runtime-ktx:2.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,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-viewmodel-ktx:2.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,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-viewmodel:2.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,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.savedstate:savedstate-ktx:1.2.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,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.startup:startup-runtime:1.1.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.test.espresso:espresso-core:3.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
androidx.test.espresso:espresso-idling-resource:3.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
androidx.test.ext:junit:1.2.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
androidx.test.services:storage:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
androidx.test:core:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
androidx.test:monitor:1.7.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
androidx.test:runner:1.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
androidx.tracing:tracing:1.1.0=debugAndroidTestCompileClasspath
|
||||||
|
androidx.tracing:tracing:1.2.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.versionedparcelable:versionedparcelable:1.1.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,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:shared:32.4.1=androidLintTool
|
||||||
|
com.android.tools.analytics-library:tracker:32.4.1=androidLintTool
|
||||||
|
com.android.tools.androidtest:android-test-engine-result-listener:1.0.1=unified-test-platform-gradle-work-action
|
||||||
|
com.android.tools.androidtest:android-test-engine:1.0.1=unified-test-platform-gradle-work-action
|
||||||
|
com.android.tools.androidtest:test-result-event-proto:1.0.1=unified-test-platform-gradle-work-action
|
||||||
|
com.android.tools.androidtest:test-result-proto:1.0.1=unified-test-platform-gradle-work-action
|
||||||
|
com.android.tools.build:aapt2-proto:9.4.1-15978811=androidLintTool
|
||||||
|
com.android.tools.build:builder-model:9.4.1=androidLintTool
|
||||||
|
com.android.tools.build:manifest-merger:32.4.1=androidLintTool
|
||||||
|
com.android.tools.ddms:ddmlib:32.4.1=androidLintTool
|
||||||
|
com.android.tools.external.com-intellij:intellij-core:32.4.1=androidLintTool
|
||||||
|
com.android.tools.external.com-intellij:kotlin-compiler:32.4.1=androidLintTool
|
||||||
|
com.android.tools.external.org-jetbrains:uast:32.4.1=androidLintTool
|
||||||
|
com.android.tools.layoutlib:layoutlib-api:32.4.1=androidLintTool
|
||||||
|
com.android.tools.lint:lint-api:32.4.1=androidLintTool
|
||||||
|
com.android.tools.lint:lint-checks:32.4.1=androidLintTool
|
||||||
|
com.android.tools.lint:lint-gradle:32.4.1=androidLintTool
|
||||||
|
com.android.tools.lint:lint-model:32.4.1=androidLintTool
|
||||||
|
com.android.tools.lint:lint-typedef-remover:32.4.1=androidLintTool
|
||||||
|
com.android.tools.lint:lint:32.4.1=androidLintTool
|
||||||
|
com.android.tools.utp:gradle-work-action:32.4.1=unified-test-platform-gradle-work-action
|
||||||
|
com.android.tools:annotations:32.4.1=androidLintTool
|
||||||
|
com.android.tools:common:32.4.1=androidLintTool
|
||||||
|
com.android.tools:dvlib:32.4.1=androidLintTool
|
||||||
|
com.android.tools:play-sdk-proto:32.4.1=androidLintTool
|
||||||
|
com.android.tools:repository:32.4.1=androidLintTool
|
||||||
|
com.android.tools:sdk-common:32.4.1=androidLintTool
|
||||||
|
com.android.tools:sdklib:32.4.1=androidLintTool
|
||||||
|
com.google.code.findbugs:jsr305:3.0.2=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
com.google.code.gson:gson:2.11.0=androidLintTool
|
||||||
|
com.google.errorprone:error_prone_annotations:2.18.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
com.google.errorprone:error_prone_annotations:2.36.0=androidLintTool
|
||||||
|
com.google.guava:failureaccess:1.0.2=androidLintTool
|
||||||
|
com.google.guava:guava:33.4.0-jre=androidLintTool
|
||||||
|
com.google.guava:listenablefuture:1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=androidLintTool
|
||||||
|
com.google.j2objc:j2objc-annotations:3.0.0=androidLintTool
|
||||||
|
com.google.jimfs:jimfs:1.1=androidLintTool
|
||||||
|
com.google.protobuf:protobuf-java:3.25.9=androidLintTool,unified-test-platform-gradle-work-action
|
||||||
|
com.sun.istack:istack-commons-runtime:3.0.8=androidLintTool
|
||||||
|
com.sun.xml.fastinfoset:FastInfoset:1.2.16=androidLintTool
|
||||||
|
commons-codec:commons-codec:1.17.1=androidLintTool
|
||||||
|
commons-io:commons-io:2.16.1=androidLintTool
|
||||||
|
commons-logging:commons-logging:1.2=androidLintTool
|
||||||
|
jakarta.activation:jakarta.activation-api:1.2.1=androidLintTool
|
||||||
|
jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=androidLintTool
|
||||||
|
javax.inject:javax.inject:1=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
junit:junit:4.13.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
net.java.dev.jna:jna-platform:5.6.0=androidLintTool
|
||||||
|
net.java.dev.jna:jna:5.6.0=androidLintTool
|
||||||
|
net.sf.kxml:kxml2:2.3.0=androidLintTool
|
||||||
|
org.apache.commons:commons-compress:1.27.1=androidLintTool
|
||||||
|
org.apache.commons:commons-lang3:3.21.0=androidLintTool
|
||||||
|
org.apache.groovy:groovy-bom:4.0.29=androidLintTool
|
||||||
|
org.apache.groovy:groovy:4.0.29=androidLintTool
|
||||||
|
org.apache.httpcomponents:httpclient:4.5.14=androidLintTool
|
||||||
|
org.apache.httpcomponents:httpcore:4.4.16=androidLintTool
|
||||||
|
org.apache.httpcomponents:httpmime:4.5.6=androidLintTool
|
||||||
|
org.bouncycastle:bcpkix-jdk18on:1.86=androidLintTool
|
||||||
|
org.bouncycastle:bcprov-jdk18on:1.86=androidLintTool
|
||||||
|
org.bouncycastle:bcutil-jdk18on:1.86=androidLintTool
|
||||||
|
org.checkerframework:checker-qual:3.43.0=androidLintTool
|
||||||
|
org.glassfish.jaxb:jaxb-runtime:2.3.2=androidLintTool
|
||||||
|
org.glassfish.jaxb:txw2:2.3.2=androidLintTool
|
||||||
|
org.hamcrest:hamcrest-core:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
org.hamcrest:hamcrest-library:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-build-tools-api:2.4.0=kotlinAbiValidationCompatClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-build-tools-api:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.21=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.0=kotlinAbiValidationCompatClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.0=kotlinAbiValidationCompatClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.0=kotlinAbiValidationCompatClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-compiler-runner:2.4.0=kotlinAbiValidationCompatClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-compiler-runner:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-daemon-client:2.4.0=kotlinAbiValidationCompatClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-daemon-client:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.0=kotlinAbiValidationCompatClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-reflect:2.2.10=androidLintTool
|
||||||
|
org.jetbrains.kotlin:kotlin-script-runtime:2.4.0=kotlinAbiValidationCompatClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-script-runtime:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib-common:2.4.21=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,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-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:2.2.10=androidLintTool,unified-test-platform-gradle-work-action
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib:2.4.0=kotlinAbiValidationCompatClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib:2.4.21=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-tooling-core:2.4.21=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4=debugCompileClasspath,debugUnitTestCompileClasspath,integrationCompileClasspath,releaseCompileClasspath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1=debugAndroidTestCompileClasspath
|
||||||
|
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-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.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.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:annotations:13.0=debugCompileClasspath,debugUnitTestCompileClasspath,integrationCompileClasspath,kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,releaseCompileClasspath
|
||||||
|
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.jspecify:jspecify:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
org.junit.platform:junit-platform-commons:1.13.3=unified-test-platform-gradle-work-action
|
||||||
|
org.junit.platform:junit-platform-engine:1.13.3=unified-test-platform-gradle-work-action
|
||||||
|
org.junit.platform:junit-platform-launcher:1.13.3=unified-test-platform-gradle-work-action
|
||||||
|
org.junit:junit-bom:5.13.3=unified-test-platform-gradle-work-action
|
||||||
|
org.jvnet.staxex:stax-ex:1.8.1=androidLintTool
|
||||||
|
org.opentest4j:opentest4j:1.3.0=unified-test-platform-gradle-work-action
|
||||||
|
org.ow2.asm:asm-analysis:9.9=androidLintTool
|
||||||
|
org.ow2.asm:asm-commons:9.9=androidLintTool
|
||||||
|
org.ow2.asm:asm-tree:9.9=androidLintTool
|
||||||
|
org.ow2.asm:asm: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
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package community.yuvomi.mobile
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.test.core.app.ActivityScenario
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class BlobExportTest {
|
||||||
|
@Test fun rejectsBlobExportFromOpaqueNativePageEvenWithClaimedOrigin() {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit()
|
||||||
|
.putString("origin", "https://family.example").commit()
|
||||||
|
val file = File(File(context.cacheDir, "captures").apply { mkdirs() }, "blob-test.csv")
|
||||||
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
|
||||||
|
val worker = Executors.newSingleThreadExecutor()
|
||||||
|
try {
|
||||||
|
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
|
||||||
|
lateinit var web: WebView
|
||||||
|
val created = CountDownLatch(1)
|
||||||
|
var blob = ""
|
||||||
|
scenario.onActivity {
|
||||||
|
// A real WebView with a controlled document, not a network mock.
|
||||||
|
// The HTTPS upstream suite separately exercises the app's client.
|
||||||
|
web = WebView(it)
|
||||||
|
web.settings.javaScriptEnabled = true
|
||||||
|
web.loadDataWithBaseURL(
|
||||||
|
"https://family.example/",
|
||||||
|
"<!doctype html><title>Export test</title>",
|
||||||
|
"text/html",
|
||||||
|
"UTF-8",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val deadline = System.nanoTime() + 10_000_000_000L
|
||||||
|
var ready = false
|
||||||
|
while (!ready && System.nanoTime() < deadline) {
|
||||||
|
val checked = CountDownLatch(1)
|
||||||
|
scenario.onActivity {
|
||||||
|
web.evaluateJavascript("location.origin") {
|
||||||
|
ready =
|
||||||
|
it == "\"https://family.example\""
|
||||||
|
checked.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue(checked.await(2, TimeUnit.SECONDS))
|
||||||
|
if (!ready) Thread.sleep(50)
|
||||||
|
}
|
||||||
|
assertTrue("Trusted document loaded", ready)
|
||||||
|
scenario.onActivity {
|
||||||
|
web.evaluateJavascript(
|
||||||
|
"URL.createObjectURL(new Blob(['name,value\\nYuvomi,Grüße\\n'],{type:'text/csv'}))",
|
||||||
|
) {
|
||||||
|
blob = JSONArray("[$it]").getString(0)
|
||||||
|
created.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue(created.await(10, TimeUnit.SECONDS))
|
||||||
|
assertTrue(
|
||||||
|
"Blob inherits trusted origin: $blob",
|
||||||
|
BlobTransfer.owns(ServerAddress.parse("https://family.example"), blob),
|
||||||
|
)
|
||||||
|
assertNotNull(
|
||||||
|
context.contentResolver.openOutputStream(uri, "w")?.also {
|
||||||
|
it.close()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val saved = CountDownLatch(1)
|
||||||
|
var success = false
|
||||||
|
scenario.onActivity {
|
||||||
|
assertFalse(
|
||||||
|
"Native page URL remains opaque despite loadDataWithBaseURL",
|
||||||
|
ServerAddress.parse("https://family.example").owns(web.url.orEmpty()),
|
||||||
|
)
|
||||||
|
BlobTransfer.save(
|
||||||
|
ServerAddress.parse("https://family.example"),
|
||||||
|
web,
|
||||||
|
blob,
|
||||||
|
context.contentResolver,
|
||||||
|
uri,
|
||||||
|
worker,
|
||||||
|
{ true },
|
||||||
|
) {
|
||||||
|
success = it
|
||||||
|
saved.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue(saved.await(50, TimeUnit.SECONDS))
|
||||||
|
assertFalse(success)
|
||||||
|
assertEquals(0L, file.length())
|
||||||
|
scenario.onActivity { web.destroy() }
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
worker.shutdownNow()
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package community.yuvomi.mobile
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.test.core.app.ActivityScenario
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class BrowserSecurityTest {
|
||||||
|
@Test fun blocksCrossOriginPostAndSubresourceBeforeNetwork() {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit()
|
||||||
|
.putString("origin", "https://family.example").commit()
|
||||||
|
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
|
||||||
|
lateinit var web: WebView
|
||||||
|
lateinit var client: android.webkit.WebViewClient
|
||||||
|
scenario.onActivity {
|
||||||
|
web = requireNotNull(findWeb(it.window.decorView))
|
||||||
|
client = web.webViewClient
|
||||||
|
}
|
||||||
|
// Interception runs off the UI thread in WebView. POST navigations do
|
||||||
|
// not reliably call shouldOverrideUrlLoading, so this is a separate gate.
|
||||||
|
for (mainFrame in listOf(true, false)) {
|
||||||
|
val response = client.shouldInterceptRequest(
|
||||||
|
web,
|
||||||
|
Request("https://evil.example/collect", mainFrame),
|
||||||
|
)
|
||||||
|
assertNotNull(response)
|
||||||
|
assertEquals(403, response!!.statusCode)
|
||||||
|
}
|
||||||
|
assertNull(
|
||||||
|
client.shouldInterceptRequest(web, Request("https://family.example/tasks", true)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findWeb(view: View): WebView? {
|
||||||
|
if (view is WebView) return view
|
||||||
|
if (view is ViewGroup) {
|
||||||
|
for (i in 0 until view.childCount) {
|
||||||
|
findWeb(view.getChildAt(i))?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
private class Request(private val value: String, private val mainFrame: Boolean) : WebResourceRequest {
|
||||||
|
override fun getUrl(): Uri = Uri.parse(value)
|
||||||
|
override fun isForMainFrame() = mainFrame
|
||||||
|
override fun isRedirect() = false
|
||||||
|
override fun hasGesture() = true
|
||||||
|
override fun getMethod() = "POST"
|
||||||
|
override fun getRequestHeaders(): MutableMap<String, String> = mutableMapOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
package community.yuvomi.mobile
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.accessibilityservice.AccessibilityServiceInfo
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.view.accessibility.AccessibilityNodeInfo
|
||||||
|
import androidx.test.core.app.ActivityScenario
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.espresso.Espresso.onView
|
||||||
|
import androidx.test.espresso.action.ViewActions.click
|
||||||
|
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class LocalNetworkPermissionTest {
|
||||||
|
@Test fun permissionIsOptionalAndHonorsSystemDenialAndGrant() {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit().clear().commit()
|
||||||
|
ActivityScenario.launch(MainActivity::class.java).use {
|
||||||
|
if (Build.VERSION.SDK_INT < 37 ||
|
||||||
|
context.checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) == PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
onView(withText("Allow private-network servers")).check(doesNotExist())
|
||||||
|
return@use
|
||||||
|
}
|
||||||
|
val automation = InstrumentationRegistry.getInstrumentation().uiAutomation
|
||||||
|
automation.serviceInfo = automation.serviceInfo.apply { flags = flags or AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS }
|
||||||
|
onView(withText("Allow private-network servers")).perform(click())
|
||||||
|
onView(withText("Cancel")).perform(click())
|
||||||
|
assertEquals(PackageManager.PERMISSION_DENIED, context.checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK))
|
||||||
|
onView(withText("Allow private-network servers")).perform(click())
|
||||||
|
onView(withText("Continue")).perform(click())
|
||||||
|
chooseSystemButton("permission_deny_button")
|
||||||
|
await { context.checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) == PackageManager.PERMISSION_DENIED }
|
||||||
|
onView(withText("Allow private-network servers")).perform(click())
|
||||||
|
onView(withText("Continue")).perform(click())
|
||||||
|
chooseSystemButton("permission_allow_button")
|
||||||
|
await { context.checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) == PackageManager.PERMISSION_GRANTED }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chooseSystemButton(id: String) {
|
||||||
|
val automation = InstrumentationRegistry.getInstrumentation().uiAutomation
|
||||||
|
await {
|
||||||
|
val node = find(automation.rootInActiveWindow, id) ?: return@await false
|
||||||
|
node.performAction(AccessibilityNodeInfo.ACTION_CLICK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private fun find(node: AccessibilityNodeInfo?, id: String): AccessibilityNodeInfo? {
|
||||||
|
if (node == null) return null
|
||||||
|
if (node.viewIdResourceName?.endsWith("/$id") == true) return node
|
||||||
|
for (index in 0 until node.childCount) find(node.getChild(index), id)?.let { return it }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
private fun await(condition: () -> Boolean) {
|
||||||
|
val deadline = System.nanoTime() + 10_000_000_000L
|
||||||
|
while (System.nanoTime() < deadline) {
|
||||||
|
if (condition()) return
|
||||||
|
Thread.sleep(100)
|
||||||
|
}
|
||||||
|
assertTrue("System permission response timed out", condition())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package community.yuvomi.mobile
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.test.core.app.ActivityScenario
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.espresso.Espresso.onView
|
||||||
|
import androidx.test.espresso.action.ViewActions.click
|
||||||
|
import androidx.test.espresso.action.ViewActions.closeSoftKeyboard
|
||||||
|
import androidx.test.espresso.action.ViewActions.replaceText
|
||||||
|
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.hasErrorText
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class OnboardingTest {
|
||||||
|
private val context: Context get() = ApplicationProvider.getApplicationContext()
|
||||||
|
|
||||||
|
@Before fun clearConfiguration() {
|
||||||
|
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit().clear().commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun rejectsHttpAndSubpathsLocally() {
|
||||||
|
ActivityScenario.launch(MainActivity::class.java).use {
|
||||||
|
onView(
|
||||||
|
withContentDescription("Yuvomi server URL"),
|
||||||
|
).perform(replaceText("http://family.example"), closeSoftKeyboard())
|
||||||
|
onView(withText("Connect securely")).perform(click())
|
||||||
|
onView(
|
||||||
|
withContentDescription("Yuvomi server URL"),
|
||||||
|
).check(matches(hasErrorText("Use HTTPS with a valid server hostname.")))
|
||||||
|
onView(
|
||||||
|
withContentDescription("Yuvomi server URL"),
|
||||||
|
).perform(replaceText("https://family.example/subpath"), closeSoftKeyboard())
|
||||||
|
onView(withText("Connect securely")).perform(click())
|
||||||
|
onView(
|
||||||
|
withContentDescription("Yuvomi server URL"),
|
||||||
|
).check(matches(hasErrorText("Yuvomi requires a dedicated hostname, not a subpath.")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun survivesRecreationWithoutLosingOnboarding() {
|
||||||
|
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
|
||||||
|
scenario.recreate()
|
||||||
|
onView(withText("Connect securely")).check(matches(isDisplayed()))
|
||||||
|
onView(withContentDescription("Yuvomi server URL")).check(matches(isEnabled()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun webViewIsHardenedAndBackgroundingPreservesInstance() {
|
||||||
|
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit()
|
||||||
|
.putString("origin", "https://family.example").commit()
|
||||||
|
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
|
||||||
|
var initial: WebView? = null
|
||||||
|
scenario.onActivity { activity ->
|
||||||
|
fun find(view: android.view.View): WebView? {
|
||||||
|
if (view is WebView) return view
|
||||||
|
if (view is android.view.ViewGroup) {
|
||||||
|
for (i in 0 until view.childCount) {
|
||||||
|
find(view.getChildAt(i))?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
initial = find(activity.window.decorView)
|
||||||
|
val web = requireNotNull(initial)
|
||||||
|
assertFalse(web.settings.allowFileAccess)
|
||||||
|
assertFalse(web.settings.allowContentAccess)
|
||||||
|
assertEquals(
|
||||||
|
android.webkit.WebSettings.MIXED_CONTENT_NEVER_ALLOW,
|
||||||
|
web.settings.mixedContentMode,
|
||||||
|
)
|
||||||
|
assertTrue(web.settings.javaScriptEnabled)
|
||||||
|
}
|
||||||
|
scenario.moveToState(androidx.lifecycle.Lifecycle.State.CREATED)
|
||||||
|
scenario.moveToState(androidx.lifecycle.Lifecycle.State.RESUMED)
|
||||||
|
scenario.onActivity { assertNotNull(initial?.parent) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+333
@@ -0,0 +1,333 @@
|
|||||||
|
package community.yuvomi.mobile
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.webkit.CookieManager
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.test.core.app.ActivityScenario
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.espresso.Espresso.onView
|
||||||
|
import androidx.test.espresso.action.ViewActions.click
|
||||||
|
import androidx.test.espresso.action.ViewActions.closeSoftKeyboard
|
||||||
|
import androidx.test.espresso.action.ViewActions.replaceText
|
||||||
|
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
|
||||||
|
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import java.io.File
|
||||||
|
import java.net.URI
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.net.ssl.HttpsURLConnection
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assert.fail
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class UpstreamIntegrationTest {
|
||||||
|
@Test fun actualUpstreamOnboardingAuthenticationLifecycleAndLogout() {
|
||||||
|
val arguments = InstrumentationRegistry.getArguments()
|
||||||
|
val origin = requireNotNull(arguments.getString("server"))
|
||||||
|
val password = requireNotNull(arguments.getString("password"))
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= 37) {
|
||||||
|
InstrumentationRegistry.getInstrumentation().uiAutomation.grantRuntimePermission(
|
||||||
|
context.packageName,
|
||||||
|
android.Manifest.permission.ACCESS_LOCAL_NETWORK,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit().clear().commit()
|
||||||
|
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
|
||||||
|
onView(
|
||||||
|
withContentDescription("Yuvomi server URL"),
|
||||||
|
).perform(replaceText(origin), closeSoftKeyboard())
|
||||||
|
onView(withText("Connect securely")).perform(click())
|
||||||
|
var web: WebView? = null
|
||||||
|
await("native onboarding completed") {
|
||||||
|
scenario.onActivity { web = findWeb(it.window.decorView) }
|
||||||
|
web != null
|
||||||
|
}
|
||||||
|
await("actual upstream application loaded") {
|
||||||
|
documentLoaded(scenario, requireNotNull(web), origin)
|
||||||
|
}
|
||||||
|
// Run in the real WebView origin, preserving real Set-Cookie and CSRF.
|
||||||
|
val credentials = JSONObject().put(
|
||||||
|
"username",
|
||||||
|
"mobiletest",
|
||||||
|
).put("password", password).toString()
|
||||||
|
evaluate(
|
||||||
|
scenario,
|
||||||
|
requireNotNull(web),
|
||||||
|
"""
|
||||||
|
window.__mobileResult = null;
|
||||||
|
fetch('/api/v1/auth/login', {method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body:JSON.stringify($credentials)}).then(async r => {
|
||||||
|
const data = await r.json(); window.__mobileCsrf=data.csrfToken;
|
||||||
|
window.__mobileResult=r.status;
|
||||||
|
});
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
await("upstream password login") {
|
||||||
|
evaluate(scenario, requireNotNull(web), "window.__mobileResult") ==
|
||||||
|
"200"
|
||||||
|
}
|
||||||
|
assertEquals(
|
||||||
|
"false",
|
||||||
|
evaluate(scenario, requireNotNull(web), "document.cookie.includes('yuvomi.sid=')"),
|
||||||
|
)
|
||||||
|
evaluate(
|
||||||
|
scenario,
|
||||||
|
requireNotNull(web),
|
||||||
|
"""
|
||||||
|
window.__mobileResult=null;
|
||||||
|
fetch('/api/v1/documents',{method:'POST',headers:{'Content-Type':'application/json',
|
||||||
|
'X-CSRF-Token':window.__mobileCsrf},body:JSON.stringify({name:'Mobile upload',
|
||||||
|
original_name:'mobile.txt',content_data:'data:text/plain;base64,WXV2b21pIGZpbGUgdGVzdAo='})})
|
||||||
|
.then(async r=>{const d=await r.json();window.__mobileDocument=d.data?.id;window.__mobileResult=r.status});
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
await("real upstream file upload") {
|
||||||
|
evaluate(scenario, requireNotNull(web), "window.__mobileResult") ==
|
||||||
|
"201"
|
||||||
|
}
|
||||||
|
val documentId = evaluate(
|
||||||
|
scenario,
|
||||||
|
requireNotNull(web),
|
||||||
|
"window.__mobileDocument",
|
||||||
|
).toLong()
|
||||||
|
val downloadURL = "$origin/api/v1/documents/$documentId/download"
|
||||||
|
var cookie = ""
|
||||||
|
scenario.onActivity { cookie = CookieManager.getInstance().getCookie(downloadURL) }
|
||||||
|
val file =
|
||||||
|
File(File(context.cacheDir, "captures").apply { mkdirs() }, "upstream-download.txt")
|
||||||
|
val destination = FileProvider.getUriForFile(
|
||||||
|
context,
|
||||||
|
"${context.packageName}.files",
|
||||||
|
file,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
FileTransfer.save(
|
||||||
|
ServerAddress.parse(origin),
|
||||||
|
downloadURL,
|
||||||
|
cookie,
|
||||||
|
context.contentResolver,
|
||||||
|
destination,
|
||||||
|
)
|
||||||
|
assertEquals("Yuvomi file test\n", file.readText())
|
||||||
|
} finally {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
val blobResult =
|
||||||
|
evaluate(
|
||||||
|
scenario,
|
||||||
|
requireNotNull(web),
|
||||||
|
"URL.createObjectURL(new Blob(['Yuvomi export'],{type:'text/plain'}))",
|
||||||
|
)
|
||||||
|
val blobURL = JSONArray("[$blobResult]").getString(0)
|
||||||
|
val exported = CountDownLatch(1)
|
||||||
|
val exportWorker = Executors.newSingleThreadExecutor()
|
||||||
|
var exportSuccess = false
|
||||||
|
try {
|
||||||
|
scenario.onActivity {
|
||||||
|
BlobTransfer.save(
|
||||||
|
ServerAddress.parse(origin),
|
||||||
|
requireNotNull(web),
|
||||||
|
blobURL,
|
||||||
|
context.contentResolver,
|
||||||
|
destination,
|
||||||
|
exportWorker,
|
||||||
|
{ true },
|
||||||
|
) {
|
||||||
|
exportSuccess = it
|
||||||
|
exported.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue(exported.await(50, TimeUnit.SECONDS))
|
||||||
|
assertTrue("Blob export in hardened application WebView", exportSuccess)
|
||||||
|
assertEquals("Yuvomi export", file.readText())
|
||||||
|
} finally {
|
||||||
|
exportWorker.shutdownNow()
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
scenario.moveToState(androidx.lifecycle.Lifecycle.State.CREATED)
|
||||||
|
scenario.moveToState(androidx.lifecycle.Lifecycle.State.RESUMED)
|
||||||
|
evaluate(
|
||||||
|
scenario,
|
||||||
|
requireNotNull(web),
|
||||||
|
"window.__mobileResult=null;fetch('/api/v1/auth/me').then(r=>window.__mobileResult=r.status)",
|
||||||
|
)
|
||||||
|
await("session survives backgrounding") {
|
||||||
|
evaluate(scenario, requireNotNull(web), "window.__mobileResult") ==
|
||||||
|
"200"
|
||||||
|
}
|
||||||
|
scenario.recreate()
|
||||||
|
await("restored WebView") {
|
||||||
|
scenario.onActivity { web = findWeb(it.window.decorView) }
|
||||||
|
web != null && documentLoaded(scenario, requireNotNull(web), origin)
|
||||||
|
}
|
||||||
|
await("persistent session after activity recreation") {
|
||||||
|
evaluate(
|
||||||
|
scenario,
|
||||||
|
requireNotNull(web),
|
||||||
|
"""
|
||||||
|
if(!window.__mobileSessionProbe){window.__mobileSessionProbe=true;
|
||||||
|
fetch('/api/v1/auth/me').then(async r=>{const d=await r.json();
|
||||||
|
window.__mobileCsrf=d.csrfToken;window.__mobileResult=r.status})}
|
||||||
|
window.__mobileResult
|
||||||
|
""".trimIndent(),
|
||||||
|
) ==
|
||||||
|
"200"
|
||||||
|
}
|
||||||
|
evaluate(
|
||||||
|
scenario,
|
||||||
|
requireNotNull(web),
|
||||||
|
"""
|
||||||
|
window.__mobileResult=null;
|
||||||
|
fetch('/api/v1/auth/logout',{method:'POST',headers:{'Content-Type':'application/json',
|
||||||
|
'X-CSRF-Token':window.__mobileCsrf},body:'{}'}).then(r=>window.__mobileResult=r.status);
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
await("CSRF-protected logout") {
|
||||||
|
evaluate(scenario, requireNotNull(web), "window.__mobileResult") ==
|
||||||
|
"200"
|
||||||
|
}
|
||||||
|
evaluate(
|
||||||
|
scenario,
|
||||||
|
requireNotNull(web),
|
||||||
|
"window.__mobileResult=null;fetch('/api/v1/auth/me').then(r=>window.__mobileResult=r.status)",
|
||||||
|
)
|
||||||
|
await("session revoked") {
|
||||||
|
evaluate(scenario, requireNotNull(web), "window.__mobileResult") ==
|
||||||
|
"401"
|
||||||
|
}
|
||||||
|
// The upstream worker may legitimately serve the cached app shell
|
||||||
|
// during an outage. Use non-cacheable metadata to exercise native
|
||||||
|
// main-frame network recovery without disabling that worker.
|
||||||
|
evaluate(
|
||||||
|
scenario,
|
||||||
|
requireNotNull(web),
|
||||||
|
"fetch('/__mobile_test/network/off',{method:'POST'}).then(()=>location.assign('/api/v1/version'))",
|
||||||
|
)
|
||||||
|
await("native network error recovery panel") {
|
||||||
|
var visible = false
|
||||||
|
scenario.onActivity { visible = hasText(it.window.decorView, "Connection interrupted") }
|
||||||
|
visible
|
||||||
|
}
|
||||||
|
val recovery = URI("$origin/__mobile_test/network/on").toURL().openConnection() as HttpsURLConnection
|
||||||
|
try {
|
||||||
|
recovery.requestMethod = "POST"
|
||||||
|
recovery.connectTimeout = 5_000
|
||||||
|
recovery.readTimeout = 5_000
|
||||||
|
assertEquals(200, recovery.responseCode)
|
||||||
|
} finally {
|
||||||
|
recovery.disconnect()
|
||||||
|
}
|
||||||
|
onView(withText("Retry")).perform(click())
|
||||||
|
await("reconnected to real upstream metadata") {
|
||||||
|
evaluate(scenario, requireNotNull(web), "document.body?.innerText.includes('app_name')") == "true"
|
||||||
|
}
|
||||||
|
scenario.onActivity { requireNotNull(web).loadUrl("$origin/") }
|
||||||
|
await("application restored after outage") {
|
||||||
|
var restored = false
|
||||||
|
scenario.onActivity { restored = requireNotNull(web).url == "$origin/" }
|
||||||
|
restored && documentLoaded(scenario, requireNotNull(web), origin)
|
||||||
|
}
|
||||||
|
scenario.onActivity { requireNotNull(web).loadUrl("$origin/api/v1/auth/oidc/start") }
|
||||||
|
await("native consent before leaving Yuvomi for OIDC") {
|
||||||
|
runCatching { onView(withText("Sign-in window")).check(matches(isDisplayed())) }.isSuccess
|
||||||
|
}
|
||||||
|
onView(withText("Sign-in window")).perform(click())
|
||||||
|
await("OIDC callback returns to main application") {
|
||||||
|
var returned = false
|
||||||
|
scenario.onActivity { returned = requireNotNull(web).url == "$origin/" }
|
||||||
|
val signInClosed = runCatching {
|
||||||
|
onView(withText("Close")).check(doesNotExist())
|
||||||
|
}.isSuccess
|
||||||
|
signInClosed && returned &&
|
||||||
|
documentLoaded(scenario, requireNotNull(web), origin)
|
||||||
|
}
|
||||||
|
evaluate(
|
||||||
|
scenario,
|
||||||
|
requireNotNull(web),
|
||||||
|
"window.__mobileSubject=null;fetch('/api/v1/auth/me').then(r=>r.json()).then(d=>window.__mobileSubject=d.user?.username)",
|
||||||
|
)
|
||||||
|
await("isolated OIDC view shares the initiating server session") {
|
||||||
|
evaluate(scenario, requireNotNull(web), "window.__mobileSubject") == "\"mobile-oidc\""
|
||||||
|
}
|
||||||
|
var previous = ""
|
||||||
|
scenario.onActivity {
|
||||||
|
previous = requireNotNull(web).url.orEmpty()
|
||||||
|
requireNotNull(web).loadUrl("$origin/tasks")
|
||||||
|
}
|
||||||
|
await("internal module navigation") {
|
||||||
|
var loaded = false
|
||||||
|
scenario.onActivity { loaded = requireNotNull(web).url == "$origin/tasks" && requireNotNull(web).canGoBack() }
|
||||||
|
loaded && documentLoaded(scenario, requireNotNull(web), origin)
|
||||||
|
}
|
||||||
|
scenario.onActivity { it.onBackPressedDispatcher.onBackPressed() }
|
||||||
|
await("native back restores WebView history") {
|
||||||
|
var restored = false
|
||||||
|
scenario.onActivity { restored = requireNotNull(web).url == previous }
|
||||||
|
restored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun documentLoaded(scenario: ActivityScenario<MainActivity>, web: WebView, origin: String): Boolean = evaluate(
|
||||||
|
scenario,
|
||||||
|
web,
|
||||||
|
"location.origin===${JSONObject.quote(origin)} && document.readyState==='complete'",
|
||||||
|
) == "true"
|
||||||
|
|
||||||
|
private fun evaluate(scenario: ActivityScenario<MainActivity>, web: WebView, javascript: String): String {
|
||||||
|
val completed = CountDownLatch(1)
|
||||||
|
var result = ""
|
||||||
|
scenario.onActivity {
|
||||||
|
web.evaluateJavascript(javascript) {
|
||||||
|
result = it
|
||||||
|
completed.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue("JavaScript evaluation timed out", completed.await(10, TimeUnit.SECONDS))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
private fun await(label: String, condition: () -> Boolean) {
|
||||||
|
val deadline = System.nanoTime() + 30_000_000_000L
|
||||||
|
while (System.nanoTime() < deadline) {
|
||||||
|
if (condition()) return
|
||||||
|
Thread.sleep(100)
|
||||||
|
}
|
||||||
|
fail("Timed out: $label")
|
||||||
|
}
|
||||||
|
private fun findWeb(view: View): WebView? {
|
||||||
|
if (view is WebView) return view
|
||||||
|
if (view is ViewGroup) {
|
||||||
|
for (i in 0 until view.childCount) {
|
||||||
|
findWeb(view.getChildAt(i))?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasText(view: View, text: String): Boolean {
|
||||||
|
if (view is android.widget.TextView && view.text.toString() == text) return true
|
||||||
|
if (view is ViewGroup) {
|
||||||
|
for (index in 0 until view.childCount) {
|
||||||
|
if (hasText(view.getChildAt(index), text)) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
@@ -13,7 +14,7 @@
|
|||||||
android:theme="@style/AppTheme"
|
android:theme="@style/AppTheme"
|
||||||
android:supportsRtl="true">
|
android:supportsRtl="true">
|
||||||
<activity android:name=".MainActivity" android:exported="true"
|
<activity android:name=".MainActivity" android:exported="true"
|
||||||
android:configChanges="orientation|screenSize|keyboardHidden|uiMode"
|
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package community.yuvomi.mobile
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Base64
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.webkit.WebViewCompat
|
||||||
|
import androidx.webkit.WebViewFeature
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.WeakHashMap
|
||||||
|
import java.util.concurrent.Executor
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/** Same-origin, user-selected blob exports. No JS bridge or CSP/network bypass. */
|
||||||
|
object BlobTransfer {
|
||||||
|
private val stores = WeakHashMap<WebView, String>()
|
||||||
|
|
||||||
|
fun owns(server: ServerAddress, value: String): Boolean = value.startsWith("blob:") && server.owns(value.substring(5))
|
||||||
|
|
||||||
|
fun install(web: WebView, server: ServerAddress) {
|
||||||
|
if (!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) return
|
||||||
|
val key = "__yuvomi_blobs_${UUID.randomUUID().toString().replace("-", "") }"
|
||||||
|
stores[web] = key
|
||||||
|
val script = """
|
||||||
|
(function(){if(window.top!==window)return;
|
||||||
|
const entries=new Map();let total=0;const limit=25*1024*1024;
|
||||||
|
const original=URL.createObjectURL.bind(URL);
|
||||||
|
URL.createObjectURL=function(blob){const url=original(blob);
|
||||||
|
if(blob instanceof Blob && blob.size<=limit){
|
||||||
|
while(entries.size && (total+blob.size>limit || entries.size>=8)){
|
||||||
|
const first=entries.keys().next().value;total-=entries.get(first).size;entries.delete(first);
|
||||||
|
}
|
||||||
|
entries.set(url,blob);total+=blob.size;
|
||||||
|
}return url;};
|
||||||
|
Object.defineProperty(window,${JSONObject.quote(key)},{value:{take:function(url){
|
||||||
|
const blob=entries.get(url);if(blob){total-=blob.size;entries.delete(url);}return blob;
|
||||||
|
}}});
|
||||||
|
})();
|
||||||
|
""".trimIndent()
|
||||||
|
// Retain a bounded Blob reference even if upstream revokes the object URL
|
||||||
|
// immediately after clicking its download anchor. Never fetch a blob URL:
|
||||||
|
// upstream's CSP correctly refuses that operation in a WebView.
|
||||||
|
WebViewCompat.addDocumentStartJavaScript(web, script, setOf(server.origin))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun forget(web: WebView) {
|
||||||
|
stores.remove(web)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(
|
||||||
|
server: ServerAddress,
|
||||||
|
web: WebView,
|
||||||
|
value: String,
|
||||||
|
resolver: ContentResolver,
|
||||||
|
destination: Uri,
|
||||||
|
worker: Executor,
|
||||||
|
authorized: () -> Boolean,
|
||||||
|
completed: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val store = stores[web]
|
||||||
|
if (store == null || !authorized() || !server.owns(web.url.orEmpty()) || !owns(server, value)) {
|
||||||
|
completed(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val handler = Handler(Looper.getMainLooper())
|
||||||
|
val key = JSONObject.quote("__yuvomi_export_${UUID.randomUUID().toString().replace("-", "")}")
|
||||||
|
val maxBytes = 25 * 1024 * 1024
|
||||||
|
var output: OutputStream? = null
|
||||||
|
var offset = 0
|
||||||
|
var finished = false
|
||||||
|
lateinit var expiry: Runnable
|
||||||
|
fun allowed() = authorized() && server.owns(web.url.orEmpty())
|
||||||
|
fun finish(success: Boolean) {
|
||||||
|
if (finished) return
|
||||||
|
finished = true
|
||||||
|
handler.removeCallbacks(expiry)
|
||||||
|
if (runCatching { allowed() }.getOrDefault(false)) {
|
||||||
|
runCatching { web.evaluateJavascript("delete window[$key]", null) }
|
||||||
|
}
|
||||||
|
val close = Runnable {
|
||||||
|
val closed = runCatching { output?.close() }.isSuccess
|
||||||
|
handler.post { completed(success && closed) }
|
||||||
|
}
|
||||||
|
runCatching { worker.execute(close) }.onFailure { close.run() }
|
||||||
|
}
|
||||||
|
expiry = Runnable { finish(false) }
|
||||||
|
lateinit var pull: Runnable
|
||||||
|
pull = Runnable {
|
||||||
|
if (!runCatching { allowed() }.getOrDefault(false)) {
|
||||||
|
finish(false)
|
||||||
|
return@Runnable
|
||||||
|
}
|
||||||
|
val script = """
|
||||||
|
(function(){const x=window[$key];
|
||||||
|
if(!x || x.state!=='ready')return {state:x?x.state:'failed'};
|
||||||
|
const b=x.bytes;if(b.length>$maxBytes)return {state:'failed'};
|
||||||
|
const end=Math.min($offset+32768,b.length);
|
||||||
|
return {state:'ready',data:btoa(String.fromCharCode.apply(null,b.subarray($offset,end))),done:end===b.length};})()
|
||||||
|
""".trimIndent()
|
||||||
|
runCatching {
|
||||||
|
web.evaluateJavascript(script) { result ->
|
||||||
|
if (finished) return@evaluateJavascript
|
||||||
|
val data = runCatching {
|
||||||
|
require(result.length < 50_000)
|
||||||
|
JSONObject(result)
|
||||||
|
}.getOrNull()
|
||||||
|
when (data?.optString("state")) {
|
||||||
|
"loading" -> handler.postDelayed(pull, 50)
|
||||||
|
"ready" -> {
|
||||||
|
val chunk = runCatching { Base64.decode(data.getString("data"), Base64.NO_WRAP) }.getOrNull()
|
||||||
|
if (chunk == null || chunk.size > 32768 || offset + chunk.size > maxBytes) {
|
||||||
|
finish(false)
|
||||||
|
} else {
|
||||||
|
offset += chunk.size
|
||||||
|
runCatching {
|
||||||
|
worker.execute {
|
||||||
|
val success = runCatching { requireNotNull(output).write(chunk) }.isSuccess
|
||||||
|
handler.post {
|
||||||
|
if (!success) {
|
||||||
|
finish(false)
|
||||||
|
} else if (data.optBoolean("done")) {
|
||||||
|
finish(true)
|
||||||
|
} else {
|
||||||
|
handler.post(pull)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { finish(false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> finish(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { finish(false) }
|
||||||
|
}
|
||||||
|
handler.postDelayed(expiry, 45_000)
|
||||||
|
runCatching {
|
||||||
|
worker.execute {
|
||||||
|
output = runCatching { resolver.openOutputStream(destination, "w") }.getOrNull()
|
||||||
|
handler.post {
|
||||||
|
if (finished) {
|
||||||
|
runCatching { output?.close() }
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
if (output == null || !allowed()) {
|
||||||
|
finish(false)
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
val script = """
|
||||||
|
(function(){window[$key]={state:'loading'};
|
||||||
|
try{const b=window[${JSONObject.quote(store)}].take(${JSONObject.quote(value)});
|
||||||
|
if(!b || b.size>$maxBytes)throw new Error('size');
|
||||||
|
b.arrayBuffer().then(a=>window[$key]={state:'ready',bytes:new Uint8Array(a)})
|
||||||
|
.catch(()=>window[$key]={state:'failed'});
|
||||||
|
}catch(e){window[$key]={state:'failed'};}})()
|
||||||
|
""".trimIndent()
|
||||||
|
runCatching { web.evaluateJavascript(script) { handler.post(pull) } }.onFailure { finish(false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { finish(false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,8 @@ object FileTransfer {
|
|||||||
val connection = URI(url).toURL().openConnection() as HttpsURLConnection
|
val connection = URI(url).toURL().openConnection() as HttpsURLConnection
|
||||||
try {
|
try {
|
||||||
connection.instanceFollowRedirects = false
|
connection.instanceFollowRedirects = false
|
||||||
connection.connectTimeout = 15_000; connection.readTimeout = 20_000
|
connection.connectTimeout = 15_000
|
||||||
|
connection.readTimeout = 20_000
|
||||||
cookie?.let { connection.setRequestProperty("Cookie", it) }
|
cookie?.let { connection.setRequestProperty("Cookie", it) }
|
||||||
require(connection.responseCode == 200) { "Download refused" }
|
require(connection.responseCode == 200) { "Download refused" }
|
||||||
require(connection.contentLengthLong <= 100L * 1024 * 1024) { "Download too large" }
|
require(connection.contentLengthLong <= 100L * 1024 * 1024) { "Download too large" }
|
||||||
@@ -31,6 +32,8 @@ object FileTransfer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally { connection.disconnect() }
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,8 +14,29 @@ import android.text.InputType
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import android.webkit.*
|
import android.webkit.CookieManager
|
||||||
import android.widget.*
|
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.OnBackPressedCallback
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@@ -32,7 +53,7 @@ import java.net.URI
|
|||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val worker = Executors.newSingleThreadExecutor()
|
private var worker = Executors.newSingleThreadExecutor()
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
private val preferences by lazy { getSharedPreferences("server", MODE_PRIVATE) }
|
private val preferences by lazy { getSharedPreferences("server", MODE_PRIVATE) }
|
||||||
private var server: ServerAddress? = null
|
private var server: ServerAddress? = null
|
||||||
@@ -45,33 +66,85 @@ class MainActivity : ComponentActivity() {
|
|||||||
private var capture: Uri? = null
|
private var capture: Uri? = null
|
||||||
private var pendingDownload: String? = null
|
private var pendingDownload: String? = null
|
||||||
private var generation = 0
|
private var generation = 0
|
||||||
private val loadTimeout = Runnable { showError("The server is taking too long. Check your connection or try again.") }
|
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 ->
|
private val picker =
|
||||||
|
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
|
val accepted = uploadSelection.consume(generation)
|
||||||
val callback = fileCallback
|
val callback = fileCallback
|
||||||
fileCallback = null
|
fileCallback = null
|
||||||
val uris = if (result.resultCode != RESULT_OK) null else {
|
val uris = if (!accepted || result.resultCode != RESULT_OK) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
val data = result.data
|
val data = result.data
|
||||||
data?.clipData?.let { clip -> Array(clip.itemCount.coerceAtMost(50)) { clip.getItemAt(it).uri } }
|
data?.clipData?.let { clip ->
|
||||||
|
Array(clip.itemCount.coerceAtMost(50)) { clip.getItemAt(it).uri }
|
||||||
|
}
|
||||||
?: data?.data?.let { arrayOf(it) } ?: capture?.let { arrayOf(it) }
|
?: data?.data?.let { arrayOf(it) } ?: capture?.let { arrayOf(it) }
|
||||||
}
|
}
|
||||||
callback?.onReceiveValue(uris?.filter { it.scheme == "content" }?.toTypedArray())
|
callback?.onReceiveValue(uris?.filter { it.scheme == "content" }?.toTypedArray())
|
||||||
|
revokeCapture()
|
||||||
capture = null
|
capture = null
|
||||||
}
|
}
|
||||||
private val saveDocument = registerForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { destination ->
|
private val saveDocument =
|
||||||
|
registerForActivityResult(
|
||||||
|
ActivityResultContracts.CreateDocument("application/octet-stream"),
|
||||||
|
) { destination ->
|
||||||
|
val accepted = downloadSelection.consume(generation)
|
||||||
val url = pendingDownload
|
val url = pendingDownload
|
||||||
pendingDownload = null
|
pendingDownload = null
|
||||||
|
if (!accepted) return@registerForActivityResult
|
||||||
val current = server
|
val current = server
|
||||||
if (destination != null && url != null && current != null && current.owns(url)) {
|
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 cookies = CookieManager.getInstance().getCookie(url)
|
||||||
val epoch = generation
|
val epoch = generation
|
||||||
worker.execute {
|
worker.execute {
|
||||||
val success = runCatching { FileTransfer.save(current, url, cookies, contentResolver, destination) }.isSuccess
|
val success = runCatching {
|
||||||
|
FileTransfer.save(current, url, cookies, contentResolver, destination)
|
||||||
|
}.isSuccess
|
||||||
|
if (!success) runCatching { contentResolver.delete(destination, null, null) }
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
if (!isDestroyed && epoch == generation) {
|
if (!isDestroyed && epoch == generation) {
|
||||||
if (success) toast("Download saved") else {
|
if (success) {
|
||||||
runCatching { contentResolver.delete(destination, null, null) }
|
toast("Download saved")
|
||||||
toast("Download failed. Check your connection and sign-in, then retry.")
|
} else {
|
||||||
|
toast(
|
||||||
|
"Download failed. Check your connection and sign-in, then retry.",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,26 +154,44 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
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.
|
// 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)
|
WebView.setWebContentsDebuggingEnabled(false)
|
||||||
root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
|
root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
|
ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
|
||||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime())
|
val bars = insets.getInsets(
|
||||||
|
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime(),
|
||||||
|
)
|
||||||
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
|
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
|
||||||
insets
|
insets
|
||||||
}
|
}
|
||||||
setContentView(root)
|
setContentView(root)
|
||||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
onBackPressedDispatcher.addCallback(
|
||||||
|
this,
|
||||||
|
object : OnBackPressedCallback(true) {
|
||||||
override fun handleOnBackPressed() {
|
override fun handleOnBackPressed() {
|
||||||
if (web?.canGoBack() == true) web?.goBack()
|
if (web?.canGoBack() == true) {
|
||||||
else if (web != null) settings()
|
web?.goBack()
|
||||||
else { isEnabled = false; onBackPressedDispatcher.onBackPressed() }
|
} else if (web != null) {
|
||||||
|
moveTaskToBack(true)
|
||||||
|
} else {
|
||||||
|
isEnabled = false
|
||||||
|
onBackPressedDispatcher.onBackPressed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
File(cacheDir, "captures").listFiles()?.filter {
|
||||||
|
it.lastModified() <
|
||||||
|
System.currentTimeMillis() - 86_400_000
|
||||||
}
|
}
|
||||||
})
|
|
||||||
File(cacheDir, "captures").listFiles()?.filter { it.lastModified() < System.currentTimeMillis() - 86_400_000 }
|
|
||||||
?.forEach { it.delete() }
|
?.forEach { it.delete() }
|
||||||
server = preferences.getString("origin", null)?.let { runCatching { ServerAddress.parse(it) }.getOrNull() }
|
server =
|
||||||
|
preferences.getString("origin", null)?.let {
|
||||||
|
runCatching { ServerAddress.parse(it) }.getOrNull()
|
||||||
|
}
|
||||||
if (server == null) onboarding() else browser()
|
if (server == null) onboarding() else browser()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,10 +200,15 @@ class MainActivity : ComponentActivity() {
|
|||||||
setPadding(dp(24), dp(20), dp(24), dp(20))
|
setPadding(dp(24), dp(20), dp(24), dp(20))
|
||||||
}
|
}
|
||||||
private fun text(value: String, size: Float = 17f) = TextView(this).apply {
|
private fun text(value: String, size: Float = 17f) = TextView(this).apply {
|
||||||
text = value; textSize = size; setPadding(0, dp(8), 0, dp(8))
|
text = value
|
||||||
|
textSize = size
|
||||||
|
setPadding(0, dp(8), 0, dp(8))
|
||||||
}
|
}
|
||||||
private fun button(label: String, action: () -> Unit) = Button(this).apply {
|
private fun button(label: String, action: () -> Unit) = Button(this).apply {
|
||||||
text = label; isAllCaps = false; minHeight = dp(48); setOnClickListener { action() }
|
text = label
|
||||||
|
isAllCaps = false
|
||||||
|
minHeight = dp(48)
|
||||||
|
setOnClickListener { action() }
|
||||||
}
|
}
|
||||||
private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt()
|
private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt()
|
||||||
|
|
||||||
@@ -120,7 +216,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
root.removeAllViews()
|
root.removeAllViews()
|
||||||
val form = column()
|
val form = column()
|
||||||
form.addView(text("Your home, connected", 30f))
|
form.addView(text("Your home, connected", 30f))
|
||||||
form.addView(text("Connect to your own Yuvomi server. Your data stays with your household."))
|
form.addView(
|
||||||
|
text("Connect to your own Yuvomi server. Your data stays with your household."),
|
||||||
|
)
|
||||||
val address = EditText(this).apply {
|
val address = EditText(this).apply {
|
||||||
hint = "https://family.example.com"
|
hint = "https://family.example.com"
|
||||||
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI
|
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI
|
||||||
@@ -129,23 +227,31 @@ class MainActivity : ComponentActivity() {
|
|||||||
setText(server?.origin.orEmpty())
|
setText(server?.origin.orEmpty())
|
||||||
id = View.generateViewId()
|
id = View.generateViewId()
|
||||||
}
|
}
|
||||||
val label = text("HTTPS required. Custom ports supported. Use a dedicated hostname, not a subpath.")
|
val label =
|
||||||
|
text("HTTPS required. Custom ports supported. Use a dedicated hostname, not a subpath.")
|
||||||
label.accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE
|
label.accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE
|
||||||
lateinit var connect: Button
|
lateinit var connect: Button
|
||||||
connect = button("Connect securely") {
|
connect = button("Connect securely") {
|
||||||
val candidate = runCatching { ServerAddress.parse(address.text.toString()) }.getOrElse {
|
val candidate = runCatching { ServerAddress.parse(address.text.toString()) }.getOrElse {
|
||||||
address.error = it.message; return@button
|
address.error = it.message
|
||||||
|
return@button
|
||||||
}
|
}
|
||||||
connect.isEnabled = false; address.isEnabled = false; label.setText(R.string.checking_server)
|
connect.isEnabled = false
|
||||||
|
address.isEnabled = false
|
||||||
|
label.setText(R.string.checking_server)
|
||||||
val epoch = generation
|
val epoch = generation
|
||||||
worker.execute {
|
worker.execute {
|
||||||
val result = runCatching { ServerProbe.check(candidate) }
|
val result = runCatching { ServerProbe.check(candidate) }
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
if (isDestroyed || epoch != generation) return@runOnUiThread
|
if (isDestroyed || epoch != generation) return@runOnUiThread
|
||||||
connect.isEnabled = true; address.isEnabled = true
|
connect.isEnabled = true
|
||||||
|
address.isEnabled = true
|
||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
server = candidate
|
server = candidate
|
||||||
preferences.edit { putString("origin", candidate.origin); remove("path") }
|
preferences.edit {
|
||||||
|
putString("origin", candidate.origin)
|
||||||
|
remove("path")
|
||||||
|
}
|
||||||
browser()
|
browser()
|
||||||
} else {
|
} else {
|
||||||
// Exception details may contain URL parameters; never expose them.
|
// Exception details may contain URL parameters; never expose them.
|
||||||
@@ -156,8 +262,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
form.addView(address)
|
form.addView(address)
|
||||||
form.addView(label)
|
form.addView(label)
|
||||||
|
localNetworkButton()?.let { form.addView(it) }
|
||||||
form.addView(connect)
|
form.addView(connect)
|
||||||
form.addView(text("Independent community app. No telemetry or mandatory cloud service.", 14f))
|
form.addView(
|
||||||
|
text("Independent community app. No telemetry or mandatory cloud service.", 14f),
|
||||||
|
)
|
||||||
root.addView(ScrollView(this).apply { addView(form) })
|
root.addView(ScrollView(this).apply { addView(form) })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,14 +275,23 @@ class MainActivity : ComponentActivity() {
|
|||||||
root.removeAllViews()
|
root.removeAllViews()
|
||||||
val toolbar = LinearLayout(this)
|
val toolbar = LinearLayout(this)
|
||||||
toolbar.addView(button("Server settings") { settings() })
|
toolbar.addView(button("Server settings") { settings() })
|
||||||
toolbar.addView(button("Share server") {
|
toolbar.addView(
|
||||||
startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply {
|
button("Share server") {
|
||||||
type = "text/plain"; putExtra(Intent.EXTRA_TEXT, current.origin)
|
startActivity(
|
||||||
}, "Share server address"))
|
Intent.createChooser(
|
||||||
})
|
Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "text/plain"
|
||||||
|
putExtra(Intent.EXTRA_TEXT, current.origin)
|
||||||
|
},
|
||||||
|
"Share server address",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
root.addView(toolbar)
|
root.addView(toolbar)
|
||||||
progress = ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply {
|
progress = ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply {
|
||||||
contentDescription = "Loading Yuvomi"; max = 100
|
contentDescription = "Loading Yuvomi"
|
||||||
|
max = 100
|
||||||
}
|
}
|
||||||
root.addView(progress, LinearLayout.LayoutParams(-1, dp(4)))
|
root.addView(progress, LinearLayout.LayoutParams(-1, dp(4)))
|
||||||
content = FrameLayout(this)
|
content = FrameLayout(this)
|
||||||
@@ -181,12 +299,20 @@ class MainActivity : ComponentActivity() {
|
|||||||
val view = WebView(this)
|
val view = WebView(this)
|
||||||
web = view
|
web = view
|
||||||
secure(view)
|
secure(view)
|
||||||
|
BlobTransfer.install(view, current)
|
||||||
CookieManager.getInstance().setAcceptCookie(true)
|
CookieManager.getInstance().setAcceptCookie(true)
|
||||||
CookieManager.getInstance().setAcceptThirdPartyCookies(view, false)
|
CookieManager.getInstance().setAcceptThirdPartyCookies(view, false)
|
||||||
view.webViewClient = object : WebViewClient() {
|
view.webViewClient = object : WebViewClient() {
|
||||||
override fun shouldInterceptRequest(v: WebView, request: WebResourceRequest): WebResourceResponse? {
|
override fun shouldInterceptRequest(v: WebView, request: WebResourceRequest): WebResourceResponse? {
|
||||||
if (!request.isForMainFrame && !current.owns(request.url.toString())) {
|
if (!current.owns(request.url.toString())) {
|
||||||
return WebResourceResponse("text/plain", "UTF-8", 403, "Blocked", emptyMap(), ByteArrayInputStream(ByteArray(0)))
|
return WebResourceResponse(
|
||||||
|
"text/plain",
|
||||||
|
"UTF-8",
|
||||||
|
403,
|
||||||
|
"Blocked",
|
||||||
|
emptyMap(),
|
||||||
|
ByteArrayInputStream(ByteArray(0)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -197,8 +323,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
override fun onPageStarted(v: WebView, url: String, icon: Bitmap?) {
|
override fun onPageStarted(v: WebView, url: String, icon: Bitmap?) {
|
||||||
|
if (!current.owns(url)) {
|
||||||
|
v.stopLoading()
|
||||||
|
external(url)
|
||||||
|
return
|
||||||
|
}
|
||||||
progress.visibility = View.VISIBLE
|
progress.visibility = View.VISIBLE
|
||||||
handler.removeCallbacks(loadTimeout); handler.postDelayed(loadTimeout, 30_000)
|
handler.removeCallbacks(loadTimeout)
|
||||||
|
handler.postDelayed(loadTimeout, 30_000)
|
||||||
}
|
}
|
||||||
override fun onPageFinished(v: WebView, url: String) {
|
override fun onPageFinished(v: WebView, url: String) {
|
||||||
handler.removeCallbacks(loadTimeout)
|
handler.removeCallbacks(loadTimeout)
|
||||||
@@ -206,74 +338,140 @@ class MainActivity : ComponentActivity() {
|
|||||||
CookieManager.getInstance().flush()
|
CookieManager.getInstance().flush()
|
||||||
if (current.owns(url)) {
|
if (current.owns(url)) {
|
||||||
val path = URI(url).rawPath.orEmpty()
|
val path = URI(url).rawPath.orEmpty()
|
||||||
if (!listOf("auth", "reset", "invite", "login").any { path.contains(it, true) }) {
|
if (!listOf("auth", "reset", "invite", "login").any {
|
||||||
|
path.contains(it, true)
|
||||||
|
}
|
||||||
|
) {
|
||||||
preferences.edit { putString("path", path) }
|
preferences.edit { putString("path", path) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
override fun onReceivedError(v: WebView, r: WebResourceRequest, e: WebResourceError) {
|
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.")
|
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) {
|
override fun onReceivedHttpError(v: WebView, r: WebResourceRequest, e: WebResourceResponse) {
|
||||||
if (r.isForMainFrame && e.statusCode >= 400) showError("The server is unavailable (HTTP ${e.statusCode}).")
|
if (r.isForMainFrame &&
|
||||||
|
e.statusCode >= 400
|
||||||
|
) {
|
||||||
|
showError("The server is unavailable (HTTP ${e.statusCode}).")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
override fun onReceivedSslError(v: WebView, h: SslErrorHandler, e: SslError) {
|
override fun onReceivedSslError(v: WebView, h: SslErrorHandler, e: SslError) {
|
||||||
h.cancel(); showError("The server certificate could not be verified. Contact your server administrator.")
|
h.cancel()
|
||||||
|
showError(
|
||||||
|
"The server certificate could not be verified. Contact your server administrator.",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
override fun onRenderProcessGone(v: WebView, detail: RenderProcessGoneDetail): Boolean {
|
override fun onRenderProcessGone(v: WebView, detail: RenderProcessGoneDetail): Boolean {
|
||||||
releaseWeb(); browser(); toast("Yuvomi recovered after a browser interruption."); return true
|
releaseWeb()
|
||||||
|
browser()
|
||||||
|
toast("Yuvomi recovered after a browser interruption.")
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
view.webChromeClient = object : WebChromeClient() {
|
view.webChromeClient = object : WebChromeClient() {
|
||||||
override fun onProgressChanged(v: WebView, value: Int) { progress.progress = value }
|
override fun onProgressChanged(v: WebView, value: Int) {
|
||||||
override fun onPermissionRequest(request: PermissionRequest) { request.deny() }
|
progress.progress = value
|
||||||
|
}
|
||||||
|
override fun onPermissionRequest(request: PermissionRequest) {
|
||||||
|
request.deny()
|
||||||
|
}
|
||||||
override fun onGeolocationPermissionsShowPrompt(origin: String, callback: GeolocationPermissions.Callback) {
|
override fun onGeolocationPermissionsShowPrompt(origin: String, callback: GeolocationPermissions.Callback) {
|
||||||
callback.invoke(origin, false, false)
|
callback.invoke(origin, false, false)
|
||||||
}
|
}
|
||||||
override fun onShowFileChooser(v: WebView, callback: ValueCallback<Array<Uri>>, params: FileChooserParams): Boolean {
|
override fun onShowFileChooser(v: WebView, callback: ValueCallback<Array<Uri>>, params: FileChooserParams): Boolean {
|
||||||
if (!current.owns(v.url.orEmpty())) return false
|
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?.onReceiveValue(null)
|
||||||
fileCallback = callback
|
fileCallback = callback
|
||||||
val types = params.acceptTypes.filter { it.matches(Regex("[a-zA-Z0-9.+*-]+/[a-zA-Z0-9.+*-]+")) }
|
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 {
|
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||||
addCategory(Intent.CATEGORY_OPENABLE)
|
addCategory(Intent.CATEGORY_OPENABLE)
|
||||||
type = types.singleOrNull() ?: "*/*"
|
type = types.singleOrNull() ?: "*/*"
|
||||||
if (types.size > 1) putExtra(Intent.EXTRA_MIME_TYPES, types.toTypedArray())
|
if (types.size > 1) putExtra(Intent.EXTRA_MIME_TYPES, types.toTypedArray())
|
||||||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, params.mode == FileChooserParams.MODE_OPEN_MULTIPLE)
|
putExtra(
|
||||||
|
Intent.EXTRA_ALLOW_MULTIPLE,
|
||||||
|
params.mode == FileChooserParams.MODE_OPEN_MULTIPLE,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val chooser = Intent.createChooser(intent, "Select a file")
|
val chooser = Intent.createChooser(intent, "Select a file")
|
||||||
if (types.any { it.startsWith("image/") }) {
|
if (types.any { it.startsWith("image/") }) {
|
||||||
val directory = File(cacheDir, "captures").apply { mkdirs() }
|
val directory = File(cacheDir, "captures").apply { mkdirs() }
|
||||||
capture = FileProvider.getUriForFile(this@MainActivity, "$packageName.files", File.createTempFile("capture-", ".jpg", directory))
|
capture =
|
||||||
chooser.putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
|
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)
|
putExtra(MediaStore.EXTRA_OUTPUT, capture)
|
||||||
addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
clipData =
|
||||||
}))
|
android.content.ClipData.newRawUri("Yuvomi photo", capture)
|
||||||
|
addFlags(
|
||||||
|
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
runCatching { picker.launch(chooser) }.onFailure {
|
picker.launch(chooser)
|
||||||
fileCallback?.onReceiveValue(null); fileCallback = null; toast("No compatible file picker is available.")
|
}.onFailure {
|
||||||
|
uploadSelection.consume(generation)
|
||||||
|
fileCallback?.onReceiveValue(null)
|
||||||
|
fileCallback = null
|
||||||
|
toast("No compatible file picker is available.")
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
view.setDownloadListener { url, _, disposition, mime, _ ->
|
view.setDownloadListener { url, _, disposition, mime, _ ->
|
||||||
if (!current.owns(url)) toast("Only HTTPS downloads from your server are supported.")
|
if (!current.owns(url) &&
|
||||||
else if (pendingDownload == null) {
|
!BlobTransfer.owns(current, url)
|
||||||
|
) {
|
||||||
|
toast("Only downloads from your server are supported.")
|
||||||
|
} else if (pendingDownload == null && downloadSelection.begin(generation)) {
|
||||||
pendingDownload = url
|
pendingDownload = url
|
||||||
val name = URLUtil.guessFileName(url, disposition, mime).replace(Regex("[^a-zA-Z0-9._ -]"), "_").take(120)
|
val name = URLUtil.guessFileName(
|
||||||
saveDocument.launch(name.ifBlank { "download" })
|
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))
|
content.addView(view, FrameLayout.LayoutParams(-1, -1))
|
||||||
val path = preferences.getString("path", "/").orEmpty()
|
val path = preferences.getString("path", "/").orEmpty()
|
||||||
view.loadUrl(current.origin + if (path.startsWith("/") && !path.startsWith("//")) path else "/")
|
view.loadUrl(
|
||||||
|
current.origin + if (path.startsWith("/") && !path.startsWith("//")) path else "/",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("SetJavaScriptEnabled")
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
private fun secure(view: WebView) {
|
private fun secure(view: WebView) {
|
||||||
view.settings.apply {
|
view.settings.apply {
|
||||||
javaScriptEnabled = true; domStorageEnabled = true
|
javaScriptEnabled = true
|
||||||
allowFileAccess = false; allowContentAccess = false
|
domStorageEnabled = true
|
||||||
|
allowFileAccess = false
|
||||||
|
allowContentAccess = false
|
||||||
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
|
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
|
||||||
javaScriptCanOpenWindowsAutomatically = false
|
javaScriptCanOpenWindowsAutomatically = false
|
||||||
setSupportMultipleWindows(false)
|
setSupportMultipleWindows(false)
|
||||||
@@ -284,12 +482,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun external(value: String) {
|
private fun external(value: String) {
|
||||||
|
handler.removeCallbacks(loadTimeout)
|
||||||
|
progress.visibility = View.GONE
|
||||||
val uri = runCatching { URI(value) }.getOrNull() ?: return
|
val uri = runCatching { URI(value) }.getOrNull() ?: return
|
||||||
if (uri.scheme != "https" || uri.host.isNullOrBlank() || uri.rawUserInfo != null) {
|
if (uri.scheme != "https" || uri.host.isNullOrBlank() || uri.rawUserInfo != null) {
|
||||||
toast("This link type is blocked for your security."); return
|
toast("This link type is blocked for your security.")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
AlertDialog.Builder(this).setTitle("Leave your Yuvomi server?")
|
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.")
|
.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) }
|
.setPositiveButton("Browser") { _, _ -> openBrowser(value) }
|
||||||
.setNeutralButton("Sign-in window") { _, _ -> authentication(value) }
|
.setNeutralButton("Sign-in window") { _, _ -> authentication(value) }
|
||||||
.setNegativeButton("Cancel", null).show()
|
.setNegativeButton("Cancel", null).show()
|
||||||
@@ -301,34 +504,56 @@ class MainActivity : ComponentActivity() {
|
|||||||
secure(auth)
|
secure(auth)
|
||||||
CookieManager.getInstance().setAcceptThirdPartyCookies(auth, false)
|
CookieManager.getInstance().setAcceptThirdPartyCookies(auth, false)
|
||||||
auth.webChromeClient = object : WebChromeClient() {
|
auth.webChromeClient = object : WebChromeClient() {
|
||||||
override fun onPermissionRequest(request: PermissionRequest) { request.deny() }
|
override fun onPermissionRequest(request: PermissionRequest) {
|
||||||
|
request.deny()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
auth.webViewClient = object : WebViewClient() {
|
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 {
|
override fun shouldOverrideUrlLoading(v: WebView, request: WebResourceRequest): Boolean {
|
||||||
val uri = request.url
|
val uri = request.url
|
||||||
return uri.scheme != "https" || !uri.userInfo.isNullOrEmpty()
|
return uri.scheme != "https" || !uri.userInfo.isNullOrEmpty()
|
||||||
}
|
}
|
||||||
override fun onReceivedSslError(v: WebView, h: SslErrorHandler, e: SslError) { h.cancel() }
|
override fun onReceivedSslError(v: WebView, h: SslErrorHandler, e: SslError) {
|
||||||
|
h.cancel()
|
||||||
|
}
|
||||||
override fun onPageFinished(v: WebView, url: String) {
|
override fun onPageFinished(v: WebView, url: String) {
|
||||||
if (server?.owns(url) == true && !URI(url).path.startsWith("/api/")) {
|
if (server?.owns(url) == true && !URI(url).path.startsWith("/api/")) {
|
||||||
CookieManager.getInstance().flush()
|
CookieManager.getInstance().flush()
|
||||||
authDialog?.dismiss(); web?.loadUrl(url)
|
authDialog?.dismiss()
|
||||||
|
web?.loadUrl(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
authDialog = AlertDialog.Builder(this).setTitle("Isolated web sign-in")
|
authDialog = AlertDialog.Builder(this).setTitle("Isolated web sign-in")
|
||||||
.setView(auth).setNegativeButton("Close", null).create().also { dialog ->
|
.setView(auth).setNegativeButton("Close", null).create().also { dialog ->
|
||||||
dialog.setOnDismissListener {
|
dialog.setOnDismissListener {
|
||||||
auth.stopLoading(); auth.removeAllViews(); auth.destroy(); authDialog = null
|
auth.stopLoading()
|
||||||
|
auth.removeAllViews()
|
||||||
|
auth.destroy()
|
||||||
|
authDialog = null
|
||||||
}
|
}
|
||||||
dialog.show()
|
dialog.show()
|
||||||
dialog.window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
dialog.window?.setLayout(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
auth.loadUrl(value)
|
auth.loadUrl(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openBrowser(value: String) {
|
private fun openBrowser(value: String) {
|
||||||
runCatching { startActivity(Intent(Intent.ACTION_VIEW, value.toUri()).addCategory(Intent.CATEGORY_BROWSABLE)) }
|
runCatching {
|
||||||
|
startActivity(
|
||||||
|
Intent(Intent.ACTION_VIEW, value.toUri()).addCategory(Intent.CATEGORY_BROWSABLE),
|
||||||
|
)
|
||||||
|
}
|
||||||
.onFailure { toast("No browser is available.") }
|
.onFailure { toast("No browser is available.") }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,12 +563,21 @@ class MainActivity : ComponentActivity() {
|
|||||||
progress.visibility = View.GONE
|
progress.visibility = View.GONE
|
||||||
if (content.childCount > 1) return
|
if (content.childCount > 1) return
|
||||||
val panel = column().apply {
|
val panel = column().apply {
|
||||||
addView(text("Connection interrupted", 25f)); addView(text(message))
|
addView(text("Connection interrupted", 25f))
|
||||||
addView(button("Retry") { content.removeView(this); web?.reload() })
|
addView(text(message))
|
||||||
|
addView(
|
||||||
|
button("Retry") {
|
||||||
|
content.removeView(this)
|
||||||
|
web?.reload()
|
||||||
|
},
|
||||||
|
)
|
||||||
addView(button("Keep viewing") { content.removeView(this) })
|
addView(button("Keep viewing") { content.removeView(this) })
|
||||||
addView(button("Server settings") { settings() })
|
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
|
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())
|
panel.setBackgroundColor(if (dark) 0xff191816.toInt() else 0xfff5f3ed.toInt())
|
||||||
content.addView(panel, FrameLayout.LayoutParams(-1, -1))
|
content.addView(panel, FrameLayout.LayoutParams(-1, -1))
|
||||||
panel.accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE
|
panel.accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE
|
||||||
@@ -352,41 +586,104 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
private fun settings() {
|
private fun settings() {
|
||||||
AlertDialog.Builder(this).setTitle("Server 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.")
|
.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") { _, _ ->
|
.setPositiveButton("Erase and change server") { _, _ ->
|
||||||
generation++
|
generation++
|
||||||
authDialog?.dismiss(); releaseWeb()
|
worker.shutdownNow()
|
||||||
if (WebViewFeature.isFeatureSupported(WebViewFeature.DELETE_BROWSING_DATA)) {
|
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()) {
|
WebStorageCompat.deleteBrowsingData(WebStorage.getInstance()) {
|
||||||
CookieManager.getInstance().removeAllCookies {
|
CookieManager.getInstance().removeAllCookies {
|
||||||
CookieManager.getInstance().flush()
|
CookieManager.getInstance().flush()
|
||||||
preferences.edit { clear() }; server = null; onboarding()
|
preferences.edit { clear() }
|
||||||
|
server = null
|
||||||
|
onboarding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Older engines cannot reliably erase CacheStorage/IndexedDB.
|
// Older engines cannot reliably erase CacheStorage/IndexedDB.
|
||||||
// OS reset is complete and scoped to this application, but closes it.
|
// OS reset is complete and scoped to this application, but closes it.
|
||||||
getSystemService(android.app.ActivityManager::class.java).clearApplicationUserData()
|
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) } }
|
}
|
||||||
|
.setNeutralButton("Open server in browser") { _, _ ->
|
||||||
|
server?.let { openBrowser(it.origin) }
|
||||||
|
}
|
||||||
.setNegativeButton("Continue", null).show()
|
.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() {
|
private fun releaseWeb() {
|
||||||
handler.removeCallbacks(loadTimeout)
|
handler.removeCallbacks(loadTimeout)
|
||||||
fileCallback?.onReceiveValue(null); fileCallback = null
|
fileCallback?.onReceiveValue(null)
|
||||||
|
fileCallback = null
|
||||||
|
pendingDownload = null
|
||||||
|
revokeCapture()
|
||||||
|
capture = null
|
||||||
web?.let { view ->
|
web?.let { view ->
|
||||||
|
BlobTransfer.forget(view)
|
||||||
view.stopLoading()
|
view.stopLoading()
|
||||||
(view.parent as? ViewGroup)?.removeView(view)
|
(view.parent as? ViewGroup)?.removeView(view)
|
||||||
view.removeAllViews(); view.destroy()
|
view.removeAllViews()
|
||||||
|
view.destroy()
|
||||||
}
|
}
|
||||||
web = null
|
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()
|
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 onPause() {
|
||||||
override fun onResume() { super.onResume(); web?.onResume() }
|
web?.onPause()
|
||||||
|
CookieManager.getInstance().flush()
|
||||||
|
super.onPause()
|
||||||
|
}
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
web?.onResume()
|
||||||
|
}
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
generation++; authDialog?.dismiss(); releaseWeb(); worker.shutdownNow(); super.onDestroy()
|
generation++
|
||||||
|
authDialog?.dismiss()
|
||||||
|
releaseWeb()
|
||||||
|
worker.shutdownNow()
|
||||||
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package community.yuvomi.mobile
|
||||||
|
|
||||||
|
/** Never delivers a late system-picker result into a different server/web context. */
|
||||||
|
class SelectionGate {
|
||||||
|
private var owner: Int? = null
|
||||||
|
val outstanding: Boolean get() = owner != null
|
||||||
|
|
||||||
|
fun begin(generation: Int): Boolean {
|
||||||
|
if (outstanding) return false
|
||||||
|
owner = generation
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consume(generation: Int): Boolean {
|
||||||
|
val previous = owner
|
||||||
|
owner = null
|
||||||
|
return previous == generation
|
||||||
|
}
|
||||||
|
|
||||||
|
fun restoreOutstanding() {
|
||||||
|
owner = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,8 @@ class ServerAddress private constructor(val origin: String) {
|
|||||||
fun owns(value: String): Boolean = runCatching {
|
fun owns(value: String): Boolean = runCatching {
|
||||||
val uri = URI(value)
|
val uri = URI(value)
|
||||||
uri.rawUserInfo == null && uri.scheme.equals("https", true) &&
|
uri.rawUserInfo == null && uri.scheme.equals("https", true) &&
|
||||||
uri.host.equals(URI(origin).host, true) && effectivePort(uri) == effectivePort(URI(origin))
|
uri.host.equals(URI(origin).host, true) &&
|
||||||
|
effectivePort(uri) == effectivePort(URI(origin))
|
||||||
}.getOrDefault(false)
|
}.getOrDefault(false)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -24,7 +25,9 @@ class ServerAddress private constructor(val origin: String) {
|
|||||||
require(uri.rawPath.isNullOrEmpty() || uri.rawPath == "/") {
|
require(uri.rawPath.isNullOrEmpty() || uri.rawPath == "/") {
|
||||||
"Yuvomi requires a dedicated hostname, not a subpath."
|
"Yuvomi requires a dedicated hostname, not a subpath."
|
||||||
}
|
}
|
||||||
require(uri.port == -1 || uri.port in 1..65535) { "The port must be between 1 and 65535." }
|
require(uri.port == -1 || uri.port in 1..65535) {
|
||||||
|
"The port must be between 1 and 65535."
|
||||||
|
}
|
||||||
val port = if (uri.port == -1 || uri.port == 443) "" else ":${uri.port}"
|
val port = if (uri.port == -1 || uri.port == 443) "" else ":${uri.port}"
|
||||||
return ServerAddress("https://${uri.host.lowercase(Locale.ROOT)}$port")
|
return ServerAddress("https://${uri.host.lowercase(Locale.ROOT)}$port")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
package community.yuvomi.mobile
|
package community.yuvomi.mobile
|
||||||
|
|
||||||
import org.json.JSONObject
|
import java.io.ByteArrayOutputStream
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
import javax.net.ssl.HttpsURLConnection
|
import javax.net.ssl.HttpsURLConnection
|
||||||
import java.io.ByteArrayOutputStream
|
import org.json.JSONObject
|
||||||
|
|
||||||
object ServerProbe {
|
object ServerProbe {
|
||||||
/** No cookies, no automatic redirects, bounded response and deadlines. */
|
/** No cookies, no automatic redirects, bounded response and deadlines. */
|
||||||
fun check(server: ServerAddress) {
|
fun check(server: ServerAddress) {
|
||||||
val version = json(server, "/api/v1/version")
|
val version = json(server, "/api/v1/version")
|
||||||
require(version.opt("app_name") is String && version.opt("setup_required") is Boolean &&
|
require(
|
||||||
version.opt("password_reset_enabled") is Boolean) {
|
version.opt("app_name") is String && version.opt("setup_required") is Boolean &&
|
||||||
|
version.opt("password_reset_enabled") is Boolean,
|
||||||
|
) {
|
||||||
"This server does not expose compatible Yuvomi bootstrap metadata."
|
"This server does not expose compatible Yuvomi bootstrap metadata."
|
||||||
}
|
}
|
||||||
val auth = json(server, "/api/v1/auth/oidc/config")
|
val auth = json(server, "/api/v1/auth/oidc/config")
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package community.yuvomi.mobile
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class SelectionGateTest {
|
||||||
|
@Test fun acceptsOnlyTheInitiatingContextOnce() {
|
||||||
|
val gate = SelectionGate()
|
||||||
|
assertTrue(gate.begin(0))
|
||||||
|
assertTrue(gate.consume(0))
|
||||||
|
assertFalse(gate.consume(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun serverSwitchCannotDeliverAnOldSelectionToTheNewServer() {
|
||||||
|
val gate = SelectionGate()
|
||||||
|
assertTrue(gate.begin(0))
|
||||||
|
assertFalse(gate.begin(1))
|
||||||
|
assertFalse(gate.consume(1))
|
||||||
|
assertTrue(gate.begin(1))
|
||||||
|
assertTrue(gate.consume(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun processRecreationCancelsSelectionWithoutReusingItsCallback() {
|
||||||
|
val gate = SelectionGate()
|
||||||
|
gate.restoreOutstanding()
|
||||||
|
assertFalse(gate.begin(0))
|
||||||
|
assertFalse(gate.consume(0))
|
||||||
|
assertTrue(gate.begin(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,31 @@
|
|||||||
package community.yuvomi.mobile
|
package community.yuvomi.mobile
|
||||||
|
|
||||||
import org.junit.Assert.*
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class ServerAddressTest {
|
class ServerAddressTest {
|
||||||
@Test fun canonicalizesAndSupportsPortsAndIPv6() {
|
@Test fun canonicalizesAndSupportsPortsAndIPv6() {
|
||||||
assertEquals("https://family.example", ServerAddress.parse(" HTTPS://Family.Example:443/ ").origin)
|
assertEquals(
|
||||||
assertEquals("https://family.example:8443", ServerAddress.parse("https://family.example:8443").origin)
|
"https://family.example",
|
||||||
|
ServerAddress.parse(" HTTPS://Family.Example:443/ ").origin,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"https://family.example:8443",
|
||||||
|
ServerAddress.parse("https://family.example:8443").origin,
|
||||||
|
)
|
||||||
assertEquals("https://[::1]:8443", ServerAddress.parse("https://[::1]:8443").origin)
|
assertEquals("https://[::1]:8443", ServerAddress.parse("https://[::1]:8443").origin)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun rejectsUnsafeConfiguration() {
|
@Test fun rejectsUnsafeConfiguration() {
|
||||||
listOf("http://family.example", "javascript:alert(1)", "https://user:pass@family.example",
|
listOf(
|
||||||
|
"http://family.example", "javascript:alert(1)", "https://user:pass@family.example",
|
||||||
"https://family.example/subpath", "https://family.example?token=secret",
|
"https://family.example/subpath", "https://family.example?token=secret",
|
||||||
"https://family.example/#token", "https://family.example:0", "https://family.example:65536",
|
"https://family.example/#token", "https://family.example:0", "https://family.example:65536",
|
||||||
"https://family.example/%2f", "https://family.example\\@evil.example", "https:///", "").forEach {
|
"https://family.example/%2f", "https://family.example\\@evil.example", "https:///", "",
|
||||||
|
).forEach {
|
||||||
assertThrows(it, IllegalArgumentException::class.java) { ServerAddress.parse(it) }
|
assertThrows(it, IllegalArgumentException::class.java) { ServerAddress.parse(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,10 +33,30 @@ class ServerAddressTest {
|
|||||||
@Test fun navigationIsOriginBoundNotStringPrefixBound() {
|
@Test fun navigationIsOriginBoundNotStringPrefixBound() {
|
||||||
val server = ServerAddress.parse("https://family.example")
|
val server = ServerAddress.parse("https://family.example")
|
||||||
assertTrue(server.owns("https://FAMILY.example:443/tasks?id=1"))
|
assertTrue(server.owns("https://FAMILY.example:443/tasks?id=1"))
|
||||||
listOf("https://family.example.evil.test/", "https://family.example@evil.test/",
|
listOf(
|
||||||
"https://user@family.example/", "http://family.example/", "https://family.example:8443/",
|
"https://family.example.evil.test/",
|
||||||
"file:///etc/passwd", "intent://family.example", "data:text/html,hello").forEach {
|
"https://family.example@evil.test/",
|
||||||
|
"https://user@family.example/",
|
||||||
|
"http://family.example/",
|
||||||
|
"https://family.example:8443/",
|
||||||
|
"file:///etc/passwd",
|
||||||
|
"intent://family.example",
|
||||||
|
"data:text/html,hello",
|
||||||
|
).forEach {
|
||||||
assertFalse(it, server.owns(it))
|
assertFalse(it, server.owns(it))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,28 @@
|
|||||||
plugins {
|
buildscript {
|
||||||
id("com.android.application") version "8.13.2" apply false
|
repositories {
|
||||||
id("org.jetbrains.kotlin.android") version "2.2.10" apply false
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
// Override AGP's bundled KGP to include CVE-2026-53914 remediation.
|
||||||
|
dependencies { classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.21") }
|
||||||
|
configurations.getByName("classpath").resolutionStrategy {
|
||||||
|
activateDependencyLocking()
|
||||||
|
eachDependency {
|
||||||
|
when (requested.group) {
|
||||||
|
"io.netty" -> useVersion("4.1.139.Final")
|
||||||
|
"com.google.protobuf" -> useVersion("3.25.9")
|
||||||
|
"org.bouncycastle" -> useVersion("1.86")
|
||||||
|
"org.apache.commons" -> if (requested.name == "commons-lang3") useVersion("3.21.0")
|
||||||
|
"org.apache.httpcomponents" -> if (requested.name == "httpclient") useVersion("4.5.14")
|
||||||
|
"org.bitbucket.b_c" -> if (requested.name == "jose4j") useVersion("0.9.6")
|
||||||
|
"org.jdom" -> if (requested.name == "jdom2") useVersion("2.0.6.1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
plugins { id("com.android.application") version "9.4.1" 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.
|
||||||
val outputRoot = providers.environmentVariable("YUVOMI_BUILD_DIR")
|
val outputRoot = providers.environmentVariable("YUVOMI_BUILD_DIR")
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# This is a Gradle generated file for dependency locking.
|
||||||
|
# Manual edits can break the build and are not advised.
|
||||||
|
# This file is expected to be part of source control.
|
||||||
|
# To regenerate this file, run: ./gradlew dependencies --write-locks
|
||||||
|
androidx.databinding:databinding-common:9.4.1=classpath
|
||||||
|
androidx.databinding:databinding-compiler-common:9.4.1=classpath
|
||||||
|
com.android.application:com.android.application.gradle.plugin:9.4.1=classpath
|
||||||
|
com.android.databinding:baseLibrary:9.4.1=classpath
|
||||||
|
com.android.tools.analytics-library:crash:32.4.1=classpath
|
||||||
|
com.android.tools.analytics-library:protos:32.4.1=classpath
|
||||||
|
com.android.tools.analytics-library:shared:32.4.1=classpath
|
||||||
|
com.android.tools.analytics-library:tracker:32.4.1=classpath
|
||||||
|
com.android.tools.build.jetifier:jetifier-core:1.0.0-beta10=classpath
|
||||||
|
com.android.tools.build.jetifier:jetifier-processor:1.0.0-beta10=classpath
|
||||||
|
com.android.tools.build:aapt2-proto:9.4.1-15978811=classpath
|
||||||
|
com.android.tools.build:aaptcompiler:9.4.1=classpath
|
||||||
|
com.android.tools.build:apksig:9.4.1=classpath
|
||||||
|
com.android.tools.build:apkzlib:9.4.1=classpath
|
||||||
|
com.android.tools.build:builder-model:9.4.1=classpath
|
||||||
|
com.android.tools.build:builder-test-api:9.4.1=classpath
|
||||||
|
com.android.tools.build:builder:9.4.1=classpath
|
||||||
|
com.android.tools.build:bundletool:1.18.3=classpath
|
||||||
|
com.android.tools.build:gradle-api:9.4.1=classpath
|
||||||
|
com.android.tools.build:gradle-common-api:9.4.1=classpath
|
||||||
|
com.android.tools.build:gradle-settings-api:9.4.1=classpath
|
||||||
|
com.android.tools.build:gradle:9.4.1=classpath
|
||||||
|
com.android.tools.build:manifest-merger:32.4.1=classpath
|
||||||
|
com.android.tools.ddms:ddmlib:32.4.1=classpath
|
||||||
|
com.android.tools.layoutlib:layoutlib-api:32.4.1=classpath
|
||||||
|
com.android.tools.lint:lint-model:32.4.1=classpath
|
||||||
|
com.android.tools.lint:lint-typedef-remover:32.4.1=classpath
|
||||||
|
com.android.tools.utp:gradle-work-action-api:32.4.1=classpath
|
||||||
|
com.android.tools:annotations:32.4.1=classpath
|
||||||
|
com.android.tools:common:32.4.1=classpath
|
||||||
|
com.android.tools:dvlib:32.4.1=classpath
|
||||||
|
com.android.tools:repository:32.4.1=classpath
|
||||||
|
com.android.tools:sdk-common:32.4.1=classpath
|
||||||
|
com.android.tools:sdklib:32.4.1=classpath
|
||||||
|
com.android:signflinger:9.4.1=classpath
|
||||||
|
com.android:zipflinger:9.4.1=classpath
|
||||||
|
com.google.auto.value:auto-value-annotations:1.6.2=classpath
|
||||||
|
com.google.code.findbugs:jsr305:3.0.2=classpath
|
||||||
|
com.google.code.gson:gson:2.11.0=classpath
|
||||||
|
com.google.crypto.tink:tink:1.7.0=classpath
|
||||||
|
com.google.dagger:dagger:2.28.3=classpath
|
||||||
|
com.google.errorprone:error_prone_annotations:2.36.0=classpath
|
||||||
|
com.google.flatbuffers:flatbuffers-java:1.12.0=classpath
|
||||||
|
com.google.guava:failureaccess:1.0.2=classpath
|
||||||
|
com.google.guava:guava:33.4.0-jre=classpath
|
||||||
|
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=classpath
|
||||||
|
com.google.j2objc:j2objc-annotations:3.0.0=classpath
|
||||||
|
com.google.jimfs:jimfs:1.1=classpath
|
||||||
|
com.google.protobuf:protobuf-java-util:3.25.9=classpath
|
||||||
|
com.google.protobuf:protobuf-java:3.25.9=classpath
|
||||||
|
com.googlecode.juniversalchardet:juniversalchardet:1.0.3=classpath
|
||||||
|
com.squareup:javapoet:1.13.0=classpath
|
||||||
|
com.squareup:javawriter:2.5.0=classpath
|
||||||
|
com.sun.istack:istack-commons-runtime:3.0.8=classpath
|
||||||
|
com.sun.xml.fastinfoset:FastInfoset:1.2.16=classpath
|
||||||
|
commons-codec:commons-codec:1.17.1=classpath
|
||||||
|
commons-io:commons-io:2.16.1=classpath
|
||||||
|
commons-logging:commons-logging:1.2=classpath
|
||||||
|
jakarta.activation:jakarta.activation-api:1.2.1=classpath
|
||||||
|
jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=classpath
|
||||||
|
javax.inject:javax.inject:1=classpath
|
||||||
|
net.java.dev.jna:jna-platform:5.6.0=classpath
|
||||||
|
net.java.dev.jna:jna:5.6.0=classpath
|
||||||
|
net.sf.jopt-simple:jopt-simple:4.9=classpath
|
||||||
|
net.sf.kxml:kxml2:2.3.0=classpath
|
||||||
|
org.apache.commons:commons-compress:1.27.1=classpath
|
||||||
|
org.apache.commons:commons-lang3:3.21.0=classpath
|
||||||
|
org.apache.httpcomponents:httpclient:4.5.14=classpath
|
||||||
|
org.apache.httpcomponents:httpcore:4.4.16=classpath
|
||||||
|
org.apache.httpcomponents:httpmime:4.5.6=classpath
|
||||||
|
org.bitbucket.b_c:jose4j:0.9.6=classpath
|
||||||
|
org.bouncycastle:bcpkix-jdk18on:1.86=classpath
|
||||||
|
org.bouncycastle:bcprov-jdk18on:1.86=classpath
|
||||||
|
org.bouncycastle:bcutil-jdk18on:1.86=classpath
|
||||||
|
org.checkerframework:checker-qual:3.43.0=classpath
|
||||||
|
org.glassfish.jaxb:jaxb-runtime:2.3.2=classpath
|
||||||
|
org.glassfish.jaxb:txw2:2.3.2=classpath
|
||||||
|
org.jdom:jdom2:2.0.6.1=classpath
|
||||||
|
org.jetbrains.kotlin:fus-statistics-gradle-plugin:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-build-statistics:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-build-tools-api:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-compiler-runner:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-daemon-client:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-gradle-plugin-annotations:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-gradle-plugin-api:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-gradle-plugin-idea-proto:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-gradle-plugin-idea:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-gradle-plugins-bom:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-klib-commonizer-api:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-native-utils:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-reflect:2.3.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.10=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.10=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib:2.3.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-tooling-core:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-util-io:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-util-klib-metadata:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-util-klib:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=classpath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0=classpath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0=classpath
|
||||||
|
org.jetbrains:annotations:13.0=classpath
|
||||||
|
org.jvnet.staxex:stax-ex:1.8.1=classpath
|
||||||
|
org.ow2.asm:asm-analysis:9.9=classpath
|
||||||
|
org.ow2.asm:asm-commons:9.9=classpath
|
||||||
|
org.ow2.asm:asm-tree:9.9=classpath
|
||||||
|
org.ow2.asm:asm-util:9.9=classpath
|
||||||
|
org.ow2.asm:asm:9.9=classpath
|
||||||
|
org.slf4j:slf4j-api:1.7.30=classpath
|
||||||
|
org.tensorflow:tensorflow-lite-metadata:0.2.0=classpath
|
||||||
|
empty=
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
|
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.workers.max=2
|
||||||
|
kotlin.compiler.execution.strategy=in-process
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
BIN
Binary file not shown.
+4
-2
@@ -1,8 +1,10 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
distributionSha256Sum=bbaeb2fef8710818cf0e261201dab964c572f92b942812df0c3620d62a529a01
|
||||||
distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
|
retries=0
|
||||||
|
retryBackOffMs=500
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
Vendored
+5
-8
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
#
|
#
|
||||||
# Copyright © 2015-2021 the original authors.
|
# Copyright © 2015 the original authors.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
##############################################################################
|
##############################################################################
|
||||||
#
|
#
|
||||||
# Gradle start up script for POSIX generated by Gradle.
|
# gradlew start up script for POSIX generated by Gradle.
|
||||||
#
|
#
|
||||||
# Important for running:
|
# Important for running:
|
||||||
#
|
#
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
# bash, then to run this script, type that shell name before the whole
|
# bash, then to run this script, type that shell name before the whole
|
||||||
# command line, like:
|
# command line, like:
|
||||||
#
|
#
|
||||||
# ksh Gradle
|
# ksh gradlew
|
||||||
#
|
#
|
||||||
# Busybox and similar reduced shells will NOT work, because this script
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
# requires all of these POSIX shell features:
|
# requires all of these POSIX shell features:
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
# Darwin, MinGW, and NonStop.
|
# Darwin, MinGW, and NonStop.
|
||||||
#
|
#
|
||||||
# (3) This script is generated from the Groovy template
|
# (3) This script is generated from the Groovy template
|
||||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
# within the Gradle project.
|
# within the Gradle project.
|
||||||
#
|
#
|
||||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
@@ -114,7 +114,6 @@ case "$( uname )" in #(
|
|||||||
NONSTOP* ) nonstop=true ;;
|
NONSTOP* ) nonstop=true ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
|
||||||
|
|
||||||
|
|
||||||
# Determine the Java command to use to start the JVM.
|
# Determine the Java command to use to start the JVM.
|
||||||
@@ -172,7 +171,6 @@ fi
|
|||||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
if "$cygwin" || "$msys" ; then
|
if "$cygwin" || "$msys" ; then
|
||||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
|
||||||
|
|
||||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
@@ -212,8 +210,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
|||||||
|
|
||||||
set -- \
|
set -- \
|
||||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
-classpath "$CLASSPATH" \
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
org.gradle.wrapper.GradleWrapperMain \
|
|
||||||
"$@"
|
"$@"
|
||||||
|
|
||||||
# Stop when "xargs" is not available.
|
# Stop when "xargs" is not available.
|
||||||
|
|||||||
Vendored
+12
-24
@@ -19,12 +19,12 @@
|
|||||||
@if "%DEBUG%"=="" @echo off
|
@if "%DEBUG%"=="" @echo off
|
||||||
@rem ##########################################################################
|
@rem ##########################################################################
|
||||||
@rem
|
@rem
|
||||||
@rem Gradle startup script for Windows
|
@rem gradlew startup script for Windows
|
||||||
@rem
|
@rem
|
||||||
@rem ##########################################################################
|
@rem ##########################################################################
|
||||||
|
|
||||||
@rem Set local scope for the variables with windows NT shell
|
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||||
if "%OS%"=="Windows_NT" setlocal
|
setlocal EnableExtensions
|
||||||
|
|
||||||
set DIRNAME=%~dp0
|
set DIRNAME=%~dp0
|
||||||
if "%DIRNAME%"=="" set DIRNAME=.
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
@@ -51,7 +51,7 @@ echo. 1>&2
|
|||||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
echo location of your Java installation. 1>&2
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
goto fail
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
:findJavaFromJavaHome
|
:findJavaFromJavaHome
|
||||||
set JAVA_HOME=%JAVA_HOME:"=%
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
@@ -65,30 +65,18 @@ echo. 1>&2
|
|||||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
echo location of your Java installation. 1>&2
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
goto fail
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
:execute
|
:execute
|
||||||
@rem Setup the command line
|
@rem Setup the command line
|
||||||
|
|
||||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
|
||||||
|
|
||||||
|
|
||||||
@rem Execute Gradle
|
@rem Execute gradlew
|
||||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||||
|
@rem which allows us to clear the local environment before executing the java command
|
||||||
|
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||||
|
|
||||||
:end
|
:exitWithErrorLevel
|
||||||
@rem End local scope for the variables with windows NT shell
|
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||||
|
|
||||||
:fail
|
|
||||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
|
||||||
rem the _cmd.exe /c_ return code!
|
|
||||||
set EXIT_CODE=%ERRORLEVEL%
|
|
||||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
|
||||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
|
||||||
exit /b %EXIT_CODE%
|
|
||||||
|
|
||||||
:mainEnd
|
|
||||||
if "%OS%"=="Windows_NT" endlocal
|
|
||||||
|
|
||||||
:omega
|
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } }
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
repositories { google(); mavenCentral() }
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
rootProject.name = "YuvomiMobile"
|
rootProject.name = "YuvomiMobile"
|
||||||
include(":app")
|
include(":app")
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
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,67 @@
|
|||||||
|
# Android development
|
||||||
|
|
||||||
|
Requirements: JDK 17, Android SDK platform 37.0, build-tools 36.0.0, current
|
||||||
|
platform-tools, and accepted Android SDK licenses. Minimum Android 8 (API 26).
|
||||||
|
Use a maintained Android System WebView. Target API 37. On Android 17, choose
|
||||||
|
"Allow private-network servers" when using a LAN instance; public HTTPS servers
|
||||||
|
do not need this optional runtime permission. No discovery/scanning is performed.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export ANDROID_HOME=/path/to/android-sdk
|
||||||
|
bash android/gradlew -p android testDebugUnitTest lintDebug lintRelease assembleDebug assembleRelease bundleRelease
|
||||||
|
bash android/gradlew -p android connectedDebugAndroidTest
|
||||||
|
```
|
||||||
|
|
||||||
|
Gradle 9.6.0 is checksum-verified by the wrapper. AGP 9.4.1 uses built-in Kotlin
|
||||||
|
with the build-cache fix in KGP 2.4.21. Maven versions are locked. No dynamic
|
||||||
|
application dependencies or private Maven repository is required.
|
||||||
|
|
||||||
|
Ordinary outputs:
|
||||||
|
|
||||||
|
- `android/app/build/outputs/apk/debug/app-debug.apk` (development-signed)
|
||||||
|
- `android/app/build/outputs/apk/release/app-release-unsigned.apk`
|
||||||
|
- `android/app/build/outputs/bundle/release/app-release.aab` (unsigned unless signed later)
|
||||||
|
- `android/app/build/reports/` (lint, JVM and emulator results)
|
||||||
|
|
||||||
|
Immediately after building, collect and inspect the outputs:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 scripts/collect-android-artifacts.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This writes ignored `artifacts/android/` copies, `SHA256SUMS`, source identity and
|
||||||
|
manifest/network-policy/signature evidence. It requires `ANDROID_HOME` and respects
|
||||||
|
`YUVOMI_BUILD_DIR`. Source identity records the checkout at collection time; always
|
||||||
|
rebuild immediately beforehand and use a clean worktree for a release candidate.
|
||||||
|
|
||||||
|
The unsigned release cannot be installed/published as-is. See release signing
|
||||||
|
instructions. `gradle-wrapper.jar` is the standard generated Gradle bootstrap
|
||||||
|
binary; it is intentionally versioned, unlike generated APKs or caches.
|
||||||
|
|
||||||
|
On WSL/Windows mounts that reject POSIX metadata operations, put outputs on the
|
||||||
|
Linux filesystem:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export YUVOMI_BUILD_DIR="$HOME/yuvomi-build"
|
||||||
|
bash android/gradlew -p android testDebugUnitTest assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
The application never permits HTTP, including debug builds. Use real HTTPS for
|
||||||
|
manual development. For a temporary local HTTPS integration environment:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone --filter=blob:none https://github.com/ulsklyc/yuvomi.git /path/to/upstream
|
||||||
|
git -C /path/to/upstream checkout 10bfef7686ed7303b4c74c7fcc55a5eb16356c9d
|
||||||
|
npm ci --omit=dev --prefix /path/to/upstream
|
||||||
|
python3 scripts/test-integration.py /path/to/upstream --android
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires Node 22.14+ and a running emulator. The harness creates and removes its
|
||||||
|
own CA/key/database, uses `adb reverse`, and builds `.integration` with explicit
|
||||||
|
test trust. It does not weaken production TLS. Test credentials are ephemeral.
|
||||||
|
Keep test reports private if you customize the harness with real data (not advised).
|
||||||
|
|
||||||
|
For dependency updates run `:app:dependencies --write-locks`, the full pipeline,
|
||||||
|
and `python3 scripts/check-dependencies.py`. Review lockfile changes. The version
|
||||||
|
update lint recommendations are informational, not suppressed correctness errors;
|
||||||
|
OSV checks independently fail on known advisories.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# iOS development
|
||||||
|
|
||||||
|
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
|
||||||
|
requests, which earlier WKWebView delegates cannot do. No CocoaPods or external
|
||||||
|
runtime package is required. For App Store submission use Apple's currently
|
||||||
|
required Xcode/iOS SDK, not merely the oldest compiler that can build the source.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bash scripts/install-xcodegen.sh "$HOME/.local/yuvomi-xcodegen"
|
||||||
|
"$HOME/.local/yuvomi-xcodegen/xcodegen/bin/xcodegen" generate --spec ios/project.yml
|
||||||
|
device=$(python3 scripts/select-simulator.py)
|
||||||
|
xcodebuild -project ios/Yuvomi.xcodeproj -scheme Yuvomi \
|
||||||
|
-destination "platform=iOS Simulator,id=$device" CODE_SIGNING_ALLOWED=NO test
|
||||||
|
xcodebuild -project ios/Yuvomi.xcodeproj -scheme Yuvomi \
|
||||||
|
-destination 'generic/platform=iOS' -archivePath artifacts/Yuvomi-unsigned.xcarchive \
|
||||||
|
CODE_SIGNING_ALLOWED=NO archive
|
||||||
|
```
|
||||||
|
|
||||||
|
`project.yml` is the source of truth. The Xcode project and generated Info.plist
|
||||||
|
are ignored. The XcodeGen download is version- and SHA256-pinned. The project
|
||||||
|
includes a privacy manifest, camera/photo/local-network usage strings, universal
|
||||||
|
app icon, orientation support, unit and UI test targets, and no ATS exception.
|
||||||
|
|
||||||
|
On macOS or Linux with Swift 6 installed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
swift test --package-path ios
|
||||||
|
swiftc -frontend -parse ios/Sources/*.swift ios/Tests/*.swift ios/UITests/*.swift
|
||||||
|
```
|
||||||
|
|
||||||
|
These portable tests compile the **production URL/metadata policies only**.
|
||||||
|
Parsing does not typecheck UIKit, SwiftUI or WebKit APIs. Neither command is
|
||||||
|
evidence that the iOS application builds. Actual Xcode testing is mandatory.
|
||||||
|
|
||||||
|
For a device build select your authorized development team in Xcode, use your own
|
||||||
|
registered bundle identifier, and let Xcode manage development provisioning.
|
||||||
|
Never commit generated profiles or certificates. See `release-process.md` for
|
||||||
|
distribution. A Linux environment cannot produce a validated iOS archive.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Maintainer continuation
|
||||||
|
|
||||||
|
This is a development delivery, not authorization to publish. Preserve the Git
|
||||||
|
history on `feat/yuvomi-mobile`; use Conventional Commits and normal pushes only.
|
||||||
|
The authorized community remote is `https://git.nesterovic.cc/nessi/Yuvomi-App`.
|
||||||
|
Do not push, submit issues or open pull requests against upstream on this task's
|
||||||
|
behalf. No production credentials or store accounts have been provided.
|
||||||
|
|
||||||
|
## Start with the evidence
|
||||||
|
|
||||||
|
Read [validation](validation.md), [security review](security-review.md), and
|
||||||
|
[release gates](release-process.md). Do not repeat completed implementation work
|
||||||
|
or equate portable Swift checks with an iOS app build. Inspect `git status` before
|
||||||
|
editing; preserve any newer maintainer changes.
|
||||||
|
|
||||||
|
The upstream reference is Yuvomi 2.78.0 at
|
||||||
|
`10bfef7686ed7303b4c74c7fcc55a5eb16356c9d`. Keep it unmodified. Subpath hosting,
|
||||||
|
browser-to-native OIDC handoff and native remote push are not implemented.
|
||||||
|
|
||||||
|
The extended API 35 HTTPS suite passes all seven tests, including the local-IdP
|
||||||
|
round trip, native transfers, recovery and back navigation. Portable Swift has
|
||||||
|
seven passing policy tests; the separate JUnit verifier has eight regressions.
|
||||||
|
Keep the fixture's scope separate from real-provider or Apple-platform validation.
|
||||||
|
|
||||||
|
## Persistent local environment (Linux/WSL)
|
||||||
|
|
||||||
|
- Repository: `/mnt/c/Users/neste/Documents/GIT/Yuvomi-App`.
|
||||||
|
- Upstream and installed dependencies: `/home/neste/yuvomi-tools/upstream`.
|
||||||
|
- SDK: `/home/neste/yuvomi-tools/android-toolchain/android-sdk`.
|
||||||
|
- Build outputs/logs: `/home/neste/yuvomi-build`.
|
||||||
|
- Swift 6.2.1: `/home/neste/yuvomi-tools/swift-6.2.1-RELEASE-ubuntu24.04/usr/bin`.
|
||||||
|
- Swift compatibility libraries: `/home/neste/yuvomi-tools/compat/usr/lib/x86_64-linux-gnu`.
|
||||||
|
- Swift scratch output: `/home/neste/yuvomi-swift-build`.
|
||||||
|
- ktlint and actionlint: `/home/neste/yuvomi-tools/ktlint.jar` and
|
||||||
|
`/home/neste/yuvomi-tools/actionlint`.
|
||||||
|
- Local collected artifacts/evidence: ignored `artifacts/android/`.
|
||||||
|
|
||||||
|
`/tmp/opencode` is temporary and was cleared by server restarts. Background
|
||||||
|
processes may also disappear. Keep durable logs under `/home/neste`; never persist
|
||||||
|
the integration fixture's generated private CA, keys or real household data.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export ANDROID_HOME=/home/neste/yuvomi-tools/android-toolchain/android-sdk
|
||||||
|
export YUVOMI_BUILD_DIR=/home/neste/yuvomi-build
|
||||||
|
"$ANDROID_HOME/platform-tools/adb" devices -l
|
||||||
|
"$ANDROID_HOME/platform-tools/adb" shell am get-started-user-state 0
|
||||||
|
# Require RUNNING_UNLOCKED, then:
|
||||||
|
python3 scripts/test-integration.py /home/neste/yuvomi-tools/upstream --android
|
||||||
|
bash android/gradlew -p android testDebugUnitTest lintDebug lintRelease assembleDebug assembleRelease bundleRelease
|
||||||
|
python3 scripts/collect-android-artifacts.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The API 35 AVD is `yuvomi-test`. Its stable attempt uses KVM, `-gpu host`,
|
||||||
|
`-no-window -no-audio -no-boot-anim -no-snapshot -memory 2560 -cores 2`.
|
||||||
|
Launch under the SDK/AVD owner with access to the `kvm` group. Other GPU modes
|
||||||
|
previously crashed the host emulator with SIGSEGV. API 37 had broken services and
|
||||||
|
locked credential-encrypted storage; no API 37 runtime success is claimed.
|
||||||
|
|
||||||
|
For portable Swift checks add the compiler directory to `PATH` and set
|
||||||
|
`LD_LIBRARY_PATH` to the compatibility-library directory above, then use the
|
||||||
|
commands in [iOS development](build-ios.md). Apple application/XCTest/UI/archive
|
||||||
|
validation must happen on macOS with Xcode and real supported devices.
|
||||||
|
|
||||||
|
## Highest-priority external work
|
||||||
|
|
||||||
|
1. Typecheck/build the iOS app against Apple SDKs, run its actual XCTest/UI tests,
|
||||||
|
and produce an unsigned archive. Fix any failures; none of these ran locally.
|
||||||
|
2. Run the API 37 permission grant/denial/revocation scenarios on a working image
|
||||||
|
or device, plus the minimum-supported Android and current WebView matrix.
|
||||||
|
3. Review iOS unknown-length download disk-pressure handling before release.
|
||||||
|
4. Validate real supported IdPs, TOTP after SSO, linking, cancellation, native
|
||||||
|
file/camera pickers, process death, accessibility and offline account isolation.
|
||||||
|
5. Establish private vulnerability reporting, owned identifiers, authorized CI,
|
||||||
|
human security review and owner-controlled signing before any distribution.
|
||||||
|
|
||||||
|
The provided GitHub Actions workflows do not automatically execute on the current
|
||||||
|
non-GitHub forge. Do not claim hosted CI or store readiness without actual runs.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Release process
|
||||||
|
|
||||||
|
No release is authorized by the existence of these scripts. No workflow publishes
|
||||||
|
to either store. A debug APK or unsigned archive is not a production release.
|
||||||
|
|
||||||
|
1. Complete every outstanding gate in `validation.md` and `security-review.md`.
|
||||||
|
Obtain independent human review of authentication, navigation, file handlers,
|
||||||
|
local erasure and privacy disclosures. Run actual supported device/OS tests.
|
||||||
|
2. Resolve bundle/application identifier ownership. `community.yuvomi.mobile` is
|
||||||
|
a provisional community identifier, not proof of registry/store ownership.
|
||||||
|
Confirm use of the Yuvomi name, required attribution and independent status.
|
||||||
|
3. Bump version/name/code in Android and the iOS project spec; update CHANGELOG.
|
||||||
|
4. Run JVM/emulator/real-upstream HTTPS tests, portable Swift and Xcode simulator
|
||||||
|
tests, static checks, OSV scan, release builds and artifact inspection. Record
|
||||||
|
upstream revision, app SHA, OS/WebView/Xcode versions, results and checksums.
|
||||||
|
5. Produce signed artifacts only using an authorized maintainer's credentials.
|
||||||
|
6. Review store privacy/Data Safety answers for the app AND selected-server model,
|
||||||
|
permission rationale, encryption/export compliance, screenshots, support URL,
|
||||||
|
vulnerability contact, and device demonstrations. A functional web companion
|
||||||
|
is not guaranteed to pass Apple's minimum-functionality review.
|
||||||
|
7. Create a release/tag and publish artifacts/store submissions manually only
|
||||||
|
after explicit maintainer authorization. Never force-push or rewrite history.
|
||||||
|
|
||||||
|
## Android
|
||||||
|
|
||||||
|
The manual `release.yml` workflow uses a protected GitHub environment named
|
||||||
|
`release`. Configure branch restrictions and required reviewers before adding
|
||||||
|
secrets. With `sign_android=false` it emits unsigned artifacts. Signing requires:
|
||||||
|
|
||||||
|
- `ANDROID_KEYSTORE_BASE64`
|
||||||
|
- `ANDROID_STORE_PASSWORD`
|
||||||
|
- `ANDROID_KEY_PASSWORD`
|
||||||
|
- `ANDROID_KEY_ALIAS`
|
||||||
|
|
||||||
|
The workflow uses `zipalign`, `apksigner`, and `jarsigner`; private material exists
|
||||||
|
only in a restrictive runner-temporary file removed on exit. Securely back up the
|
||||||
|
real upload key and use Play App Signing according to the owner's policy. No
|
||||||
|
production key is generated here. Manually verify APK/AAB signatures and inspect
|
||||||
|
the final manifest: no debuggable flag, HTTP/test CA or unintended exported component.
|
||||||
|
Never distribute an `.integration` package or include its generated test resources.
|
||||||
|
Run `python3 scripts/collect-android-artifacts.py` immediately after the clean-source
|
||||||
|
build to inspect unsigned outputs and record local hashes/source identity. This
|
||||||
|
collector intentionally rejects signed release outputs; collect before signing.
|
||||||
|
|
||||||
|
## iOS
|
||||||
|
|
||||||
|
The CI archive is unsigned and cannot be uploaded to App Store Connect. An owner
|
||||||
|
needs Apple Developer Program membership, a registered app ID, distribution
|
||||||
|
certificate/private key, provisioning profile and App Store Connect permissions.
|
||||||
|
|
||||||
|
Generate/open the project, choose the authorized team, set the owned bundle ID,
|
||||||
|
select a physical/generic iOS destination, and use Product -> Archive. Validate
|
||||||
|
the archive in Organizer and export using the owner's distribution method. For
|
||||||
|
CLI export use a **locally supplied** ExportOptions.plist with the correct team,
|
||||||
|
method and provisioning mappings:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
xcodebuild -exportArchive -archivePath artifacts/Yuvomi.xcarchive \
|
||||||
|
-exportPath artifacts/ios -exportOptionsPlist /secure/path/ExportOptions.plist
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not add distribution certificates, profiles, App Store keys or passwords to the
|
||||||
|
repository. Device camera, Files, VoiceOver, memory pressure and app-switcher
|
||||||
|
privacy need physical-device validation beyond simulator tests.
|
||||||
|
|
||||||
|
## CI hosting
|
||||||
|
|
||||||
|
GitHub Actions files are provided, but the configured community remote is hosted
|
||||||
|
on a different forge. GitHub workflows will not automatically run there. Enable
|
||||||
|
an authorized GitHub mirror/repository (or port the jobs to the forge's runner)
|
||||||
|
and inspect real runs before claiming CI is operational. No destination is
|
||||||
|
created automatically. CI service availability is not an application dependency.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Security review record
|
||||||
|
|
||||||
|
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,70 @@
|
|||||||
|
# Server compatibility and authentication
|
||||||
|
|
||||||
|
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,130 @@
|
|||||||
|
# Validation status
|
||||||
|
|
||||||
|
**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.
|
||||||
|
- 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).
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// swift-tools-version: 6.0
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
// Portable tests for the exact production URL policy. This is not an iOS build.
|
||||||
|
let package = Package(
|
||||||
|
name: "YuvomiPolicy",
|
||||||
|
products: [.library(name: "Yuvomi", targets: ["Yuvomi"])],
|
||||||
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "Yuvomi", path: "Sources",
|
||||||
|
exclude: [
|
||||||
|
"AuthenticationView.swift", "BrowserModel.swift", "ContentView.swift",
|
||||||
|
"ServerProbe.swift", "YuvomiApp.swift", "PrivacyInfo.xcprivacy", "Assets.xcassets",
|
||||||
|
], sources: ["ServerAddress.swift", "ServerMetadata.swift"]),
|
||||||
|
.testTarget(
|
||||||
|
name: "YuvomiPolicyTests", dependencies: ["Yuvomi"], path: "Tests", exclude: ["BrowserModelTests.swift"]),
|
||||||
|
]
|
||||||
|
)
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"filename": "AppIcon.png",
|
||||||
|
"idiom": "universal",
|
||||||
|
"platform": "ios",
|
||||||
|
"size": "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": {
|
||||||
|
"author": "Yuvomi Mobile contributors",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
struct AuthenticationView: View {
|
||||||
|
let destination: URL
|
||||||
|
@ObservedObject var model: BrowserModel
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
IsolatedAuthentication(url: destination, model: model)
|
||||||
|
.navigationTitle(model.authenticationHost)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar { Button("Close") { model.authentication = nil } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct IsolatedAuthentication: UIViewRepresentable {
|
||||||
|
let url: URL
|
||||||
|
let model: BrowserModel
|
||||||
|
func makeCoordinator() -> Coordinator { Coordinator(model) }
|
||||||
|
func makeUIView(context: Context) -> WKWebView {
|
||||||
|
let configuration = WKWebViewConfiguration()
|
||||||
|
// Same cookie store as the initiating Yuvomi handshake; no manual cookie export.
|
||||||
|
configuration.websiteDataStore = model.web.configuration.websiteDataStore
|
||||||
|
configuration.preferences.javaScriptCanOpenWindowsAutomatically = false
|
||||||
|
configuration.mediaTypesRequiringUserActionForPlayback = .all
|
||||||
|
let view = WKWebView(frame: .zero, configuration: configuration)
|
||||||
|
view.navigationDelegate = context.coordinator
|
||||||
|
view.uiDelegate = context.coordinator
|
||||||
|
if #available(iOS 16.4, *) { view.isInspectable = false }
|
||||||
|
view.load(URLRequest(url: url))
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
func updateUIView(_ uiView: WKWebView, context: Context) {}
|
||||||
|
static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) {
|
||||||
|
uiView.stopLoading()
|
||||||
|
uiView.navigationDelegate = nil
|
||||||
|
uiView.uiDelegate = nil
|
||||||
|
}
|
||||||
|
final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate {
|
||||||
|
let model: BrowserModel
|
||||||
|
init(_ model: BrowserModel) { self.model = model }
|
||||||
|
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||||
|
model.authenticationHost = webView.url?.host ?? "Sign in"
|
||||||
|
}
|
||||||
|
func webView(
|
||||||
|
_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
|
||||||
|
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
|
||||||
|
) {
|
||||||
|
guard let url = navigationAction.request.url, url.scheme == "https",
|
||||||
|
url.user == nil, url.password == nil
|
||||||
|
else {
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
decisionHandler(.allow)
|
||||||
|
}
|
||||||
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
|
if let url = webView.url, model.server?.owns(url) == true, !url.path.hasPrefix("/api/") {
|
||||||
|
model.authentication = nil
|
||||||
|
model.web.load(URLRequest(url: url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func webView(
|
||||||
|
_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
|
||||||
|
initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType,
|
||||||
|
decisionHandler: @escaping (WKPermissionDecision) -> Void
|
||||||
|
) { decisionHandler(.deny) }
|
||||||
|
func webView(
|
||||||
|
_ webView: WKWebView, runOpenPanelWith parameters: WKOpenPanelParameters,
|
||||||
|
initiatedByFrame frame: WKFrameInfo,
|
||||||
|
completionHandler: @escaping @MainActor @Sendable ([URL]?) -> Void
|
||||||
|
) {
|
||||||
|
completionHandler(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
struct WebDestination: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let url: URL
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUIDelegate, WKDownloadDelegate {
|
||||||
|
@Published var loading = false
|
||||||
|
@Published var canGoBack = false
|
||||||
|
@Published var error: String?
|
||||||
|
@Published var externalURL: URL?
|
||||||
|
@Published var authentication: WebDestination?
|
||||||
|
@Published var authenticationHost = "Sign in"
|
||||||
|
@Published var download: WebDestination?
|
||||||
|
private(set) var server: ServerAddress?
|
||||||
|
private(set) var web: WKWebView
|
||||||
|
private var observations: [NSKeyValueObservation] = []
|
||||||
|
private var deadline: Timer?
|
||||||
|
private var activeDownload: WKDownload?
|
||||||
|
private var downloadLocation: URL?
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
web = Self.makeWebView()
|
||||||
|
super.init()
|
||||||
|
configureWebView()
|
||||||
|
// Only app-owned temporary exports are removed, never user-saved documents.
|
||||||
|
let folder = Self.exportDirectory
|
||||||
|
try? FileManager.default.removeItem(at: folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeWebView() -> WKWebView {
|
||||||
|
let configuration = WKWebViewConfiguration()
|
||||||
|
configuration.websiteDataStore = .default()
|
||||||
|
configuration.preferences.javaScriptCanOpenWindowsAutomatically = false
|
||||||
|
configuration.allowsInlineMediaPlayback = true
|
||||||
|
configuration.mediaTypesRequiringUserActionForPlayback = .all
|
||||||
|
return WKWebView(frame: .zero, configuration: configuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureWebView() {
|
||||||
|
web.navigationDelegate = self
|
||||||
|
web.uiDelegate = self
|
||||||
|
web.allowsBackForwardNavigationGestures = true
|
||||||
|
web.isOpaque = false
|
||||||
|
if #available(iOS 16.4, *) { web.isInspectable = false }
|
||||||
|
observations.append(
|
||||||
|
web.observe(\.canGoBack, options: [.new]) { [weak self] view, _ in
|
||||||
|
self?.canGoBack = view.canGoBack
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static var exportDirectory: URL {
|
||||||
|
FileManager.default.temporaryDirectory.appendingPathComponent("YuvomiExports", isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func open(_ server: ServerAddress) {
|
||||||
|
self.server = server
|
||||||
|
let path = UserDefaults.standard.string(forKey: "path") ?? "/"
|
||||||
|
let restored = URL(string: path, relativeTo: server.url)?.absoluteURL
|
||||||
|
let url = restored.flatMap { server.owns($0) ? $0 : nil } ?? server.url
|
||||||
|
web.load(URLRequest(url: url))
|
||||||
|
}
|
||||||
|
|
||||||
|
func rememberPath() {
|
||||||
|
guard let url = web.url, server?.owns(url) == true else { return }
|
||||||
|
let path = url.path
|
||||||
|
guard !["auth", "reset", "invite", "login"].contains(where: { path.localizedCaseInsensitiveContains($0) })
|
||||||
|
else { return }
|
||||||
|
UserDefaults.standard.set(path, forKey: "path")
|
||||||
|
}
|
||||||
|
|
||||||
|
func erase(completion: @escaping () -> Void) {
|
||||||
|
deadline?.invalidate()
|
||||||
|
web.stopLoading()
|
||||||
|
activeDownload?.cancel { _ in }
|
||||||
|
activeDownload = nil
|
||||||
|
authentication = nil
|
||||||
|
removeDownload()
|
||||||
|
server = nil
|
||||||
|
web.navigationDelegate = nil
|
||||||
|
web.uiDelegate = nil
|
||||||
|
observations.removeAll()
|
||||||
|
WKWebsiteDataStore.default().removeData(
|
||||||
|
ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(),
|
||||||
|
modifiedSince: .distantPast
|
||||||
|
) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.web = Self.makeWebView()
|
||||||
|
self.configureWebView()
|
||||||
|
self.canGoBack = false
|
||||||
|
self.loading = false
|
||||||
|
self.error = nil
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startAuthentication(_ url: URL) {
|
||||||
|
guard url.scheme == "https", url.user == nil, url.password == nil else { return }
|
||||||
|
authenticationHost = url.host ?? "Sign in"
|
||||||
|
authentication = WebDestination(url: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeDownload(expected: URL? = nil) {
|
||||||
|
if let expected, download?.url != expected, downloadLocation != expected { return }
|
||||||
|
download = nil
|
||||||
|
if let location = downloadLocation {
|
||||||
|
try? FileManager.default.removeItem(at: location.deletingLastPathComponent())
|
||||||
|
}
|
||||||
|
downloadLocation = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(
|
||||||
|
_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
|
||||||
|
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
|
||||||
|
) {
|
||||||
|
guard let url = navigationAction.request.url else {
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if server?.owns(url) == true || (navigationAction.shouldPerformDownload && server?.ownsDownload(url) == true) {
|
||||||
|
decisionHandler(navigationAction.shouldPerformDownload ? .download : .allow)
|
||||||
|
} else {
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
if navigationAction.targetFrame?.isMainFrame != false {
|
||||||
|
if url.scheme == "https", url.host != nil, url.user == nil, url.password == nil {
|
||||||
|
externalURL = url
|
||||||
|
} else {
|
||||||
|
error = "This link type is blocked for your security."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(
|
||||||
|
_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse,
|
||||||
|
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void
|
||||||
|
) {
|
||||||
|
if let response = navigationResponse.response as? HTTPURLResponse,
|
||||||
|
navigationResponse.isForMainFrame, response.statusCode >= 400
|
||||||
|
{
|
||||||
|
error = "The server is unavailable (HTTP \(response.statusCode))."
|
||||||
|
}
|
||||||
|
guard let url = navigationResponse.response.url, server?.ownsDownload(url) == true else {
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !navigationResponse.canShowMIMEType {
|
||||||
|
guard navigationResponse.response.expectedContentLength <= 100 * 1024 * 1024 else {
|
||||||
|
error = "This file exceeds the 100 MB download limit."
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
decisionHandler(.download)
|
||||||
|
} else {
|
||||||
|
decisionHandler(.allow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||||
|
loading = true
|
||||||
|
deadline?.invalidate()
|
||||||
|
deadline = Timer.scheduledTimer(withTimeInterval: 30, repeats: false) { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.loading = false
|
||||||
|
self?.error = "The server is taking too long. Check your connection or try again."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
|
loading = false
|
||||||
|
deadline?.invalidate()
|
||||||
|
rememberPath()
|
||||||
|
}
|
||||||
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { failed(error) }
|
||||||
|
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||||
|
failed(error)
|
||||||
|
}
|
||||||
|
private func failed(_ failure: Error) {
|
||||||
|
loading = false
|
||||||
|
deadline?.invalidate()
|
||||||
|
guard (failure as NSError).code != NSURLErrorCancelled else { return }
|
||||||
|
error =
|
||||||
|
"Unable to connect securely. Check your network, VPN and server certificate. Cached pages may still be available."
|
||||||
|
}
|
||||||
|
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||||
|
error = "Yuvomi's browser was interrupted. Tap Retry to restore the page."
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
func webView(
|
||||||
|
_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
|
||||||
|
for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures
|
||||||
|
) -> WKWebView? {
|
||||||
|
if let url = navigationAction.request.url {
|
||||||
|
if server?.owns(url) == true {
|
||||||
|
web.load(navigationAction.request)
|
||||||
|
} else if url.scheme == "https", url.user == nil, url.password == nil {
|
||||||
|
externalURL = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func webView(
|
||||||
|
_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
|
||||||
|
initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType,
|
||||||
|
decisionHandler: @escaping (WKPermissionDecision) -> Void
|
||||||
|
) {
|
||||||
|
// File input capture uses Apple's picker. Live camera/microphone access is not required by upstream.
|
||||||
|
decisionHandler(.deny)
|
||||||
|
}
|
||||||
|
func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
|
||||||
|
attach(download)
|
||||||
|
}
|
||||||
|
func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) {
|
||||||
|
attach(download)
|
||||||
|
}
|
||||||
|
private func attach(_ download: WKDownload) {
|
||||||
|
guard activeDownload == nil, self.download == nil else {
|
||||||
|
download.cancel { _ in }
|
||||||
|
error = "Finish the current download before starting another."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeDownload = download
|
||||||
|
download.delegate = self
|
||||||
|
}
|
||||||
|
func download(
|
||||||
|
_ download: WKDownload, decideDestinationUsing response: URLResponse,
|
||||||
|
suggestedFilename: String, completionHandler: @escaping (URL?) -> Void
|
||||||
|
) {
|
||||||
|
guard let url = response.url, server?.ownsDownload(url) == true,
|
||||||
|
response.expectedContentLength <= 100 * 1024 * 1024
|
||||||
|
else {
|
||||||
|
completionHandler(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let folder = Self.exportDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: folder, withIntermediateDirectories: true,
|
||||||
|
attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication])
|
||||||
|
var values = URLResourceValues()
|
||||||
|
values.isExcludedFromBackup = true
|
||||||
|
var directory = folder
|
||||||
|
try directory.setResourceValues(values)
|
||||||
|
let name = String(
|
||||||
|
suggestedFilename.map { $0.isLetter || $0.isNumber || "._ -".contains($0) ? $0 : "_" }.prefix(120))
|
||||||
|
let safeName = name.isEmpty || name == "." || name == ".." ? "download" : name
|
||||||
|
downloadLocation = folder.appendingPathComponent(safeName)
|
||||||
|
completionHandler(downloadLocation)
|
||||||
|
} catch {
|
||||||
|
completionHandler(nil)
|
||||||
|
self.error = "Could not prepare the download."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func download(
|
||||||
|
_ download: WKDownload, willPerformHTTPRedirection response: HTTPURLResponse,
|
||||||
|
newRequest request: URLRequest, decisionHandler: @escaping (WKDownload.RedirectPolicy) -> Void
|
||||||
|
) {
|
||||||
|
// Do not replay session credentials to a redirect target.
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
}
|
||||||
|
func downloadDidFinish(_ download: WKDownload) {
|
||||||
|
guard activeDownload === download else { return }
|
||||||
|
activeDownload = nil
|
||||||
|
guard let location = downloadLocation,
|
||||||
|
let size = try? location.resourceValues(forKeys: [.fileSizeKey]).fileSize,
|
||||||
|
size <= 100 * 1024 * 1024
|
||||||
|
else {
|
||||||
|
removeDownload()
|
||||||
|
error = "Download exceeded the size limit."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.download = WebDestination(url: location)
|
||||||
|
}
|
||||||
|
func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) {
|
||||||
|
guard activeDownload === download else { return }
|
||||||
|
activeDownload = nil
|
||||||
|
removeDownload()
|
||||||
|
self.error = "Download failed. Check your connection and sign-in, then retry."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BrowserView: UIViewRepresentable {
|
||||||
|
@ObservedObject var model: BrowserModel
|
||||||
|
func makeUIView(context: Context) -> WKWebView { model.web }
|
||||||
|
func updateUIView(_ uiView: WKWebView, context: Context) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExportView: UIViewControllerRepresentable {
|
||||||
|
let url: URL
|
||||||
|
let completion: () -> Void
|
||||||
|
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
||||||
|
let picker = UIDocumentPickerViewController(forExporting: [url], asCopy: true)
|
||||||
|
picker.delegate = context.coordinator
|
||||||
|
return picker
|
||||||
|
}
|
||||||
|
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
|
||||||
|
func makeCoordinator() -> Coordinator { Coordinator(completion) }
|
||||||
|
final class Coordinator: NSObject, UIDocumentPickerDelegate {
|
||||||
|
let completion: () -> Void
|
||||||
|
init(_ completion: @escaping () -> Void) { self.completion = completion }
|
||||||
|
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { completion() }
|
||||||
|
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@StateObject private var browser = BrowserModel()
|
||||||
|
@State private var input = UserDefaults.standard.string(forKey: "origin") ?? ""
|
||||||
|
@State private var server: ServerAddress?
|
||||||
|
@State private var connecting = false
|
||||||
|
@State private var message: String?
|
||||||
|
@State private var settings = false
|
||||||
|
@State private var erasing = false
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if server != nil {
|
||||||
|
ZStack {
|
||||||
|
BrowserView(model: browser)
|
||||||
|
if let error = browser.error {
|
||||||
|
VStack(spacing: 18) {
|
||||||
|
Image(systemName: "wifi.exclamationmark").font(.largeTitle)
|
||||||
|
Text("Connection interrupted").font(.title2).bold()
|
||||||
|
Text(error)
|
||||||
|
Button("Retry") {
|
||||||
|
browser.error = nil
|
||||||
|
browser.web.reload()
|
||||||
|
}
|
||||||
|
Button("Keep viewing") { browser.error = nil }
|
||||||
|
Button("Server settings") { settings = true }
|
||||||
|
}
|
||||||
|
.padding(24).frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color(uiColor: .systemGroupedBackground))
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
}
|
||||||
|
if browser.loading {
|
||||||
|
ProgressView("Loading Yuvomi").padding().background(.regularMaterial).cornerRadius(12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button {
|
||||||
|
browser.web.goBack()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.backward")
|
||||||
|
}
|
||||||
|
.disabled(!browser.canGoBack).accessibilityLabel("Back")
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Server settings") { settings = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
Image(systemName: "house.fill").font(.system(size: 48)).foregroundStyle(.tint)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
Text("Your home, connected").font(.largeTitle).bold()
|
||||||
|
Text("Connect to your own Yuvomi server. Your data stays with your household.")
|
||||||
|
TextField("https://family.example.com", text: $input)
|
||||||
|
.keyboardType(.URL).textContentType(.URL)
|
||||||
|
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||||
|
.textFieldStyle(.roundedBorder).accessibilityLabel("Yuvomi server URL")
|
||||||
|
Text("HTTPS required. Custom ports supported. Use a dedicated hostname, not a subpath.")
|
||||||
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
|
if let message { Text(message).accessibilityIdentifier("connectionError") }
|
||||||
|
if connecting { ProgressView("Checking your server") }
|
||||||
|
Button("Connect securely") { connect() }
|
||||||
|
.buttonStyle(.borderedProminent).disabled(connecting || erasing)
|
||||||
|
.frame(minHeight: 48)
|
||||||
|
Text("Independent community app. No telemetry or mandatory cloud service.")
|
||||||
|
.font(.footnote).foregroundStyle(.secondary)
|
||||||
|
}.padding(24)
|
||||||
|
}.background(Color(uiColor: .systemGroupedBackground))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Yuvomi Mobile").navigationBarTitleDisplayMode(.inline)
|
||||||
|
.tint(
|
||||||
|
colorScheme == .dark
|
||||||
|
? Color(red: 167 / 255, green: 139 / 255, blue: 250 / 255)
|
||||||
|
: Color(red: 108 / 255, green: 58 / 255, blue: 237 / 255))
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
if server == nil, let existing = try? ServerAddress(input) {
|
||||||
|
server = existing
|
||||||
|
browser.open(existing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: scenePhase) { _, phase in
|
||||||
|
if phase == .background { browser.rememberPath() }
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
if scenePhase != .active {
|
||||||
|
Color(uiColor: .systemBackground).ignoresSafeArea()
|
||||||
|
.overlay(Text("Yuvomi Mobile").font(.title).bold())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $settings) {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Your server") {
|
||||||
|
Text(server?.url.absoluteString ?? "")
|
||||||
|
if let url = server?.url {
|
||||||
|
ShareLink("Share server address", item: url)
|
||||||
|
Link("Open server in system browser", destination: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Text(
|
||||||
|
"Changing servers erases cookies, storage and cached household data. Exported files remain. Sign out in Yuvomi first to revoke the server session."
|
||||||
|
)
|
||||||
|
Button("Erase local data and change server", role: .destructive) {
|
||||||
|
settings = false
|
||||||
|
erasing = true
|
||||||
|
browser.erase {
|
||||||
|
UserDefaults.standard.removeObject(forKey: "origin")
|
||||||
|
UserDefaults.standard.removeObject(forKey: "path")
|
||||||
|
server = nil
|
||||||
|
input = ""
|
||||||
|
erasing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section("About") {
|
||||||
|
Text(
|
||||||
|
"Independent community contribution. MIT licensed. Not endorsed by upstream Yuvomi. No mobile telemetry. Your server's own privacy settings still apply."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.navigationTitle("Server settings")
|
||||||
|
.toolbar { Button("Done") { settings = false } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert(
|
||||||
|
"External destination",
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { browser.externalURL != nil }, set: { if !$0 { browser.externalURL = nil } }
|
||||||
|
), presenting: browser.externalURL
|
||||||
|
) { url in
|
||||||
|
Button("Open in browser") {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
browser.externalURL = nil
|
||||||
|
}
|
||||||
|
Button("Isolated sign-in window") {
|
||||||
|
browser.startAuthentication(url)
|
||||||
|
browser.externalURL = nil
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) { browser.externalURL = nil }
|
||||||
|
} message: { url in
|
||||||
|
Text(
|
||||||
|
"Destination: \(url.host ?? "")\nFor SSO, use the isolated window only if you trust this provider. Browser sessions do not transfer into the app."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sheet(item: $browser.authentication) { destination in
|
||||||
|
AuthenticationView(destination: destination.url, model: browser)
|
||||||
|
}
|
||||||
|
.sheet(item: $browser.download) { file in
|
||||||
|
ExportView(url: file.url) { browser.removeDownload(expected: file.url) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connect() {
|
||||||
|
do {
|
||||||
|
let candidate = try ServerAddress(input)
|
||||||
|
connecting = true
|
||||||
|
message = nil
|
||||||
|
Task { @MainActor in
|
||||||
|
defer { connecting = false }
|
||||||
|
do {
|
||||||
|
try await ServerProbe.check(candidate)
|
||||||
|
UserDefaults.standard.set(candidate.url.absoluteString, forKey: "origin")
|
||||||
|
server = candidate
|
||||||
|
browser.open(candidate)
|
||||||
|
} catch { message = ConnectionError.unavailable.localizedDescription }
|
||||||
|
}
|
||||||
|
} catch { message = ConnectionError.invalidAddress.localizedDescription }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0"><dict>
|
||||||
|
<key>NSPrivacyTracking</key><false/>
|
||||||
|
<key>NSPrivacyTrackingDomains</key><array/>
|
||||||
|
<key>NSPrivacyCollectedDataTypes</key><array/>
|
||||||
|
<key>NSPrivacyAccessedAPITypes</key><array>
|
||||||
|
<dict><key>NSPrivacyAccessedAPIType</key><string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key><array><string>CA92.1</string></array></dict>
|
||||||
|
</array>
|
||||||
|
</dict></plist>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ServerAddress: Equatable {
|
||||||
|
let url: URL
|
||||||
|
|
||||||
|
init(_ input: String) throws {
|
||||||
|
let value = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !value.contains("\\"), !value.contains(where: { $0.isWhitespace }),
|
||||||
|
var parts = URLComponents(string: value),
|
||||||
|
parts.scheme?.lowercased() == "https",
|
||||||
|
let host = parts.host, !host.isEmpty,
|
||||||
|
parts.user == nil, parts.password == nil,
|
||||||
|
parts.query == nil, parts.fragment == nil,
|
||||||
|
parts.percentEncodedPath.isEmpty || parts.percentEncodedPath == "/",
|
||||||
|
parts.port == nil || (1...65535).contains(parts.port!)
|
||||||
|
else {
|
||||||
|
throw ConnectionError.invalidAddress
|
||||||
|
}
|
||||||
|
parts.scheme = "https"
|
||||||
|
parts.host = host.lowercased()
|
||||||
|
parts.path = ""
|
||||||
|
if parts.port == 443 { parts.port = nil }
|
||||||
|
guard let url = parts.url else { throw ConnectionError.invalidAddress }
|
||||||
|
self.url = url
|
||||||
|
}
|
||||||
|
|
||||||
|
func owns(_ candidate: URL) -> Bool {
|
||||||
|
guard let parts = URLComponents(url: candidate, resolvingAgainstBaseURL: false),
|
||||||
|
let own = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||||
|
else { return false }
|
||||||
|
return parts.scheme?.lowercased() == "https" && parts.user == nil && parts.password == nil
|
||||||
|
&& parts.host?.lowercased() == own.host?.lowercased()
|
||||||
|
&& (parts.port ?? 443) == (own.port ?? 443)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ownsDownload(_ candidate: URL) -> Bool {
|
||||||
|
if owns(candidate) { return true }
|
||||||
|
guard candidate.scheme == "blob",
|
||||||
|
let inner = URL(string: String(candidate.absoluteString.dropFirst(5)))
|
||||||
|
else { return false }
|
||||||
|
return owns(inner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ConnectionError: LocalizedError {
|
||||||
|
case invalidAddress, incompatible, unavailable
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidAddress:
|
||||||
|
return "Use a root HTTPS URL with a valid host and port. Remove credentials, subpaths, query and fragment."
|
||||||
|
case .incompatible:
|
||||||
|
return "This server does not expose compatible Yuvomi metadata."
|
||||||
|
case .unavailable:
|
||||||
|
return "Cannot verify the server. Check its certificate, VPN, network and reverse proxy."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum ServerMetadata {
|
||||||
|
private struct Bootstrap: Decodable {
|
||||||
|
let appName: String
|
||||||
|
let setupRequired: Bool
|
||||||
|
let passwordResetEnabled: Bool
|
||||||
|
}
|
||||||
|
private struct Authentication: Decodable {
|
||||||
|
let enabled: Bool
|
||||||
|
let passwordLoginEnabled: Bool
|
||||||
|
}
|
||||||
|
static func validate(bootstrap: Data, authentication: Data) throws {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||||
|
_ = try decoder.decode(Bootstrap.self, from: bootstrap)
|
||||||
|
_ = try decoder.decode(Authentication.self, from: authentication)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class NoRedirect: NSObject, URLSessionTaskDelegate {
|
||||||
|
func urlSession(
|
||||||
|
_ session: URLSession, task: URLSessionTask,
|
||||||
|
willPerformHTTPRedirection response: HTTPURLResponse,
|
||||||
|
newRequest request: URLRequest,
|
||||||
|
completionHandler: @escaping (URLRequest?) -> Void
|
||||||
|
) {
|
||||||
|
completionHandler(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ServerProbe {
|
||||||
|
static func check(_ server: ServerAddress) async throws {
|
||||||
|
let configuration = URLSessionConfiguration.ephemeral
|
||||||
|
configuration.httpCookieStorage = nil
|
||||||
|
configuration.urlCache = nil
|
||||||
|
configuration.timeoutIntervalForRequest = 10
|
||||||
|
configuration.timeoutIntervalForResource = 20
|
||||||
|
let session = URLSession(configuration: configuration, delegate: NoRedirect(), delegateQueue: nil)
|
||||||
|
defer { session.invalidateAndCancel() }
|
||||||
|
let version = try await json(session, server.url.appendingPathComponent("api/v1/version"))
|
||||||
|
let auth = try await json(session, server.url.appendingPathComponent("api/v1/auth/oidc/config"))
|
||||||
|
try ServerMetadata.validate(bootstrap: version, authentication: auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func json(_ session: URLSession, _ url: URL) async throws -> Data {
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
let (stream, response) = try await session.bytes(for: request)
|
||||||
|
guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw ConnectionError.unavailable }
|
||||||
|
var data = Data()
|
||||||
|
for try await byte in stream {
|
||||||
|
guard data.count < 65_536 else { throw ConnectionError.incompatible }
|
||||||
|
data.append(byte)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct YuvomiApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup { ContentView() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import WebKit
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import Yuvomi
|
||||||
|
|
||||||
|
final class BrowserModelTests: XCTestCase {
|
||||||
|
@MainActor
|
||||||
|
func testMainViewHasNoNativeBridgeOrAutomaticPopupAccess() {
|
||||||
|
let model = BrowserModel()
|
||||||
|
XCTAssertFalse(model.web.configuration.preferences.javaScriptCanOpenWindowsAutomatically)
|
||||||
|
XCTAssertTrue(model.web.configuration.userContentController.userScripts.isEmpty)
|
||||||
|
XCTAssertTrue(model.web.configuration.websiteDataStore.isPersistent)
|
||||||
|
XCTAssertFalse(model.web.isInspectable)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testLateExportCancellationDoesNotEraseAnotherServersDownload() {
|
||||||
|
let model = BrowserModel()
|
||||||
|
let previous = URL(fileURLWithPath: "/old-selection.txt")
|
||||||
|
let current = URL(fileURLWithPath: "/new-selection.txt")
|
||||||
|
model.download = WebDestination(url: current)
|
||||||
|
model.removeDownload(expected: previous)
|
||||||
|
XCTAssertEqual(model.download?.url, current)
|
||||||
|
model.removeDownload(expected: current)
|
||||||
|
XCTAssertNil(model.download)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testServerErasureReplacesHistoryContext() async {
|
||||||
|
let model = BrowserModel()
|
||||||
|
let previousView = model.web
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
model.erase { continuation.resume() }
|
||||||
|
}
|
||||||
|
XCTAssertFalse(previousView === model.web)
|
||||||
|
XCTAssertNil(model.server)
|
||||||
|
XCTAssertFalse(model.canGoBack)
|
||||||
|
XCTAssertNil(model.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import Yuvomi
|
||||||
|
|
||||||
|
final class ServerAddressTests: XCTestCase {
|
||||||
|
func testCanonicalizationAndPorts() throws {
|
||||||
|
XCTAssertEqual(try ServerAddress(" HTTPS://Family.Example:443/ ").url.absoluteString, "https://family.example")
|
||||||
|
XCTAssertEqual(try ServerAddress("https://family.example:8443").url.port, 8443)
|
||||||
|
XCTAssertEqual(try ServerAddress("https://[::1]:8443").url.port, 8443)
|
||||||
|
}
|
||||||
|
func testUnsafeConfigurations() {
|
||||||
|
for value in [
|
||||||
|
"http://family.example", "javascript:alert(1)", "https://u:p@family.example",
|
||||||
|
"https://family.example/subpath", "https://family.example?token=secret",
|
||||||
|
"https://family.example/#token", "https://family.example:0",
|
||||||
|
"https://family.example:65536", "https://family.example/%2f",
|
||||||
|
"https://family.example\\@evil.test", "https:///", "",
|
||||||
|
] {
|
||||||
|
XCTAssertThrowsError(try ServerAddress(value), value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func testOriginIsolation() throws {
|
||||||
|
let server = try ServerAddress("https://family.example")
|
||||||
|
XCTAssertTrue(server.owns(URL(string: "https://FAMILY.example:443/tasks?id=1")!))
|
||||||
|
for value in [
|
||||||
|
"https://family.example.evil.test", "https://family.example@evil.test",
|
||||||
|
"https://user@family.example", "http://family.example", "https://family.example:8443",
|
||||||
|
"file:///etc/passwd", "data:text/html,hi",
|
||||||
|
] {
|
||||||
|
XCTAssertFalse(server.owns(URL(string: value)!), value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func testBlobExportsAreOriginBound() throws {
|
||||||
|
let server = try ServerAddress("https://family.example")
|
||||||
|
XCTAssertTrue(server.ownsDownload(URL(string: "blob:https://family.example/unique-id")!))
|
||||||
|
XCTAssertFalse(server.ownsDownload(URL(string: "blob:https://evil.example/unique-id")!))
|
||||||
|
XCTAssertFalse(server.ownsDownload(URL(string: "blob:http://family.example/unique-id")!))
|
||||||
|
XCTAssertFalse(server.ownsDownload(URL(string: "file:///tmp/export")!))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import Yuvomi
|
||||||
|
|
||||||
|
final class ServerMetadataTests: XCTestCase {
|
||||||
|
private let bootstrap = Data(#"{"app_name":"Yuvomi","setup_required":false,"password_reset_enabled":true}"#.utf8)
|
||||||
|
private let authentication = Data(#"{"enabled":false,"password_login_enabled":true}"#.utf8)
|
||||||
|
func testAcceptsActualAnonymousMetadataWithoutVersion() throws {
|
||||||
|
try ServerMetadata.validate(bootstrap: bootstrap, authentication: authentication)
|
||||||
|
}
|
||||||
|
func testRejectsNumericBooleanAndMissingAuthenticationMetadata() {
|
||||||
|
let numeric = Data(#"{"enabled":1,"password_login_enabled":true}"#.utf8)
|
||||||
|
XCTAssertThrowsError(try ServerMetadata.validate(bootstrap: bootstrap, authentication: numeric))
|
||||||
|
XCTAssertThrowsError(try ServerMetadata.validate(bootstrap: bootstrap, authentication: Data("{}".utf8)))
|
||||||
|
}
|
||||||
|
func testRejectsProxyHtmlAndInvalidJson() {
|
||||||
|
XCTAssertThrowsError(
|
||||||
|
try ServerMetadata.validate(bootstrap: Data("<html>SSO proxy</html>".utf8), authentication: authentication))
|
||||||
|
XCTAssertThrowsError(try ServerMetadata.validate(bootstrap: Data("null".utf8), authentication: authentication))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class OnboardingTests: XCTestCase {
|
||||||
|
func testRejectsCleartextWithoutNetwork() {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launch()
|
||||||
|
let field = app.textFields["Yuvomi server URL"]
|
||||||
|
XCTAssertTrue(field.waitForExistence(timeout: 5))
|
||||||
|
field.tap()
|
||||||
|
field.typeText("http://family.example")
|
||||||
|
app.buttons["Connect securely"].tap()
|
||||||
|
XCTAssertTrue(app.staticTexts["connectionError"].waitForExistence(timeout: 3))
|
||||||
|
XCTAssertTrue(field.exists)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
name: Yuvomi
|
||||||
|
options:
|
||||||
|
bundleIdPrefix: community.yuvomi
|
||||||
|
deploymentTarget:
|
||||||
|
iOS: "18.4"
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
SWIFT_VERSION: "5.0"
|
||||||
|
MARKETING_VERSION: "0.1.0"
|
||||||
|
CURRENT_PROJECT_VERSION: 1
|
||||||
|
CODE_SIGN_STYLE: Automatic
|
||||||
|
DEVELOPMENT_TEAM: ""
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING: YES
|
||||||
|
SWIFT_TREAT_WARNINGS_AS_ERRORS: YES
|
||||||
|
GCC_TREAT_WARNINGS_AS_ERRORS: YES
|
||||||
|
targets:
|
||||||
|
Yuvomi:
|
||||||
|
type: application
|
||||||
|
platform: iOS
|
||||||
|
sources: [Sources]
|
||||||
|
info:
|
||||||
|
path: Info.plist
|
||||||
|
properties:
|
||||||
|
CFBundleDisplayName: Yuvomi Mobile
|
||||||
|
UILaunchScreen: {}
|
||||||
|
NSCameraUsageDescription: Take a photo to upload to your Yuvomi server.
|
||||||
|
NSPhotoLibraryUsageDescription: Choose photos to upload to your Yuvomi server.
|
||||||
|
NSLocalNetworkUsageDescription: Connect to the private Yuvomi server you configure.
|
||||||
|
ITSAppUsesNonExemptEncryption: false
|
||||||
|
UISupportedInterfaceOrientations:
|
||||||
|
- UIInterfaceOrientationPortrait
|
||||||
|
- UIInterfaceOrientationLandscapeLeft
|
||||||
|
- UIInterfaceOrientationLandscapeRight
|
||||||
|
UISupportedInterfaceOrientations~ipad:
|
||||||
|
- UIInterfaceOrientationPortrait
|
||||||
|
- UIInterfaceOrientationPortraitUpsideDown
|
||||||
|
- UIInterfaceOrientationLandscapeLeft
|
||||||
|
- UIInterfaceOrientationLandscapeRight
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: community.yuvomi.mobile
|
||||||
|
TARGETED_DEVICE_FAMILY: "1,2"
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
|
dependencies:
|
||||||
|
- sdk: WebKit.framework
|
||||||
|
YuvomiTests:
|
||||||
|
type: bundle.unit-test
|
||||||
|
platform: iOS
|
||||||
|
sources: [Tests]
|
||||||
|
dependencies:
|
||||||
|
- target: Yuvomi
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
YuvomiUITests:
|
||||||
|
type: bundle.ui-testing
|
||||||
|
platform: iOS
|
||||||
|
sources: [UITests]
|
||||||
|
dependencies:
|
||||||
|
- target: Yuvomi
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
schemes:
|
||||||
|
Yuvomi:
|
||||||
|
build:
|
||||||
|
targets:
|
||||||
|
Yuvomi: all
|
||||||
|
test:
|
||||||
|
targets: [YuvomiTests, YuvomiUITests]
|
||||||
|
gatherCoverageData: true
|
||||||
|
archive:
|
||||||
|
config: Release
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fail closed on OSV findings or unavailable advisory service. No app runtime use."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
coordinates = set()
|
||||||
|
lockfile = ROOT / "android/app/gradle.lockfile"
|
||||||
|
assert lockfile.is_file(), "Generate the reviewed Gradle dependency lockfile before scanning."
|
||||||
|
for source in [ROOT / "android/app/build.gradle.kts", ROOT / "android/build.gradle.kts", lockfile,
|
||||||
|
ROOT / "android/buildscript-gradle.lockfile"]:
|
||||||
|
if source.exists():
|
||||||
|
coordinates.update(re.findall(r'([\w.-]+:[\w.-]+:[\w.+-]+)(?:["=])', source.read_text()))
|
||||||
|
agp = re.search(r'id\("com.android.application"\) version "([^"]+)"', (ROOT / "android/build.gradle.kts").read_text())
|
||||||
|
assert agp, "Cannot determine Android build plugin version"
|
||||||
|
coordinates.add("com.android.tools.build:gradle:" + agp.group(1))
|
||||||
|
queries = [{"package": {"ecosystem": "Maven", "name": coordinate.rsplit(":", 1)[0]},
|
||||||
|
"version": coordinate.rsplit(":", 1)[1]} for coordinate in sorted(coordinates)]
|
||||||
|
request = urllib.request.Request("https://api.osv.dev/v1/querybatch", method="POST",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
data=json.dumps({"queries": queries}).encode())
|
||||||
|
with urllib.request.urlopen(request, timeout=60) as response:
|
||||||
|
results = json.load(response)["results"]
|
||||||
|
assert len(results) == len(queries)
|
||||||
|
findings = [(coordinate, [item["id"] for item in result.get("vulns", [])])
|
||||||
|
for coordinate, result in zip(sorted(coordinates), results) if result.get("vulns")]
|
||||||
|
if findings:
|
||||||
|
for coordinate, identifiers in findings:
|
||||||
|
print(coordinate, ", ".join(identifiers))
|
||||||
|
raise SystemExit("Dependency advisories require review and remediation before release.")
|
||||||
|
print(f"OSV: no listed advisories for {len(coordinates)} locked/direct Maven coordinates and build plugins.")
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
directory="${TMPDIR:-/tmp}/yuvomi-format-tools"
|
||||||
|
mkdir -p "$directory"
|
||||||
|
jar="$directory/ktlint-1.7.1.jar"
|
||||||
|
if [ ! -f "$jar" ]; then
|
||||||
|
curl --fail --location https://repo.maven.apache.org/maven2/com/pinterest/ktlint/ktlint-cli/1.7.1/ktlint-cli-1.7.1-all.jar -o "$jar"
|
||||||
|
fi
|
||||||
|
printf '%s %s\n' 5f3de4ee4014137b05c72ed68eb46fe686aeb11fbdef27faf77f55d51c1b2536 "$jar" | sha256sum -c -
|
||||||
|
java -jar "$jar" 'android/**/*.kt' 'android/**/*.kts'
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Static release invariants; these checks do not replace platform/runtime tests."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import plistlib
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
required = ["README.md", "LICENSE", "THIRD_PARTY_NOTICES.md", "CONTRIBUTING.md", "SECURITY.md",
|
||||||
|
"CHANGELOG.md", "docs/architecture.md", "docs/build-android.md", "docs/build-ios.md",
|
||||||
|
"docs/server-compatibility.md", "docs/release-process.md", "docs/validation.md"]
|
||||||
|
for name in required:
|
||||||
|
assert (ROOT / name).is_file(), f"Missing {name}"
|
||||||
|
|
||||||
|
paths = subprocess.check_output(["git", "ls-files", "--cached", "--others", "--exclude-standard"], cwd=ROOT, text=True).splitlines()
|
||||||
|
for name in paths:
|
||||||
|
path = ROOT / name
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
assert not any(part in {"node_modules", "build", ".gradle", "DerivedData"} for part in path.relative_to(ROOT).parts), name
|
||||||
|
assert path.suffix not in {".jks", ".keystore", ".p12", ".mobileprovision", ".apk", ".aab"}, name
|
||||||
|
if path.suffix == ".xml":
|
||||||
|
ET.parse(path)
|
||||||
|
if path.suffix == ".xcprivacy":
|
||||||
|
with path.open("rb") as source:
|
||||||
|
assert plistlib.load(source)["NSPrivacyTracking"] is False
|
||||||
|
if path.suffix == ".json":
|
||||||
|
json.loads(path.read_text())
|
||||||
|
if path.suffix in {".swift", ".kt", ".kts", ".py", ".md", ".yml", ".xml", ".properties", ".mjs", ".sh"}:
|
||||||
|
text = path.read_text()
|
||||||
|
assert not re.search(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----", text), f"Private key in {name}"
|
||||||
|
if path.suffix == ".md":
|
||||||
|
for target in re.findall(r"\]\(([^)]+)\)", text):
|
||||||
|
if ":" not in target and not target.startswith("#"):
|
||||||
|
assert (path.parent / target.split("#")[0]).exists(), f"Broken link in {name}: {target}"
|
||||||
|
|
||||||
|
android = "{http://schemas.android.com/apk/res/android}"
|
||||||
|
manifest = ET.parse(ROOT / "android/app/src/main/AndroidManifest.xml").getroot()
|
||||||
|
app = manifest.find("application")
|
||||||
|
assert app.get(android + "usesCleartextTraffic") == "false"
|
||||||
|
assert app.get(android + "allowBackup") == "false"
|
||||||
|
assert app.get(android + "debuggable") != "true"
|
||||||
|
assert len(manifest.findall("application/activity/intent-filter")) == 1, "Unreviewed deep-link entry point"
|
||||||
|
network = ET.parse(ROOT / "android/app/src/main/res/xml/network_security_config.xml").getroot()
|
||||||
|
assert network.find("base-config").get("cleartextTrafficPermitted") == "false"
|
||||||
|
assert [node.get("src") for node in network.findall(".//certificates")] == ["system"]
|
||||||
|
assert "distributionSha256Sum=" in (ROOT / "android/gradle/wrapper/gradle-wrapper.properties").read_text()
|
||||||
|
for workflow in (ROOT / ".github/workflows").glob("*.yml"):
|
||||||
|
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 "NSAllowsArbitraryLoads" not in (ROOT / "ios/project.yml").read_text()
|
||||||
|
print(f"Repository invariants: {len(paths)} source/documentation paths checked.")
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Collect development/unsigned outputs with source identity and inspection evidence."""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
BUILD = Path(os.environ.get("YUVOMI_BUILD_DIR", ROOT / "android"))
|
||||||
|
APP = BUILD / "app" if "YUVOMI_BUILD_DIR" in os.environ else BUILD / "app/build"
|
||||||
|
SDK = Path(os.environ["ANDROID_HOME"])
|
||||||
|
TOOLS = SDK / "build-tools/36.0.0"
|
||||||
|
DESTINATION = ROOT / "artifacts/android"
|
||||||
|
DESTINATION.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def tool(name, *arguments):
|
||||||
|
return subprocess.check_output([str(TOOLS / name), *map(str, arguments)], text=True)
|
||||||
|
|
||||||
|
|
||||||
|
release = APP / "outputs/apk/release/app-release-unsigned.apk"
|
||||||
|
manifest = tool("aapt2", "dump", "xmltree", release, "--file", "AndroidManifest.xml")
|
||||||
|
assert 'package="community.yuvomi.mobile"' in manifest
|
||||||
|
assert re.search(r":allowBackup\([^\n]*=false", manifest)
|
||||||
|
assert re.search(r":usesCleartextTraffic\([^\n]*=false", manifest)
|
||||||
|
assert not re.search(r":debuggable\([^\n]*=true", manifest)
|
||||||
|
resources = tool("aapt2", "dump", "resources", release)
|
||||||
|
network = re.search(r"xml/network_security_config\s+\(\) \(file\) (\S+)", resources)
|
||||||
|
assert network, "Missing production network security configuration"
|
||||||
|
policy = tool("aapt2", "dump", "xmltree", release, "--file", network.group(1))
|
||||||
|
assert "cleartextTrafficPermitted=false" in policy
|
||||||
|
assert policy.count("E: certificates") == 1 and 'src="system"' in policy
|
||||||
|
(DESTINATION / "release-manifest.txt").write_text(manifest)
|
||||||
|
(DESTINATION / "release-network-policy.txt").write_text(policy)
|
||||||
|
|
||||||
|
debug = APP / "outputs/apk/debug/app-debug.apk"
|
||||||
|
(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)
|
||||||
|
assert unsigned.returncode != 0 and "Missing META-INF/MANIFEST.MF" in unsigned.stderr, unsigned.stderr
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
for source in [debug, release, APP / "outputs/bundle/release/app-release.aab"]:
|
||||||
|
with zipfile.ZipFile(source) as archive:
|
||||||
|
for name in archive.namelist():
|
||||||
|
assert "integration_ca" not in name and not name.endswith((".pem", ".jks", ".keystore", ".p12")), name
|
||||||
|
if source.suffix == ".aab":
|
||||||
|
assert not any(name.upper().startswith("META-INF/") and name.upper().endswith((".RSA", ".DSA", ".EC"))
|
||||||
|
for name in archive.namelist()), "Expected unsigned AAB"
|
||||||
|
destination = DESTINATION / source.name
|
||||||
|
shutil.copyfile(source, destination)
|
||||||
|
entries.append({"file": source.name, "bytes": destination.stat().st_size,
|
||||||
|
"sha256": hashlib.sha256(destination.read_bytes()).hexdigest()})
|
||||||
|
|
||||||
|
revision = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip()
|
||||||
|
dirty = bool(subprocess.check_output(["git", "status", "--porcelain"], cwd=ROOT, text=True).strip())
|
||||||
|
(DESTINATION / "SHA256SUMS").write_text("".join(f"{entry['sha256']} {entry['file']}\n" for entry in entries))
|
||||||
|
(DESTINATION / "build-info.json").write_text(json.dumps({
|
||||||
|
"source_revision": revision, "worktree_dirty_at_collection": dirty,
|
||||||
|
"upstream_revision": "10bfef7686ed7303b4c74c7fcc55a5eb16356c9d",
|
||||||
|
"status": "Development debug APK; unsigned release APK/AAB. Not production-ready.",
|
||||||
|
"artifacts": entries,
|
||||||
|
}, indent=2) + "\n")
|
||||||
|
print(f"Inspected and collected {len(entries)} artifacts in {DESTINATION}")
|
||||||
|
print("Build source identity assumes the documented build ran immediately before collection.")
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate the original community Y mark (not the upstream logo), without dependencies."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import struct
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def png(size):
|
||||||
|
raw = bytearray()
|
||||||
|
segments = [(32, 29, 54, 54), (54, 54, 76, 29), (54, 54, 54, 80)]
|
||||||
|
for y in range(size):
|
||||||
|
raw.append(0)
|
||||||
|
for x in range(size):
|
||||||
|
coverage = 0
|
||||||
|
for dx, dy in [(0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)]:
|
||||||
|
px, py = (x + dx) * 108 / size, (y + dy) * 108 / size
|
||||||
|
for ax, ay, bx, by in segments:
|
||||||
|
projection = max(0, min(1, ((px-ax)*(bx-ax)+(py-ay)*(by-ay))/((bx-ax)**2+(by-ay)**2)))
|
||||||
|
if (px-ax-projection*(bx-ax))**2 + (py-ay-projection*(by-ay))**2 <= 4.5**2:
|
||||||
|
coverage += 1
|
||||||
|
break
|
||||||
|
raw.extend(round(background + (255-background)*coverage/4) for background in (108, 58, 237))
|
||||||
|
|
||||||
|
def chunk(name, data):
|
||||||
|
return struct.pack(">I", len(data)) + name + data + struct.pack(">I", zlib.crc32(name+data))
|
||||||
|
return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
folder = ROOT / "ios/Sources/Assets.xcassets/AppIcon.appiconset"
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
(folder / "AppIcon.png").write_bytes(png(1024))
|
||||||
|
(folder / "Contents.json").write_text(json.dumps({"images": [
|
||||||
|
{"filename": "AppIcon.png", "idiom": "universal", "platform": "ios", "size": "1024x1024"}
|
||||||
|
], "info": {"author": "Yuvomi Mobile contributors", "version": 1}}, indent=2) + "\n")
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
version=2.44.1
|
||||||
|
destination="${1:-${TMPDIR:-/tmp}/yuvomi-xcodegen}"
|
||||||
|
mkdir -p "$destination"
|
||||||
|
curl --fail --location "https://github.com/yonaskolb/XcodeGen/releases/download/$version/xcodegen.zip" -o "$destination/xcodegen.zip"
|
||||||
|
printf '%s %s\n' a2e905fb68446e9bb4008cdfe2e13e3f176d0cbcca828b71770f8e53fca91b73 "$destination/xcodegen.zip" | shasum -a 256 -c -
|
||||||
|
unzip -qo "$destination/xcodegen.zip" -d "$destination"
|
||||||
|
"$destination/xcodegen/bin/xcodegen" --version
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Test-only reverse proxy around the unmodified pinned upstream application.
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import http from 'node:http';
|
||||||
|
import https from 'node:https';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { once } from 'node:events';
|
||||||
|
import { startIdentityProvider } from './oidc-fixture.mjs';
|
||||||
|
|
||||||
|
const [upstream, directory] = process.argv.slice(2);
|
||||||
|
process.env.PORT = '0';
|
||||||
|
process.env.BIND_ADDRESS = '127.0.0.1';
|
||||||
|
process.env.DB_PATH = path.join(directory, 'fixture.db');
|
||||||
|
process.env.SESSION_SECRET = randomBytes(48).toString('hex');
|
||||||
|
process.env.SESSION_SECURE = 'true';
|
||||||
|
process.env.DB_ENCRYPTION_KEY = '';
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
process.env.LOG_LEVEL = 'warn';
|
||||||
|
const tls = { key: fs.readFileSync(path.join(directory, 'key.pem')), cert: fs.readFileSync(path.join(directory, 'cert.pem')) };
|
||||||
|
const idp = await startIdentityProvider(tls);
|
||||||
|
process.env.OIDC_ISSUER = idp.issuer;
|
||||||
|
process.env.OIDC_CLIENT_ID = idp.clientId;
|
||||||
|
process.env.OIDC_CLIENT_SECRET = idp.secret;
|
||||||
|
const { server } = await import(pathToFileURL(path.join(upstream, 'server/index.js')));
|
||||||
|
if (!server.listening) await once(server, 'listening');
|
||||||
|
let networkAvailable = true;
|
||||||
|
const proxy = https.createServer(tls, (request, response) => {
|
||||||
|
// Fault injection lives only in this loopback fixture, never in upstream/app code.
|
||||||
|
if (request.method === 'POST' && ['/__mobile_test/network/off', '/__mobile_test/network/on'].includes(request.url)) {
|
||||||
|
networkAvailable = request.url.endsWith('/on');
|
||||||
|
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
response.end('{}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!networkAvailable) { request.socket.destroy(); return; }
|
||||||
|
const backend = http.request({
|
||||||
|
hostname: '127.0.0.1', port: server.address().port,
|
||||||
|
method: request.method, path: request.url,
|
||||||
|
headers: { ...request.headers, 'x-forwarded-proto': 'https', 'x-forwarded-for': '127.0.0.1' },
|
||||||
|
}, incoming => {
|
||||||
|
response.writeHead(incoming.statusCode, incoming.headers);
|
||||||
|
incoming.pipe(response);
|
||||||
|
});
|
||||||
|
backend.on('error', () => { response.writeHead(502); response.end(); });
|
||||||
|
request.pipe(backend);
|
||||||
|
});
|
||||||
|
proxy.listen(0, '127.0.0.1');
|
||||||
|
await once(proxy, 'listening');
|
||||||
|
process.env.OIDC_REDIRECT_URI = `https://127.0.0.1:${proxy.address().port}/api/v1/auth/oidc/callback`;
|
||||||
|
idp.setRedirect(process.env.OIDC_REDIRECT_URI);
|
||||||
|
fs.writeFileSync(path.join(directory, 'ready.json'), JSON.stringify({ port: proxy.address().port, idpPort: idp.port }));
|
||||||
|
process.on('SIGTERM', () => { idp.close(); proxy.close(); server.close(); process.exit(0); });
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Standards-shaped loopback test IdP, never shipped in the mobile apps.
|
||||||
|
// It validates single-use codes, exact redirect URI, Basic client auth and S256
|
||||||
|
// PKCE, and signs real RS256 ID tokens containing the original nonce.
|
||||||
|
import https from 'node:https';
|
||||||
|
import { createHash, generateKeyPairSync, randomBytes, sign } from 'node:crypto';
|
||||||
|
import { once } from 'node:events';
|
||||||
|
|
||||||
|
export async function startIdentityProvider(tls) {
|
||||||
|
const clientId = 'mobile-test-client';
|
||||||
|
const secret = randomBytes(32).toString('hex');
|
||||||
|
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||||
|
const key = { ...publicKey.export({ format: 'jwk' }), kid: 'fixture-key', alg: 'RS256', use: 'sig' };
|
||||||
|
const codes = new Map();
|
||||||
|
const access = new Set();
|
||||||
|
const authenticatesClient = header => {
|
||||||
|
if (!/^Basic /i.test(header || '')) return false;
|
||||||
|
const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8');
|
||||||
|
const separator = decoded.indexOf(':');
|
||||||
|
const formDecode = value => decodeURIComponent(value.replace(/\+/g, ' '));
|
||||||
|
try {
|
||||||
|
return separator >= 0 && formDecode(decoded.slice(0, separator)) === clientId &&
|
||||||
|
formDecode(decoded.slice(separator + 1)) === secret;
|
||||||
|
} catch { return false; }
|
||||||
|
};
|
||||||
|
let issuer;
|
||||||
|
let redirect;
|
||||||
|
const profile = { sub: 'mobile-oidc-subject', preferred_username: 'mobile-oidc', name: 'Mobile OIDC Test',
|
||||||
|
email: 'mobile-test@example.invalid', email_verified: true };
|
||||||
|
const json = (response, status, data) => {
|
||||||
|
response.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||||
|
response.end(JSON.stringify(data));
|
||||||
|
};
|
||||||
|
const idp = https.createServer(tls, async (request, response) => {
|
||||||
|
const url = new URL(request.url, issuer);
|
||||||
|
if (url.pathname === '/.well-known/openid-configuration') {
|
||||||
|
return json(response, 200, { issuer, authorization_endpoint: issuer + '/authorize',
|
||||||
|
token_endpoint: issuer + '/token', userinfo_endpoint: issuer + '/userinfo', jwks_uri: issuer + '/jwks',
|
||||||
|
response_types_supported: ['code'], subject_types_supported: ['public'],
|
||||||
|
id_token_signing_alg_values_supported: ['RS256'], token_endpoint_auth_methods_supported: ['client_secret_basic'],
|
||||||
|
code_challenge_methods_supported: ['S256'] });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/jwks') return json(response, 200, { keys: [key] });
|
||||||
|
if (url.pathname === '/authorize') {
|
||||||
|
const parameters = url.searchParams;
|
||||||
|
if (parameters.get('client_id') !== clientId || parameters.get('redirect_uri') !== redirect ||
|
||||||
|
parameters.get('code_challenge_method') !== 'S256' || !parameters.get('state') || !parameters.get('nonce')) {
|
||||||
|
return json(response, 400, { error: 'invalid_request' });
|
||||||
|
}
|
||||||
|
const code = randomBytes(24).toString('hex');
|
||||||
|
codes.set(code, { challenge: parameters.get('code_challenge'), nonce: parameters.get('nonce'), expires: Date.now() + 60_000 });
|
||||||
|
const callback = new URL(redirect);
|
||||||
|
callback.searchParams.set('code', code); callback.searchParams.set('state', parameters.get('state'));
|
||||||
|
response.writeHead(302, { Location: callback.href, 'Cache-Control': 'no-store' }); response.end(); return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/token' && request.method === 'POST') {
|
||||||
|
let body = '';
|
||||||
|
for await (const chunk of request) { body += chunk; if (body.length > 8192) return json(response, 413, {}); }
|
||||||
|
const parameters = new URLSearchParams(body);
|
||||||
|
const code = parameters.get('code'); const grant = codes.get(code); codes.delete(code);
|
||||||
|
if (!grant || grant.expires < Date.now() || parameters.get('grant_type') !== 'authorization_code' ||
|
||||||
|
parameters.get('redirect_uri') !== redirect || !authenticatesClient(request.headers.authorization) ||
|
||||||
|
createHash('sha256').update(parameters.get('code_verifier') || '').digest('base64url') !== grant.challenge) {
|
||||||
|
console.error('Test IdP grant rejected:', JSON.stringify({
|
||||||
|
knownCode: !!grant, expired: !!grant && grant.expires < Date.now(),
|
||||||
|
redirectMatches: parameters.get('redirect_uri') === redirect,
|
||||||
|
clientMatches: authenticatesClient(request.headers.authorization),
|
||||||
|
pkceMatches: !!grant && createHash('sha256').update(parameters.get('code_verifier') || '').digest('base64url') === grant.challenge,
|
||||||
|
}));
|
||||||
|
return json(response, 400, { error: 'invalid_grant' });
|
||||||
|
}
|
||||||
|
const header = Buffer.from(JSON.stringify({ alg: 'RS256', kid: key.kid, typ: 'JWT' })).toString('base64url');
|
||||||
|
const payload = Buffer.from(JSON.stringify({ iss: issuer, aud: clientId, sub: profile.sub, nonce: grant.nonce,
|
||||||
|
iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 60 })).toString('base64url');
|
||||||
|
const token = randomBytes(24).toString('hex'); access.add(token);
|
||||||
|
const signature = sign('RSA-SHA256', Buffer.from(header + '.' + payload), privateKey).toString('base64url');
|
||||||
|
return json(response, 200, { access_token: token, token_type: 'Bearer', expires_in: 60, id_token: header + '.' + payload + '.' + signature });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/userinfo' && access.has(request.headers.authorization?.replace(/^Bearer /, ''))) {
|
||||||
|
return json(response, 200, profile);
|
||||||
|
}
|
||||||
|
json(response, 404, { error: 'not_found' });
|
||||||
|
});
|
||||||
|
idp.listen(0, '127.0.0.1'); await once(idp, 'listening');
|
||||||
|
issuer = 'https://127.0.0.1:' + idp.address().port;
|
||||||
|
return { issuer, clientId, secret, port: idp.address().port, setRedirect(value) { redirect = value; }, close() { idp.close(); } };
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Select an available iPhone instead of depending on a runner-specific device name."""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
devices = json.loads(subprocess.check_output(["xcrun", "simctl", "list", "devices", "available", "--json"]))
|
||||||
|
phones = []
|
||||||
|
for runtime, entries in devices["devices"].items():
|
||||||
|
match = re.search(r"\.iOS-(\d+)-(\d+)", runtime)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
version = tuple(map(int, match.groups()))
|
||||||
|
if version < (18, 4):
|
||||||
|
continue
|
||||||
|
phones.extend((version, device) for device in entries
|
||||||
|
if device.get("isAvailable") and device["name"].startswith("iPhone"))
|
||||||
|
phones.sort(key=lambda entry: entry[0], reverse=True)
|
||||||
|
if not phones:
|
||||||
|
raise SystemExit("No available iPhone simulator; install an iOS simulator runtime in Xcode.")
|
||||||
|
print(phones[0][1]["udid"])
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Real upstream HTTPS/reverse-proxy tests; optionally drive the Android emulator.
|
||||||
|
|
||||||
|
Keys and CA trust exist only in a private temporary directory and the .integration
|
||||||
|
APK. Production and ordinary debug builds never include this trust configuration.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import http.cookiejar
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import secrets
|
||||||
|
import shutil
|
||||||
|
import ssl
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
REVISION = "10bfef7686ed7303b4c74c7fcc55a5eb16356c9d"
|
||||||
|
|
||||||
|
|
||||||
|
def run(upstream, android):
|
||||||
|
revision = subprocess.check_output(["git", "-C", str(upstream), "rev-parse", "HEAD"], text=True).strip()
|
||||||
|
assert revision == REVISION, f"Expected pinned upstream {REVISION}, got {revision}"
|
||||||
|
base_temp = Path("/tmp/opencode") if Path("/tmp/opencode").exists() else Path(tempfile.gettempdir())
|
||||||
|
with tempfile.TemporaryDirectory(prefix="yuvomi-https-", dir=base_temp) 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 temporary test CA",
|
||||||
|
"-addext", "subjectAltName=IP:127.0.0.1,IP:10.0.2.2,DNS:localhost",
|
||||||
|
], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
with (directory / "server.log").open("w") as log:
|
||||||
|
process = subprocess.Popen(["node", str(ROOT / "scripts/integration-server.mjs"), str(upstream), temporary],
|
||||||
|
cwd=upstream, stdout=log, stderr=log,
|
||||||
|
env={**os.environ, "NODE_EXTRA_CA_CERTS": str(directory / "cert.pem")})
|
||||||
|
try:
|
||||||
|
for _ in range(200):
|
||||||
|
if (directory / "ready.json").exists():
|
||||||
|
break
|
||||||
|
if process.poll() is not None:
|
||||||
|
raise RuntimeError((directory / "server.log").read_text())
|
||||||
|
time.sleep(0.1)
|
||||||
|
ready = json.loads((directory / "ready.json").read_text())
|
||||||
|
port = ready["port"]
|
||||||
|
base = f"https://127.0.0.1:{port}"
|
||||||
|
context = ssl.create_default_context(cafile=str(directory / "cert.pem"))
|
||||||
|
cookies = http.cookiejar.CookieJar()
|
||||||
|
client = urllib.request.build_opener(urllib.request.HTTPSHandler(context=context),
|
||||||
|
urllib.request.HTTPCookieProcessor(cookies))
|
||||||
|
|
||||||
|
def request(route, payload=None, headers=None):
|
||||||
|
body = None if payload is None else json.dumps(payload).encode()
|
||||||
|
req = urllib.request.Request(base + route, body, headers={
|
||||||
|
"Accept": "application/json", "Content-Type": "application/json", **(headers or {})})
|
||||||
|
try:
|
||||||
|
with client.open(req, timeout=15) as response:
|
||||||
|
return response.status, response.read(), response.headers
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
return error.code, error.read(), error.headers
|
||||||
|
|
||||||
|
# A certificate unknown to the system MUST be rejected.
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(base + "/api/v1/version", timeout=5)
|
||||||
|
raise AssertionError("Untrusted certificate was accepted")
|
||||||
|
except urllib.error.URLError as error:
|
||||||
|
assert isinstance(error.reason, ssl.SSLCertVerificationError)
|
||||||
|
print("PASS: untrusted TLS certificate rejected")
|
||||||
|
status, body, _ = request("/api/v1/version")
|
||||||
|
metadata = json.loads(body)
|
||||||
|
assert status == 200 and isinstance(metadata["app_name"], str)
|
||||||
|
assert metadata["setup_required"] is True and "version" not in metadata
|
||||||
|
assert isinstance(metadata["password_reset_enabled"], bool)
|
||||||
|
status, body, _ = request("/api/v1/auth/oidc/config")
|
||||||
|
assert status == 200 and json.loads(body)["enabled"] is True
|
||||||
|
print("PASS: actual upstream compatibility metadata over HTTPS reverse proxy")
|
||||||
|
password = secrets.token_urlsafe(24)
|
||||||
|
assert request("/api/v1/auth/setup", {"username": "mobiletest", "display_name": "Mobile Test", "password": password})[0] == 201
|
||||||
|
status, body, headers = request("/api/v1/auth/login", {"username": "mobiletest", "password": password})
|
||||||
|
assert status == 200
|
||||||
|
session = [cookie for cookie in cookies if cookie.name == "yuvomi.sid"][0]
|
||||||
|
assert session.secure and session.has_nonstandard_attr("HttpOnly")
|
||||||
|
assert "SameSite=Lax" in " ".join(headers.get_all("Set-Cookie"))
|
||||||
|
csrf = json.loads(body)["csrfToken"]
|
||||||
|
assert request("/api/v1/auth/me")[0] == 200
|
||||||
|
assert json.loads(request("/api/v1/version")[1])["version"] == "2.78.0"
|
||||||
|
assert request("/api/v1/auth/logout", {})[0] == 403
|
||||||
|
assert request("/api/v1/auth/logout", {}, {"X-CSRF-Token": csrf})[0] == 200
|
||||||
|
assert request("/api/v1/auth/me")[0] == 401
|
||||||
|
print("PASS: login, Secure/HttpOnly/SameSite cookies, persistence, CSRF rejection and logout/revocation")
|
||||||
|
assert request("/api/v1/auth/oidc/start")[0] == 200
|
||||||
|
status, body, _ = request("/api/v1/auth/me")
|
||||||
|
identity = json.loads(body)
|
||||||
|
assert status == 200 and identity.get("user", {}).get("username") == "mobile-oidc", (
|
||||||
|
f"OIDC session rejected (HTTP {status}); " + "\n".join(
|
||||||
|
(directory / "server.log").read_text().splitlines()[-20:]))
|
||||||
|
assert request("/api/v1/auth/logout", {}, {"X-CSRF-Token": identity["csrfToken"]})[0] == 200
|
||||||
|
print("PASS: upstream OIDC discovery, RS256/nonce validation, S256 PKCE exchange and test-IdP session")
|
||||||
|
if android:
|
||||||
|
resources = directory / "res"
|
||||||
|
(resources / "xml").mkdir(parents=True)
|
||||||
|
(resources / "raw").mkdir()
|
||||||
|
shutil.copyfile(directory / "cert.pem", resources / "raw/integration_ca.pem")
|
||||||
|
(resources / "xml/network_security_config.xml").write_text('''<network-security-config>
|
||||||
|
<base-config cleartextTrafficPermitted="false"><trust-anchors>
|
||||||
|
<certificates src="system"/><certificates src="@raw/integration_ca"/>
|
||||||
|
</trust-anchors></base-config></network-security-config>''')
|
||||||
|
env = {**os.environ, "YUVOMI_TEST_RES": str(resources),
|
||||||
|
"YUVOMI_TEST_URL": base, "YUVOMI_TEST_PASSWORD": password}
|
||||||
|
sdk = Path(env.get("ANDROID_HOME", env.get("ANDROID_SDK_ROOT", "")))
|
||||||
|
adb = sdk / "platform-tools/adb"
|
||||||
|
user_state = subprocess.check_output(
|
||||||
|
[str(adb), "shell", "am", "get-started-user-state", "0"], text=True).strip()
|
||||||
|
if user_state != "RUNNING_UNLOCKED":
|
||||||
|
raise RuntimeError(f"Unlock the emulator before testing (user state: {user_state}).")
|
||||||
|
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)
|
||||||
|
try:
|
||||||
|
# The ephemeral password is passed through the local test runner only,
|
||||||
|
# never written into a source file or printed by this harness.
|
||||||
|
result = subprocess.run(["bash", str(ROOT / "android/gradlew"), "-p", str(ROOT / "android"),
|
||||||
|
"-Pintegration", "connectedIntegrationAndroidTest"], env=env, check=False)
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError("Android integration tests failed. Inspect the Gradle test report.")
|
||||||
|
output = Path(env.get("YUVOMI_BUILD_DIR", ROOT / "android"))
|
||||||
|
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"),
|
||||||
|
str(reports), "--minimum", "7"], check=True)
|
||||||
|
finally:
|
||||||
|
subprocess.run([str(adb), "reverse", "--remove", f"tcp:{port}"], check=False)
|
||||||
|
subprocess.run([str(adb), "reverse", "--remove", f"tcp:{ready['idpPort']}"], check=False)
|
||||||
|
finally:
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
process.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("upstream", type=Path)
|
||||||
|
parser.add_argument("--android", action="store_true")
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
run(arguments.upstream.resolve(), arguments.android)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Regression tests for fail-closed JUnit evidence verification."""
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
VERIFIER = Path(__file__).with_name("verify-test-results.py")
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceVerifierTests(unittest.TestCase):
|
||||||
|
def verify(self, xml=None, minimum=1):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="yuvomi-evidence-") as temporary:
|
||||||
|
if xml is not None:
|
||||||
|
(Path(temporary) / "TEST-fixture.xml").write_text(xml)
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, str(VERIFIER), temporary, "--minimum", str(minimum)],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
).returncode
|
||||||
|
|
||||||
|
def test_missing_reports_fail(self):
|
||||||
|
self.assertNotEqual(self.verify(), 0)
|
||||||
|
|
||||||
|
def test_empty_reports_fail(self):
|
||||||
|
self.assertNotEqual(self.verify("<testsuite/>"), 0)
|
||||||
|
|
||||||
|
def test_malformed_reports_fail(self):
|
||||||
|
self.assertNotEqual(self.verify("<testsuite>"), 0)
|
||||||
|
|
||||||
|
def test_failed_cases_fail(self):
|
||||||
|
self.assertNotEqual(self.verify("<testsuite><testcase><failure/></testcase></testsuite>"), 0)
|
||||||
|
|
||||||
|
def test_errored_cases_fail(self):
|
||||||
|
self.assertNotEqual(self.verify("<testsuite><testcase><error/></testcase></testsuite>"), 0)
|
||||||
|
|
||||||
|
def test_skipped_cases_fail(self):
|
||||||
|
self.assertNotEqual(self.verify("<testsuite><testcase><skipped/></testcase></testsuite>"), 0)
|
||||||
|
|
||||||
|
def test_insufficient_cases_fail(self):
|
||||||
|
self.assertNotEqual(self.verify("<testsuite><testcase/></testsuite>", minimum=2), 0)
|
||||||
|
|
||||||
|
def test_passing_cases_succeed(self):
|
||||||
|
self.assertEqual(self.verify("<testsuite><testcase/></testsuite>"), 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Require real, nonempty JUnit results; a build tool's zero exit is insufficient."""
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("directory", type=Path)
|
||||||
|
parser.add_argument("--minimum", type=int, required=True)
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
cases = []
|
||||||
|
for path in arguments.directory.rglob("TEST*.xml"):
|
||||||
|
cases.extend(ET.parse(path).getroot().iter("testcase"))
|
||||||
|
failed = sum(case.find("failure") is not None or case.find("error") is not None for case in cases)
|
||||||
|
skipped = sum(case.find("skipped") is not None for case in cases)
|
||||||
|
print(f"JUnit evidence: {len(cases)} tests, {failed} failures/errors, {skipped} skipped")
|
||||||
|
if len(cases) < arguments.minimum or failed or skipped:
|
||||||
|
raise SystemExit("Incomplete or failing test evidence; do not report this run as passing.")
|
||||||
Reference in new issue
Block a user