diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..cfd4de7 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 7fb1fa2..839c5b1 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,15 +1,14 @@ plugins { id("com.android.application") - id("org.jetbrains.kotlin.android") } android { namespace = "community.yuvomi.mobile" - compileSdk = 36 + compileSdk { version = release(37) { minorApiLevel = 0 } } defaultConfig { applicationId = "community.yuvomi.mobile" minSdk = 26 - targetSdk = 36 + targetSdk = 37 versionCode = 1 versionName = "0.1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -18,19 +17,75 @@ android { release { isMinifyEnabled = 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. } + create("integration") { + initWith(getByName("debug")) + applicationIdSuffix = ".integration" + matchingFallbacks += "debug" + } } compileOptions { sourceCompatibility = 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) } } +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 { implementation("androidx.activity:activity-ktx:1.10.1") implementation("androidx.core:core-ktx:1.16.0") diff --git a/android/app/gradle.lockfile b/android/app/gradle.lockfile new file mode 100644 index 0000000..96100c3 --- /dev/null +++ b/android/app/gradle.lockfile @@ -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 diff --git a/android/app/src/androidTest/java/community/yuvomi/mobile/BlobExportTest.kt b/android/app/src/androidTest/java/community/yuvomi/mobile/BlobExportTest.kt new file mode 100644 index 0000000..f9a37ec --- /dev/null +++ b/android/app/src/androidTest/java/community/yuvomi/mobile/BlobExportTest.kt @@ -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.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/", + "Export test", + "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() + } + } +} diff --git a/android/app/src/androidTest/java/community/yuvomi/mobile/BrowserSecurityTest.kt b/android/app/src/androidTest/java/community/yuvomi/mobile/BrowserSecurityTest.kt new file mode 100644 index 0000000..4189669 --- /dev/null +++ b/android/app/src/androidTest/java/community/yuvomi/mobile/BrowserSecurityTest.kt @@ -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.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 = mutableMapOf() + } +} diff --git a/android/app/src/androidTest/java/community/yuvomi/mobile/LocalNetworkPermissionTest.kt b/android/app/src/androidTest/java/community/yuvomi/mobile/LocalNetworkPermissionTest.kt new file mode 100644 index 0000000..e87100b --- /dev/null +++ b/android/app/src/androidTest/java/community/yuvomi/mobile/LocalNetworkPermissionTest.kt @@ -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.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()) + } +} diff --git a/android/app/src/androidTest/java/community/yuvomi/mobile/OnboardingTest.kt b/android/app/src/androidTest/java/community/yuvomi/mobile/OnboardingTest.kt new file mode 100644 index 0000000..6651fde --- /dev/null +++ b/android/app/src/androidTest/java/community/yuvomi/mobile/OnboardingTest.kt @@ -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) } + } + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fa06748..1ffa84a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + diff --git a/android/app/src/main/java/community/yuvomi/mobile/BlobTransfer.kt b/android/app/src/main/java/community/yuvomi/mobile/BlobTransfer.kt new file mode 100644 index 0000000..17ad224 --- /dev/null +++ b/android/app/src/main/java/community/yuvomi/mobile/BlobTransfer.kt @@ -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() + + 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) } + } +} diff --git a/android/app/src/main/java/community/yuvomi/mobile/FileTransfer.kt b/android/app/src/main/java/community/yuvomi/mobile/FileTransfer.kt index 4188f7e..ed4001d 100644 --- a/android/app/src/main/java/community/yuvomi/mobile/FileTransfer.kt +++ b/android/app/src/main/java/community/yuvomi/mobile/FileTransfer.kt @@ -12,7 +12,8 @@ object FileTransfer { val connection = URI(url).toURL().openConnection() as HttpsURLConnection try { 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) } require(connection.responseCode == 200) { "Download refused" } require(connection.contentLengthLong <= 100L * 1024 * 1024) { "Download too large" } @@ -31,6 +32,8 @@ object FileTransfer { } } } - } finally { connection.disconnect() } + } finally { + connection.disconnect() + } } } diff --git a/android/app/src/main/java/community/yuvomi/mobile/MainActivity.kt b/android/app/src/main/java/community/yuvomi/mobile/MainActivity.kt index ffc0e66..805b733 100644 --- a/android/app/src/main/java/community/yuvomi/mobile/MainActivity.kt +++ b/android/app/src/main/java/community/yuvomi/mobile/MainActivity.kt @@ -14,8 +14,29 @@ import android.text.InputType import android.view.View import android.view.ViewGroup import android.view.WindowManager -import android.webkit.* -import android.widget.* +import android.webkit.CookieManager +import android.webkit.GeolocationPermissions +import android.webkit.PermissionRequest +import android.webkit.RenderProcessGoneDetail +import android.webkit.SslErrorHandler +import android.webkit.URLUtil +import android.webkit.ValueCallback +import android.webkit.WebChromeClient +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebSettings +import android.webkit.WebStorage +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Button +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.ScrollView +import android.widget.TextView +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts @@ -32,7 +53,7 @@ import java.net.URI import java.util.concurrent.Executors class MainActivity : ComponentActivity() { - private val worker = Executors.newSingleThreadExecutor() + private var worker = Executors.newSingleThreadExecutor() private val handler = Handler(Looper.getMainLooper()) private val preferences by lazy { getSharedPreferences("server", MODE_PRIVATE) } private var server: ServerAddress? = null @@ -45,62 +66,132 @@ class MainActivity : ComponentActivity() { private var capture: Uri? = null private var pendingDownload: String? = null private var generation = 0 - private val loadTimeout = Runnable { showError("The server is taking too long. Check your connection or try again.") } - - private val picker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - val callback = fileCallback - fileCallback = null - val uris = if (result.resultCode != RESULT_OK) null else { - val data = result.data - data?.clipData?.let { clip -> Array(clip.itemCount.coerceAtMost(50)) { clip.getItemAt(it).uri } } - ?: data?.data?.let { arrayOf(it) } ?: capture?.let { arrayOf(it) } + private val uploadSelection = SelectionGate() + private val downloadSelection = SelectionGate() + private val loadTimeout = + Runnable { showError("The server is taking too long. Check your connection or try again.") } + private val localNetworkPermission = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) { + toast("Private-network access allowed. Retry your server connection.") + } else { + toast("Private-network access was not granted. Public HTTPS servers can still be used.") + } } - callback?.onReceiveValue(uris?.filter { it.scheme == "content" }?.toTypedArray()) - capture = null - } - private val saveDocument = registerForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { destination -> - val url = pendingDownload - pendingDownload = null - val current = server - 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 - runOnUiThread { - if (!isDestroyed && epoch == generation) { - if (success) toast("Download saved") else { - runCatching { contentResolver.delete(destination, null, null) } - toast("Download failed. Check your connection and sign-in, then retry.") + + private val picker = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val accepted = uploadSelection.consume(generation) + val callback = fileCallback + fileCallback = null + val uris = if (!accepted || result.resultCode != RESULT_OK) { + null + } else { + val data = result.data + data?.clipData?.let { clip -> + Array(clip.itemCount.coerceAtMost(50)) { clip.getItemAt(it).uri } + } + ?: data?.data?.let { arrayOf(it) } ?: capture?.let { arrayOf(it) } + } + callback?.onReceiveValue(uris?.filter { it.scheme == "content" }?.toTypedArray()) + revokeCapture() + capture = null + } + private val saveDocument = + registerForActivityResult( + ActivityResultContracts.CreateDocument("application/octet-stream"), + ) { destination -> + val accepted = downloadSelection.consume(generation) + val url = pendingDownload + pendingDownload = null + if (!accepted) return@registerForActivityResult + val current = server + if (destination != null && url != null && current != null && + BlobTransfer.owns(current, url) + ) { + val view = web ?: return@registerForActivityResult + val epoch = generation + BlobTransfer.save( + current, + view, + url, + contentResolver, + destination, + worker, + { !isDestroyed && epoch == generation }, + ) { success -> + if (!success) runCatching { contentResolver.delete(destination, null, null) } + if (!isDestroyed && + epoch == generation + ) { + toast( + if (success) "Export saved" else "Export failed or exceeded the 25 MB limit.", + ) + } + } + } else if (destination != null && url != null && current != null && current.owns(url)) { + val cookies = CookieManager.getInstance().getCookie(url) + val epoch = generation + worker.execute { + val success = runCatching { + FileTransfer.save(current, url, cookies, contentResolver, destination) + }.isSuccess + if (!success) runCatching { contentResolver.delete(destination, null, null) } + runOnUiThread { + if (!isDestroyed && epoch == generation) { + if (success) { + toast("Download saved") + } else { + toast( + "Download failed. Check your connection and sign-in, then retry.", + ) + } } } } } } - } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (savedInstanceState?.getBoolean("uploadOutstanding") == true) uploadSelection.restoreOutstanding() + if (savedInstanceState?.getBoolean("downloadOutstanding") == true) downloadSelection.restoreOutstanding() // Household data is hidden from screenshots and the recent-apps preview. window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) WebView.setWebContentsDebuggingEnabled(false) root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets -> - val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime()) + val bars = insets.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime(), + ) view.setPadding(bars.left, bars.top, bars.right, bars.bottom) insets } setContentView(root) - onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - if (web?.canGoBack() == true) web?.goBack() - else if (web != null) settings() - else { isEnabled = false; onBackPressedDispatcher.onBackPressed() } - } - }) - File(cacheDir, "captures").listFiles()?.filter { it.lastModified() < System.currentTimeMillis() - 86_400_000 } + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (web?.canGoBack() == true) { + web?.goBack() + } else if (web != null) { + moveTaskToBack(true) + } else { + isEnabled = false + onBackPressedDispatcher.onBackPressed() + } + } + }, + ) + File(cacheDir, "captures").listFiles()?.filter { + it.lastModified() < + System.currentTimeMillis() - 86_400_000 + } ?.forEach { it.delete() } - server = preferences.getString("origin", null)?.let { runCatching { ServerAddress.parse(it) }.getOrNull() } + server = + preferences.getString("origin", null)?.let { + runCatching { ServerAddress.parse(it) }.getOrNull() + } if (server == null) onboarding() else browser() } @@ -109,10 +200,15 @@ class MainActivity : ComponentActivity() { setPadding(dp(24), dp(20), dp(24), dp(20)) } private fun text(value: String, size: Float = 17f) = TextView(this).apply { - text = value; textSize = size; setPadding(0, dp(8), 0, dp(8)) + text = value + textSize = size + setPadding(0, dp(8), 0, dp(8)) } private fun button(label: String, action: () -> Unit) = Button(this).apply { - text = label; isAllCaps = false; minHeight = dp(48); setOnClickListener { action() } + text = label + isAllCaps = false + minHeight = dp(48) + setOnClickListener { action() } } private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt() @@ -120,7 +216,9 @@ class MainActivity : ComponentActivity() { root.removeAllViews() val form = column() form.addView(text("Your home, connected", 30f)) - form.addView(text("Connect to your own Yuvomi server. Your data stays with your household.")) + form.addView( + text("Connect to your own Yuvomi server. Your data stays with your household."), + ) val address = EditText(this).apply { hint = "https://family.example.com" inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI @@ -129,23 +227,31 @@ class MainActivity : ComponentActivity() { setText(server?.origin.orEmpty()) 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 lateinit var connect: Button connect = button("Connect securely") { 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 worker.execute { val result = runCatching { ServerProbe.check(candidate) } runOnUiThread { if (isDestroyed || epoch != generation) return@runOnUiThread - connect.isEnabled = true; address.isEnabled = true + connect.isEnabled = true + address.isEnabled = true if (result.isSuccess) { server = candidate - preferences.edit { putString("origin", candidate.origin); remove("path") } + preferences.edit { + putString("origin", candidate.origin) + remove("path") + } browser() } else { // Exception details may contain URL parameters; never expose them. @@ -156,8 +262,11 @@ class MainActivity : ComponentActivity() { } form.addView(address) form.addView(label) + localNetworkButton()?.let { form.addView(it) } form.addView(connect) - form.addView(text("Independent community app. No telemetry or mandatory cloud service.", 14f)) + form.addView( + text("Independent community app. No telemetry or mandatory cloud service.", 14f), + ) root.addView(ScrollView(this).apply { addView(form) }) } @@ -166,14 +275,23 @@ class MainActivity : ComponentActivity() { root.removeAllViews() val toolbar = LinearLayout(this) toolbar.addView(button("Server settings") { settings() }) - toolbar.addView(button("Share server") { - startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply { - type = "text/plain"; putExtra(Intent.EXTRA_TEXT, current.origin) - }, "Share server address")) - }) + toolbar.addView( + button("Share server") { + startActivity( + Intent.createChooser( + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, current.origin) + }, + "Share server address", + ), + ) + }, + ) root.addView(toolbar) progress = ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply { - contentDescription = "Loading Yuvomi"; max = 100 + contentDescription = "Loading Yuvomi" + max = 100 } root.addView(progress, LinearLayout.LayoutParams(-1, dp(4))) content = FrameLayout(this) @@ -181,12 +299,20 @@ class MainActivity : ComponentActivity() { val view = WebView(this) web = view secure(view) + BlobTransfer.install(view, current) CookieManager.getInstance().setAcceptCookie(true) CookieManager.getInstance().setAcceptThirdPartyCookies(view, false) view.webViewClient = object : WebViewClient() { override fun shouldInterceptRequest(v: WebView, request: WebResourceRequest): WebResourceResponse? { - if (!request.isForMainFrame && !current.owns(request.url.toString())) { - return WebResourceResponse("text/plain", "UTF-8", 403, "Blocked", emptyMap(), ByteArrayInputStream(ByteArray(0))) + if (!current.owns(request.url.toString())) { + return WebResourceResponse( + "text/plain", + "UTF-8", + 403, + "Blocked", + emptyMap(), + ByteArrayInputStream(ByteArray(0)), + ) } return null } @@ -197,8 +323,14 @@ class MainActivity : ComponentActivity() { return true } override fun onPageStarted(v: WebView, url: String, icon: Bitmap?) { + if (!current.owns(url)) { + v.stopLoading() + external(url) + return + } progress.visibility = View.VISIBLE - handler.removeCallbacks(loadTimeout); handler.postDelayed(loadTimeout, 30_000) + handler.removeCallbacks(loadTimeout) + handler.postDelayed(loadTimeout, 30_000) } override fun onPageFinished(v: WebView, url: String) { handler.removeCallbacks(loadTimeout) @@ -206,74 +338,140 @@ class MainActivity : ComponentActivity() { CookieManager.getInstance().flush() if (current.owns(url)) { 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) } } } } 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) { - 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) { - 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 { - 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() { - override fun onProgressChanged(v: WebView, value: Int) { progress.progress = value } - override fun onPermissionRequest(request: PermissionRequest) { request.deny() } + override fun onProgressChanged(v: WebView, value: Int) { + progress.progress = value + } + override fun onPermissionRequest(request: PermissionRequest) { + request.deny() + } override fun onGeolocationPermissionsShowPrompt(origin: String, callback: GeolocationPermissions.Callback) { callback.invoke(origin, false, false) } override fun onShowFileChooser(v: WebView, callback: ValueCallback>, params: FileChooserParams): Boolean { if (!current.owns(v.url.orEmpty())) return false + if (!uploadSelection.begin(generation)) { + callback.onReceiveValue(null) + toast("Finish or cancel the existing system file picker first.") + return true + } fileCallback?.onReceiveValue(null) fileCallback = callback - val types = params.acceptTypes.filter { it.matches(Regex("[a-zA-Z0-9.+*-]+/[a-zA-Z0-9.+*-]+")) } - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = types.singleOrNull() ?: "*/*" - if (types.size > 1) putExtra(Intent.EXTRA_MIME_TYPES, types.toTypedArray()) - putExtra(Intent.EXTRA_ALLOW_MULTIPLE, params.mode == FileChooserParams.MODE_OPEN_MULTIPLE) - } - val chooser = Intent.createChooser(intent, "Select a file") - if (types.any { it.startsWith("image/") }) { - val directory = File(cacheDir, "captures").apply { mkdirs() } - capture = FileProvider.getUriForFile(this@MainActivity, "$packageName.files", File.createTempFile("capture-", ".jpg", directory)) - chooser.putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply { - putExtra(MediaStore.EXTRA_OUTPUT, capture) - addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION) - })) - } - runCatching { picker.launch(chooser) }.onFailure { - fileCallback?.onReceiveValue(null); fileCallback = null; toast("No compatible file picker is available.") + runCatching { + val types = params.acceptTypes.filter { + it.matches(Regex("[a-zA-Z0-9.+*-]+/[a-zA-Z0-9.+*-]+")) + } + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = types.singleOrNull() ?: "*/*" + if (types.size > 1) putExtra(Intent.EXTRA_MIME_TYPES, types.toTypedArray()) + putExtra( + Intent.EXTRA_ALLOW_MULTIPLE, + params.mode == FileChooserParams.MODE_OPEN_MULTIPLE, + ) + } + val chooser = Intent.createChooser(intent, "Select a file") + if (types.any { it.startsWith("image/") }) { + val directory = File(cacheDir, "captures").apply { mkdirs() } + capture = + FileProvider.getUriForFile( + this@MainActivity, + "$packageName.files", + File.createTempFile("capture-", ".jpg", directory), + ) + chooser.putExtra( + Intent.EXTRA_INITIAL_INTENTS, + arrayOf( + Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply { + putExtra(MediaStore.EXTRA_OUTPUT, capture) + clipData = + android.content.ClipData.newRawUri("Yuvomi photo", capture) + addFlags( + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or + Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + }, + ), + ) + } + picker.launch(chooser) + }.onFailure { + uploadSelection.consume(generation) + fileCallback?.onReceiveValue(null) + fileCallback = null + toast("No compatible file picker is available.") } return true } } view.setDownloadListener { url, _, disposition, mime, _ -> - if (!current.owns(url)) toast("Only HTTPS downloads from your server are supported.") - else if (pendingDownload == null) { + if (!current.owns(url) && + !BlobTransfer.owns(current, url) + ) { + toast("Only downloads from your server are supported.") + } else if (pendingDownload == null && downloadSelection.begin(generation)) { pendingDownload = url - val name = URLUtil.guessFileName(url, disposition, mime).replace(Regex("[^a-zA-Z0-9._ -]"), "_").take(120) - saveDocument.launch(name.ifBlank { "download" }) + val name = URLUtil.guessFileName( + url, + disposition, + mime, + ).replace(Regex("[^a-zA-Z0-9._ -]"), "_").take(120) + runCatching { saveDocument.launch(name.ifBlank { "download" }) }.onFailure { + downloadSelection.consume(generation) + pendingDownload = null + toast("No compatible document provider is available.") + } } } content.addView(view, FrameLayout.LayoutParams(-1, -1)) val path = preferences.getString("path", "/").orEmpty() - view.loadUrl(current.origin + if (path.startsWith("/") && !path.startsWith("//")) path else "/") + view.loadUrl( + current.origin + if (path.startsWith("/") && !path.startsWith("//")) path else "/", + ) } @SuppressLint("SetJavaScriptEnabled") private fun secure(view: WebView) { view.settings.apply { - javaScriptEnabled = true; domStorageEnabled = true - allowFileAccess = false; allowContentAccess = false + javaScriptEnabled = true + domStorageEnabled = true + allowFileAccess = false + allowContentAccess = false mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW javaScriptCanOpenWindowsAutomatically = false setSupportMultipleWindows(false) @@ -284,12 +482,17 @@ class MainActivity : ComponentActivity() { } private fun external(value: String) { + handler.removeCallbacks(loadTimeout) + progress.visibility = View.GONE val uri = runCatching { URI(value) }.getOrNull() ?: return if (uri.scheme != "https" || uri.host.isNullOrBlank() || uri.rawUserInfo != null) { - toast("This link type is blocked for your security."); return + toast("This link type is blocked for your security.") + return } AlertDialog.Builder(this).setTitle("Leave your Yuvomi server?") - .setMessage("Destination: ${uri.host}\n\nOpen normal links in your browser. For SSO, use the isolated sign-in window only if you trust this provider. Providers that require a system browser cannot transfer a session back to this app.") + .setMessage( + "Destination: ${uri.host}\n\nOpen normal links in your browser. For SSO, use the isolated sign-in window only if you trust this provider. Providers that require a system browser cannot transfer a session back to this app.", + ) .setPositiveButton("Browser") { _, _ -> openBrowser(value) } .setNeutralButton("Sign-in window") { _, _ -> authentication(value) } .setNegativeButton("Cancel", null).show() @@ -301,34 +504,56 @@ class MainActivity : ComponentActivity() { secure(auth) CookieManager.getInstance().setAcceptThirdPartyCookies(auth, false) auth.webChromeClient = object : WebChromeClient() { - override fun onPermissionRequest(request: PermissionRequest) { request.deny() } + override fun onPermissionRequest(request: PermissionRequest) { + request.deny() + } } auth.webViewClient = object : WebViewClient() { + override fun onPageStarted(v: WebView, url: String, favicon: Bitmap?) { + authDialog?.setTitle( + "Sign in: ${runCatching { + URI(url).host + }.getOrNull().orEmpty()}", + ) + } override fun shouldOverrideUrlLoading(v: WebView, request: WebResourceRequest): Boolean { val uri = request.url return uri.scheme != "https" || !uri.userInfo.isNullOrEmpty() } - override fun onReceivedSslError(v: WebView, h: SslErrorHandler, e: SslError) { h.cancel() } + override fun onReceivedSslError(v: WebView, h: SslErrorHandler, e: SslError) { + h.cancel() + } override fun onPageFinished(v: WebView, url: String) { if (server?.owns(url) == true && !URI(url).path.startsWith("/api/")) { CookieManager.getInstance().flush() - authDialog?.dismiss(); web?.loadUrl(url) + authDialog?.dismiss() + web?.loadUrl(url) } } } authDialog = AlertDialog.Builder(this).setTitle("Isolated web sign-in") .setView(auth).setNegativeButton("Close", null).create().also { dialog -> dialog.setOnDismissListener { - auth.stopLoading(); auth.removeAllViews(); auth.destroy(); authDialog = null + auth.stopLoading() + auth.removeAllViews() + auth.destroy() + authDialog = null } 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) } 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.") } } @@ -338,12 +563,21 @@ class MainActivity : ComponentActivity() { progress.visibility = View.GONE if (content.childCount > 1) return val panel = column().apply { - addView(text("Connection interrupted", 25f)); addView(text(message)) - addView(button("Retry") { content.removeView(this); web?.reload() }) + addView(text("Connection interrupted", 25f)) + addView(text(message)) + addView( + button("Retry") { + content.removeView(this) + web?.reload() + }, + ) addView(button("Keep viewing") { content.removeView(this) }) addView(button("Server settings") { settings() }) + localNetworkButton()?.let { addView(it) } } - val dark = resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK == android.content.res.Configuration.UI_MODE_NIGHT_YES + val dark = + resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK == + android.content.res.Configuration.UI_MODE_NIGHT_YES panel.setBackgroundColor(if (dark) 0xff191816.toInt() else 0xfff5f3ed.toInt()) content.addView(panel, FrameLayout.LayoutParams(-1, -1)) panel.accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE @@ -352,41 +586,104 @@ class MainActivity : ComponentActivity() { private fun settings() { AlertDialog.Builder(this).setTitle("Server settings") - .setMessage("${server?.origin.orEmpty()}\n\nSwitching servers erases all app cookies, storage and cached household data. Downloads saved outside the app remain. This does not revoke the server session; sign out in Yuvomi first where possible.") + .setMessage( + "${server?.origin.orEmpty()}\n\nSwitching servers erases all app cookies, storage and cached household data. Downloads saved outside the app remain. This does not revoke the server session; sign out in Yuvomi first where possible.", + ) .setPositiveButton("Erase and change server") { _, _ -> generation++ - authDialog?.dismiss(); releaseWeb() - if (WebViewFeature.isFeatureSupported(WebViewFeature.DELETE_BROWSING_DATA)) { + worker.shutdownNow() + worker = Executors.newSingleThreadExecutor() + authDialog?.dismiss() + releaseWeb() + val captures = File(cacheDir, "captures") + val capturesErased = !captures.exists() || captures.listFiles()?.all { !it.exists() || it.delete() } == true + if (capturesErased && WebViewFeature.isFeatureSupported(WebViewFeature.DELETE_BROWSING_DATA)) { WebStorageCompat.deleteBrowsingData(WebStorage.getInstance()) { CookieManager.getInstance().removeAllCookies { CookieManager.getInstance().flush() - preferences.edit { clear() }; server = null; onboarding() + preferences.edit { clear() } + server = null + onboarding() } } } else { // Older engines cannot reliably erase CacheStorage/IndexedDB. // OS reset is complete and scoped to this application, but closes it. - 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() } + private fun localNetworkButton(): Button? { + if (android.os.Build.VERSION.SDK_INT < 37 || + checkSelfPermission(android.Manifest.permission.ACCESS_LOCAL_NETWORK) == + android.content.pm.PackageManager.PERMISSION_GRANTED + ) { + return null + } + return button("Allow private-network servers") { + AlertDialog.Builder(this).setTitle("Private server access") + .setMessage( + "Android requires permission to connect to a Yuvomi server on your local network. The app connects only to your configured server; it does not scan for devices. Public internet servers do not need this permission.", + ) + .setPositiveButton("Continue") { _, _ -> + localNetworkPermission.launch(android.Manifest.permission.ACCESS_LOCAL_NETWORK) + } + .setNegativeButton("Cancel", null).show() + } + } + private fun releaseWeb() { handler.removeCallbacks(loadTimeout) - fileCallback?.onReceiveValue(null); fileCallback = null + fileCallback?.onReceiveValue(null) + fileCallback = null + pendingDownload = null + revokeCapture() + capture = null web?.let { view -> + BlobTransfer.forget(view) view.stopLoading() (view.parent as? ViewGroup)?.removeView(view) - view.removeAllViews(); view.destroy() + view.removeAllViews() + view.destroy() } web = null } + private fun revokeCapture() { + capture?.let { + revokeUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + } + } + override fun onSaveInstanceState(outState: Bundle) { + outState.putBoolean("uploadOutstanding", uploadSelection.outstanding) + outState.putBoolean("downloadOutstanding", downloadSelection.outstanding) + super.onSaveInstanceState(outState) + } private fun toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_LONG).show() - override fun onPause() { web?.onPause(); CookieManager.getInstance().flush(); super.onPause() } - override fun onResume() { super.onResume(); web?.onResume() } + override fun onPause() { + web?.onPause() + CookieManager.getInstance().flush() + super.onPause() + } + override fun onResume() { + super.onResume() + web?.onResume() + } override fun onDestroy() { - generation++; authDialog?.dismiss(); releaseWeb(); worker.shutdownNow(); super.onDestroy() + generation++ + authDialog?.dismiss() + releaseWeb() + worker.shutdownNow() + super.onDestroy() } } diff --git a/android/app/src/main/java/community/yuvomi/mobile/SelectionGate.kt b/android/app/src/main/java/community/yuvomi/mobile/SelectionGate.kt new file mode 100644 index 0000000..7d804af --- /dev/null +++ b/android/app/src/main/java/community/yuvomi/mobile/SelectionGate.kt @@ -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 + } +} diff --git a/android/app/src/main/java/community/yuvomi/mobile/ServerAddress.kt b/android/app/src/main/java/community/yuvomi/mobile/ServerAddress.kt index fc53947..3ca5170 100644 --- a/android/app/src/main/java/community/yuvomi/mobile/ServerAddress.kt +++ b/android/app/src/main/java/community/yuvomi/mobile/ServerAddress.kt @@ -8,7 +8,8 @@ class ServerAddress private constructor(val origin: String) { fun owns(value: String): Boolean = runCatching { val uri = URI(value) 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) companion object { @@ -24,7 +25,9 @@ class ServerAddress private constructor(val origin: String) { require(uri.rawPath.isNullOrEmpty() || uri.rawPath == "/") { "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}" return ServerAddress("https://${uri.host.lowercase(Locale.ROOT)}$port") } diff --git a/android/app/src/main/java/community/yuvomi/mobile/ServerProbe.kt b/android/app/src/main/java/community/yuvomi/mobile/ServerProbe.kt index 0489ceb..64b7186 100644 --- a/android/app/src/main/java/community/yuvomi/mobile/ServerProbe.kt +++ b/android/app/src/main/java/community/yuvomi/mobile/ServerProbe.kt @@ -1,16 +1,18 @@ package community.yuvomi.mobile -import org.json.JSONObject +import java.io.ByteArrayOutputStream import java.net.URI import javax.net.ssl.HttpsURLConnection -import java.io.ByteArrayOutputStream +import org.json.JSONObject object ServerProbe { /** No cookies, no automatic redirects, bounded response and deadlines. */ fun check(server: ServerAddress) { val version = json(server, "/api/v1/version") - require(version.opt("app_name") is String && version.opt("setup_required") is Boolean && - version.opt("password_reset_enabled") is Boolean) { + require( + 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." } val auth = json(server, "/api/v1/auth/oidc/config") diff --git a/android/app/src/test/java/community/yuvomi/mobile/SelectionGateTest.kt b/android/app/src/test/java/community/yuvomi/mobile/SelectionGateTest.kt new file mode 100644 index 0000000..96cdc93 --- /dev/null +++ b/android/app/src/test/java/community/yuvomi/mobile/SelectionGateTest.kt @@ -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)) + } +} diff --git a/android/app/src/test/java/community/yuvomi/mobile/ServerAddressTest.kt b/android/app/src/test/java/community/yuvomi/mobile/ServerAddressTest.kt index 48f3ecc..36b3344 100644 --- a/android/app/src/test/java/community/yuvomi/mobile/ServerAddressTest.kt +++ b/android/app/src/test/java/community/yuvomi/mobile/ServerAddressTest.kt @@ -1,20 +1,31 @@ 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 class ServerAddressTest { @Test fun canonicalizesAndSupportsPortsAndIPv6() { - assertEquals("https://family.example", ServerAddress.parse(" HTTPS://Family.Example:443/ ").origin) - assertEquals("https://family.example:8443", ServerAddress.parse("https://family.example:8443").origin) + assertEquals( + "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) } @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/#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) } } } @@ -22,10 +33,30 @@ class ServerAddressTest { @Test fun navigationIsOriginBoundNotStringPrefixBound() { val server = ServerAddress.parse("https://family.example") assertTrue(server.owns("https://FAMILY.example:443/tasks?id=1")) - listOf("https://family.example.evil.test/", "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 { + listOf( + "https://family.example.evil.test/", + "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)) } } + + @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)) + } + } } diff --git a/android/build.gradle.kts b/android/build.gradle.kts index fef8ec1..ec29718 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,8 +1,28 @@ -plugins { - id("com.android.application") version "8.13.2" apply false - id("org.jetbrains.kotlin.android") version "2.2.10" apply false +buildscript { + repositories { + 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. // Opt-in external output root; ordinary developer/CI builds retain standard paths. val outputRoot = providers.environmentVariable("YUVOMI_BUILD_DIR") diff --git a/android/buildscript-gradle.lockfile b/android/buildscript-gradle.lockfile new file mode 100644 index 0000000..f684690 --- /dev/null +++ b/android/buildscript-gradle.lockfile @@ -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= diff --git a/android/gradle.properties b/android/gradle.properties index 3735595..bfedcec 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,6 @@ org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +org.gradle.workers.max=2 +kotlin.compiler.execution.strategy=in-process android.useAndroidX=true kotlin.code.style=official android.nonTransitiveRClass=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar index 096fefc..e10449e 100644 Binary files a/android/gradle/wrapper/gradle-wrapper.jar and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 04fd49e..6f9340a 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,8 +1,10 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip -distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78 +distributionSha256Sum=bbaeb2fef8710818cf0e261201dab964c572f92b942812df0c3620d62a529a01 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew index 770e0b3..91acf08 100755 --- a/android/gradlew +++ b/android/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (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: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (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. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # 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 if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -212,8 +210,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/android/gradlew.bat b/android/gradlew.bat index 57f73cc..7043f60 100644 --- a/android/gradlew.bat +++ b/android/gradlew.bat @@ -19,12 +19,12 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 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 location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome 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 location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +@rem Execute gradlew +@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 -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -: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 +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 9828438..651be41 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -1,7 +1,16 @@ -pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } } +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) - repositories { google(); mavenCentral() } + repositories { + google() + mavenCentral() + } } rootProject.name = "YuvomiMobile" include(":app")