SHA256
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:
1 parent
f776c853c6
commit
2b9996ff3f
24 files changed
+1446
-173
No files matched your search
@@ -0,0 +1,18 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.{yml,yaml,json,xml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.{kt,kts}]
|
||||||
|
max_line_length = 140
|
||||||
|
ktlint_code_style = android_studio
|
||||||
|
ij_kotlin_allow_trailing_comma = true
|
||||||
|
ij_kotlin_allow_trailing_comma_on_call_site = true
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "community.yuvomi.mobile"
|
namespace = "community.yuvomi.mobile"
|
||||||
compileSdk = 36
|
compileSdk { version = release(37) { minorApiLevel = 0 } }
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "community.yuvomi.mobile"
|
applicationId = "community.yuvomi.mobile"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 37
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.1.0"
|
versionName = "0.1.0"
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
@@ -18,19 +17,75 @@ android {
|
|||||||
release {
|
release {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro",
|
||||||
|
)
|
||||||
// Signing is deliberately supplied by the authorized release maintainer.
|
// Signing is deliberately supplied by the authorized release maintainer.
|
||||||
}
|
}
|
||||||
|
create("integration") {
|
||||||
|
initWith(getByName("debug"))
|
||||||
|
applicationIdSuffix = ".integration"
|
||||||
|
matchingFallbacks += "debug"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
lint { abortOnError = true; warningsAsErrors = true }
|
lint {
|
||||||
|
abortOnError = true
|
||||||
|
warningsAsErrors = true
|
||||||
|
}
|
||||||
|
if (providers.gradleProperty("integration").isPresent) {
|
||||||
|
testBuildType = "integration"
|
||||||
|
sourceSets.getByName(
|
||||||
|
"androidTest",
|
||||||
|
).kotlin.directories.add("src/androidTestIntegration/java")
|
||||||
|
val testResources = providers.environmentVariable("YUVOMI_TEST_RES").get()
|
||||||
|
sourceSets.getByName("integration").res.directories.add(testResources)
|
||||||
|
defaultConfig.testInstrumentationRunnerArguments["server"] =
|
||||||
|
providers.environmentVariable("YUVOMI_TEST_URL").get()
|
||||||
|
defaultConfig.testInstrumentationRunnerArguments["password"] =
|
||||||
|
providers.environmentVariable("YUVOMI_TEST_PASSWORD").get()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } }
|
kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } }
|
||||||
|
|
||||||
|
dependencyLocking { lockAllConfigurations() }
|
||||||
|
|
||||||
|
// AGP's device-test tools still request vulnerable older wire libraries. These
|
||||||
|
// are host-only, not APK dependencies; stay within binary-compatible minor lines.
|
||||||
|
configurations.configureEach {
|
||||||
|
if (name.startsWith("_internal-unified-test-platform") || name == "androidLintTool" ||
|
||||||
|
name == "unified-test-platform-gradle-work-action"
|
||||||
|
) {
|
||||||
|
resolutionStrategy.eachDependency {
|
||||||
|
when (requested.group) {
|
||||||
|
"io.netty" -> {
|
||||||
|
useVersion("4.1.139.Final")
|
||||||
|
because("Remediate published host-test HTTP/TLS advisories")
|
||||||
|
}
|
||||||
|
"com.google.protobuf" -> {
|
||||||
|
useVersion("3.25.9")
|
||||||
|
because("Remediate protobuf recursion/DoS advisories")
|
||||||
|
}
|
||||||
|
"org.bouncycastle" -> {
|
||||||
|
useVersion("1.86")
|
||||||
|
because("Remediate published build-tool cryptography advisories")
|
||||||
|
}
|
||||||
|
"org.apache.commons" -> if (requested.name == "commons-lang3") useVersion("3.21.0")
|
||||||
|
"org.apache.httpcomponents" -> if (requested.name ==
|
||||||
|
"httpclient"
|
||||||
|
) {
|
||||||
|
useVersion("4.5.14")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("androidx.activity:activity-ktx:1.10.1")
|
implementation("androidx.activity:activity-ktx:1.10.1")
|
||||||
implementation("androidx.core:core-ktx:1.16.0")
|
implementation("androidx.core:core-ktx:1.16.0")
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# This is a Gradle generated file for dependency locking.
|
||||||
|
# Manual edits can break the build and are not advised.
|
||||||
|
# This file is expected to be part of source control.
|
||||||
|
# To regenerate this file, run: ./gradlew :app:dependencies --write-locks
|
||||||
|
androidx.activity:activity-ktx:1.10.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.activity:activity:1.10.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.annotation:annotation-experimental:1.4.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.annotation:annotation-jvm:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.annotation:annotation:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.arch.core:core-common:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.arch.core:core-runtime:2.2.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.collection:collection-jvm:1.4.2=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.collection:collection:1.0.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,integrationCompileClasspath,releaseCompileClasspath
|
||||||
|
androidx.collection:collection:1.4.2=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.concurrent:concurrent-futures-ktx:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
androidx.concurrent:concurrent-futures:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.core:core-ktx:1.16.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.core:core-viewtree:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.core:core:1.16.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.interpolator:interpolator:1.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.lifecycle:lifecycle-common:2.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.lifecycle:lifecycle-livedata-core:2.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.lifecycle:lifecycle-runtime-ktx:2.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.lifecycle:lifecycle-runtime:2.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.lifecycle:lifecycle-viewmodel:2.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.profileinstaller:profileinstaller:1.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.savedstate:savedstate-ktx:1.2.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.savedstate:savedstate:1.2.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.startup:startup-runtime:1.1.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.test.espresso:espresso-core:3.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
androidx.test.espresso:espresso-idling-resource:3.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
androidx.test.ext:junit:1.2.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
androidx.test.services:storage:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
androidx.test:core:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
androidx.test:monitor:1.7.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
androidx.test:runner:1.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
androidx.tracing:tracing:1.1.0=debugAndroidTestCompileClasspath
|
||||||
|
androidx.tracing:tracing:1.2.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.versionedparcelable:versionedparcelable:1.1.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.webkit:webkit:1.14.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.android.tools.analytics-library:protos:32.4.1=androidLintTool
|
||||||
|
com.android.tools.analytics-library:shared:32.4.1=androidLintTool
|
||||||
|
com.android.tools.analytics-library:tracker:32.4.1=androidLintTool
|
||||||
|
com.android.tools.androidtest:android-test-engine-result-listener:1.0.1=unified-test-platform-gradle-work-action
|
||||||
|
com.android.tools.androidtest:android-test-engine:1.0.1=unified-test-platform-gradle-work-action
|
||||||
|
com.android.tools.androidtest:test-result-event-proto:1.0.1=unified-test-platform-gradle-work-action
|
||||||
|
com.android.tools.androidtest:test-result-proto:1.0.1=unified-test-platform-gradle-work-action
|
||||||
|
com.android.tools.build:aapt2-proto:9.4.1-15978811=androidLintTool
|
||||||
|
com.android.tools.build:builder-model:9.4.1=androidLintTool
|
||||||
|
com.android.tools.build:manifest-merger:32.4.1=androidLintTool
|
||||||
|
com.android.tools.ddms:ddmlib:32.4.1=androidLintTool
|
||||||
|
com.android.tools.external.com-intellij:intellij-core:32.4.1=androidLintTool
|
||||||
|
com.android.tools.external.com-intellij:kotlin-compiler:32.4.1=androidLintTool
|
||||||
|
com.android.tools.external.org-jetbrains:uast:32.4.1=androidLintTool
|
||||||
|
com.android.tools.layoutlib:layoutlib-api:32.4.1=androidLintTool
|
||||||
|
com.android.tools.lint:lint-api:32.4.1=androidLintTool
|
||||||
|
com.android.tools.lint:lint-checks:32.4.1=androidLintTool
|
||||||
|
com.android.tools.lint:lint-gradle:32.4.1=androidLintTool
|
||||||
|
com.android.tools.lint:lint-model:32.4.1=androidLintTool
|
||||||
|
com.android.tools.lint:lint-typedef-remover:32.4.1=androidLintTool
|
||||||
|
com.android.tools.lint:lint:32.4.1=androidLintTool
|
||||||
|
com.android.tools.utp:gradle-work-action:32.4.1=unified-test-platform-gradle-work-action
|
||||||
|
com.android.tools:annotations:32.4.1=androidLintTool
|
||||||
|
com.android.tools:common:32.4.1=androidLintTool
|
||||||
|
com.android.tools:dvlib:32.4.1=androidLintTool
|
||||||
|
com.android.tools:play-sdk-proto:32.4.1=androidLintTool
|
||||||
|
com.android.tools:repository:32.4.1=androidLintTool
|
||||||
|
com.android.tools:sdk-common:32.4.1=androidLintTool
|
||||||
|
com.android.tools:sdklib:32.4.1=androidLintTool
|
||||||
|
com.google.code.findbugs:jsr305:3.0.2=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
com.google.code.gson:gson:2.11.0=androidLintTool
|
||||||
|
com.google.errorprone:error_prone_annotations:2.18.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
com.google.errorprone:error_prone_annotations:2.36.0=androidLintTool
|
||||||
|
com.google.guava:failureaccess:1.0.2=androidLintTool
|
||||||
|
com.google.guava:guava:33.4.0-jre=androidLintTool
|
||||||
|
com.google.guava:listenablefuture:1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=androidLintTool
|
||||||
|
com.google.j2objc:j2objc-annotations:3.0.0=androidLintTool
|
||||||
|
com.google.jimfs:jimfs:1.1=androidLintTool
|
||||||
|
com.google.protobuf:protobuf-java:3.25.9=androidLintTool,unified-test-platform-gradle-work-action
|
||||||
|
com.sun.istack:istack-commons-runtime:3.0.8=androidLintTool
|
||||||
|
com.sun.xml.fastinfoset:FastInfoset:1.2.16=androidLintTool
|
||||||
|
commons-codec:commons-codec:1.17.1=androidLintTool
|
||||||
|
commons-io:commons-io:2.16.1=androidLintTool
|
||||||
|
commons-logging:commons-logging:1.2=androidLintTool
|
||||||
|
jakarta.activation:jakarta.activation-api:1.2.1=androidLintTool
|
||||||
|
jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=androidLintTool
|
||||||
|
javax.inject:javax.inject:1=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
junit:junit:4.13.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
net.java.dev.jna:jna-platform:5.6.0=androidLintTool
|
||||||
|
net.java.dev.jna:jna:5.6.0=androidLintTool
|
||||||
|
net.sf.kxml:kxml2:2.3.0=androidLintTool
|
||||||
|
org.apache.commons:commons-compress:1.27.1=androidLintTool
|
||||||
|
org.apache.commons:commons-lang3:3.21.0=androidLintTool
|
||||||
|
org.apache.groovy:groovy-bom:4.0.29=androidLintTool
|
||||||
|
org.apache.groovy:groovy:4.0.29=androidLintTool
|
||||||
|
org.apache.httpcomponents:httpclient:4.5.14=androidLintTool
|
||||||
|
org.apache.httpcomponents:httpcore:4.4.16=androidLintTool
|
||||||
|
org.apache.httpcomponents:httpmime:4.5.6=androidLintTool
|
||||||
|
org.bouncycastle:bcpkix-jdk18on:1.86=androidLintTool
|
||||||
|
org.bouncycastle:bcprov-jdk18on:1.86=androidLintTool
|
||||||
|
org.bouncycastle:bcutil-jdk18on:1.86=androidLintTool
|
||||||
|
org.checkerframework:checker-qual:3.43.0=androidLintTool
|
||||||
|
org.glassfish.jaxb:jaxb-runtime:2.3.2=androidLintTool
|
||||||
|
org.glassfish.jaxb:txw2:2.3.2=androidLintTool
|
||||||
|
org.hamcrest:hamcrest-core:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
org.hamcrest:hamcrest-library:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-build-tools-api:2.4.0=kotlinAbiValidationCompatClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-build-tools-api:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.21=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.0=kotlinAbiValidationCompatClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.0=kotlinAbiValidationCompatClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.0=kotlinAbiValidationCompatClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-compiler-runner:2.4.0=kotlinAbiValidationCompatClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-compiler-runner:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-daemon-client:2.4.0=kotlinAbiValidationCompatClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-daemon-client:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.0=kotlinAbiValidationCompatClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-reflect:2.2.10=androidLintTool
|
||||||
|
org.jetbrains.kotlin:kotlin-script-runtime:2.4.0=kotlinAbiValidationCompatClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-script-runtime:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib-common:2.4.21=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0=debugCompileClasspath,debugUnitTestCompileClasspath,integrationCompileClasspath,releaseCompileClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.20=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.10=androidLintTool,unified-test-platform-gradle-work-action
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0=debugCompileClasspath,debugUnitTestCompileClasspath,integrationCompileClasspath,releaseCompileClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.20=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.10=androidLintTool,unified-test-platform-gradle-work-action
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib:2.2.10=androidLintTool,unified-test-platform-gradle-work-action
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib:2.4.0=kotlinAbiValidationCompatClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib:2.4.21=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-tooling-core:2.4.21=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4=debugCompileClasspath,debugUnitTestCompileClasspath,integrationCompileClasspath,releaseCompileClasspath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1=debugAndroidTestCompileClasspath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.6.4=debugCompileClasspath,debugUnitTestCompileClasspath,integrationCompileClasspath,releaseCompileClasspath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.1=debugAndroidTestCompileClasspath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=androidLintTool,unified-test-platform-gradle-work-action
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.6.4=debugCompileClasspath,debugUnitTestCompileClasspath,integrationCompileClasspath,releaseCompileClasspath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.1=debugAndroidTestCompileClasspath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0=androidLintTool,unified-test-platform-gradle-work-action
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4=debugCompileClasspath,debugUnitTestCompileClasspath,integrationCompileClasspath,releaseCompileClasspath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1=debugAndroidTestCompileClasspath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0=androidLintTool,unified-test-platform-gradle-work-action
|
||||||
|
org.jetbrains:annotations:13.0=debugCompileClasspath,debugUnitTestCompileClasspath,integrationCompileClasspath,kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,releaseCompileClasspath
|
||||||
|
org.jetbrains:annotations:23.0.0=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-gradle-work-action
|
||||||
|
org.jspecify:jspecify:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
org.junit.platform:junit-platform-commons:1.13.3=unified-test-platform-gradle-work-action
|
||||||
|
org.junit.platform:junit-platform-engine:1.13.3=unified-test-platform-gradle-work-action
|
||||||
|
org.junit.platform:junit-platform-launcher:1.13.3=unified-test-platform-gradle-work-action
|
||||||
|
org.junit:junit-bom:5.13.3=unified-test-platform-gradle-work-action
|
||||||
|
org.jvnet.staxex:stax-ex:1.8.1=androidLintTool
|
||||||
|
org.opentest4j:opentest4j:1.3.0=unified-test-platform-gradle-work-action
|
||||||
|
org.ow2.asm:asm-analysis:9.9=androidLintTool
|
||||||
|
org.ow2.asm:asm-commons:9.9=androidLintTool
|
||||||
|
org.ow2.asm:asm-tree:9.9=androidLintTool
|
||||||
|
org.ow2.asm:asm:9.9=androidLintTool
|
||||||
|
empty=androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugReverseMetadataValues,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,integrationAnnotationProcessorClasspath,integrationImplementationDependenciesMetadata,integrationReverseMetadataValues,kotlinCompilerPluginClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathIntegration,kotlinCompilerPluginClasspathRelease,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata,releaseReverseMetadataValues
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package community.yuvomi.mobile
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.test.core.app.ActivityScenario
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class BlobExportTest {
|
||||||
|
@Test fun rejectsBlobExportFromOpaqueNativePageEvenWithClaimedOrigin() {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit()
|
||||||
|
.putString("origin", "https://family.example").commit()
|
||||||
|
val file = File(File(context.cacheDir, "captures").apply { mkdirs() }, "blob-test.csv")
|
||||||
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
|
||||||
|
val worker = Executors.newSingleThreadExecutor()
|
||||||
|
try {
|
||||||
|
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
|
||||||
|
lateinit var web: WebView
|
||||||
|
val created = CountDownLatch(1)
|
||||||
|
var blob = ""
|
||||||
|
scenario.onActivity {
|
||||||
|
// A real WebView with a controlled document, not a network mock.
|
||||||
|
// The HTTPS upstream suite separately exercises the app's client.
|
||||||
|
web = WebView(it)
|
||||||
|
web.settings.javaScriptEnabled = true
|
||||||
|
web.loadDataWithBaseURL(
|
||||||
|
"https://family.example/",
|
||||||
|
"<!doctype html><title>Export test</title>",
|
||||||
|
"text/html",
|
||||||
|
"UTF-8",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val deadline = System.nanoTime() + 10_000_000_000L
|
||||||
|
var ready = false
|
||||||
|
while (!ready && System.nanoTime() < deadline) {
|
||||||
|
val checked = CountDownLatch(1)
|
||||||
|
scenario.onActivity {
|
||||||
|
web.evaluateJavascript("location.origin") {
|
||||||
|
ready =
|
||||||
|
it == "\"https://family.example\""
|
||||||
|
checked.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue(checked.await(2, TimeUnit.SECONDS))
|
||||||
|
if (!ready) Thread.sleep(50)
|
||||||
|
}
|
||||||
|
assertTrue("Trusted document loaded", ready)
|
||||||
|
scenario.onActivity {
|
||||||
|
web.evaluateJavascript(
|
||||||
|
"URL.createObjectURL(new Blob(['name,value\\nYuvomi,Grüße\\n'],{type:'text/csv'}))",
|
||||||
|
) {
|
||||||
|
blob = JSONArray("[$it]").getString(0)
|
||||||
|
created.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue(created.await(10, TimeUnit.SECONDS))
|
||||||
|
assertTrue(
|
||||||
|
"Blob inherits trusted origin: $blob",
|
||||||
|
BlobTransfer.owns(ServerAddress.parse("https://family.example"), blob),
|
||||||
|
)
|
||||||
|
assertNotNull(
|
||||||
|
context.contentResolver.openOutputStream(uri, "w")?.also {
|
||||||
|
it.close()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val saved = CountDownLatch(1)
|
||||||
|
var success = false
|
||||||
|
scenario.onActivity {
|
||||||
|
assertFalse(
|
||||||
|
"Native page URL remains opaque despite loadDataWithBaseURL",
|
||||||
|
ServerAddress.parse("https://family.example").owns(web.url.orEmpty()),
|
||||||
|
)
|
||||||
|
BlobTransfer.save(
|
||||||
|
ServerAddress.parse("https://family.example"),
|
||||||
|
web,
|
||||||
|
blob,
|
||||||
|
context.contentResolver,
|
||||||
|
uri,
|
||||||
|
worker,
|
||||||
|
{ true },
|
||||||
|
) {
|
||||||
|
success = it
|
||||||
|
saved.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue(saved.await(50, TimeUnit.SECONDS))
|
||||||
|
assertFalse(success)
|
||||||
|
assertEquals(0L, file.length())
|
||||||
|
scenario.onActivity { web.destroy() }
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
worker.shutdownNow()
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package community.yuvomi.mobile
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.test.core.app.ActivityScenario
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class BrowserSecurityTest {
|
||||||
|
@Test fun blocksCrossOriginPostAndSubresourceBeforeNetwork() {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit()
|
||||||
|
.putString("origin", "https://family.example").commit()
|
||||||
|
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
|
||||||
|
lateinit var web: WebView
|
||||||
|
lateinit var client: android.webkit.WebViewClient
|
||||||
|
scenario.onActivity {
|
||||||
|
web = requireNotNull(findWeb(it.window.decorView))
|
||||||
|
client = web.webViewClient
|
||||||
|
}
|
||||||
|
// Interception runs off the UI thread in WebView. POST navigations do
|
||||||
|
// not reliably call shouldOverrideUrlLoading, so this is a separate gate.
|
||||||
|
for (mainFrame in listOf(true, false)) {
|
||||||
|
val response = client.shouldInterceptRequest(
|
||||||
|
web,
|
||||||
|
Request("https://evil.example/collect", mainFrame),
|
||||||
|
)
|
||||||
|
assertNotNull(response)
|
||||||
|
assertEquals(403, response!!.statusCode)
|
||||||
|
}
|
||||||
|
assertNull(
|
||||||
|
client.shouldInterceptRequest(web, Request("https://family.example/tasks", true)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findWeb(view: View): WebView? {
|
||||||
|
if (view is WebView) return view
|
||||||
|
if (view is ViewGroup) {
|
||||||
|
for (i in 0 until view.childCount) {
|
||||||
|
findWeb(view.getChildAt(i))?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
private class Request(private val value: String, private val mainFrame: Boolean) : WebResourceRequest {
|
||||||
|
override fun getUrl(): Uri = Uri.parse(value)
|
||||||
|
override fun isForMainFrame() = mainFrame
|
||||||
|
override fun isRedirect() = false
|
||||||
|
override fun hasGesture() = true
|
||||||
|
override fun getMethod() = "POST"
|
||||||
|
override fun getRequestHeaders(): MutableMap<String, String> = mutableMapOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
package community.yuvomi.mobile
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.accessibilityservice.AccessibilityServiceInfo
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.view.accessibility.AccessibilityNodeInfo
|
||||||
|
import androidx.test.core.app.ActivityScenario
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.espresso.Espresso.onView
|
||||||
|
import androidx.test.espresso.action.ViewActions.click
|
||||||
|
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class LocalNetworkPermissionTest {
|
||||||
|
@Test fun permissionIsOptionalAndHonorsSystemDenialAndGrant() {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit().clear().commit()
|
||||||
|
ActivityScenario.launch(MainActivity::class.java).use {
|
||||||
|
if (Build.VERSION.SDK_INT < 37 ||
|
||||||
|
context.checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) == PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
onView(withText("Allow private-network servers")).check(doesNotExist())
|
||||||
|
return@use
|
||||||
|
}
|
||||||
|
val automation = InstrumentationRegistry.getInstrumentation().uiAutomation
|
||||||
|
automation.serviceInfo = automation.serviceInfo.apply { flags = flags or AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS }
|
||||||
|
onView(withText("Allow private-network servers")).perform(click())
|
||||||
|
onView(withText("Cancel")).perform(click())
|
||||||
|
assertEquals(PackageManager.PERMISSION_DENIED, context.checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK))
|
||||||
|
onView(withText("Allow private-network servers")).perform(click())
|
||||||
|
onView(withText("Continue")).perform(click())
|
||||||
|
chooseSystemButton("permission_deny_button")
|
||||||
|
await { context.checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) == PackageManager.PERMISSION_DENIED }
|
||||||
|
onView(withText("Allow private-network servers")).perform(click())
|
||||||
|
onView(withText("Continue")).perform(click())
|
||||||
|
chooseSystemButton("permission_allow_button")
|
||||||
|
await { context.checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) == PackageManager.PERMISSION_GRANTED }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chooseSystemButton(id: String) {
|
||||||
|
val automation = InstrumentationRegistry.getInstrumentation().uiAutomation
|
||||||
|
await {
|
||||||
|
val node = find(automation.rootInActiveWindow, id) ?: return@await false
|
||||||
|
node.performAction(AccessibilityNodeInfo.ACTION_CLICK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private fun find(node: AccessibilityNodeInfo?, id: String): AccessibilityNodeInfo? {
|
||||||
|
if (node == null) return null
|
||||||
|
if (node.viewIdResourceName?.endsWith("/$id") == true) return node
|
||||||
|
for (index in 0 until node.childCount) find(node.getChild(index), id)?.let { return it }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
private fun await(condition: () -> Boolean) {
|
||||||
|
val deadline = System.nanoTime() + 10_000_000_000L
|
||||||
|
while (System.nanoTime() < deadline) {
|
||||||
|
if (condition()) return
|
||||||
|
Thread.sleep(100)
|
||||||
|
}
|
||||||
|
assertTrue("System permission response timed out", condition())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package community.yuvomi.mobile
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.test.core.app.ActivityScenario
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.espresso.Espresso.onView
|
||||||
|
import androidx.test.espresso.action.ViewActions.click
|
||||||
|
import androidx.test.espresso.action.ViewActions.closeSoftKeyboard
|
||||||
|
import androidx.test.espresso.action.ViewActions.replaceText
|
||||||
|
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.hasErrorText
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
|
||||||
|
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class OnboardingTest {
|
||||||
|
private val context: Context get() = ApplicationProvider.getApplicationContext()
|
||||||
|
|
||||||
|
@Before fun clearConfiguration() {
|
||||||
|
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit().clear().commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun rejectsHttpAndSubpathsLocally() {
|
||||||
|
ActivityScenario.launch(MainActivity::class.java).use {
|
||||||
|
onView(
|
||||||
|
withContentDescription("Yuvomi server URL"),
|
||||||
|
).perform(replaceText("http://family.example"), closeSoftKeyboard())
|
||||||
|
onView(withText("Connect securely")).perform(click())
|
||||||
|
onView(
|
||||||
|
withContentDescription("Yuvomi server URL"),
|
||||||
|
).check(matches(hasErrorText("Use HTTPS with a valid server hostname.")))
|
||||||
|
onView(
|
||||||
|
withContentDescription("Yuvomi server URL"),
|
||||||
|
).perform(replaceText("https://family.example/subpath"), closeSoftKeyboard())
|
||||||
|
onView(withText("Connect securely")).perform(click())
|
||||||
|
onView(
|
||||||
|
withContentDescription("Yuvomi server URL"),
|
||||||
|
).check(matches(hasErrorText("Yuvomi requires a dedicated hostname, not a subpath.")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun survivesRecreationWithoutLosingOnboarding() {
|
||||||
|
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
|
||||||
|
scenario.recreate()
|
||||||
|
onView(withText("Connect securely")).check(matches(isDisplayed()))
|
||||||
|
onView(withContentDescription("Yuvomi server URL")).check(matches(isEnabled()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun webViewIsHardenedAndBackgroundingPreservesInstance() {
|
||||||
|
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit()
|
||||||
|
.putString("origin", "https://family.example").commit()
|
||||||
|
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
|
||||||
|
var initial: WebView? = null
|
||||||
|
scenario.onActivity { activity ->
|
||||||
|
fun find(view: android.view.View): WebView? {
|
||||||
|
if (view is WebView) return view
|
||||||
|
if (view is android.view.ViewGroup) {
|
||||||
|
for (i in 0 until view.childCount) {
|
||||||
|
find(view.getChildAt(i))?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
initial = find(activity.window.decorView)
|
||||||
|
val web = requireNotNull(initial)
|
||||||
|
assertFalse(web.settings.allowFileAccess)
|
||||||
|
assertFalse(web.settings.allowContentAccess)
|
||||||
|
assertEquals(
|
||||||
|
android.webkit.WebSettings.MIXED_CONTENT_NEVER_ALLOW,
|
||||||
|
web.settings.mixedContentMode,
|
||||||
|
)
|
||||||
|
assertTrue(web.settings.javaScriptEnabled)
|
||||||
|
}
|
||||||
|
scenario.moveToState(androidx.lifecycle.Lifecycle.State.CREATED)
|
||||||
|
scenario.moveToState(androidx.lifecycle.Lifecycle.State.RESUMED)
|
||||||
|
scenario.onActivity { assertNotNull(initial?.parent) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,28 @@
|
|||||||
plugins {
|
buildscript {
|
||||||
id("com.android.application") version "8.13.2" apply false
|
repositories {
|
||||||
id("org.jetbrains.kotlin.android") version "2.2.10" apply false
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
// Override AGP's bundled KGP to include CVE-2026-53914 remediation.
|
||||||
|
dependencies { classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.21") }
|
||||||
|
configurations.getByName("classpath").resolutionStrategy {
|
||||||
|
activateDependencyLocking()
|
||||||
|
eachDependency {
|
||||||
|
when (requested.group) {
|
||||||
|
"io.netty" -> useVersion("4.1.139.Final")
|
||||||
|
"com.google.protobuf" -> useVersion("3.25.9")
|
||||||
|
"org.bouncycastle" -> useVersion("1.86")
|
||||||
|
"org.apache.commons" -> if (requested.name == "commons-lang3") useVersion("3.21.0")
|
||||||
|
"org.apache.httpcomponents" -> if (requested.name == "httpclient") useVersion("4.5.14")
|
||||||
|
"org.bitbucket.b_c" -> if (requested.name == "jose4j") useVersion("0.9.6")
|
||||||
|
"org.jdom" -> if (requested.name == "jdom2") useVersion("2.0.6.1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
plugins { id("com.android.application") version "9.4.1" apply false }
|
||||||
|
|
||||||
// WSL mounted Windows volumes may reject Gradle's POSIX file metadata operations.
|
// WSL mounted Windows volumes may reject Gradle's POSIX file metadata operations.
|
||||||
// Opt-in external output root; ordinary developer/CI builds retain standard paths.
|
// Opt-in external output root; ordinary developer/CI builds retain standard paths.
|
||||||
val outputRoot = providers.environmentVariable("YUVOMI_BUILD_DIR")
|
val outputRoot = providers.environmentVariable("YUVOMI_BUILD_DIR")
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# This is a Gradle generated file for dependency locking.
|
||||||
|
# Manual edits can break the build and are not advised.
|
||||||
|
# This file is expected to be part of source control.
|
||||||
|
# To regenerate this file, run: ./gradlew dependencies --write-locks
|
||||||
|
androidx.databinding:databinding-common:9.4.1=classpath
|
||||||
|
androidx.databinding:databinding-compiler-common:9.4.1=classpath
|
||||||
|
com.android.application:com.android.application.gradle.plugin:9.4.1=classpath
|
||||||
|
com.android.databinding:baseLibrary:9.4.1=classpath
|
||||||
|
com.android.tools.analytics-library:crash:32.4.1=classpath
|
||||||
|
com.android.tools.analytics-library:protos:32.4.1=classpath
|
||||||
|
com.android.tools.analytics-library:shared:32.4.1=classpath
|
||||||
|
com.android.tools.analytics-library:tracker:32.4.1=classpath
|
||||||
|
com.android.tools.build.jetifier:jetifier-core:1.0.0-beta10=classpath
|
||||||
|
com.android.tools.build.jetifier:jetifier-processor:1.0.0-beta10=classpath
|
||||||
|
com.android.tools.build:aapt2-proto:9.4.1-15978811=classpath
|
||||||
|
com.android.tools.build:aaptcompiler:9.4.1=classpath
|
||||||
|
com.android.tools.build:apksig:9.4.1=classpath
|
||||||
|
com.android.tools.build:apkzlib:9.4.1=classpath
|
||||||
|
com.android.tools.build:builder-model:9.4.1=classpath
|
||||||
|
com.android.tools.build:builder-test-api:9.4.1=classpath
|
||||||
|
com.android.tools.build:builder:9.4.1=classpath
|
||||||
|
com.android.tools.build:bundletool:1.18.3=classpath
|
||||||
|
com.android.tools.build:gradle-api:9.4.1=classpath
|
||||||
|
com.android.tools.build:gradle-common-api:9.4.1=classpath
|
||||||
|
com.android.tools.build:gradle-settings-api:9.4.1=classpath
|
||||||
|
com.android.tools.build:gradle:9.4.1=classpath
|
||||||
|
com.android.tools.build:manifest-merger:32.4.1=classpath
|
||||||
|
com.android.tools.ddms:ddmlib:32.4.1=classpath
|
||||||
|
com.android.tools.layoutlib:layoutlib-api:32.4.1=classpath
|
||||||
|
com.android.tools.lint:lint-model:32.4.1=classpath
|
||||||
|
com.android.tools.lint:lint-typedef-remover:32.4.1=classpath
|
||||||
|
com.android.tools.utp:gradle-work-action-api:32.4.1=classpath
|
||||||
|
com.android.tools:annotations:32.4.1=classpath
|
||||||
|
com.android.tools:common:32.4.1=classpath
|
||||||
|
com.android.tools:dvlib:32.4.1=classpath
|
||||||
|
com.android.tools:repository:32.4.1=classpath
|
||||||
|
com.android.tools:sdk-common:32.4.1=classpath
|
||||||
|
com.android.tools:sdklib:32.4.1=classpath
|
||||||
|
com.android:signflinger:9.4.1=classpath
|
||||||
|
com.android:zipflinger:9.4.1=classpath
|
||||||
|
com.google.auto.value:auto-value-annotations:1.6.2=classpath
|
||||||
|
com.google.code.findbugs:jsr305:3.0.2=classpath
|
||||||
|
com.google.code.gson:gson:2.11.0=classpath
|
||||||
|
com.google.crypto.tink:tink:1.7.0=classpath
|
||||||
|
com.google.dagger:dagger:2.28.3=classpath
|
||||||
|
com.google.errorprone:error_prone_annotations:2.36.0=classpath
|
||||||
|
com.google.flatbuffers:flatbuffers-java:1.12.0=classpath
|
||||||
|
com.google.guava:failureaccess:1.0.2=classpath
|
||||||
|
com.google.guava:guava:33.4.0-jre=classpath
|
||||||
|
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=classpath
|
||||||
|
com.google.j2objc:j2objc-annotations:3.0.0=classpath
|
||||||
|
com.google.jimfs:jimfs:1.1=classpath
|
||||||
|
com.google.protobuf:protobuf-java-util:3.25.9=classpath
|
||||||
|
com.google.protobuf:protobuf-java:3.25.9=classpath
|
||||||
|
com.googlecode.juniversalchardet:juniversalchardet:1.0.3=classpath
|
||||||
|
com.squareup:javapoet:1.13.0=classpath
|
||||||
|
com.squareup:javawriter:2.5.0=classpath
|
||||||
|
com.sun.istack:istack-commons-runtime:3.0.8=classpath
|
||||||
|
com.sun.xml.fastinfoset:FastInfoset:1.2.16=classpath
|
||||||
|
commons-codec:commons-codec:1.17.1=classpath
|
||||||
|
commons-io:commons-io:2.16.1=classpath
|
||||||
|
commons-logging:commons-logging:1.2=classpath
|
||||||
|
jakarta.activation:jakarta.activation-api:1.2.1=classpath
|
||||||
|
jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=classpath
|
||||||
|
javax.inject:javax.inject:1=classpath
|
||||||
|
net.java.dev.jna:jna-platform:5.6.0=classpath
|
||||||
|
net.java.dev.jna:jna:5.6.0=classpath
|
||||||
|
net.sf.jopt-simple:jopt-simple:4.9=classpath
|
||||||
|
net.sf.kxml:kxml2:2.3.0=classpath
|
||||||
|
org.apache.commons:commons-compress:1.27.1=classpath
|
||||||
|
org.apache.commons:commons-lang3:3.21.0=classpath
|
||||||
|
org.apache.httpcomponents:httpclient:4.5.14=classpath
|
||||||
|
org.apache.httpcomponents:httpcore:4.4.16=classpath
|
||||||
|
org.apache.httpcomponents:httpmime:4.5.6=classpath
|
||||||
|
org.bitbucket.b_c:jose4j:0.9.6=classpath
|
||||||
|
org.bouncycastle:bcpkix-jdk18on:1.86=classpath
|
||||||
|
org.bouncycastle:bcprov-jdk18on:1.86=classpath
|
||||||
|
org.bouncycastle:bcutil-jdk18on:1.86=classpath
|
||||||
|
org.checkerframework:checker-qual:3.43.0=classpath
|
||||||
|
org.glassfish.jaxb:jaxb-runtime:2.3.2=classpath
|
||||||
|
org.glassfish.jaxb:txw2:2.3.2=classpath
|
||||||
|
org.jdom:jdom2:2.0.6.1=classpath
|
||||||
|
org.jetbrains.kotlin:fus-statistics-gradle-plugin:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-build-statistics:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-build-tools-api:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-compiler-runner:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-daemon-client:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-gradle-plugin-annotations:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-gradle-plugin-api:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-gradle-plugin-idea-proto:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-gradle-plugin-idea:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-gradle-plugins-bom:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-klib-commonizer-api:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-native-utils:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-reflect:2.3.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.10=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.10=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib:2.3.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-tooling-core:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-util-io:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-util-klib-metadata:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlin:kotlin-util-klib:2.4.21=classpath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=classpath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0=classpath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0=classpath
|
||||||
|
org.jetbrains:annotations:13.0=classpath
|
||||||
|
org.jvnet.staxex:stax-ex:1.8.1=classpath
|
||||||
|
org.ow2.asm:asm-analysis:9.9=classpath
|
||||||
|
org.ow2.asm:asm-commons:9.9=classpath
|
||||||
|
org.ow2.asm:asm-tree:9.9=classpath
|
||||||
|
org.ow2.asm:asm-util:9.9=classpath
|
||||||
|
org.ow2.asm:asm:9.9=classpath
|
||||||
|
org.slf4j:slf4j-api:1.7.30=classpath
|
||||||
|
org.tensorflow:tensorflow-lite-metadata:0.2.0=classpath
|
||||||
|
empty=
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
|
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.workers.max=2
|
||||||
|
kotlin.compiler.execution.strategy=in-process
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
BIN
Binary file not shown.
+4
-2
@@ -1,8 +1,10 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
distributionSha256Sum=bbaeb2fef8710818cf0e261201dab964c572f92b942812df0c3620d62a529a01
|
||||||
distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
|
retries=0
|
||||||
|
retryBackOffMs=500
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
Vendored
+5
-8
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
#
|
#
|
||||||
# Copyright © 2015-2021 the original authors.
|
# Copyright © 2015 the original authors.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
##############################################################################
|
##############################################################################
|
||||||
#
|
#
|
||||||
# Gradle start up script for POSIX generated by Gradle.
|
# gradlew start up script for POSIX generated by Gradle.
|
||||||
#
|
#
|
||||||
# Important for running:
|
# Important for running:
|
||||||
#
|
#
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
# bash, then to run this script, type that shell name before the whole
|
# bash, then to run this script, type that shell name before the whole
|
||||||
# command line, like:
|
# command line, like:
|
||||||
#
|
#
|
||||||
# ksh Gradle
|
# ksh gradlew
|
||||||
#
|
#
|
||||||
# Busybox and similar reduced shells will NOT work, because this script
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
# requires all of these POSIX shell features:
|
# requires all of these POSIX shell features:
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
# Darwin, MinGW, and NonStop.
|
# Darwin, MinGW, and NonStop.
|
||||||
#
|
#
|
||||||
# (3) This script is generated from the Groovy template
|
# (3) This script is generated from the Groovy template
|
||||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
# within the Gradle project.
|
# within the Gradle project.
|
||||||
#
|
#
|
||||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
@@ -114,7 +114,6 @@ case "$( uname )" in #(
|
|||||||
NONSTOP* ) nonstop=true ;;
|
NONSTOP* ) nonstop=true ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
|
||||||
|
|
||||||
|
|
||||||
# Determine the Java command to use to start the JVM.
|
# Determine the Java command to use to start the JVM.
|
||||||
@@ -172,7 +171,6 @@ fi
|
|||||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
if "$cygwin" || "$msys" ; then
|
if "$cygwin" || "$msys" ; then
|
||||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
|
||||||
|
|
||||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
@@ -212,8 +210,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
|||||||
|
|
||||||
set -- \
|
set -- \
|
||||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
-classpath "$CLASSPATH" \
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
org.gradle.wrapper.GradleWrapperMain \
|
|
||||||
"$@"
|
"$@"
|
||||||
|
|
||||||
# Stop when "xargs" is not available.
|
# Stop when "xargs" is not available.
|
||||||
|
|||||||
Vendored
+12
-24
@@ -19,12 +19,12 @@
|
|||||||
@if "%DEBUG%"=="" @echo off
|
@if "%DEBUG%"=="" @echo off
|
||||||
@rem ##########################################################################
|
@rem ##########################################################################
|
||||||
@rem
|
@rem
|
||||||
@rem Gradle startup script for Windows
|
@rem gradlew startup script for Windows
|
||||||
@rem
|
@rem
|
||||||
@rem ##########################################################################
|
@rem ##########################################################################
|
||||||
|
|
||||||
@rem Set local scope for the variables with windows NT shell
|
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||||
if "%OS%"=="Windows_NT" setlocal
|
setlocal EnableExtensions
|
||||||
|
|
||||||
set DIRNAME=%~dp0
|
set DIRNAME=%~dp0
|
||||||
if "%DIRNAME%"=="" set DIRNAME=.
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
@@ -51,7 +51,7 @@ echo. 1>&2
|
|||||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
echo location of your Java installation. 1>&2
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
goto fail
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
:findJavaFromJavaHome
|
:findJavaFromJavaHome
|
||||||
set JAVA_HOME=%JAVA_HOME:"=%
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
@@ -65,30 +65,18 @@ echo. 1>&2
|
|||||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
echo location of your Java installation. 1>&2
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
goto fail
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
:execute
|
:execute
|
||||||
@rem Setup the command line
|
@rem Setup the command line
|
||||||
|
|
||||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
|
||||||
|
|
||||||
|
|
||||||
@rem Execute Gradle
|
@rem Execute gradlew
|
||||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||||
|
@rem which allows us to clear the local environment before executing the java command
|
||||||
|
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||||
|
|
||||||
:end
|
:exitWithErrorLevel
|
||||||
@rem End local scope for the variables with windows NT shell
|
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||||
|
|
||||||
:fail
|
|
||||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
|
||||||
rem the _cmd.exe /c_ return code!
|
|
||||||
set EXIT_CODE=%ERRORLEVEL%
|
|
||||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
|
||||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
|
||||||
exit /b %EXIT_CODE%
|
|
||||||
|
|
||||||
:mainEnd
|
|
||||||
if "%OS%"=="Windows_NT" endlocal
|
|
||||||
|
|
||||||
:omega
|
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } }
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
repositories { google(); mavenCentral() }
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
rootProject.name = "YuvomiMobile"
|
rootProject.name = "YuvomiMobile"
|
||||||
include(":app")
|
include(":app")
|
||||||
Reference in new issue
Block a user