Compare commits

..
9 Commits
Author SHA256 Message Date
Yuvomi Mobile Contributors 2d12491516 docs: record verified development delivery and explicit release gates
iOS validation / simulator (push) Canceled after 0s
Android validation / build (push) Canceled after 0s
Android validation / emulator (35) (push) Canceled after 0s
Android validation / emulator (37.0) (push) Canceled after 0s
Document independent native architecture, server compatibility, build/signing procedures, privacy limitations and persistent continuation steps. Record two consecutive seven-test API 35 HTTPS/OIDC passes, portable and host-tool checks, and inspected unsigned artifacts. Explicitly withhold production readiness pending Apple SDK validation, API 37/current-device testing, real IdPs, resource-limit review, human security review and maintainer signing.
2026-10-21 07:32:50 +02:00
Yuvomi Mobile Contributors 9ca11e4fb5 test: exercise native Android flows against pinned upstream HTTPS and OIDC
Add an ephemeral HTTPS proxy and standards-shaped RS256/S256-PKCE test IdP around the unmodified upstream checkout. Require unlocked devices and nonempty, failure-free JUnit evidence. The extended API 35 suite passes all seven cases, including native Blob/file transfer, session recreation, outage recovery, isolated OIDC and back navigation; eight evidence-verifier regressions also pass. API 37 and real-provider/device validation remain release gates.
2026-10-21 07:32:17 +02:00
Yuvomi Mobile Contributors f7b9dcd91e build: collect inspected unsigned Android artifacts with checksums
Verify the production manifest, system-only trust, development APK signature and unsigned release outputs before collecting local artifacts. Record source identity and hashes outside Git. Exercised successfully against the built APKs and AAB; collection must follow a clean-source build.
2026-10-21 07:30:51 +02:00
Yuvomi Mobile Contributors 9088ee7e6a ci: add pinned platform validation and maintainer-only signing workflows
Provide Android API 35/37 and Xcode jobs, dependency advisory checks, source invariants, and checksum-verified tools. Actions lint and available Linux static checks pass; hosted CI and all Apple-platform builds still require authorized runners. The manual release workflow never publishes and uses only owner-supplied signing credentials.
2026-10-21 07:29:12 +02:00
Yuvomi Mobile Contributors d70c5cc2de fix(ios): bind export cancellation to the originating download
Ignore late picker callbacks that no longer match the active download. Add an Apple-platform regression; Swift formatting, parsing and seven portable policy tests pass locally, but the new WKWebView regression requires Xcode and has not run here.
2026-10-21 07:27:34 +02:00
Yuvomi Mobile Contributors 2b9996ff3f fix(android): harden origin isolation and native file lifecycles
Block cross-origin POSTs, preserve bounded generated exports without relaxing CSP, bind picker results to their initiating context, erase camera cache and constrain platform permissions. Upgrade vulnerable build/test tooling and lock runtime/build dependencies. JVM regressions, lint and unsigned release builds pass; extended emulator validation remains separate.
2026-10-21 07:25:00 +02:00
Yuvomi Mobile Contributors f776c853c6 test(ios): cover context erasure and native capability restrictions
Add Apple-platform tests without presenting them as Linux results. Improve native dark-mode contrast and keep the portable policy suite separate from WebKit tests. Swift formatting and syntax checks pass; Xcode execution remains required.
2026-10-21 07:18:42 +02:00
Yuvomi Mobile Contributors 69c48169e2 fix(ios): restrict authentication capabilities and validate bootstrap types
Require iOS 18.4 for explicit upload denial in the isolated authentication window. Add origin-bound generated download policy, app-switcher privacy, typed metadata regressions and original community icon assets. Seven portable tests and Swift formatting checks pass; Apple SDK validation remains external.
2026-10-20 23:45:01 +02:00
Yuvomi Mobile Contributors 74c2c9e6e8 feat(ios): implement SwiftUI companion with isolated WKWebView sessions
Add HTTPS onboarding, origin policy, native recovery/settings, isolated web sign-in and file export. Portable production URL policy tests pass on Linux; all Swift sources parse. Xcode build and simulator validation remain required on macOS.
2026-10-20 23:28:07 +02:00
72 changed files with 4122 additions and 144 deletions

No files matched your search

+18
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: gradle
directory: /android
schedule:
interval: weekly
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
+82
View File
@@ -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
+39
View File
@@ -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
+48
View File
@@ -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
+2
View File
@@ -1,6 +1,7 @@
.DS_Store .DS_Store
.idea/ .idea/
.gradle/ .gradle/
.kotlin/
**/build/ **/build/
local.properties local.properties
*.keystore *.keystore
@@ -13,6 +14,7 @@ DerivedData/
*.xcuserstate *.xcuserstate
xcuserdata/ xcuserdata/
ios/Yuvomi.xcodeproj/ ios/Yuvomi.xcodeproj/
ios/Info.plist
.swiftpm/ .swiftpm/
.build/ .build/
artifacts/ artifacts/
+6
View File
@@ -0,0 +1,6 @@
{
"version": 1,
"indentation": { "spaces": 4 },
"lineLength": 120,
"respectsExistingLineBreaks": true
}
+14
View File
@@ -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.
+27
View File
@@ -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.
+1
View File
@@ -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
View File
@@ -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`.
+20
View File
@@ -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.
+60 -5
View 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")
+169
View File
@@ -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()
}
}
@@ -0,0 +1,71 @@
package community.yuvomi.mobile
import android.Manifest
import android.accessibilityservice.AccessibilityServiceInfo
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.view.accessibility.AccessibilityNodeInfo
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class LocalNetworkPermissionTest {
@Test fun permissionIsOptionalAndHonorsSystemDenialAndGrant() {
val context = ApplicationProvider.getApplicationContext<Context>()
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit().clear().commit()
ActivityScenario.launch(MainActivity::class.java).use {
if (Build.VERSION.SDK_INT < 37 ||
context.checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) == PackageManager.PERMISSION_GRANTED
) {
onView(withText("Allow private-network servers")).check(doesNotExist())
return@use
}
val automation = InstrumentationRegistry.getInstrumentation().uiAutomation
automation.serviceInfo = automation.serviceInfo.apply { flags = flags or AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS }
onView(withText("Allow private-network servers")).perform(click())
onView(withText("Cancel")).perform(click())
assertEquals(PackageManager.PERMISSION_DENIED, context.checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK))
onView(withText("Allow private-network servers")).perform(click())
onView(withText("Continue")).perform(click())
chooseSystemButton("permission_deny_button")
await { context.checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) == PackageManager.PERMISSION_DENIED }
onView(withText("Allow private-network servers")).perform(click())
onView(withText("Continue")).perform(click())
chooseSystemButton("permission_allow_button")
await { context.checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) == PackageManager.PERMISSION_GRANTED }
}
}
private fun chooseSystemButton(id: String) {
val automation = InstrumentationRegistry.getInstrumentation().uiAutomation
await {
val node = find(automation.rootInActiveWindow, id) ?: return@await false
node.performAction(AccessibilityNodeInfo.ACTION_CLICK)
}
}
private fun find(node: AccessibilityNodeInfo?, id: String): AccessibilityNodeInfo? {
if (node == null) return null
if (node.viewIdResourceName?.endsWith("/$id") == true) return node
for (index in 0 until node.childCount) find(node.getChild(index), id)?.let { return it }
return null
}
private fun await(condition: () -> Boolean) {
val deadline = System.nanoTime() + 10_000_000_000L
while (System.nanoTime() < deadline) {
if (condition()) return
Thread.sleep(100)
}
assertTrue("System permission response timed out", condition())
}
}
@@ -0,0 +1,93 @@
package community.yuvomi.mobile
import android.content.Context
import android.webkit.WebView
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.closeSoftKeyboard
import androidx.test.espresso.action.ViewActions.replaceText
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.hasErrorText
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class OnboardingTest {
private val context: Context get() = ApplicationProvider.getApplicationContext()
@Before fun clearConfiguration() {
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit().clear().commit()
}
@Test fun rejectsHttpAndSubpathsLocally() {
ActivityScenario.launch(MainActivity::class.java).use {
onView(
withContentDescription("Yuvomi server URL"),
).perform(replaceText("http://family.example"), closeSoftKeyboard())
onView(withText("Connect securely")).perform(click())
onView(
withContentDescription("Yuvomi server URL"),
).check(matches(hasErrorText("Use HTTPS with a valid server hostname.")))
onView(
withContentDescription("Yuvomi server URL"),
).perform(replaceText("https://family.example/subpath"), closeSoftKeyboard())
onView(withText("Connect securely")).perform(click())
onView(
withContentDescription("Yuvomi server URL"),
).check(matches(hasErrorText("Yuvomi requires a dedicated hostname, not a subpath.")))
}
}
@Test fun survivesRecreationWithoutLosingOnboarding() {
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
scenario.recreate()
onView(withText("Connect securely")).check(matches(isDisplayed()))
onView(withContentDescription("Yuvomi server URL")).check(matches(isEnabled()))
}
}
@Test fun webViewIsHardenedAndBackgroundingPreservesInstance() {
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit()
.putString("origin", "https://family.example").commit()
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
var initial: WebView? = null
scenario.onActivity { activity ->
fun find(view: android.view.View): WebView? {
if (view is WebView) return view
if (view is android.view.ViewGroup) {
for (i in 0 until view.childCount) {
find(view.getChildAt(i))?.let {
return it
}
}
}
return null
}
initial = find(activity.window.decorView)
val web = requireNotNull(initial)
assertFalse(web.settings.allowFileAccess)
assertFalse(web.settings.allowContentAccess)
assertEquals(
android.webkit.WebSettings.MIXED_CONTENT_NEVER_ALLOW,
web.settings.mixedContentMode,
)
assertTrue(web.settings.javaScriptEnabled)
}
scenario.moveToState(androidx.lifecycle.Lifecycle.State.CREATED)
scenario.moveToState(androidx.lifecycle.Lifecycle.State.RESUMED)
scenario.onActivity { assertNotNull(initial?.parent) }
}
}
}
@@ -0,0 +1,333 @@
package community.yuvomi.mobile
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.WebView
import androidx.core.content.FileProvider
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.closeSoftKeyboard
import androidx.test.espresso.action.ViewActions.replaceText
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import java.io.File
import java.net.URI
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import javax.net.ssl.HttpsURLConnection
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class UpstreamIntegrationTest {
@Test fun actualUpstreamOnboardingAuthenticationLifecycleAndLogout() {
val arguments = InstrumentationRegistry.getArguments()
val origin = requireNotNull(arguments.getString("server"))
val password = requireNotNull(arguments.getString("password"))
val context = ApplicationProvider.getApplicationContext<Context>()
if (android.os.Build.VERSION.SDK_INT >= 37) {
InstrumentationRegistry.getInstrumentation().uiAutomation.grantRuntimePermission(
context.packageName,
android.Manifest.permission.ACCESS_LOCAL_NETWORK,
)
}
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit().clear().commit()
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
onView(
withContentDescription("Yuvomi server URL"),
).perform(replaceText(origin), closeSoftKeyboard())
onView(withText("Connect securely")).perform(click())
var web: WebView? = null
await("native onboarding completed") {
scenario.onActivity { web = findWeb(it.window.decorView) }
web != null
}
await("actual upstream application loaded") {
documentLoaded(scenario, requireNotNull(web), origin)
}
// Run in the real WebView origin, preserving real Set-Cookie and CSRF.
val credentials = JSONObject().put(
"username",
"mobiletest",
).put("password", password).toString()
evaluate(
scenario,
requireNotNull(web),
"""
window.__mobileResult = null;
fetch('/api/v1/auth/login', {method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify($credentials)}).then(async r => {
const data = await r.json(); window.__mobileCsrf=data.csrfToken;
window.__mobileResult=r.status;
});
""".trimIndent(),
)
await("upstream password login") {
evaluate(scenario, requireNotNull(web), "window.__mobileResult") ==
"200"
}
assertEquals(
"false",
evaluate(scenario, requireNotNull(web), "document.cookie.includes('yuvomi.sid=')"),
)
evaluate(
scenario,
requireNotNull(web),
"""
window.__mobileResult=null;
fetch('/api/v1/documents',{method:'POST',headers:{'Content-Type':'application/json',
'X-CSRF-Token':window.__mobileCsrf},body:JSON.stringify({name:'Mobile upload',
original_name:'mobile.txt',content_data:'data:text/plain;base64,WXV2b21pIGZpbGUgdGVzdAo='})})
.then(async r=>{const d=await r.json();window.__mobileDocument=d.data?.id;window.__mobileResult=r.status});
""".trimIndent(),
)
await("real upstream file upload") {
evaluate(scenario, requireNotNull(web), "window.__mobileResult") ==
"201"
}
val documentId = evaluate(
scenario,
requireNotNull(web),
"window.__mobileDocument",
).toLong()
val downloadURL = "$origin/api/v1/documents/$documentId/download"
var cookie = ""
scenario.onActivity { cookie = CookieManager.getInstance().getCookie(downloadURL) }
val file =
File(File(context.cacheDir, "captures").apply { mkdirs() }, "upstream-download.txt")
val destination = FileProvider.getUriForFile(
context,
"${context.packageName}.files",
file,
)
try {
FileTransfer.save(
ServerAddress.parse(origin),
downloadURL,
cookie,
context.contentResolver,
destination,
)
assertEquals("Yuvomi file test\n", file.readText())
} finally {
file.delete()
}
val blobResult =
evaluate(
scenario,
requireNotNull(web),
"URL.createObjectURL(new Blob(['Yuvomi export'],{type:'text/plain'}))",
)
val blobURL = JSONArray("[$blobResult]").getString(0)
val exported = CountDownLatch(1)
val exportWorker = Executors.newSingleThreadExecutor()
var exportSuccess = false
try {
scenario.onActivity {
BlobTransfer.save(
ServerAddress.parse(origin),
requireNotNull(web),
blobURL,
context.contentResolver,
destination,
exportWorker,
{ true },
) {
exportSuccess = it
exported.countDown()
}
}
assertTrue(exported.await(50, TimeUnit.SECONDS))
assertTrue("Blob export in hardened application WebView", exportSuccess)
assertEquals("Yuvomi export", file.readText())
} finally {
exportWorker.shutdownNow()
file.delete()
}
scenario.moveToState(androidx.lifecycle.Lifecycle.State.CREATED)
scenario.moveToState(androidx.lifecycle.Lifecycle.State.RESUMED)
evaluate(
scenario,
requireNotNull(web),
"window.__mobileResult=null;fetch('/api/v1/auth/me').then(r=>window.__mobileResult=r.status)",
)
await("session survives backgrounding") {
evaluate(scenario, requireNotNull(web), "window.__mobileResult") ==
"200"
}
scenario.recreate()
await("restored WebView") {
scenario.onActivity { web = findWeb(it.window.decorView) }
web != null && documentLoaded(scenario, requireNotNull(web), origin)
}
await("persistent session after activity recreation") {
evaluate(
scenario,
requireNotNull(web),
"""
if(!window.__mobileSessionProbe){window.__mobileSessionProbe=true;
fetch('/api/v1/auth/me').then(async r=>{const d=await r.json();
window.__mobileCsrf=d.csrfToken;window.__mobileResult=r.status})}
window.__mobileResult
""".trimIndent(),
) ==
"200"
}
evaluate(
scenario,
requireNotNull(web),
"""
window.__mobileResult=null;
fetch('/api/v1/auth/logout',{method:'POST',headers:{'Content-Type':'application/json',
'X-CSRF-Token':window.__mobileCsrf},body:'{}'}).then(r=>window.__mobileResult=r.status);
""".trimIndent(),
)
await("CSRF-protected logout") {
evaluate(scenario, requireNotNull(web), "window.__mobileResult") ==
"200"
}
evaluate(
scenario,
requireNotNull(web),
"window.__mobileResult=null;fetch('/api/v1/auth/me').then(r=>window.__mobileResult=r.status)",
)
await("session revoked") {
evaluate(scenario, requireNotNull(web), "window.__mobileResult") ==
"401"
}
// The upstream worker may legitimately serve the cached app shell
// during an outage. Use non-cacheable metadata to exercise native
// main-frame network recovery without disabling that worker.
evaluate(
scenario,
requireNotNull(web),
"fetch('/__mobile_test/network/off',{method:'POST'}).then(()=>location.assign('/api/v1/version'))",
)
await("native network error recovery panel") {
var visible = false
scenario.onActivity { visible = hasText(it.window.decorView, "Connection interrupted") }
visible
}
val recovery = URI("$origin/__mobile_test/network/on").toURL().openConnection() as HttpsURLConnection
try {
recovery.requestMethod = "POST"
recovery.connectTimeout = 5_000
recovery.readTimeout = 5_000
assertEquals(200, recovery.responseCode)
} finally {
recovery.disconnect()
}
onView(withText("Retry")).perform(click())
await("reconnected to real upstream metadata") {
evaluate(scenario, requireNotNull(web), "document.body?.innerText.includes('app_name')") == "true"
}
scenario.onActivity { requireNotNull(web).loadUrl("$origin/") }
await("application restored after outage") {
var restored = false
scenario.onActivity { restored = requireNotNull(web).url == "$origin/" }
restored && documentLoaded(scenario, requireNotNull(web), origin)
}
scenario.onActivity { requireNotNull(web).loadUrl("$origin/api/v1/auth/oidc/start") }
await("native consent before leaving Yuvomi for OIDC") {
runCatching { onView(withText("Sign-in window")).check(matches(isDisplayed())) }.isSuccess
}
onView(withText("Sign-in window")).perform(click())
await("OIDC callback returns to main application") {
var returned = false
scenario.onActivity { returned = requireNotNull(web).url == "$origin/" }
val signInClosed = runCatching {
onView(withText("Close")).check(doesNotExist())
}.isSuccess
signInClosed && returned &&
documentLoaded(scenario, requireNotNull(web), origin)
}
evaluate(
scenario,
requireNotNull(web),
"window.__mobileSubject=null;fetch('/api/v1/auth/me').then(r=>r.json()).then(d=>window.__mobileSubject=d.user?.username)",
)
await("isolated OIDC view shares the initiating server session") {
evaluate(scenario, requireNotNull(web), "window.__mobileSubject") == "\"mobile-oidc\""
}
var previous = ""
scenario.onActivity {
previous = requireNotNull(web).url.orEmpty()
requireNotNull(web).loadUrl("$origin/tasks")
}
await("internal module navigation") {
var loaded = false
scenario.onActivity { loaded = requireNotNull(web).url == "$origin/tasks" && requireNotNull(web).canGoBack() }
loaded && documentLoaded(scenario, requireNotNull(web), origin)
}
scenario.onActivity { it.onBackPressedDispatcher.onBackPressed() }
await("native back restores WebView history") {
var restored = false
scenario.onActivity { restored = requireNotNull(web).url == previous }
restored
}
}
}
private fun documentLoaded(scenario: ActivityScenario<MainActivity>, web: WebView, origin: String): Boolean = evaluate(
scenario,
web,
"location.origin===${JSONObject.quote(origin)} && document.readyState==='complete'",
) == "true"
private fun evaluate(scenario: ActivityScenario<MainActivity>, web: WebView, javascript: String): String {
val completed = CountDownLatch(1)
var result = ""
scenario.onActivity {
web.evaluateJavascript(javascript) {
result = it
completed.countDown()
}
}
assertTrue("JavaScript evaluation timed out", completed.await(10, TimeUnit.SECONDS))
return result
}
private fun await(label: String, condition: () -> Boolean) {
val deadline = System.nanoTime() + 30_000_000_000L
while (System.nanoTime() < deadline) {
if (condition()) return
Thread.sleep(100)
}
fail("Timed out: $label")
}
private fun findWeb(view: View): WebView? {
if (view is WebView) return view
if (view is ViewGroup) {
for (i in 0 until view.childCount) {
findWeb(view.getChildAt(i))?.let {
return it
}
}
}
return null
}
private fun hasText(view: View, text: String): Boolean {
if (view is android.widget.TextView && view.text.toString() == text) return true
if (view is ViewGroup) {
for (index in 0 until view.childCount) {
if (hasText(view.getChildAt(index), text)) return true
}
}
return false
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-feature android:name="android.hardware.camera" android:required="false" /> <uses-feature android:name="android.hardware.camera" android:required="false" />
<application <application
android:allowBackup="false" android:allowBackup="false"
@@ -13,7 +14,7 @@
android:theme="@style/AppTheme" android:theme="@style/AppTheme"
android:supportsRtl="true"> android:supportsRtl="true">
<activity android:name=".MainActivity" android:exported="true" <activity android:name=".MainActivity" android:exported="true"
android:configChanges="orientation|screenSize|keyboardHidden|uiMode" android:configChanges="orientation|screenSize|keyboardHidden"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -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))
}
}
} }
+23 -3
View File
@@ -1,7 +1,27 @@
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.
+116
View File
@@ -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=
+2
View File
@@ -1,4 +1,6 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
org.gradle.workers.max=2
kotlin.compiler.execution.strategy=in-process
android.useAndroidX=true android.useAndroidX=true
kotlin.code.style=official kotlin.code.style=official
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
Binary file not shown.
+4 -2
View File
@@ -1,8 +1,10 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip distributionSha256Sum=bbaeb2fef8710818cf0e261201dab964c572f92b942812df0c3620d62a529a01
distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78 distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
networkTimeout=10000 networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
+5 -8
View File
@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
# #
# Copyright © 2015-2021 the original authors. # Copyright © 2015 the original authors.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -20,7 +20,7 @@
############################################################################## ##############################################################################
# #
# Gradle start up script for POSIX generated by Gradle. # gradlew start up script for POSIX generated by Gradle.
# #
# Important for running: # Important for running:
# #
@@ -29,7 +29,7 @@
# bash, then to run this script, type that shell name before the whole # bash, then to run this script, type that shell name before the whole
# command line, like: # command line, like:
# #
# ksh Gradle # ksh gradlew
# #
# Busybox and similar reduced shells will NOT work, because this script # Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features: # requires all of these POSIX shell features:
@@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop. # Darwin, MinGW, and NonStop.
# #
# (3) This script is generated from the Groovy template # (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project. # within the Gradle project.
# #
# You can find Gradle at https://github.com/gradle/gradle/. # You can find Gradle at https://github.com/gradle/gradle/.
@@ -114,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;; NONSTOP* ) nonstop=true ;;
esac esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM. # Determine the Java command to use to start the JVM.
@@ -172,7 +171,6 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java # For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" ) JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -212,8 +210,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \ set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \ "-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
org.gradle.wrapper.GradleWrapperMain \
"$@" "$@"
# Stop when "xargs" is not available. # Stop when "xargs" is not available.
+12 -24
View File
@@ -19,12 +19,12 @@
@if "%DEBUG%"=="" @echo off @if "%DEBUG%"=="" @echo off
@rem ########################################################################## @rem ##########################################################################
@rem @rem
@rem Gradle startup script for Windows @rem gradlew startup script for Windows
@rem @rem
@rem ########################################################################## @rem ##########################################################################
@rem Set local scope for the variables with windows NT shell @rem Set local scope for the variables, and ensure extensions are enabled
if "%OS%"=="Windows_NT" setlocal setlocal EnableExtensions
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=. if "%DIRNAME%"=="" set DIRNAME=.
@@ -51,7 +51,7 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
goto fail "%COMSPEC%" /c exit 1
:findJavaFromJavaHome :findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=% set JAVA_HOME=%JAVA_HOME:"=%
@@ -65,30 +65,18 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
goto fail "%COMSPEC%" /c exit 1
:execute :execute
@rem Setup the command line @rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle @rem Execute gradlew
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* @rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:end :exitWithErrorLevel
@rem End local scope for the variables with windows NT shell @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
if %ERRORLEVEL% equ 0 goto mainEnd "%COMSPEC%" /c exit %ERRORLEVEL%
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+11 -2
View File
@@ -1,7 +1,16 @@
pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } } pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement { dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories { google(); mavenCentral() } repositories {
google()
mavenCentral()
}
} }
rootProject.name = "YuvomiMobile" rootProject.name = "YuvomiMobile"
include(":app") include(":app")
+41
View File
@@ -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.
+67
View File
@@ -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.
+39
View File
@@ -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.
+77
View File
@@ -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.
+72
View File
@@ -0,0 +1,72 @@
# Release process
No release is authorized by the existence of these scripts. No workflow publishes
to either store. A debug APK or unsigned archive is not a production release.
1. Complete every outstanding gate in `validation.md` and `security-review.md`.
Obtain independent human review of authentication, navigation, file handlers,
local erasure and privacy disclosures. Run actual supported device/OS tests.
2. Resolve bundle/application identifier ownership. `community.yuvomi.mobile` is
a provisional community identifier, not proof of registry/store ownership.
Confirm use of the Yuvomi name, required attribution and independent status.
3. Bump version/name/code in Android and the iOS project spec; update CHANGELOG.
4. Run 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.
+47
View File
@@ -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.
+70
View File
@@ -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.
+130
View File
@@ -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).
+18
View File
@@ -0,0 +1,18 @@
// swift-tools-version: 6.0
import PackageDescription
// Portable tests for the exact production URL policy. This is not an iOS build.
let package = Package(
name: "YuvomiPolicy",
products: [.library(name: "Yuvomi", targets: ["Yuvomi"])],
targets: [
.target(
name: "Yuvomi", path: "Sources",
exclude: [
"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
}
}
+77
View File
@@ -0,0 +1,77 @@
import SwiftUI
import WebKit
struct AuthenticationView: View {
let destination: URL
@ObservedObject var model: BrowserModel
var body: some View {
NavigationStack {
IsolatedAuthentication(url: destination, model: model)
.navigationTitle(model.authenticationHost)
.navigationBarTitleDisplayMode(.inline)
.toolbar { Button("Close") { model.authentication = nil } }
}
}
}
struct IsolatedAuthentication: UIViewRepresentable {
let url: URL
let model: BrowserModel
func makeCoordinator() -> Coordinator { Coordinator(model) }
func makeUIView(context: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
// Same cookie store as the initiating Yuvomi handshake; no manual cookie export.
configuration.websiteDataStore = model.web.configuration.websiteDataStore
configuration.preferences.javaScriptCanOpenWindowsAutomatically = false
configuration.mediaTypesRequiringUserActionForPlayback = .all
let view = WKWebView(frame: .zero, configuration: configuration)
view.navigationDelegate = context.coordinator
view.uiDelegate = context.coordinator
if #available(iOS 16.4, *) { view.isInspectable = false }
view.load(URLRequest(url: url))
return view
}
func updateUIView(_ uiView: WKWebView, context: Context) {}
static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) {
uiView.stopLoading()
uiView.navigationDelegate = nil
uiView.uiDelegate = nil
}
final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate {
let model: BrowserModel
init(_ model: BrowserModel) { self.model = model }
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
model.authenticationHost = webView.url?.host ?? "Sign in"
}
func webView(
_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
guard let url = navigationAction.request.url, url.scheme == "https",
url.user == nil, url.password == nil
else {
decisionHandler(.cancel)
return
}
decisionHandler(.allow)
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
if let url = webView.url, model.server?.owns(url) == true, !url.path.hasPrefix("/api/") {
model.authentication = nil
model.web.load(URLRequest(url: url))
}
}
func webView(
_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType,
decisionHandler: @escaping (WKPermissionDecision) -> Void
) { decisionHandler(.deny) }
func webView(
_ webView: WKWebView, runOpenPanelWith parameters: WKOpenPanelParameters,
initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping @MainActor @Sendable ([URL]?) -> Void
) {
completionHandler(nil)
}
}
}
+310
View File
@@ -0,0 +1,310 @@
import SwiftUI
import WebKit
struct WebDestination: Identifiable {
let id = UUID()
let url: URL
}
@MainActor
final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUIDelegate, WKDownloadDelegate {
@Published var loading = false
@Published var canGoBack = false
@Published var error: String?
@Published var externalURL: URL?
@Published var authentication: WebDestination?
@Published var authenticationHost = "Sign in"
@Published var download: WebDestination?
private(set) var server: ServerAddress?
private(set) var web: WKWebView
private var observations: [NSKeyValueObservation] = []
private var deadline: Timer?
private var activeDownload: WKDownload?
private var downloadLocation: URL?
override init() {
web = Self.makeWebView()
super.init()
configureWebView()
// Only app-owned temporary exports are removed, never user-saved documents.
let folder = Self.exportDirectory
try? FileManager.default.removeItem(at: folder)
}
private static func makeWebView() -> WKWebView {
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .default()
configuration.preferences.javaScriptCanOpenWindowsAutomatically = false
configuration.allowsInlineMediaPlayback = true
configuration.mediaTypesRequiringUserActionForPlayback = .all
return WKWebView(frame: .zero, configuration: configuration)
}
private func configureWebView() {
web.navigationDelegate = self
web.uiDelegate = self
web.allowsBackForwardNavigationGestures = true
web.isOpaque = false
if #available(iOS 16.4, *) { web.isInspectable = false }
observations.append(
web.observe(\.canGoBack, options: [.new]) { [weak self] view, _ in
self?.canGoBack = view.canGoBack
})
}
static var exportDirectory: URL {
FileManager.default.temporaryDirectory.appendingPathComponent("YuvomiExports", isDirectory: true)
}
func open(_ server: ServerAddress) {
self.server = server
let path = UserDefaults.standard.string(forKey: "path") ?? "/"
let restored = URL(string: path, relativeTo: server.url)?.absoluteURL
let url = restored.flatMap { server.owns($0) ? $0 : nil } ?? server.url
web.load(URLRequest(url: url))
}
func rememberPath() {
guard let url = web.url, server?.owns(url) == true else { return }
let path = url.path
guard !["auth", "reset", "invite", "login"].contains(where: { path.localizedCaseInsensitiveContains($0) })
else { return }
UserDefaults.standard.set(path, forKey: "path")
}
func erase(completion: @escaping () -> Void) {
deadline?.invalidate()
web.stopLoading()
activeDownload?.cancel { _ in }
activeDownload = nil
authentication = nil
removeDownload()
server = nil
web.navigationDelegate = nil
web.uiDelegate = nil
observations.removeAll()
WKWebsiteDataStore.default().removeData(
ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(),
modifiedSince: .distantPast
) { [weak self] in
guard let self else { return }
self.web = Self.makeWebView()
self.configureWebView()
self.canGoBack = false
self.loading = false
self.error = nil
completion()
}
}
func startAuthentication(_ url: URL) {
guard url.scheme == "https", url.user == nil, url.password == nil else { return }
authenticationHost = url.host ?? "Sign in"
authentication = WebDestination(url: url)
}
func removeDownload(expected: URL? = nil) {
if let expected, download?.url != expected, downloadLocation != expected { return }
download = nil
if let location = downloadLocation {
try? FileManager.default.removeItem(at: location.deletingLastPathComponent())
}
downloadLocation = nil
}
func webView(
_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
guard let url = navigationAction.request.url else {
decisionHandler(.cancel)
return
}
if server?.owns(url) == true || (navigationAction.shouldPerformDownload && server?.ownsDownload(url) == true) {
decisionHandler(navigationAction.shouldPerformDownload ? .download : .allow)
} else {
decisionHandler(.cancel)
if navigationAction.targetFrame?.isMainFrame != false {
if url.scheme == "https", url.host != nil, url.user == nil, url.password == nil {
externalURL = url
} else {
error = "This link type is blocked for your security."
}
}
}
}
func webView(
_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse,
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void
) {
if let response = navigationResponse.response as? HTTPURLResponse,
navigationResponse.isForMainFrame, response.statusCode >= 400
{
error = "The server is unavailable (HTTP \(response.statusCode))."
}
guard let url = navigationResponse.response.url, server?.ownsDownload(url) == true else {
decisionHandler(.cancel)
return
}
if !navigationResponse.canShowMIMEType {
guard navigationResponse.response.expectedContentLength <= 100 * 1024 * 1024 else {
error = "This file exceeds the 100 MB download limit."
decisionHandler(.cancel)
return
}
decisionHandler(.download)
} else {
decisionHandler(.allow)
}
}
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
loading = true
deadline?.invalidate()
deadline = Timer.scheduledTimer(withTimeInterval: 30, repeats: false) { [weak self] _ in
Task { @MainActor in
self?.loading = false
self?.error = "The server is taking too long. Check your connection or try again."
}
}
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
loading = false
deadline?.invalidate()
rememberPath()
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { failed(error) }
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
failed(error)
}
private func failed(_ failure: Error) {
loading = false
deadline?.invalidate()
guard (failure as NSError).code != NSURLErrorCancelled else { return }
error =
"Unable to connect securely. Check your network, VPN and server certificate. Cached pages may still be available."
}
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
error = "Yuvomi's browser was interrupted. Tap Retry to restore the page."
loading = false
}
func webView(
_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures
) -> WKWebView? {
if let url = navigationAction.request.url {
if server?.owns(url) == true {
web.load(navigationAction.request)
} else if url.scheme == "https", url.user == nil, url.password == nil {
externalURL = url
}
}
return nil
}
func webView(
_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType,
decisionHandler: @escaping (WKPermissionDecision) -> Void
) {
// File input capture uses Apple's picker. Live camera/microphone access is not required by upstream.
decisionHandler(.deny)
}
func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
attach(download)
}
func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) {
attach(download)
}
private func attach(_ download: WKDownload) {
guard activeDownload == nil, self.download == nil else {
download.cancel { _ in }
error = "Finish the current download before starting another."
return
}
activeDownload = download
download.delegate = self
}
func download(
_ download: WKDownload, decideDestinationUsing response: URLResponse,
suggestedFilename: String, completionHandler: @escaping (URL?) -> Void
) {
guard let url = response.url, server?.ownsDownload(url) == true,
response.expectedContentLength <= 100 * 1024 * 1024
else {
completionHandler(nil)
return
}
do {
let folder = Self.exportDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(
at: folder, withIntermediateDirectories: true,
attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication])
var values = URLResourceValues()
values.isExcludedFromBackup = true
var directory = folder
try directory.setResourceValues(values)
let name = String(
suggestedFilename.map { $0.isLetter || $0.isNumber || "._ -".contains($0) ? $0 : "_" }.prefix(120))
let safeName = name.isEmpty || name == "." || name == ".." ? "download" : name
downloadLocation = folder.appendingPathComponent(safeName)
completionHandler(downloadLocation)
} catch {
completionHandler(nil)
self.error = "Could not prepare the download."
}
}
func download(
_ download: WKDownload, willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest, decisionHandler: @escaping (WKDownload.RedirectPolicy) -> Void
) {
// Do not replay session credentials to a redirect target.
decisionHandler(.cancel)
}
func downloadDidFinish(_ download: WKDownload) {
guard activeDownload === download else { return }
activeDownload = nil
guard let location = downloadLocation,
let size = try? location.resourceValues(forKeys: [.fileSizeKey]).fileSize,
size <= 100 * 1024 * 1024
else {
removeDownload()
error = "Download exceeded the size limit."
return
}
self.download = WebDestination(url: location)
}
func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) {
guard activeDownload === download else { return }
activeDownload = nil
removeDownload()
self.error = "Download failed. Check your connection and sign-in, then retry."
}
}
struct BrowserView: UIViewRepresentable {
@ObservedObject var model: BrowserModel
func makeUIView(context: Context) -> WKWebView { model.web }
func updateUIView(_ uiView: WKWebView, context: Context) {}
}
struct ExportView: UIViewControllerRepresentable {
let url: URL
let completion: () -> Void
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
let picker = UIDocumentPickerViewController(forExporting: [url], asCopy: true)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
func makeCoordinator() -> Coordinator { Coordinator(completion) }
final class Coordinator: NSObject, UIDocumentPickerDelegate {
let completion: () -> Void
init(_ completion: @escaping () -> Void) { self.completion = completion }
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { completion() }
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
completion()
}
}
}
+178
View File
@@ -0,0 +1,178 @@
import SwiftUI
import WebKit
struct ContentView: View {
@StateObject private var browser = BrowserModel()
@State private var input = UserDefaults.standard.string(forKey: "origin") ?? ""
@State private var server: ServerAddress?
@State private var connecting = false
@State private var message: String?
@State private var settings = false
@State private var erasing = false
@Environment(\.scenePhase) private var scenePhase
@Environment(\.colorScheme) private var colorScheme
var body: some View {
NavigationStack {
Group {
if server != nil {
ZStack {
BrowserView(model: browser)
if let error = browser.error {
VStack(spacing: 18) {
Image(systemName: "wifi.exclamationmark").font(.largeTitle)
Text("Connection interrupted").font(.title2).bold()
Text(error)
Button("Retry") {
browser.error = nil
browser.web.reload()
}
Button("Keep viewing") { browser.error = nil }
Button("Server settings") { settings = true }
}
.padding(24).frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(uiColor: .systemGroupedBackground))
.accessibilityElement(children: .contain)
}
if browser.loading {
ProgressView("Loading Yuvomi").padding().background(.regularMaterial).cornerRadius(12)
}
}
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
browser.web.goBack()
} label: {
Image(systemName: "chevron.backward")
}
.disabled(!browser.canGoBack).accessibilityLabel("Back")
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Server settings") { settings = true }
}
}
} else {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Image(systemName: "house.fill").font(.system(size: 48)).foregroundStyle(.tint)
.accessibilityHidden(true)
Text("Your home, connected").font(.largeTitle).bold()
Text("Connect to your own Yuvomi server. Your data stays with your household.")
TextField("https://family.example.com", text: $input)
.keyboardType(.URL).textContentType(.URL)
.textInputAutocapitalization(.never).autocorrectionDisabled()
.textFieldStyle(.roundedBorder).accessibilityLabel("Yuvomi server URL")
Text("HTTPS required. Custom ports supported. Use a dedicated hostname, not a subpath.")
.font(.callout).foregroundStyle(.secondary)
if let message { Text(message).accessibilityIdentifier("connectionError") }
if connecting { ProgressView("Checking your server") }
Button("Connect securely") { connect() }
.buttonStyle(.borderedProminent).disabled(connecting || erasing)
.frame(minHeight: 48)
Text("Independent community app. No telemetry or mandatory cloud service.")
.font(.footnote).foregroundStyle(.secondary)
}.padding(24)
}.background(Color(uiColor: .systemGroupedBackground))
}
}
.navigationTitle("Yuvomi Mobile").navigationBarTitleDisplayMode(.inline)
.tint(
colorScheme == .dark
? Color(red: 167 / 255, green: 139 / 255, blue: 250 / 255)
: Color(red: 108 / 255, green: 58 / 255, blue: 237 / 255))
}
.task {
if server == nil, let existing = try? ServerAddress(input) {
server = existing
browser.open(existing)
}
}
.onChange(of: scenePhase) { _, phase in
if phase == .background { browser.rememberPath() }
}
.overlay {
if scenePhase != .active {
Color(uiColor: .systemBackground).ignoresSafeArea()
.overlay(Text("Yuvomi Mobile").font(.title).bold())
}
}
.sheet(isPresented: $settings) {
NavigationStack {
Form {
Section("Your server") {
Text(server?.url.absoluteString ?? "")
if let url = server?.url {
ShareLink("Share server address", item: url)
Link("Open server in system browser", destination: url)
}
}
Section {
Text(
"Changing servers erases cookies, storage and cached household data. Exported files remain. Sign out in Yuvomi first to revoke the server session."
)
Button("Erase local data and change server", role: .destructive) {
settings = false
erasing = true
browser.erase {
UserDefaults.standard.removeObject(forKey: "origin")
UserDefaults.standard.removeObject(forKey: "path")
server = nil
input = ""
erasing = false
}
}
}
Section("About") {
Text(
"Independent community contribution. MIT licensed. Not endorsed by upstream Yuvomi. No mobile telemetry. Your server's own privacy settings still apply."
)
}
}.navigationTitle("Server settings")
.toolbar { Button("Done") { settings = false } }
}
}
.alert(
"External destination",
isPresented: Binding(
get: { browser.externalURL != nil }, set: { if !$0 { browser.externalURL = nil } }
), presenting: browser.externalURL
) { url in
Button("Open in browser") {
UIApplication.shared.open(url)
browser.externalURL = nil
}
Button("Isolated sign-in window") {
browser.startAuthentication(url)
browser.externalURL = nil
}
Button("Cancel", role: .cancel) { browser.externalURL = nil }
} message: { url in
Text(
"Destination: \(url.host ?? "")\nFor SSO, use the isolated window only if you trust this provider. Browser sessions do not transfer into the app."
)
}
.sheet(item: $browser.authentication) { destination in
AuthenticationView(destination: destination.url, model: browser)
}
.sheet(item: $browser.download) { file in
ExportView(url: file.url) { browser.removeDownload(expected: file.url) }
}
}
private func connect() {
do {
let candidate = try ServerAddress(input)
connecting = true
message = nil
Task { @MainActor in
defer { connecting = false }
do {
try await ServerProbe.check(candidate)
UserDefaults.standard.set(candidate.url.absoluteString, forKey: "origin")
server = candidate
browser.open(candidate)
} catch { message = ConnectionError.unavailable.localizedDescription }
}
} catch { message = ConnectionError.invalidAddress.localizedDescription }
}
}
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>NSPrivacyTracking</key><false/>
<key>NSPrivacyTrackingDomains</key><array/>
<key>NSPrivacyCollectedDataTypes</key><array/>
<key>NSPrivacyAccessedAPITypes</key><array>
<dict><key>NSPrivacyAccessedAPIType</key><string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key><array><string>CA92.1</string></array></dict>
</array>
</dict></plist>
+57
View File
@@ -0,0 +1,57 @@
import Foundation
struct ServerAddress: Equatable {
let url: URL
init(_ input: String) throws {
let value = input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.contains("\\"), !value.contains(where: { $0.isWhitespace }),
var parts = URLComponents(string: value),
parts.scheme?.lowercased() == "https",
let host = parts.host, !host.isEmpty,
parts.user == nil, parts.password == nil,
parts.query == nil, parts.fragment == nil,
parts.percentEncodedPath.isEmpty || parts.percentEncodedPath == "/",
parts.port == nil || (1...65535).contains(parts.port!)
else {
throw ConnectionError.invalidAddress
}
parts.scheme = "https"
parts.host = host.lowercased()
parts.path = ""
if parts.port == 443 { parts.port = nil }
guard let url = parts.url else { throw ConnectionError.invalidAddress }
self.url = url
}
func owns(_ candidate: URL) -> Bool {
guard let parts = URLComponents(url: candidate, resolvingAgainstBaseURL: false),
let own = URLComponents(url: url, resolvingAgainstBaseURL: false)
else { return false }
return parts.scheme?.lowercased() == "https" && parts.user == nil && parts.password == nil
&& parts.host?.lowercased() == own.host?.lowercased()
&& (parts.port ?? 443) == (own.port ?? 443)
}
func ownsDownload(_ candidate: URL) -> Bool {
if owns(candidate) { return true }
guard candidate.scheme == "blob",
let inner = URL(string: String(candidate.absoluteString.dropFirst(5)))
else { return false }
return owns(inner)
}
}
enum ConnectionError: LocalizedError {
case invalidAddress, incompatible, unavailable
var errorDescription: String? {
switch self {
case .invalidAddress:
return "Use a root HTTPS URL with a valid host and port. Remove credentials, subpaths, query and fragment."
case .incompatible:
return "This server does not expose compatible Yuvomi metadata."
case .unavailable:
return "Cannot verify the server. Check its certificate, VPN, network and reverse proxy."
}
}
}
+19
View File
@@ -0,0 +1,19 @@
import Foundation
enum ServerMetadata {
private struct Bootstrap: Decodable {
let appName: String
let setupRequired: Bool
let passwordResetEnabled: Bool
}
private struct Authentication: Decodable {
let enabled: Bool
let passwordLoginEnabled: Bool
}
static func validate(bootstrap: Data, authentication: Data) throws {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
_ = try decoder.decode(Bootstrap.self, from: bootstrap)
_ = try decoder.decode(Authentication.self, from: authentication)
}
}
+40
View File
@@ -0,0 +1,40 @@
import Foundation
final class NoRedirect: NSObject, URLSessionTaskDelegate {
func urlSession(
_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void
) {
completionHandler(nil)
}
}
enum ServerProbe {
static func check(_ server: ServerAddress) async throws {
let configuration = URLSessionConfiguration.ephemeral
configuration.httpCookieStorage = nil
configuration.urlCache = nil
configuration.timeoutIntervalForRequest = 10
configuration.timeoutIntervalForResource = 20
let session = URLSession(configuration: configuration, delegate: NoRedirect(), delegateQueue: nil)
defer { session.invalidateAndCancel() }
let version = try await json(session, server.url.appendingPathComponent("api/v1/version"))
let auth = try await json(session, server.url.appendingPathComponent("api/v1/auth/oidc/config"))
try ServerMetadata.validate(bootstrap: version, authentication: auth)
}
private static func json(_ session: URLSession, _ url: URL) async throws -> Data {
var request = URLRequest(url: url)
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (stream, response) = try await session.bytes(for: request)
guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw ConnectionError.unavailable }
var data = Data()
for try await byte in stream {
guard data.count < 65_536 else { throw ConnectionError.incompatible }
data.append(byte)
}
return data
}
}
+8
View File
@@ -0,0 +1,8 @@
import SwiftUI
@main
struct YuvomiApp: App {
var body: some Scene {
WindowGroup { ContentView() }
}
}
+40
View File
@@ -0,0 +1,40 @@
import WebKit
import XCTest
@testable import Yuvomi
final class BrowserModelTests: XCTestCase {
@MainActor
func testMainViewHasNoNativeBridgeOrAutomaticPopupAccess() {
let model = BrowserModel()
XCTAssertFalse(model.web.configuration.preferences.javaScriptCanOpenWindowsAutomatically)
XCTAssertTrue(model.web.configuration.userContentController.userScripts.isEmpty)
XCTAssertTrue(model.web.configuration.websiteDataStore.isPersistent)
XCTAssertFalse(model.web.isInspectable)
}
@MainActor
func testLateExportCancellationDoesNotEraseAnotherServersDownload() {
let model = BrowserModel()
let previous = URL(fileURLWithPath: "/old-selection.txt")
let current = URL(fileURLWithPath: "/new-selection.txt")
model.download = WebDestination(url: current)
model.removeDownload(expected: previous)
XCTAssertEqual(model.download?.url, current)
model.removeDownload(expected: current)
XCTAssertNil(model.download)
}
@MainActor
func testServerErasureReplacesHistoryContext() async {
let model = BrowserModel()
let previousView = model.web
await withCheckedContinuation { continuation in
model.erase { continuation.resume() }
}
XCTAssertFalse(previousView === model.web)
XCTAssertNil(model.server)
XCTAssertFalse(model.canGoBack)
XCTAssertNil(model.error)
}
}
+40
View File
@@ -0,0 +1,40 @@
import XCTest
@testable import Yuvomi
final class ServerAddressTests: XCTestCase {
func testCanonicalizationAndPorts() throws {
XCTAssertEqual(try ServerAddress(" HTTPS://Family.Example:443/ ").url.absoluteString, "https://family.example")
XCTAssertEqual(try ServerAddress("https://family.example:8443").url.port, 8443)
XCTAssertEqual(try ServerAddress("https://[::1]:8443").url.port, 8443)
}
func testUnsafeConfigurations() {
for value in [
"http://family.example", "javascript:alert(1)", "https://u:p@family.example",
"https://family.example/subpath", "https://family.example?token=secret",
"https://family.example/#token", "https://family.example:0",
"https://family.example:65536", "https://family.example/%2f",
"https://family.example\\@evil.test", "https:///", "",
] {
XCTAssertThrowsError(try ServerAddress(value), value)
}
}
func testOriginIsolation() throws {
let server = try ServerAddress("https://family.example")
XCTAssertTrue(server.owns(URL(string: "https://FAMILY.example:443/tasks?id=1")!))
for value in [
"https://family.example.evil.test", "https://family.example@evil.test",
"https://user@family.example", "http://family.example", "https://family.example:8443",
"file:///etc/passwd", "data:text/html,hi",
] {
XCTAssertFalse(server.owns(URL(string: value)!), value)
}
}
func testBlobExportsAreOriginBound() throws {
let server = try ServerAddress("https://family.example")
XCTAssertTrue(server.ownsDownload(URL(string: "blob:https://family.example/unique-id")!))
XCTAssertFalse(server.ownsDownload(URL(string: "blob:https://evil.example/unique-id")!))
XCTAssertFalse(server.ownsDownload(URL(string: "blob:http://family.example/unique-id")!))
XCTAssertFalse(server.ownsDownload(URL(string: "file:///tmp/export")!))
}
}
+21
View File
@@ -0,0 +1,21 @@
import XCTest
@testable import Yuvomi
final class ServerMetadataTests: XCTestCase {
private let bootstrap = Data(#"{"app_name":"Yuvomi","setup_required":false,"password_reset_enabled":true}"#.utf8)
private let authentication = Data(#"{"enabled":false,"password_login_enabled":true}"#.utf8)
func testAcceptsActualAnonymousMetadataWithoutVersion() throws {
try ServerMetadata.validate(bootstrap: bootstrap, authentication: authentication)
}
func testRejectsNumericBooleanAndMissingAuthenticationMetadata() {
let numeric = Data(#"{"enabled":1,"password_login_enabled":true}"#.utf8)
XCTAssertThrowsError(try ServerMetadata.validate(bootstrap: bootstrap, authentication: numeric))
XCTAssertThrowsError(try ServerMetadata.validate(bootstrap: bootstrap, authentication: Data("{}".utf8)))
}
func testRejectsProxyHtmlAndInvalidJson() {
XCTAssertThrowsError(
try ServerMetadata.validate(bootstrap: Data("<html>SSO proxy</html>".utf8), authentication: authentication))
XCTAssertThrowsError(try ServerMetadata.validate(bootstrap: Data("null".utf8), authentication: authentication))
}
}
+15
View File
@@ -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)
}
}
+73
View File
@@ -0,0 +1,73 @@
name: Yuvomi
options:
bundleIdPrefix: community.yuvomi
deploymentTarget:
iOS: "18.4"
settings:
base:
SWIFT_VERSION: "5.0"
MARKETING_VERSION: "0.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
+33
View File
@@ -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.")
+10
View File
@@ -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'
+54
View File
@@ -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.")
+68
View File
@@ -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.")
+38
View File
@@ -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")
+9
View File
@@ -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
+53
View File
@@ -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); });
+86
View File
@@ -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(); } };
}
+21
View File
@@ -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"])
+152
View File
@@ -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)
+48
View File
@@ -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()
+18
View File
@@ -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.")