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.
This commit is contained in:
Yuvomi Mobile Contributors committed 2026-10-21 07:25:00 +02:00
1 parent f776c853c6
commit 2b9996ff3f
24 files changed
+1446 -173

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
+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) }
}
}
}
+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,62 +66,132 @@ 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 picker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> private val loadTimeout =
val callback = fileCallback Runnable { showError("The server is taking too long. Check your connection or try again.") }
fileCallback = null private val localNetworkPermission =
val uris = if (result.resultCode != RESULT_OK) null else { registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
val data = result.data if (granted) {
data?.clipData?.let { clip -> Array(clip.itemCount.coerceAtMost(50)) { clip.getItemAt(it).uri } } toast("Private-network access allowed. Retry your server connection.")
?: data?.data?.let { arrayOf(it) } ?: capture?.let { arrayOf(it) } } else {
toast("Private-network access was not granted. Public HTTPS servers can still be used.")
}
} }
callback?.onReceiveValue(uris?.filter { it.scheme == "content" }?.toTypedArray())
capture = null private val picker =
} registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
private val saveDocument = registerForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { destination -> val accepted = uploadSelection.consume(generation)
val url = pendingDownload val callback = fileCallback
pendingDownload = null fileCallback = null
val current = server val uris = if (!accepted || result.resultCode != RESULT_OK) {
if (destination != null && url != null && current != null && current.owns(url)) { null
val cookies = CookieManager.getInstance().getCookie(url) } else {
val epoch = generation val data = result.data
worker.execute { data?.clipData?.let { clip ->
val success = runCatching { FileTransfer.save(current, url, cookies, contentResolver, destination) }.isSuccess Array(clip.itemCount.coerceAtMost(50)) { clip.getItemAt(it).uri }
runOnUiThread { }
if (!isDestroyed && epoch == generation) { ?: data?.data?.let { arrayOf(it) } ?: capture?.let { arrayOf(it) }
if (success) toast("Download saved") else { }
runCatching { contentResolver.delete(destination, null, null) } callback?.onReceiveValue(uris?.filter { it.scheme == "content" }?.toTypedArray())
toast("Download failed. Check your connection and sign-in, then retry.") revokeCapture()
capture = null
}
private val saveDocument =
registerForActivityResult(
ActivityResultContracts.CreateDocument("application/octet-stream"),
) { destination ->
val accepted = downloadSelection.consume(generation)
val url = pendingDownload
pendingDownload = null
if (!accepted) return@registerForActivityResult
val current = server
if (destination != null && url != null && current != null &&
BlobTransfer.owns(current, url)
) {
val view = web ?: return@registerForActivityResult
val epoch = generation
BlobTransfer.save(
current,
view,
url,
contentResolver,
destination,
worker,
{ !isDestroyed && epoch == generation },
) { success ->
if (!success) runCatching { contentResolver.delete(destination, null, null) }
if (!isDestroyed &&
epoch == generation
) {
toast(
if (success) "Export saved" else "Export failed or exceeded the 25 MB limit.",
)
}
}
} else if (destination != null && url != null && current != null && current.owns(url)) {
val cookies = CookieManager.getInstance().getCookie(url)
val epoch = generation
worker.execute {
val success = runCatching {
FileTransfer.save(current, url, cookies, contentResolver, destination)
}.isSuccess
if (!success) runCatching { contentResolver.delete(destination, null, null) }
runOnUiThread {
if (!isDestroyed && epoch == generation) {
if (success) {
toast("Download saved")
} else {
toast(
"Download failed. Check your connection and sign-in, then retry.",
)
}
} }
} }
} }
} }
} }
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (savedInstanceState?.getBoolean("uploadOutstanding") == true) uploadSelection.restoreOutstanding()
if (savedInstanceState?.getBoolean("downloadOutstanding") == true) downloadSelection.restoreOutstanding()
// Household data is hidden from screenshots and the recent-apps preview. // 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(
override fun handleOnBackPressed() { this,
if (web?.canGoBack() == true) web?.goBack() object : OnBackPressedCallback(true) {
else if (web != null) settings() override fun handleOnBackPressed() {
else { isEnabled = false; onBackPressedDispatcher.onBackPressed() } if (web?.canGoBack() == true) {
} web?.goBack()
}) } else if (web != null) {
File(cacheDir, "captures").listFiles()?.filter { it.lastModified() < System.currentTimeMillis() - 86_400_000 } moveTaskToBack(true)
} else {
isEnabled = false
onBackPressedDispatcher.onBackPressed()
}
}
},
)
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 intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { val types = params.acceptTypes.filter {
addCategory(Intent.CATEGORY_OPENABLE) it.matches(Regex("[a-zA-Z0-9.+*-]+/[a-zA-Z0-9.+*-]+"))
type = types.singleOrNull() ?: "*/*" }
if (types.size > 1) putExtra(Intent.EXTRA_MIME_TYPES, types.toTypedArray()) val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, params.mode == FileChooserParams.MODE_OPEN_MULTIPLE) addCategory(Intent.CATEGORY_OPENABLE)
} type = types.singleOrNull() ?: "*/*"
val chooser = Intent.createChooser(intent, "Select a file") if (types.size > 1) putExtra(Intent.EXTRA_MIME_TYPES, types.toTypedArray())
if (types.any { it.startsWith("image/") }) { putExtra(
val directory = File(cacheDir, "captures").apply { mkdirs() } Intent.EXTRA_ALLOW_MULTIPLE,
capture = FileProvider.getUriForFile(this@MainActivity, "$packageName.files", File.createTempFile("capture-", ".jpg", directory)) params.mode == FileChooserParams.MODE_OPEN_MULTIPLE,
chooser.putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply { )
putExtra(MediaStore.EXTRA_OUTPUT, capture) }
addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION) val chooser = Intent.createChooser(intent, "Select a file")
})) if (types.any { it.startsWith("image/") }) {
} val directory = File(cacheDir, "captures").apply { mkdirs() }
runCatching { picker.launch(chooser) }.onFailure { capture =
fileCallback?.onReceiveValue(null); fileCallback = null; toast("No compatible file picker is available.") FileProvider.getUriForFile(
this@MainActivity,
"$packageName.files",
File.createTempFile("capture-", ".jpg", directory),
)
chooser.putExtra(
Intent.EXTRA_INITIAL_INTENTS,
arrayOf(
Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
putExtra(MediaStore.EXTRA_OUTPUT, capture)
clipData =
android.content.ClipData.newRawUri("Yuvomi photo", capture)
addFlags(
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_READ_URI_PERMISSION,
)
},
),
)
}
picker.launch(chooser)
}.onFailure {
uploadSelection.consume(generation)
fileCallback?.onReceiveValue(null)
fileCallback = null
toast("No compatible file picker is available.")
} }
return true 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,8 +1,28 @@
plugins { buildscript {
id("com.android.application") version "8.13.2" apply false repositories {
id("org.jetbrains.kotlin.android") version "2.2.10" apply false google()
mavenCentral()
}
// Override AGP's bundled KGP to include CVE-2026-53914 remediation.
dependencies { classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.21") }
configurations.getByName("classpath").resolutionStrategy {
activateDependencyLocking()
eachDependency {
when (requested.group) {
"io.netty" -> useVersion("4.1.139.Final")
"com.google.protobuf" -> useVersion("3.25.9")
"org.bouncycastle" -> useVersion("1.86")
"org.apache.commons" -> if (requested.name == "commons-lang3") useVersion("3.21.0")
"org.apache.httpcomponents" -> if (requested.name == "httpclient") useVersion("4.5.14")
"org.bitbucket.b_c" -> if (requested.name == "jose4j") useVersion("0.9.6")
"org.jdom" -> if (requested.name == "jdom2") useVersion("2.0.6.1")
}
}
}
} }
plugins { id("com.android.application") version "9.4.1" apply false }
// WSL mounted Windows volumes may reject Gradle's POSIX file metadata operations. // WSL mounted Windows volumes may reject Gradle's POSIX file metadata operations.
// Opt-in external output root; ordinary developer/CI builds retain standard paths. // Opt-in external output root; ordinary developer/CI builds retain standard paths.
val outputRoot = providers.environmentVariable("YUVOMI_BUILD_DIR") val outputRoot = providers.environmentVariable("YUVOMI_BUILD_DIR")
+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")