From 8c480ddca3f4d3c5bdd9f837df27c9649e6a6986091477df877ff734bbc6dcfa Mon Sep 17 00:00:00 2001 From: Yuvomi Mobile Contributors Date: Wed, 21 Oct 2026 09:12:47 +0200 Subject: [PATCH] feat(android): replace WebView with tested native Compose API slice Add real native onboarding/auth/dashboard/tasks/notes/shopping, origin-bound HTTPS and encrypted AndroidKeyStore session persistence. Archive the superseded browser renderer and tests. Verify 10 JVM tests, 6 native API-35 emulator tests, lintDebug/lintRelease and debug/unsigned release APK/AAB builds; the real upstream suite passes 7 tests and exports a nonblank native fixture screenshot. Tink host-tool update removes the old generated-protobuf warning. Full module/visual parity remains explicitly incomplete. --- .editorconfig | 1 + android/app/build.gradle.kts | 18 +- android/app/gradle.lockfile | 147 ++- .../yuvomi/mobile/NativeOnboardingTest.kt | 90 ++ .../yuvomi/mobile/NativeUpstreamTest.kt | 200 ++++ .../community/yuvomi/mobile/MainActivity.kt | 688 +------------- .../java/community/yuvomi/mobile/NativeApi.kt | 153 ++++ .../java/community/yuvomi/mobile/NativeApp.kt | 859 ++++++++++++++++++ .../yuvomi/mobile/NativeController.kt | 334 +++++++ .../community/yuvomi/mobile/NativeSession.kt | 63 ++ android/app/src/main/res/values/strings.xml | 2 - .../yuvomi/mobile/NativeCookiesTest.kt | 74 ++ .../yuvomi/mobile/ServerAddressTest.kt | 13 - android/build.gradle.kts | 6 +- android/buildscript-gradle.lockfile | 4 +- legacy/README.md | 8 + legacy/android/BlobOriginsTest.kt | 16 + .../mobile => legacy/android}/BlobTransfer.kt | 0 .../mobile => legacy/android}/FileTransfer.kt | 0 legacy/android/MainActivity.kt | 689 ++++++++++++++ .../android}/SelectionGate.kt | 0 .../android}/SelectionGateTest.kt | 0 legacy/android/strings.xml | 5 + .../android/tests}/BlobExportTest.kt | 0 .../android/tests}/BrowserSecurityTest.kt | 0 .../tests}/LocalNetworkPermissionTest.kt | 0 .../android/tests}/OnboardingTest.kt | 0 .../android/tests}/UpstreamIntegrationTest.kt | 0 28 files changed, 2628 insertions(+), 742 deletions(-) create mode 100644 android/app/src/androidTestNative/java/community/yuvomi/mobile/NativeOnboardingTest.kt create mode 100644 android/app/src/androidTestNativeIntegration/java/community/yuvomi/mobile/NativeUpstreamTest.kt create mode 100644 android/app/src/main/java/community/yuvomi/mobile/NativeApi.kt create mode 100644 android/app/src/main/java/community/yuvomi/mobile/NativeApp.kt create mode 100644 android/app/src/main/java/community/yuvomi/mobile/NativeController.kt create mode 100644 android/app/src/main/java/community/yuvomi/mobile/NativeSession.kt create mode 100644 android/app/src/test/java/community/yuvomi/mobile/NativeCookiesTest.kt create mode 100644 legacy/README.md create mode 100644 legacy/android/BlobOriginsTest.kt rename {android/app/src/main/java/community/yuvomi/mobile => legacy/android}/BlobTransfer.kt (100%) rename {android/app/src/main/java/community/yuvomi/mobile => legacy/android}/FileTransfer.kt (100%) create mode 100644 legacy/android/MainActivity.kt rename {android/app/src/main/java/community/yuvomi/mobile => legacy/android}/SelectionGate.kt (100%) rename {android/app/src/test/java/community/yuvomi/mobile => legacy/android}/SelectionGateTest.kt (100%) create mode 100644 legacy/android/strings.xml rename {android/app/src/androidTest/java/community/yuvomi/mobile => legacy/android/tests}/BlobExportTest.kt (100%) rename {android/app/src/androidTest/java/community/yuvomi/mobile => legacy/android/tests}/BrowserSecurityTest.kt (100%) rename {android/app/src/androidTest/java/community/yuvomi/mobile => legacy/android/tests}/LocalNetworkPermissionTest.kt (100%) rename {android/app/src/androidTest/java/community/yuvomi/mobile => legacy/android/tests}/OnboardingTest.kt (100%) rename {android/app/src/androidTestIntegration/java/community/yuvomi/mobile => legacy/android/tests}/UpstreamIntegrationTest.kt (100%) diff --git a/.editorconfig b/.editorconfig index cfd4de7..14126ad 100644 --- a/.editorconfig +++ b/.editorconfig @@ -16,3 +16,4 @@ max_line_length = 140 ktlint_code_style = android_studio ij_kotlin_allow_trailing_comma = true ij_kotlin_allow_trailing_comma_on_call_site = true +ktlint_function_naming_ignore_when_annotated_with = Composable diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 839c5b1..810e86d 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,5 +1,6 @@ plugins { id("com.android.application") + id("org.jetbrains.kotlin.plugin.compose") } android { @@ -9,8 +10,8 @@ android { applicationId = "community.yuvomi.mobile" minSdk = 26 targetSdk = 37 - versionCode = 1 - versionName = "0.1.0" + versionCode = 2 + versionName = "0.2.0-native-preview" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { @@ -33,6 +34,11 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } + buildFeatures { compose = true } + sourceSets.getByName("androidTest").kotlin.directories.apply { + clear() + add("src/androidTestNative/java") + } lint { abortOnError = true warningsAsErrors = true @@ -41,7 +47,7 @@ android { testBuildType = "integration" sourceSets.getByName( "androidTest", - ).kotlin.directories.add("src/androidTestIntegration/java") + ).kotlin.directories.add("src/androidTestNativeIntegration/java") val testResources = providers.environmentVariable("YUVOMI_TEST_RES").get() sourceSets.getByName("integration").res.directories.add(testResources) defaultConfig.testInstrumentationRunnerArguments["server"] = @@ -89,9 +95,13 @@ configurations.configureEach { dependencies { implementation("androidx.activity:activity-ktx:1.10.1") implementation("androidx.core:core-ktx:1.16.0") - implementation("androidx.webkit:webkit:1.14.0") + implementation("androidx.activity:activity-compose:1.10.1") + implementation("androidx.compose.material3:material3:1.4.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.4") testImplementation("junit:junit:4.13.2") + testImplementation("org.json:json:20260814") androidTestImplementation("androidx.test:runner:1.6.2") androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") + androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.9.0") } diff --git a/android/app/gradle.lockfile b/android/app/gradle.lockfile index 96100c3..e627c32 100644 --- a/android/app/gradle.lockfile +++ b/android/app/gradle.lockfile @@ -2,33 +2,86 @@ # 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.activity:activity-compose:1.10.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.activity:activity-ktx:1.10.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.activity:activity:1.10.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation-experimental:1.4.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation-jvm:1.9.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation:1.9.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,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.arch.core:core-runtime:2.2.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.autofill:autofill:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.collection:collection-jvm:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.collection:collection-ktx:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.collection:collection:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation-android:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation-core-android:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation-core:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation-android:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation-layout-android:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation-layout:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material3:material3-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material3:material3:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material:material-ripple-android:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material:material-ripple:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-android:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-annotation-android:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-annotation:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-saveable-android:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-saveable:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-android:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-geometry-android:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-geometry:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-graphics-android:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-graphics:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-test-android:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-test-junit4-android:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-test-junit4:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-test:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-text-android:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-text:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-unit-android:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-unit:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-util-android:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-util:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui:1.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,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.core:core-ktx:1.16.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.core:core-viewtree:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.core:core:1.16.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.customview:customview-poolingcontainer:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.emoji2:emoji2:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.graphics:graphics-path:1.0.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.interpolator:interpolator:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-common-java8:2.9.4=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-common-jvm:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-common:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-livedata-core:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-process:2.9.4=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-android:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-compose-android:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-compose:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-ktx-android:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-ktx:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-android:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-compose-android:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-compose:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.profileinstaller:profileinstaller:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.savedstate:savedstate-android:1.3.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.savedstate:savedstate-compose-android:1.3.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.savedstate:savedstate-compose:1.3.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.savedstate:savedstate-ktx:1.3.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.savedstate:savedstate:1.3.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.startup:startup-runtime:1.1.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,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 @@ -38,8 +91,7 @@ androidx.test:monitor:1.7.2=debugAndroidTestCompileClasspath,debugAndroidTestLin 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 +androidx.versionedparcelable:versionedparcelable:1.1.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,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 @@ -107,6 +159,7 @@ 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:compose-group-mapping:2.4.21=composeMappingProducerClasspath 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 @@ -118,6 +171,7 @@ org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.0=kotlinAbiValidationCompatC 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-compose-compiler-plugin-embeddable:2.4.21=kotlin-extension,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathIntegration,kotlinCompilerPluginClasspathRelease 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 @@ -126,36 +180,31 @@ org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinAbiValidationCompatClasspath,ko 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-common:2.4.21=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.2.21=composeMappingProducerClasspath 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-android:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,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.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath 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.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,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.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains:annotations:13.0=composeMappingProducerClasspath,kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains:annotations:23.0.0=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-gradle-work-action +org.json:json:20260814=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.jspecify:jspecify:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,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 @@ -165,5 +214,7 @@ 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-tree:9.9.1=composeMappingProducerClasspath 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 +org.ow2.asm:asm:9.9.1=composeMappingProducerClasspath +empty=androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugReverseMetadataValues,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,integrationAnnotationProcessorClasspath,integrationImplementationDependenciesMetadata,integrationReverseMetadataValues,kotlinCompilerPluginClasspath,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata,releaseReverseMetadataValues diff --git a/android/app/src/androidTestNative/java/community/yuvomi/mobile/NativeOnboardingTest.kt b/android/app/src/androidTestNative/java/community/yuvomi/mobile/NativeOnboardingTest.kt new file mode 100644 index 0000000..a15bc1f --- /dev/null +++ b/android/app/src/androidTestNative/java/community/yuvomi/mobile/NativeOnboardingTest.kt @@ -0,0 +1,90 @@ +package community.yuvomi.mobile + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.ExternalResource + +class NativeOnboardingTest { + @get:Rule(order = 0) + val reset = object : ExternalResource() { + override fun before() { + ApplicationProvider.getApplicationContext().getSharedPreferences( + "native-server", + Context.MODE_PRIVATE, + ).edit().clear().commit() + } + } + + @get:Rule(order = 1) + val compose = createAndroidComposeRule() + + @Test fun onboardingIsNativeAndHasNoWebView() { + compose.onNodeWithTag("server-url").assertIsDisplayed() + compose.onNodeWithTag("connect").assertIsDisplayed() + compose.activityRule.scenario.onActivity { assertFalse(containsWebView(it.window.decorView)) } + } + + @Test fun rejectsHttpBeforeAnyNetworkCall() { + compose.onNodeWithTag("server-url").performTextInput("http://family.example") + compose.onNodeWithTag("connect").performClick() + compose.onNodeWithText("Bitte eine gültige HTTPS-Adresse ohne Unterpfad eingeben.").assertIsDisplayed() + } + + @Test fun rejectsSubpathBeforeAnyNetworkCall() { + compose.onNodeWithTag("server-url").performTextInput("https://family.example/subpath") + compose.onNodeWithTag("connect").performClick() + compose.onNodeWithText("Bitte eine gültige HTTPS-Adresse ohne Unterpfad eingeben.").assertIsDisplayed() + } + + @Test fun recreatesNativeOnboarding() { + compose.activityRule.scenario.recreate() + compose.onNodeWithTag("server-url").assertIsDisplayed() + compose.onNodeWithTag("connect").assertIsDisplayed() + } + + @Test fun blocksScreenshots() { + compose.activityRule.scenario.onActivity { + assertTrue(it.window.attributes.flags and WindowManager.LayoutParams.FLAG_SECURE != 0) + } + } + + @Test fun nativeCookiesAreEncryptedAndBoundToOrigin() { + val context = ApplicationProvider.getApplicationContext() + val first = NativeSession(context, "https://native-vault-test.example") + val second = NativeSession(context, "https://other-vault-test.example") + try { + first.save("synthetic-private-session") + assertEquals("synthetic-private-session", first.load()) + val preferences = context.getSharedPreferences("native-session", Context.MODE_PRIVATE) + val encrypted = preferences.getString("https://native-vault-test.example", null) + assertNotNull(encrypted) + assertFalse(requireNotNull(encrypted).contains("synthetic-private-session")) + preferences.edit().putString("https://other-vault-test.example", encrypted).commit() + assertEquals(null, second.load()) + } finally { + first.clear() + second.clear() + } + } + + private fun containsWebView(view: View): Boolean { + if (view.javaClass.name.contains("WebView")) return true + if (view is ViewGroup) for (index in 0 until view.childCount) if (containsWebView(view.getChildAt(index))) return true + return false + } +} diff --git a/android/app/src/androidTestNativeIntegration/java/community/yuvomi/mobile/NativeUpstreamTest.kt b/android/app/src/androidTestNativeIntegration/java/community/yuvomi/mobile/NativeUpstreamTest.kt new file mode 100644 index 0000000..a12d727 --- /dev/null +++ b/android/app/src/androidTestNativeIntegration/java/community/yuvomi/mobile/NativeUpstreamTest.kt @@ -0,0 +1,200 @@ +package community.yuvomi.mobile + +import android.content.Context +import android.graphics.Bitmap +import android.view.WindowManager +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isEnabled +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider +import androidx.test.core.app.ApplicationProvider +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.platform.io.PlatformTestStorageRegistry +import java.time.LocalDate +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.ExternalResource + +/** Drives native semantics and real upstream API calls; never evaluates JavaScript. */ +class NativeUpstreamTest { + @get:Rule(order = 0) + val reset = object : ExternalResource() { + override fun before() { + val context = ApplicationProvider.getApplicationContext() + context.getSharedPreferences("native-server", Context.MODE_PRIVATE).edit().clear().commit() + context.getSharedPreferences("native-session", Context.MODE_PRIVATE).edit().clear().commit() + if (android.os.Build.VERSION.SDK_INT >= 37) { + InstrumentationRegistry.getInstrumentation().uiAutomation.grantRuntimePermission( + context.packageName, + android.Manifest.permission.ACCESS_LOCAL_NETWORK, + ) + } + } + } + + @get:Rule(order = 1) + val compose = createAndroidComposeRule() + + @Test fun nativeLoginDashboardTaskNoteShoppingAndLogoutAgainstRealServer() { + val arguments = InstrumentationRegistry.getArguments() + val origin = requireNotNull(arguments.getString("server")) + val password = requireNotNull(arguments.getString("password")) + val seed = NativeApi(ServerAddress.parse(origin), NativeCookies(ServerAddress.parse(origin)), {}) + seed.request("/auth/login", "POST", org.json.JSONObject().put("username", "mobiletest").put("password", password)) + seed.request( + "/tasks", + "POST", + org.json.JSONObject().put( + "title", + "Gemeinsam den Alltag planen", + ).put("priority", "high").put("due_date", LocalDate.now().toString()), + ) + seed.request( + "/notes", + "POST", + org.json.JSONObject().put("title", "Willkommen zuhause").put("content", "Native Oberfläche, eigener Server.").put("pinned", 1), + ) + val seedList = seed.request( + "/shopping", + "POST", + org.json.JSONObject().put("name", "Wochenmarkt"), + ).getJSONObject("data").getLong("id") + seed.request("/shopping/$seedList/items", "POST", org.json.JSONObject().put("name", "Äpfel").put("quantity", "4")) + seed.request("/auth/logout", "POST") + compose.onNodeWithTag("server-url").performTextInput(origin) + compose.onNodeWithTag("connect").performClick() + awaitTag("username") + compose.onNodeWithTag("username").performTextInput("mobiletest") + compose.onNodeWithTag("password").performTextInput(password) + compose.onNodeWithTag("login").performScrollTo().performClick() + awaitTag("native-dashboard") + captureFixtureDashboard() + compose.activityRule.scenario.moveToState(Lifecycle.State.CREATED) + compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED) + compose.activityRule.scenario.recreate() + awaitTag("native-dashboard") + + compose.onNodeWithTag("nav-TASKS").performClick() + awaitText("Hinzufügen") + compose.onNodeWithText("Hinzufügen").performClick() + compose.onNodeWithTag("entry-title").performTextInput("Native task regression") + compose.onNodeWithTag("entry-detail").performTextInput("Created with native controls") + compose.onNodeWithTag("entry-save").performClick() + awaitText("Native task regression") + assertTrue(readApi(origin).request("/tasks").rows().any { it.text("title") == "Native task regression" }) + + compose.onNodeWithTag("nav-NOTES").performClick() + awaitText("Hinzufügen") + compose.onNodeWithText("Hinzufügen").performClick() + compose.onNodeWithTag("entry-title").performTextInput("Native note regression") + compose.onNodeWithTag("entry-detail").performTextInput("Not an embedded web page") + compose.onNodeWithTag("entry-save").performClick() + awaitText("Native note regression") + assertTrue(readApi(origin).request("/notes").rows().any { it.text("content") == "Not an embedded web page" }) + + compose.onNodeWithTag("nav-SHOPPING").performClick() + awaitText("Neue Liste") + compose.onNodeWithText("Neue Liste").performClick() + compose.onNodeWithTag("entry-title").performTextInput("Native shopping regression") + compose.onNodeWithTag("entry-save").performClick() + awaitText("Native shopping regression") + compose.onNodeWithText("Native shopping regression").performClick() + awaitText("Hinzufügen") + compose.onNodeWithText("Hinzufügen").performClick() + compose.onNodeWithTag("entry-title").performTextInput("Native apples") + compose.onNodeWithTag("entry-detail").performTextInput("3") + compose.onNodeWithTag("entry-save").performClick() + awaitText("Native apples") + val api = readApi(origin) + val list = api.request("/shopping").rows().first { it.text("name") == "Native shopping regression" } + assertEquals( + "3", + api.request("/shopping/${list.getLong("id")}/items").rows().first { + it.text("name") == "Native apples" + }.text("quantity"), + ) + + // Verify the same native transport rejects a cross-origin OIDC redirect. + try { + api.request("/auth/oidc/start") + throw AssertionError("Native API followed an authentication redirect") + } catch (error: NativeApiError) { + assertEquals(302, error.status) + } + + compose.onNodeWithTag("nav-MORE").performClick() + awaitText("Abmelden") + compose.onNodeWithText("Abmelden").performScrollTo().performClick() + awaitTag("username") + val context = ApplicationProvider.getApplicationContext() + assertEquals(null, NativeSession(context, origin).load()) + } + + private fun readApi(origin: String): NativeApi { + val context = ApplicationProvider.getApplicationContext() + val vault = NativeSession(context, origin) + val api = NativeApi(ServerAddress.parse(origin), NativeCookies(ServerAddress.parse(origin), vault.load()), {}) + api.request("/auth/me") + return api + } + + private fun captureFixtureDashboard() { + // Only this temporary-fixture test clears the screenshot restriction. + // Ordinary builds always retain FLAG_SECURE, including native login. + compose.activityRule.scenario.onActivity { it.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } + try { + compose.waitForIdle() + // Capture the native window through PixelCopy, not a potentially stale + // secure compositor frame returned by UiAutomation immediately after clearing. + val image = compose.onRoot().captureToImage().asAndroidBitmap() + try { + val first = image.getPixel(0, 0) + assertTrue( + "Native fixture screenshot is blank", + (0 until image.height step 8).any { y -> + (0 until image.width step 8).any { x -> image.getPixel(x, y) != first } + }, + ) + // AGP collects this test output before uninstalling the fixture APK. + PlatformTestStorageRegistry.getInstance().openOutputFile("native-dashboard.png").use { + check(image.compress(Bitmap.CompressFormat.PNG, 100, it)) + } + } finally { + image.recycle() + } + } finally { + compose.activityRule.scenario.onActivity { it.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) } + } + } + + private fun awaitTag(tag: String) { + try { + compose.waitUntil(30_000) { compose.onAllNodes(androidx.compose.ui.test.hasTestTag(tag)).fetchSemanticsNodes().isNotEmpty() } + } catch (error: Exception) { + var diagnostic = "" + compose.activityRule.scenario.onActivity { + val state = ViewModelProvider(it)[NativeController::class.java].state + diagnostic = "busy=${state.busy}, signedIn=${state.user != null}, error=${state.error}" + } + throw AssertionError("Native screen '$tag' not reached: $diagnostic", error) + } + compose.onNodeWithTag(tag).assertIsDisplayed() + } + + private fun awaitText(text: String) { + compose.waitUntil(30_000) { compose.onAllNodes(hasText(text) and isEnabled()).fetchSemanticsNodes().isNotEmpty() } + compose.onNodeWithText(text).assertIsDisplayed() + } +} 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 805b733..1d1808b 100644 --- a/android/app/src/main/java/community/yuvomi/mobile/MainActivity.kt +++ b/android/app/src/main/java/community/yuvomi/mobile/MainActivity.kt @@ -1,689 +1,31 @@ package community.yuvomi.mobile -import android.annotation.SuppressLint -import android.app.AlertDialog -import android.content.Intent -import android.graphics.Bitmap -import android.net.Uri -import android.net.http.SslError +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.provider.MediaStore -import android.text.InputType -import android.view.View -import android.view.ViewGroup import android.view.WindowManager -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.compose.setContent import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.content.FileProvider -import androidx.core.content.edit -import androidx.core.net.toUri -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.webkit.WebStorageCompat -import androidx.webkit.WebViewFeature -import java.io.ByteArrayInputStream -import java.io.File -import java.net.URI -import java.util.concurrent.Executors +import androidx.lifecycle.ViewModelProvider +/** The shipped entry point is Compose. No WebView is created or embedded. */ class MainActivity : ComponentActivity() { - 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 - private var web: WebView? = null - private var authDialog: AlertDialog? = null - private lateinit var root: LinearLayout - private lateinit var content: FrameLayout - private lateinit var progress: ProgressBar - private var fileCallback: ValueCallback>? = null - private var capture: Uri? = null - private var pendingDownload: String? = null - private var generation = 0 - private val uploadSelection = SelectionGate() - private val downloadSelection = SelectionGate() - private val loadTimeout = - Runnable { showError("The server is taking too long. Check your connection or try again.") } - private val localNetworkPermission = - registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - if (granted) { - toast("Private-network access allowed. Retry your server connection.") - } else { - toast("Private-network access was not granted. Public HTTPS servers can still be used.") - } - } - - private val picker = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - 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.", - ) - } - } - } - } - } - } + private val localNetwork = registerForActivityResult(ActivityResultContracts.RequestPermission()) { recreate() } 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(), - ) - 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) { - 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() - } - if (server == null) onboarding() else browser() - } - - private fun column(): LinearLayout = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - 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)) - } - private fun button(label: String, action: () -> Unit) = Button(this).apply { - text = label - isAllCaps = false - minHeight = dp(48) - setOnClickListener { action() } - } - private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt() - - private fun onboarding() { - 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."), - ) - val address = EditText(this).apply { - hint = "https://family.example.com" - inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI - setSingleLine(true) - contentDescription = "Yuvomi server URL" - setText(server?.origin.orEmpty()) - id = View.generateViewId() - } - 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 - } - 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 - if (result.isSuccess) { - server = candidate - preferences.edit { - putString("origin", candidate.origin) - remove("path") - } - browser() - } else { - // Exception details may contain URL parameters; never expose them. - label.setText(R.string.probe_failed) - } - } - } - } - 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), - ) - root.addView(ScrollView(this).apply { addView(form) }) - } - - private fun browser() { - val current = server ?: return onboarding() - 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", - ), - ) - }, - ) - root.addView(toolbar) - progress = ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply { - contentDescription = "Loading Yuvomi" - max = 100 - } - root.addView(progress, LinearLayout.LayoutParams(-1, dp(4))) - content = FrameLayout(this) - root.addView(content, LinearLayout.LayoutParams(-1, 0, 1f)) - 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 (!current.owns(request.url.toString())) { - return WebResourceResponse( - "text/plain", - "UTF-8", - 403, - "Blocked", - emptyMap(), - ByteArrayInputStream(ByteArray(0)), - ) - } - return null - } - override fun shouldOverrideUrlLoading(v: WebView, request: WebResourceRequest): Boolean { - val url = request.url.toString() - if (current.owns(url)) return false - if (request.isForMainFrame) external(url) - 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) - } - override fun onPageFinished(v: WebView, url: String) { - handler.removeCallbacks(loadTimeout) - progress.visibility = View.GONE - CookieManager.getInstance().flush() - if (current.owns(url)) { - val path = URI(url).rawPath.orEmpty() - 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.", - ) - } - } - override fun onReceivedHttpError(v: WebView, r: WebResourceRequest, e: WebResourceResponse) { - 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.", - ) - } - override fun onRenderProcessGone(v: WebView, detail: RenderProcessGoneDetail): Boolean { - 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 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 - 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) && - !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) - 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 "/", - ) - } - - @SuppressLint("SetJavaScriptEnabled") - private fun secure(view: WebView) { - view.settings.apply { - javaScriptEnabled = true - domStorageEnabled = true - allowFileAccess = false - allowContentAccess = false - mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW - javaScriptCanOpenWindowsAutomatically = false - setSupportMultipleWindows(false) - mediaPlaybackRequiresUserGesture = true - safeBrowsingEnabled = true - setGeolocationEnabled(false) - } - } - - 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 - } - 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.", - ) - .setPositiveButton("Browser") { _, _ -> openBrowser(value) } - .setNeutralButton("Sign-in window") { _, _ -> authentication(value) } - .setNegativeButton("Cancel", null).show() - } - - private fun authentication(value: String) { - if (authDialog != null) return - val auth = WebView(this) - secure(auth) - CookieManager.getInstance().setAcceptThirdPartyCookies(auth, false) - auth.webChromeClient = object : WebChromeClient() { - 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 onPageFinished(v: WebView, url: String) { - if (server?.owns(url) == true && !URI(url).path.startsWith("/api/")) { - CookieManager.getInstance().flush() - 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 - } - dialog.show() - 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), + val controller = ViewModelProvider(this)[NativeController::class.java] + setContent { + NativeApp( + controller, + needsLocalNetwork = + Build.VERSION.SDK_INT >= 37 && + checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) != PackageManager.PERMISSION_GRANTED, + requestLocalNetwork = { if (Build.VERSION.SDK_INT >= 37) localNetwork.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) }, ) } - .onFailure { toast("No browser is available.") } - } - - private fun showError(message: String) { - if (isDestroyed || web == null) return - handler.removeCallbacks(loadTimeout) - 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(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 - panel.setBackgroundColor(if (dark) 0xff191816.toInt() else 0xfff5f3ed.toInt()) - content.addView(panel, FrameLayout.LayoutParams(-1, -1)) - panel.accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE - ViewCompat.setAccessibilityPaneTitle(panel, "Connection interrupted") - } - - 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.", - ) - .setPositiveButton("Erase and change server") { _, _ -> - generation++ - 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() - } - } - } else { - // Older engines cannot reliably erase CacheStorage/IndexedDB. - // OS reset is complete and scoped to this application, but closes it. - 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) } - } - .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 - pendingDownload = null - revokeCapture() - capture = null - web?.let { view -> - BlobTransfer.forget(view) - view.stopLoading() - (view.parent as? ViewGroup)?.removeView(view) - 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 onDestroy() { - generation++ - authDialog?.dismiss() - releaseWeb() - worker.shutdownNow() - super.onDestroy() } } diff --git a/android/app/src/main/java/community/yuvomi/mobile/NativeApi.kt b/android/app/src/main/java/community/yuvomi/mobile/NativeApi.kt new file mode 100644 index 0000000..0aaa485 --- /dev/null +++ b/android/app/src/main/java/community/yuvomi/mobile/NativeApi.kt @@ -0,0 +1,153 @@ +package community.yuvomi.mobile + +import java.io.ByteArrayOutputStream +import java.net.HttpCookie +import java.net.URI +import javax.net.ssl.HttpsURLConnection +import org.json.JSONArray +import org.json.JSONObject + +data class SessionCookie(val name: String, val value: String, val expires: Long) + +/** An explicit same-origin jar; never installs a process-wide CookieHandler. */ +class NativeCookies(private val server: ServerAddress, serialized: String? = null) { + private val values = linkedMapOf() + + init { + if (serialized != null) { + runCatching { + val rows = JSONArray(serialized) + for (index in 0 until rows.length()) { + val row = rows.getJSONObject(index) + val cookie = SessionCookie(row.getString("name"), row.getString("value"), row.getLong("expires")) + if (allowed(cookie.name) && safe(cookie.value) && + cookie.expires > System.currentTimeMillis() + ) { + values[cookie.name] = cookie + } + } + }.onFailure { values.clear() } + } + } + + fun accept(headers: List, now: Long = System.currentTimeMillis()) { + val host = URI(server.origin).host + for (header in headers) { + val parsed = runCatching { HttpCookie.parse(header) }.getOrDefault(emptyList()) + for (cookie in parsed) { + if (!allowed(cookie.name) || !cookie.secure || !safe(cookie.value)) continue + if (cookie.domain != null && !cookie.domain.trimStart('.').equals(host, true)) continue + if (cookie.path != null && cookie.path != "/") continue + if (cookie.maxAge == 0L) { + values.remove(cookie.name) + } else { + // Session-only cookies never become persistent across launches. + val expiry = if (cookie.maxAge < 0) Long.MAX_VALUE else now + cookie.maxAge.coerceAtMost(31_536_000L) * 1000 + values[cookie.name] = SessionCookie(cookie.name, cookie.value, expiry) + } + } + } + } + + fun header(now: Long = System.currentTimeMillis()): String = values.values.filter { + it.expires > now + }.joinToString("; ") { "${it.name}=${it.value}" } + + fun serialized(now: Long = System.currentTimeMillis()): String = JSONArray().apply { + values.values.filter { it.expires > now && it.expires != Long.MAX_VALUE }.forEach { + put(JSONObject().put("name", it.name).put("value", it.value).put("expires", it.expires)) + } + }.toString() + + fun clear() = values.clear() + + private fun allowed(name: String) = name == "yuvomi.sid" || name == "csrf-token" + private fun safe(value: String) = value.length <= 8192 && value.none { it <= ' ' || it == ';' || it == ',' || it == '\u007f' } +} + +class NativeApiError(val status: Int, val reason: String = "") : Exception() { + fun userMessage(): String = when (status) { + 401 -> "Anmeldung oder Sitzung ist nicht mehr gültig." + 403 -> "Diese Aktion ist nicht erlaubt. Bitte prüfe deine Berechtigungen." + 409 -> "Der Eintrag wurde inzwischen geändert. Bitte neu laden." + 429 -> "Zu viele Versuche. Bitte warte einen Moment." + in 500..599 -> "Der Server ist derzeit nicht verfügbar." + else -> "Die Anfrage konnte nicht abgeschlossen werden (HTTP $status)." + } +} + +/** Synchronous transport; the controller always invokes it on its worker thread. */ +class NativeApi(val server: ServerAddress, private val cookies: NativeCookies, private val persist: (String) -> Unit) { + private var csrf = "" + + fun clear() { + csrf = "" + cookies.clear() + } + + fun request(path: String, method: String = "GET", body: JSONObject? = null, retry: Boolean = true): JSONObject { + require(path.startsWith("/") && !path.startsWith("//") && !path.contains('#') && !path.contains('\\')) + val target = URI(server.origin + "/api/v1" + path) + require(server.owns(target.toString())) + val connection = target.toURL().openConnection() as HttpsURLConnection + val mutation = method in setOf("POST", "PUT", "PATCH", "DELETE") + try { + connection.instanceFollowRedirects = false + connection.requestMethod = method + connection.connectTimeout = 10_000 + connection.readTimeout = 15_000 + connection.setRequestProperty("Accept", "application/json") + connection.setRequestProperty("Cache-Control", "no-store") + connection.setRequestProperty("Cookie", cookies.header()) + if (mutation) { + connection.setRequestProperty("Content-Type", "application/json") + connection.setRequestProperty("X-CSRF-Token", csrf) + connection.doOutput = true + connection.outputStream.use { it.write((body ?: JSONObject()).toString().toByteArray()) } + } + val status = connection.responseCode + if (status in 300..399) throw NativeApiError(status, "redirect_refused") + cookies.accept(connection.headerFields.filterKeys { it.equals("Set-Cookie", true) }.values.flatten()) + persist(cookies.serialized()) + val input = if (status in 200..299) connection.inputStream else connection.errorStream + val bytes = input?.use { stream -> + val output = ByteArrayOutputStream() + val buffer = ByteArray(8192) + val deadline = System.nanoTime() + 30_000_000_000L + while (true) { + check(System.nanoTime() < deadline) + val count = stream.read(buffer) + if (count < 0) break + require(output.size() + count <= 4 * 1024 * 1024) + output.write(buffer, 0, count) + } + output.toByteArray() + } ?: ByteArray(0) + val data = if (bytes.isEmpty()) { + JSONObject() + } else { + runCatching { + JSONObject(String(bytes, Charsets.UTF_8)) + }.getOrElse { throw NativeApiError(status, "invalid_json") } + } + val updated = connection.getHeaderField("X-CSRF-Token") ?: data.optString("csrfToken") + if (updated.isNotBlank()) csrf = updated + if (status == 403 && mutation && retry && data.optString("reason") == "csrf_invalid") { + request("/auth/me", retry = false) + return request(path, method, body, retry = false) + } + if (status !in 200..299) throw NativeApiError(status, data.optString("reason")) + return data + } finally { + connection.disconnect() + } + } +} + +fun JSONObject.rows(key: String = "data"): List = optJSONArray(key)?.let { array -> + (0 until array.length()).mapNotNull { array.optJSONObject(it) } +}.orEmpty() + +fun JSONObject.text(key: String): String = if (isNull(key)) "" else optString(key) + +fun JSONObject.countText(key: String): String = if (opt(key) is Number) get(key).toString() else "—" diff --git a/android/app/src/main/java/community/yuvomi/mobile/NativeApp.kt b/android/app/src/main/java/community/yuvomi/mobile/NativeApp.kt new file mode 100644 index 0000000..97825d6 --- /dev/null +++ b/android/app/src/main/java/community/yuvomi/mobile/NativeApp.kt @@ -0,0 +1,859 @@ +package community.yuvomi.mobile + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale +import org.json.JSONObject + +private val Violet = Color(0xFF6C3AED) +private val PageShape = RoundedCornerShape(16.dp) + +@Composable +fun NativeApp(controller: NativeController, needsLocalNetwork: Boolean = false, requestLocalNetwork: () -> Unit = {}) { + val dark = isSystemInDarkTheme() + val palette = if (dark) { + darkColorScheme( + primary = Color(0xFFA78BFA), + background = Color(0xFF191816), + surface = Color(0xFF2B2825), + onSurface = Color(0xFFF5F3ED), + onSurfaceVariant = Color(0xFFB4AEA5), + outline = Color(0xFF948E85), + onBackground = Color(0xFFF5F3ED), + ) + } else { + lightColorScheme( + primary = Violet, + background = Color(0xFFF5F3ED), + surface = Color.White, + onSurface = Color(0xFF1D1B17), + onSurfaceVariant = Color(0xFF63615B), + outline = Color(0xFF8C8880), + onBackground = Color(0xFF1D1B17), + ) + } + MaterialTheme(colorScheme = palette) { + val state = controller.state + var confirmForget by remember { mutableStateOf(false) } + var editor by remember { mutableStateOf(null) } + var editing by remember { mutableStateOf(false) } + var submitted by remember { mutableStateOf(false) } + var remove by remember { mutableStateOf(null) } + var info by remember { mutableStateOf(null) } + LaunchedEffect(state.user?.text("id"), state.server) { + editing = false + editor = null + submitted = false + } + LaunchedEffect(state.busy, state.error) { + if (submitted && !state.busy) { + submitted = false + if (state.error == null) editing = false + } + } + BackHandler(state.user != null && (state.page != NativePage.HOME || state.listId != null)) { + if (!state.busy) controller.navigate(if (state.listId != null) NativePage.SHOPPING else NativePage.HOME) + } + Surface(Modifier.fillMaxSize(), color = palette.background) { + if (state.server.isEmpty() || state.user == null) { + AuthenticationScreen( + state, + needsLocalNetwork, + requestLocalNetwork, + controller::connect, + controller::login, + controller::verify, + controller::cancelTwoFactor, + { confirmForget = true }, { + info = + "SSO wird für den nativen API-Client noch nicht unterstützt. " + + "Es wird keine Browser-Sitzung als native Anmeldung vorgetäuscht." + }, + ) + } else { + Scaffold( + containerColor = palette.background, + bottomBar = { + Surface( + Modifier.padding(horizontal = 12.dp, vertical = 8.dp).fillMaxWidth(), + shape = RoundedCornerShape(32.dp), + shadowElevation = 8.dp, + ) { + Row(Modifier.padding(6.dp), horizontalArrangement = Arrangement.SpaceEvenly) { + NativePage.entries.filter { + it == NativePage.HOME || it == NativePage.MORE || + state.access(it.name.lowercase()) != "none" + }.forEach { page -> + Column( + Modifier.weight(1f).clip(CircleShape).clickable(enabled = !state.busy) { + controller.navigate(page) + }.padding(vertical = 8.dp).testTag("nav-${page.name}"), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Glyph(page, if (state.page == page) palette.primary else palette.onSurfaceVariant) + Text( + page.title, + fontSize = 11.sp, + color = if (state.page == + page + ) { + palette.primary + } else { + palette.onSurfaceVariant + }, + ) + } + } + } + } + }, + ) { padding -> + Column(Modifier.padding(padding).fillMaxSize()) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + if (state.listId == + null + ) { + state.page.title + } else { + state.listName + }, + fontSize = 34.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = controller::refresh, enabled = !state.busy) { Text("Aktualisieren") } + } + if (state.busy) LinearProgressIndicator(Modifier.fillMaxWidth()) + state.error?.let { ErrorCard(it, controller::dismissError) } + when (state.page) { + NativePage.HOME -> DashboardScreen(state, controller::navigate) + NativePage.MORE -> MoreScreen(state, { controller.logout() }, { confirmForget = true }) + else -> { + val module = state.page.name.lowercase() + val write = state.access(module) == "write" && !state.busy + Row(Modifier.padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically) { + if (state.listId != + null + ) { + TextButton(onClick = { + controller.navigate(NativePage.SHOPPING) + }, enabled = !state.busy) { Text("Alle Listen") } + } + Spacer(Modifier.weight(1f)) + if (write) { + Button(colors = nativeButtonColors(), onClick = { + editor = null + editing = true + }) { + Text( + if (state.page == + NativePage.SHOPPING && + state.listId == null + ) { + "Neue Liste" + } else { + "Hinzufügen" + }, + ) + } + } + } + NativeCollection( + state, + write, + onEdit = { + editor = it + editing = true + }, + onTask = controller::completeTask, + onPin = controller::pinNote, + onItem = controller::checkItem, + onList = { controller.openList(it.getLong("id"), it.text("name")) }, + onRemove = { remove = it }, + ) + } + } + } + } + } + if (editing) { + EntryEditor(state, editor, onDismiss = { editing = false }) { title, detail, priority, date -> + when (state.page) { + NativePage.TASKS -> controller.saveTask(editor?.getLong("id"), title, detail, priority, date) + NativePage.NOTES -> controller.saveNote(editor?.getLong("id"), title, detail) + NativePage.SHOPPING -> controller.saveShopping(editor?.getLong("id"), title, detail) + else -> Unit + } + submitted = true + } + } + if (confirmForget) { + AlertDialog( + onDismissRequest = { confirmForget = false }, + title = { Text("Server entfernen?") }, + text = { + Text( + "Die lokale native Sitzung und geladene Inhalte werden gelöscht. " + + "Eine entfernte Sitzung wird dadurch nicht widerrufen; melde dich möglichst zuerst ab.", + ) + }, + confirmButton = { + TextButton(onClick = { + confirmForget = false + controller.forgetLocalServer() + }, enabled = !state.busy) { Text("Lokal entfernen") } + }, + dismissButton = { TextButton(onClick = { confirmForget = false }) { Text("Abbrechen") } }, + ) + } + remove?.let { path -> + AlertDialog( + onDismissRequest = { remove = null }, + title = { Text("Eintrag löschen?") }, + text = { Text("Der Eintrag wird auf deinem Server gelöscht. Diese Aktion kann nicht zurückgenommen werden.") }, + confirmButton = { + TextButton(onClick = { + remove = null + controller.remove(path) + }) { Text("Löschen") } + }, + dismissButton = { TextButton(onClick = { remove = null }) { Text("Abbrechen") } }, + ) + } + info?.let { message -> + AlertDialog(onDismissRequest = { + info = null + }, title = { Text("Native Anmeldung") }, text = { Text(message) }, confirmButton = { + TextButton(onClick = { + info = + null + }) { Text("Verstanden") } + }) + } + } + } +} + +@Composable +private fun AuthenticationScreen( + state: NativeState, + needsNetwork: Boolean, + requestNetwork: () -> Unit, + connect: (String) -> Unit, + login: (String, String, String) -> Unit, + verify: (String) -> Unit, + cancelTwoFactor: () -> Unit, + forget: () -> Unit, + sso: () -> Unit, +) { + val keyboard = LocalSoftwareKeyboardController.current + var address by remember { mutableStateOf("") } + var username by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var name by remember { mutableStateOf("") } + var code by remember { mutableStateOf("") } + var invalidAddress by remember { mutableStateOf(false) } + Column( + Modifier.fillMaxSize().imePadding().verticalScroll(rememberScrollState()).padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Surface(shape = RoundedCornerShape(26.dp), modifier = Modifier.widthIn(max = 412.dp).fillMaxWidth()) { + Column( + Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + BrandMark() + Text(state.appName, fontSize = 34.sp, fontWeight = FontWeight.Bold) + Text( + if (state.server.isEmpty()) { + "Dein Zuhause. Dein Server." + } else if (state.twoFactor) { + "Bestätige deine Anmeldung" + } else { + "Alles für euren Alltag an einem Ort." + }, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (state.server.isEmpty()) { + NativeField(address, { + address = it + invalidAddress = false + }, "Server-Adresse", Modifier.testTag("server-url"), keyboard = KeyboardType.Uri) + if (invalidAddress) { + Text( + "Bitte eine gültige HTTPS-Adresse ohne Unterpfad eingeben.", + color = MaterialTheme.colorScheme.error, + ) + } + Text( + "HTTPS · eigener Server · keine zentrale Cloud", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (needsNetwork) TextButton(onClick = requestNetwork) { Text("Zugriff auf private Netzwerke erlauben") } + Button( + colors = nativeButtonColors(), + modifier = Modifier.fillMaxWidth().height(48.dp).testTag("connect"), + enabled = + !state.busy && address.isNotBlank(), + onClick = { + keyboard?.hide() + if (runCatching { ServerAddress.parse(address) }.isSuccess) connect(address) else invalidAddress = true + }, + ) { Text("Verbinden") } + } else if (state.twoFactor) { + NativeField(code, { code = it }, "Code oder Wiederherstellungscode", Modifier.testTag("two-factor-code")) + Button(colors = nativeButtonColors(), onClick = { + keyboard?.hide() + verify(code) + code = "" + }, enabled = !state.busy && code.isNotBlank(), modifier = Modifier.fillMaxWidth()) { Text("Bestätigen") } + TextButton(onClick = cancelTwoFactor, enabled = !state.busy) { Text("Zurück zur Anmeldung") } + } else { + if (state.setup) NativeField(name, { name = it }, "Anzeigename") + if (state.passwordAllowed || state.setup) { + NativeField(username, { username = it }, "Benutzername", Modifier.testTag("username")) + NativeField(password, { password = it }, "Passwort", Modifier.testTag("password"), password = true) + Button( + colors = nativeButtonColors(), + onClick = { + keyboard?.hide() + val submitted = password + password = "" + login(username, submitted, name) + }, + enabled = + !state.busy && username.isNotBlank() && password.isNotEmpty(), + modifier = Modifier.fillMaxWidth().height(48.dp).testTag("login"), + ) { + Text(if (state.setup) "Haushalt einrichten" else "Anmelden") + } + } + if (state.oidc) TextButton(onClick = sso) { Text("SSO – noch in Umsetzung") } + TextButton(onClick = forget, enabled = !state.busy) { Text("Anderen Server verwenden") } + } + if (state.busy) CircularProgressIndicator(Modifier.size(24.dp)) + state.error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.testTag("auth-error")) } + } + } + } +} + +@Composable +private fun DashboardScreen(state: NativeState, navigate: (NativePage) -> Unit) { + val data = state.dashboard + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.testTag("native-dashboard"), + ) { + item { + Text( + "Hallo, ${state.user?.text("display_name").orEmpty().ifBlank { + state.user?.text("username").orEmpty() + }}", + fontSize = 24.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + LocalDate.now().format(DateTimeFormatter.ofPattern("EEEE, d. MMMM", Locale.GERMAN)), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (state.access("tasks") != "none" && state.widget("tasks")) { + item { + DashboardCard("Aufgaben", "${data.countText("openTaskCount")} offen", NativePage.TASKS, { navigate(NativePage.TASKS) }) { + val tasks = data.rows("urgentTasks") + if (tasks.isEmpty()) EmptyHint("Keine dringenden Aufgaben") + tasks.forEach { PreviewRow(it.text("title"), it.text("due_date")) } + } + } + } + if (state.access("calendar") != "none" && state.widget("calendar")) { + item { + DashboardCard("Kalender", "Nächste Termine", null, {}) { + val events = data.rows("upcomingEvents") + if (events.isEmpty()) EmptyHint("Keine anstehenden Termine") + events.forEach { PreviewRow(it.text("title"), it.text("start_datetime").replace('T', ' ').take(16)) } + } + } + } + if (state.access("shopping") != "none" && state.widget("shopping")) { + item { + DashboardCard("Einkauf", "${data.countText("shoppingOpenCount")} offene Artikel", NativePage.SHOPPING, { + navigate(NativePage.SHOPPING) + }) { + data.rows("shoppingLists").forEach { PreviewRow(it.text("name"), "${it.optInt("open_count")} offen") } + if (data.rows("shoppingLists").isEmpty()) EmptyHint("Alles eingekauft") + } + } + } + if (state.access("notes") != "none" && state.widget("notes")) { + item { + DashboardCard("Notizen", "Angeheftet", NativePage.NOTES, { navigate(NativePage.NOTES) }) { + val notes = data.rows("pinnedNotes") + if (notes.isEmpty()) EmptyHint("Noch keine Notizen") + notes.forEach { PreviewRow(it.text("title").ifBlank { "Notiz" }, it.text("content").take(140)) } + } + } + } + } +} + +@Composable +private fun DashboardCard( + title: String, + subtitle: String, + page: NativePage?, + onClick: () -> Unit, + content: @Composable ColumnScope.() -> Unit, +) { + Surface(shape = PageShape, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (page != null) { + Glyph(page, MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(8.dp)) + } + Column(Modifier.weight(1f)) { + Text(title, fontSize = 20.sp, fontWeight = FontWeight.SemiBold) + Text(subtitle, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (page != null) TextButton(onClick = onClick) { Text("Alle") } + } + content() + } + } +} + +@Composable +private fun NativeCollection( + state: NativeState, + write: Boolean, + onEdit: (JSONObject) -> Unit, + onTask: (JSONObject) -> Unit, + onPin: (JSONObject) -> Unit, + onItem: (JSONObject) -> Unit, + onList: (JSONObject) -> Unit, + onRemove: (String) -> Unit, +) { + var query by remember(state.page, state.listId) { mutableStateOf("") } + var status by remember { mutableStateOf("open") } + Column(Modifier.fillMaxSize()) { + NativeField(query, { query = it }, "Suchen", Modifier.padding(16.dp)) + if (state.page == NativePage.TASKS) { + Row(Modifier.padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf("open" to "Offen", "done" to "Erledigt", "all" to "Alle").forEach { (value, label) -> + FilterChip( + selected = + status == value, + onClick = { status = value }, + label = { Text(label) }, + ) + } + } + } + val rows = state.rows.filter { row -> + val title = if (state.page == NativePage.SHOPPING) row.text("name") else row.text("title") + val matches = query.isBlank() || (title + row.text("content") + row.text("description")).contains(query, true) + matches && + ( + state.page != NativePage.TASKS || status == "all" || + if (status == "done") row.text("status") == "done" else row.text("status") != "done" + ) + } + LazyColumn( + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (rows.isEmpty() && + !state.busy + ) { + item { EmptyHint(if (query.isBlank()) "Hier gibt es noch keine Einträge." else "Keine passenden Einträge.") } + } + items(rows, key = { it.getLong("id") }) { row -> + Surface(shape = PageShape, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + val checked = if (state.page == + NativePage.TASKS + ) { + row.text("status") == "done" + } else { + row.optInt("is_checked") == 1 + } + if (state.page == NativePage.TASKS || + (state.page == NativePage.SHOPPING && state.listId != null) + ) { + Checkbox(checked, onCheckedChange = { + if (state.page == + NativePage.TASKS + ) { + onTask(row) + } else { + onItem(row) + } + }, enabled = write) + } + val title = if (state.page == NativePage.SHOPPING) row.text("name") else row.text("title").ifBlank { "Notiz" } + Column( + Modifier.weight(1f).clip(RoundedCornerShape(8.dp)).clickable( + enabled = + !state.busy && (write || state.page == NativePage.SHOPPING && state.listId == null), + ) { + if (state.page == NativePage.SHOPPING && state.listId == null) onList(row) else onEdit(row) + }.padding(4.dp), + ) { + Text( + title, + fontWeight = FontWeight.SemiBold, + textDecoration = if (checked) TextDecoration.LineThrough else null, + ) + val detail = when (state.page) { + NativePage.TASKS -> listOf( + row.text("category"), + row.text("due_date"), + row.text("priority").takeUnless { + it == + "none" + }.orEmpty(), + ).filter { it.isNotBlank() }.joinToString(" · ") + NativePage.NOTES -> row.text("content") + NativePage.SHOPPING -> if (state.listId != + null + ) { + listOf(row.text("quantity"), row.text("category")).filter { + it.isNotBlank() + }.joinToString(" · ") + } else { + "${row.optInt("item_total")} Artikel" + } + else -> "" + } + if (detail.isNotBlank()) Text(detail, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 14.sp) + } + if (state.page == NativePage.NOTES && + write + ) { + TextButton(onClick = { onPin(row) }) { Text(if (row.optInt("pinned") == 1) "Lösen" else "Anheften") } + } + } + if (write) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = { + onRemove( + when (state.page) { + NativePage.TASKS -> "/tasks/${row.getLong("id")}" + NativePage.NOTES -> "/notes/${row.getLong("id")}" + else -> if (state.listId == + null + ) { + "/shopping/${row.getLong("id")}" + } else { + "/shopping/items/${row.getLong("id")}" + } + }, + ) + }) { Text("Löschen", color = MaterialTheme.colorScheme.error) } + } + } + } + } + } + } + } +} + +@Composable +private fun EntryEditor(state: NativeState, entry: JSONObject?, onDismiss: () -> Unit, save: (String, String, String, String) -> Unit) { + val keyboard = LocalSoftwareKeyboardController.current + var title by remember { mutableStateOf(entry?.text(if (state.page == NativePage.SHOPPING) "name" else "title").orEmpty()) } + var detail by remember { + mutableStateOf( + entry?.text( + when (state.page) { + NativePage.NOTES -> "content" + NativePage.SHOPPING -> "quantity" + else -> "description" + }, + ).orEmpty(), + ) + } + var priority by remember { mutableStateOf(entry?.text("priority")?.ifBlank { "none" } ?: "none") } + var date by remember { mutableStateOf(entry?.text("due_date").orEmpty()) } + val validDate = date.isBlank() || runCatching { LocalDate.parse(date) }.isSuccess + AlertDialog(onDismissRequest = { if (!state.busy) onDismiss() }, title = { + Text( + if (entry == + null + ) { + "Hinzufügen" + } else { + "Bearbeiten" + }, + ) + }, text = { + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { + state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + NativeField(title, { title = it }, if (state.page == NativePage.SHOPPING) "Name" else "Titel", Modifier.testTag("entry-title")) + if (state.page != NativePage.SHOPPING || + state.listId != null + ) { + NativeField( + detail, + { detail = it }, + when (state.page) { + NativePage.NOTES -> "Inhalt" + NativePage.SHOPPING -> "Menge" + else -> "Beschreibung" + }, + Modifier.testTag("entry-detail"), + singleLine = + state.page == NativePage.SHOPPING, + ) + } + if (state.page == NativePage.TASKS) { + Text("Priorität") + Column { + listOf( + "none" to "Keine", + "low" to "Niedrig", + "medium" to "Mittel", + "high" to "Hoch", + "urgent" to "Dringend", + ).forEach { (value, label) -> + FilterChip(selected = priority == value, onClick = { priority = value }, label = { Text(label) }) + } + } + NativeField(date, { date = it }, "Fällig am (JJJJ-MM-TT)") + if (!validDate) Text("Bitte ein gültiges Datum eingeben.", color = MaterialTheme.colorScheme.error) + } + } + }, confirmButton = { + TextButton( + onClick = { + keyboard?.hide() + save(title, detail, priority, date) + }, + enabled = + validDate && !state.busy && (title.isNotBlank() || state.page == NativePage.NOTES && detail.isNotBlank()) && + (state.page != NativePage.NOTES || detail.isNotBlank()), + modifier = Modifier.testTag("entry-save"), + ) { Text("Speichern") } + }, dismissButton = { TextButton(onClick = onDismiss, enabled = !state.busy) { Text("Abbrechen") } }) +} + +@Composable +private fun MoreScreen(state: NativeState, logout: () -> Unit, forget: () -> Unit) { + LazyColumn(contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + item { + DashboardCard("Dein Haushalt", state.appName, null, {}) { + Text(state.user?.text("display_name").orEmpty()) + Text(state.server, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + item { + DashboardCard("Konto", "Native Sitzung", null, {}) { + TextButton(onClick = logout, enabled = !state.busy) { Text("Abmelden") } + TextButton(onClick = forget, enabled = !state.busy) { Text("Server und lokale Sitzung entfernen") } + } + } + item { + DashboardCard("Nativer Neubau", "Noch keine vollständige PWA-Parität", null, {}) { + Text( + "Kalender, Schichtplan, Geburtstage, Küche, Vorrat, Budget, Kontakte, Dokumente, Inventar, " + + "Haushaltshilfe, Abfall, Belohnungen, Gesundheit und Einstellungen werden separat nativ nachgebaut. " + + "Es gibt keinen Browser-Fallback.", + ) + } + } + } +} + +@Composable +private fun nativeButtonColors() = ButtonDefaults.buttonColors( + containerColor = if (isSystemInDarkTheme()) Color(0xFF7C3AED) else Violet, + contentColor = Color.White, +) + +@Composable +private fun NativeField( + value: String, + change: (String) -> Unit, + label: String, + modifier: Modifier = Modifier, + password: Boolean = false, + keyboard: KeyboardType = KeyboardType.Text, + singleLine: Boolean = true, +) { + OutlinedTextField( + value, + change, + label = { Text(label) }, + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + singleLine = singleLine, + visualTransformation = if (password) PasswordVisualTransformation() else VisualTransformation.None, + keyboardOptions = KeyboardOptions(keyboardType = if (password) KeyboardType.Password else keyboard), + ) +} + +@Composable +private fun EmptyHint(text: String) { + Text(text, Modifier.padding(vertical = 20.dp), color = MaterialTheme.colorScheme.onSurfaceVariant) +} + +@Composable +private fun PreviewRow(title: String, detail: String) { + Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Text(title, fontWeight = FontWeight.Medium) + if (detail.isNotBlank()) Text(detail, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun ErrorCard(text: String, dismiss: () -> Unit) { + Surface(Modifier.padding(horizontal = 16.dp).fillMaxWidth(), shape = PageShape, color = MaterialTheme.colorScheme.errorContainer) { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Text(text, Modifier.weight(1f)) + TextButton(onClick = dismiss) { Text("Schließen") } + } + } +} + +@Composable +private fun BrandMark() { + Canvas(Modifier.size(64.dp).semantics { contentDescription = "Yuvomi" }) { + drawRoundRect(Brush.linearGradient(listOf(Color(0xFF8B5CF6), Violet)), cornerRadius = CornerRadius(size.width * .225f)) + listOf(Triple(64f, 72f, 27f), Triple(100f, 78f, 25f), Triple(80f, 106f, 24f)).forEach { (x, y, radius) -> + drawCircle(Color.White.copy(alpha = .82f), radius * size.width / 160, Offset(x * size.width / 160, y * size.height / 160)) + } + } +} + +@Composable +private fun Glyph(page: NativePage, color: Color) { + Canvas(Modifier.size(24.dp)) { + val scale = size.width / 24 + val path = Path() + when (page) { + NativePage.HOME -> { + path.moveTo(3 * scale, 10 * scale) + path.lineTo(12 * scale, 3 * scale) + path.lineTo(21 * scale, 10 * scale) + path.lineTo(21 * scale, 21 * scale) + path.lineTo(3 * scale, 21 * scale) + path.close() + } + NativePage.TASKS -> { + path.moveTo(4 * scale, 12 * scale) + path.lineTo(10 * scale, 18 * scale) + path.lineTo(21 * scale, 5 * scale) + } + NativePage.SHOPPING -> { + path.moveTo(2 * scale, 3 * scale) + path.lineTo(5 * scale, 3 * scale) + path.lineTo(8 * scale, 16 * scale) + path.lineTo(20 * scale, 16 * scale) + path.lineTo(22 * scale, 7 * scale) + path.lineTo(6 * scale, 7 * scale) + drawCircle( + color, + 1.5f * scale, + Offset(9 * scale, 21 * scale), + ) + drawCircle(color, 1.5f * scale, Offset(19 * scale, 21 * scale)) + } + NativePage.NOTES -> { + path.moveTo(5 * scale, 3 * scale) + path.lineTo(19 * scale, 3 * scale) + path.lineTo(19 * scale, 21 * scale) + path.lineTo(5 * scale, 21 * scale) + path.close() + path.moveTo(9 * scale, 8 * scale) + path.lineTo(15 * scale, 8 * scale) + path.moveTo(9 * scale, 13 * scale) + path.lineTo(15 * scale, 13 * scale) + } + NativePage.MORE -> { + listOf(5f, 12f, 19f).forEach { drawCircle(color, 1.5f * scale, Offset(it * scale, 12 * scale)) } + } + } + drawPath(path, color, style = Stroke(width = 1.8f * scale)) + } +} diff --git a/android/app/src/main/java/community/yuvomi/mobile/NativeController.kt b/android/app/src/main/java/community/yuvomi/mobile/NativeController.kt new file mode 100644 index 0000000..9b56907 --- /dev/null +++ b/android/app/src/main/java/community/yuvomi/mobile/NativeController.kt @@ -0,0 +1,334 @@ +package community.yuvomi.mobile + +import android.app.Application +import android.content.Context +import android.os.Handler +import android.os.Looper +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.AndroidViewModel +import java.util.concurrent.Executors +import org.json.JSONObject + +enum class NativePage(val title: String) { + HOME("Übersicht"), + TASKS("Aufgaben"), + SHOPPING("Einkauf"), + NOTES("Notizen"), + MORE("Mehr"), +} + +data class NativeState( + val server: String = "", + val appName: String = "Yuvomi", + val user: JSONObject? = null, + val permissions: JSONObject = JSONObject(), + val setup: Boolean = false, + val passwordAllowed: Boolean = true, + val oidc: Boolean = false, + val twoFactor: Boolean = false, + val resetAllowed: Boolean = false, + val page: NativePage = NativePage.HOME, + val dashboard: JSONObject = JSONObject(), + val rows: List = emptyList(), + val listId: Long? = null, + val listName: String = "", + val busy: Boolean = false, + val error: String? = null, +) { + fun access(module: String): String = if (permissions.optBoolean("admin")) { + "write" + } else { + permissions.optJSONObject("modules")?.optString(module, "write") + ?: "write" + } + fun widget(id: String): Boolean = permissions.optBoolean("admin") || permissions.optJSONObject("widgets")?.optString(id) != "none" +} + +class NativeController(application: Application) : AndroidViewModel(application) { + var state by mutableStateOf(NativeState()) + private set + private val preferences = application.getSharedPreferences("native-server", Context.MODE_PRIVATE) + private val worker = Executors.newSingleThreadExecutor() + private val main = Handler(Looper.getMainLooper()) + private var generation = 0 + private var client: NativeApi? = null + private var vault: NativeSession? = null + + init { + preferences.getString("origin", null)?.let { connect(it) } + } + + private fun perform(clearOnFailure: Boolean = false, block: (NativeState) -> NativeState) { + if (state.busy) return + val previous = state + val selectedGeneration = generation + state = previous.copy(busy = true, error = null) + worker.execute { + val result = runCatching { block(previous) } + main.post { + if (generation != selectedGeneration) return@post + state = result.fold( + { it.copy(busy = false) }, + { error -> + if (clearOnFailure || error is NativeApiError && error.status == 401 && previous.user != null) { + previous.copy( + user = null, + permissions = JSONObject(), + dashboard = JSONObject(), + rows = emptyList(), + page = NativePage.HOME, + listId = null, + twoFactor = false, + busy = false, + error = if (clearOnFailure) { + "Der lokale Sitzungsspeicher konnte nicht vollständig gelöscht werden. " + + "Bitte die App-Daten über Android entfernen." + } else { + "Deine Sitzung ist abgelaufen. Bitte erneut anmelden." + }, + ) + } else { + previous.copy( + busy = false, + error = if (error is NativeApiError) { + error.userMessage() + } else { + "Verbindung fehlgeschlagen. Bitte Server, Zertifikat und Netzwerk prüfen." + }, + ) + } + }, + ) + } + } + } + + fun dismissError() { + state = state.copy(error = null) + } + + fun connect(input: String) = perform { + val server = ServerAddress.parse(input) + val storage = NativeSession(getApplication(), server.origin) + val api = NativeApi(server, NativeCookies(server, storage.load()), storage::save) + val metadata = api.request("/version") + require( + metadata.opt("app_name") is String && metadata.opt("setup_required") is Boolean && + metadata.opt("password_reset_enabled") is Boolean, + ) + val oidc = api.request("/auth/oidc/config") + require(oidc.opt("enabled") is Boolean && oidc.opt("password_login_enabled") is Boolean) + client = api + vault = storage + preferences.checkedEdit { putString("origin", server.origin) } + val base = NativeState( + server = server.origin, + appName = metadata.getString("app_name"), + setup = metadata.getBoolean("setup_required"), + passwordAllowed = oidc.getBoolean("password_login_enabled") || oidc.optBoolean("guest_password_login_enabled"), + oidc = oidc.getBoolean("enabled"), + resetAllowed = metadata.getBoolean("password_reset_enabled"), + ) + try { + authenticated(base, api.request("/auth/me")) + } catch (error: NativeApiError) { + if (error.status == 401) base else throw error + } + } + + fun login(username: String, password: String, displayName: String = "") = perform { previous -> + val api = requireNotNull(client) + if (previous.setup) { + api.request( + "/auth/setup", + "POST", + JSONObject().put("username", username.trim()).put("password", password).put( + "display_name", + displayName.trim().ifEmpty { + username.trim() + }, + ), + ) + } + val result = api.request("/auth/login", "POST", JSONObject().put("username", username.trim()).put("password", password)) + if (result.optBoolean("twoFactorRequired")) previous.copy(setup = false, twoFactor = true) else authenticated(previous, result) + } + + fun verify(code: String) = perform { previous -> + authenticated(previous, requireNotNull(client).request("/auth/2fa/verify", "POST", JSONObject().put("code", code.trim()))) + } + + fun cancelTwoFactor() { + if (!state.busy) state = state.copy(twoFactor = false, error = null) + } + + private fun authenticated(previous: NativeState, response: JSONObject): NativeState { + val user = response.getJSONObject("user") + val permissions = response.getJSONObject("permissions") + val signedIn = previous.copy( + user = user, + permissions = permissions, + setup = false, + twoFactor = false, + page = NativePage.HOME, + dashboard = JSONObject(), + rows = emptyList(), + ) + return try { + signedIn.copy(dashboard = requireNotNull(client).request("/dashboard")) + } catch (error: Exception) { + if (error is NativeApiError && error.status == 401) throw error + signedIn.copy(error = "Die Anmeldung war erfolgreich. Die Übersicht konnte noch nicht geladen werden.") + } + } + + fun navigate(page: NativePage) = perform { previous -> load(previous.copy(page = page, listId = null, rows = emptyList())) } + fun refresh() = perform { previous -> load(previous) } + fun openList(id: Long, name: String) = + perform { previous -> load(previous.copy(page = NativePage.SHOPPING, listId = id, listName = name)) } + + private fun load(previous: NativeState): NativeState { + val api = requireNotNull(client) + return when (previous.page) { + NativePage.HOME -> previous.copy(dashboard = api.request("/dashboard")) + NativePage.TASKS -> previous.copy(rows = api.request("/tasks").rows()) + NativePage.NOTES -> previous.copy(rows = api.request("/notes").rows()) + NativePage.SHOPPING -> previous.copy(rows = api.request(previous.listId?.let { "/shopping/$it/items" } ?: "/shopping").rows()) + NativePage.MORE -> previous + } + } + + fun saveTask(id: Long?, title: String, description: String, priority: String, date: String) = perform { previous -> + val payload = JSONObject().put("title", title.trim()).put("description", description).put("priority", priority).put( + "due_date", + date.ifBlank { null } ?: JSONObject.NULL, + ) + requireNotNull(client).request(id?.let { "/tasks/$it" } ?: "/tasks", if (id == null) "POST" else "PUT", payload) + load(previous) + } + + fun completeTask(task: JSONObject) = perform { previous -> + requireNotNull(client).request( + "/tasks/${task.getLong("id")}/status", + "PATCH", + JSONObject().put( + "status", + if (task.text("status") == + "done" + ) { + "open" + } else { + "done" + }, + ), + ) + load(previous) + } + + fun saveNote(id: Long?, title: String, content: String) = perform { previous -> + requireNotNull(client).request( + id?.let { "/notes/$it" } ?: "/notes", + if (id == + null + ) { + "POST" + } else { + "PUT" + }, + JSONObject().put("title", title.trim()).put("content", content), + ) + load(previous) + } + + fun pinNote(note: JSONObject) = perform { previous -> + requireNotNull(client).request("/notes/${note.getLong("id")}/pin", "PATCH", JSONObject()) + load(previous) + } + + fun saveShopping(id: Long?, name: String, quantity: String) = perform { previous -> + val path = id?.let { "/shopping/items/$it" } ?: previous.listId?.let { "/shopping/$it/items" } ?: "/shopping" + requireNotNull(client).request( + path, + if (id == + null + ) { + "POST" + } else { + "PATCH" + }, + JSONObject().put("name", name.trim()).apply { + if (previous.listId != + null + ) { + put("quantity", quantity) + } + }, + ) + load(previous) + } + + fun checkItem(item: JSONObject) = perform { previous -> + requireNotNull(client).request( + "/shopping/items/${item.getLong("id")}", + "PATCH", + JSONObject().put( + "is_checked", + if (item.optInt("is_checked") == + 1 + ) { + 0 + } else { + 1 + }, + ), + ) + load(previous) + } + + fun remove(path: String) = perform { previous -> + requireNotNull(client).request(path, "DELETE") + load(previous) + } + + fun logout(forgetServer: Boolean = false) = perform(clearOnFailure = true) { previous -> + val revoked = runCatching { + requireNotNull(client).request("/auth/logout", "POST", JSONObject()) + }.isSuccess + client?.clear() + vault?.clear() + if (forgetServer) { + preferences.checkedEdit { clear() } + client = null + vault = null + NativeState() + } else { + previous.copy( + user = null, + permissions = JSONObject(), + dashboard = JSONObject(), + rows = emptyList(), + listId = null, + page = NativePage.HOME, + twoFactor = false, + error = if (revoked) null else "Lokal abgemeldet. Die Abmeldung auf dem Server konnte nicht bestätigt werden.", + ) + } + } + + fun forgetLocalServer() = perform(clearOnFailure = true) { + client?.clear() + vault?.clear() + preferences.checkedEdit { clear() } + client = null + vault = null + NativeState() + } + + override fun onCleared() { + generation++ + worker.shutdownNow() + super.onCleared() + } +} diff --git a/android/app/src/main/java/community/yuvomi/mobile/NativeSession.kt b/android/app/src/main/java/community/yuvomi/mobile/NativeSession.kt new file mode 100644 index 0000000..70147e7 --- /dev/null +++ b/android/app/src/main/java/community/yuvomi/mobile/NativeSession.kt @@ -0,0 +1,63 @@ +package community.yuvomi.mobile + +import android.annotation.SuppressLint +import android.content.Context +import android.content.SharedPreferences +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** Native cookies are encrypted with an OS-keystore key and bound to one origin. */ +class NativeSession(context: Context, private val origin: String) { + private val preferences = context.getSharedPreferences("native-session", Context.MODE_PRIVATE) + private val alias = "yuvomi-native-session-v1" + + private fun key(): SecretKey { + val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (store.getKey(alias, null) as? SecretKey)?.let { return it } + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply { + init( + KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build(), + ) + }.generateKey() + } + + fun load(): String? = runCatching { + val encoded = preferences.getString(origin, null) ?: return null + val bytes = Base64.decode(encoded, Base64.NO_WRAP) + require(bytes.size > 28) + Cipher.getInstance("AES/GCM/NoPadding").run { + init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, bytes.copyOfRange(0, 12))) + updateAAD(origin.toByteArray()) + String(doFinal(bytes.copyOfRange(12, bytes.size)), Charsets.UTF_8) + } + }.getOrNull() + + fun save(value: String) { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key()) + cipher.updateAAD(origin.toByteArray()) + val encoded = Base64.encodeToString(cipher.iv + cipher.doFinal(value.toByteArray()), Base64.NO_WRAP) + preferences.checkedEdit { putString(origin, encoded) } + } + + fun clear() { + preferences.checkedEdit { remove(origin) } + } +} + +/** Unlike KTX edit(commit=true), this does not discard a failed persistence result. */ +@SuppressLint("UseKtx") +fun SharedPreferences.checkedEdit(change: SharedPreferences.Editor.() -> Unit) { + val editor = edit() + editor.change() + check(editor.commit()) +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 58a2819..7d080c4 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,5 +1,3 @@ Yuvomi Mobile - Checking your server… - Cannot verify a compatible Yuvomi server. Check the URL, TLS certificate, VPN, and reverse-proxy access. diff --git a/android/app/src/test/java/community/yuvomi/mobile/NativeCookiesTest.kt b/android/app/src/test/java/community/yuvomi/mobile/NativeCookiesTest.kt new file mode 100644 index 0000000..d8b78cc --- /dev/null +++ b/android/app/src/test/java/community/yuvomi/mobile/NativeCookiesTest.kt @@ -0,0 +1,74 @@ +package community.yuvomi.mobile + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class NativeCookiesTest { + private val server = ServerAddress.parse("https://home.example:8443") + + @Test fun acceptsOnlySecureSameHostSessionCookies() { + val jar = NativeCookies(server) + jar.accept(listOf("yuvomi.sid=valid; Secure; HttpOnly; Path=/; Max-Age=60")) + assertEquals("yuvomi.sid=valid", jar.header()) + jar.accept(listOf("yuvomi.sid=insecure; Path=/", "other=unwanted; Secure", "yuvomi.sid=other-host; Domain=evil.example; Secure")) + assertEquals("yuvomi.sid=valid", jar.header()) + } + + @Test fun absoluteExpiryDoesNotExtendWhenRestoring() { + val now = System.currentTimeMillis() + val jar = NativeCookies(server) + jar.accept(listOf("yuvomi.sid=short; Secure; Path=/; Max-Age=60"), now) + val restored = NativeCookies(server, jar.serialized(now)) + assertEquals("yuvomi.sid=short", restored.header(now + 59_000)) + assertEquals("", restored.header(now + 61_000)) + } + + @Test fun sessionOnlyCookieIsNotPersisted() { + val jar = NativeCookies(server) + jar.accept(listOf("yuvomi.sid=temporary; Secure; Path=/")) + assertEquals("yuvomi.sid=temporary", jar.header()) + assertEquals("[]", jar.serialized()) + } + + @Test fun deletionAndClearRemoveSession() { + val jar = NativeCookies(server) + jar.accept(listOf("yuvomi.sid=valid; Secure; Max-Age=60")) + jar.accept(listOf("yuvomi.sid=deleted; Secure; Max-Age=0")) + assertEquals("", jar.header()) + jar.accept(listOf("csrf-token=token; Secure; Max-Age=60")) + jar.clear() + assertEquals("", jar.header()) + } + + @Test fun rejectsMalformedAndHeaderInjectionPersistence() { + assertEquals("", NativeCookies(server, "not-json").header()) + val rows = JSONArray().put( + JSONObject().put("name", "yuvomi.sid").put("value", "bad\r\nInjected: value").put( + "expires", + Long.MAX_VALUE - 1, + ), + ) + assertEquals("", NativeCookies(server, rows.toString()).header()) + } + + @Test fun permissionPayloadKeepsReadOnlyAndHiddenModulesDistinct() { + val state = + NativeState( + permissions = JSONObject("""{"admin":false,"modules":{"tasks":"read","notes":"none"},"widgets":{"shopping":"none"}}"""), + ) + assertEquals("read", state.access("tasks")) + assertEquals("none", state.access("notes")) + assertEquals("write", state.access("shopping")) + assertFalse(state.widget("shopping")) + assertTrue(state.widget("notes")) + } + + @Test fun unavailableCounterIsNotShownAsZero() { + assertEquals("—", JSONObject("""{"openTaskCount":null}""").countText("openTaskCount")) + assertEquals("0", JSONObject("""{"openTaskCount":0}""").countText("openTaskCount")) + } +} 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 36b3344..9e53106 100644 --- a/android/app/src/test/java/community/yuvomi/mobile/ServerAddressTest.kt +++ b/android/app/src/test/java/community/yuvomi/mobile/ServerAddressTest.kt @@ -46,17 +46,4 @@ class ServerAddressTest { 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 ec29718..0c1eaf8 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -11,6 +11,7 @@ buildscript { when (requested.group) { "io.netty" -> useVersion("4.1.139.Final") "com.google.protobuf" -> useVersion("3.25.9") + "com.google.crypto.tink" -> useVersion("1.15.0") "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") @@ -21,7 +22,10 @@ buildscript { } } -plugins { id("com.android.application") version "9.4.1" apply false } +plugins { + id("com.android.application") version "9.4.1" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.4.21" 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. diff --git a/android/buildscript-gradle.lockfile b/android/buildscript-gradle.lockfile index f684690..e7a3ff7 100644 --- a/android/buildscript-gradle.lockfile +++ b/android/buildscript-gradle.lockfile @@ -41,7 +41,7 @@ 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.crypto.tink:tink:1.15.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 @@ -80,6 +80,8 @@ 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.plugin.compose:org.jetbrains.kotlin.plugin.compose.gradle.plugin:2.4.21=classpath +org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.4.21=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 diff --git a/legacy/README.md b/legacy/README.md new file mode 100644 index 0000000..7eeee45 --- /dev/null +++ b/legacy/README.md @@ -0,0 +1,8 @@ +# Superseded WebView prototype + +These files are retained as historical reference, not shipped application code. +The Android browser tests in `legacy/android/tests` and Apple BrowserModel tests +describe this prototype, not native feature parity. +Android now selects the explicitly named native test source directories instead. +Do not restore a WebView as the application surface or count old browser tests as +native-client validation. diff --git a/legacy/android/BlobOriginsTest.kt b/legacy/android/BlobOriginsTest.kt new file mode 100644 index 0000000..98ec327 --- /dev/null +++ b/legacy/android/BlobOriginsTest.kt @@ -0,0 +1,16 @@ +package community.yuvomi.mobile + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BlobOriginsTest { + @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/app/src/main/java/community/yuvomi/mobile/BlobTransfer.kt b/legacy/android/BlobTransfer.kt similarity index 100% rename from android/app/src/main/java/community/yuvomi/mobile/BlobTransfer.kt rename to legacy/android/BlobTransfer.kt diff --git a/android/app/src/main/java/community/yuvomi/mobile/FileTransfer.kt b/legacy/android/FileTransfer.kt similarity index 100% rename from android/app/src/main/java/community/yuvomi/mobile/FileTransfer.kt rename to legacy/android/FileTransfer.kt diff --git a/legacy/android/MainActivity.kt b/legacy/android/MainActivity.kt new file mode 100644 index 0000000..805b733 --- /dev/null +++ b/legacy/android/MainActivity.kt @@ -0,0 +1,689 @@ +package community.yuvomi.mobile + +import android.annotation.SuppressLint +import android.app.AlertDialog +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import android.net.http.SslError +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.provider.MediaStore +import android.text.InputType +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +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 +import androidx.core.content.FileProvider +import androidx.core.content.edit +import androidx.core.net.toUri +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.webkit.WebStorageCompat +import androidx.webkit.WebViewFeature +import java.io.ByteArrayInputStream +import java.io.File +import java.net.URI +import java.util.concurrent.Executors + +class MainActivity : ComponentActivity() { + 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 + private var web: WebView? = null + private var authDialog: AlertDialog? = null + private lateinit var root: LinearLayout + private lateinit var content: FrameLayout + private lateinit var progress: ProgressBar + private var fileCallback: ValueCallback>? = null + private var capture: Uri? = null + private var pendingDownload: String? = null + private var generation = 0 + private val uploadSelection = SelectionGate() + private val downloadSelection = SelectionGate() + private val loadTimeout = + Runnable { showError("The server is taking too long. Check your connection or try again.") } + private val localNetworkPermission = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) { + toast("Private-network access allowed. Retry your server connection.") + } else { + toast("Private-network access was not granted. Public HTTPS servers can still be used.") + } + } + + private val picker = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + 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(), + ) + 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) { + 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() + } + if (server == null) onboarding() else browser() + } + + private fun column(): LinearLayout = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + 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)) + } + private fun button(label: String, action: () -> Unit) = Button(this).apply { + text = label + isAllCaps = false + minHeight = dp(48) + setOnClickListener { action() } + } + private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt() + + private fun onboarding() { + 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."), + ) + val address = EditText(this).apply { + hint = "https://family.example.com" + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI + setSingleLine(true) + contentDescription = "Yuvomi server URL" + setText(server?.origin.orEmpty()) + id = View.generateViewId() + } + 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 + } + 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 + if (result.isSuccess) { + server = candidate + preferences.edit { + putString("origin", candidate.origin) + remove("path") + } + browser() + } else { + // Exception details may contain URL parameters; never expose them. + label.setText(R.string.probe_failed) + } + } + } + } + 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), + ) + root.addView(ScrollView(this).apply { addView(form) }) + } + + private fun browser() { + val current = server ?: return onboarding() + 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", + ), + ) + }, + ) + root.addView(toolbar) + progress = ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply { + contentDescription = "Loading Yuvomi" + max = 100 + } + root.addView(progress, LinearLayout.LayoutParams(-1, dp(4))) + content = FrameLayout(this) + root.addView(content, LinearLayout.LayoutParams(-1, 0, 1f)) + 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 (!current.owns(request.url.toString())) { + return WebResourceResponse( + "text/plain", + "UTF-8", + 403, + "Blocked", + emptyMap(), + ByteArrayInputStream(ByteArray(0)), + ) + } + return null + } + override fun shouldOverrideUrlLoading(v: WebView, request: WebResourceRequest): Boolean { + val url = request.url.toString() + if (current.owns(url)) return false + if (request.isForMainFrame) external(url) + 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) + } + override fun onPageFinished(v: WebView, url: String) { + handler.removeCallbacks(loadTimeout) + progress.visibility = View.GONE + CookieManager.getInstance().flush() + if (current.owns(url)) { + val path = URI(url).rawPath.orEmpty() + 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.", + ) + } + } + override fun onReceivedHttpError(v: WebView, r: WebResourceRequest, e: WebResourceResponse) { + 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.", + ) + } + override fun onRenderProcessGone(v: WebView, detail: RenderProcessGoneDetail): Boolean { + 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 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 + 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) && + !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) + 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 "/", + ) + } + + @SuppressLint("SetJavaScriptEnabled") + private fun secure(view: WebView) { + view.settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + allowFileAccess = false + allowContentAccess = false + mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW + javaScriptCanOpenWindowsAutomatically = false + setSupportMultipleWindows(false) + mediaPlaybackRequiresUserGesture = true + safeBrowsingEnabled = true + setGeolocationEnabled(false) + } + } + + 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 + } + 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.", + ) + .setPositiveButton("Browser") { _, _ -> openBrowser(value) } + .setNeutralButton("Sign-in window") { _, _ -> authentication(value) } + .setNegativeButton("Cancel", null).show() + } + + private fun authentication(value: String) { + if (authDialog != null) return + val auth = WebView(this) + secure(auth) + CookieManager.getInstance().setAcceptThirdPartyCookies(auth, false) + auth.webChromeClient = object : WebChromeClient() { + 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 onPageFinished(v: WebView, url: String) { + if (server?.owns(url) == true && !URI(url).path.startsWith("/api/")) { + CookieManager.getInstance().flush() + 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 + } + dialog.show() + 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), + ) + } + .onFailure { toast("No browser is available.") } + } + + private fun showError(message: String) { + if (isDestroyed || web == null) return + handler.removeCallbacks(loadTimeout) + 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(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 + panel.setBackgroundColor(if (dark) 0xff191816.toInt() else 0xfff5f3ed.toInt()) + content.addView(panel, FrameLayout.LayoutParams(-1, -1)) + panel.accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE + ViewCompat.setAccessibilityPaneTitle(panel, "Connection interrupted") + } + + 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.", + ) + .setPositiveButton("Erase and change server") { _, _ -> + generation++ + 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() + } + } + } else { + // Older engines cannot reliably erase CacheStorage/IndexedDB. + // OS reset is complete and scoped to this application, but closes it. + 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) } + } + .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 + pendingDownload = null + revokeCapture() + capture = null + web?.let { view -> + BlobTransfer.forget(view) + view.stopLoading() + (view.parent as? ViewGroup)?.removeView(view) + 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 onDestroy() { + generation++ + authDialog?.dismiss() + releaseWeb() + worker.shutdownNow() + super.onDestroy() + } +} diff --git a/android/app/src/main/java/community/yuvomi/mobile/SelectionGate.kt b/legacy/android/SelectionGate.kt similarity index 100% rename from android/app/src/main/java/community/yuvomi/mobile/SelectionGate.kt rename to legacy/android/SelectionGate.kt diff --git a/android/app/src/test/java/community/yuvomi/mobile/SelectionGateTest.kt b/legacy/android/SelectionGateTest.kt similarity index 100% rename from android/app/src/test/java/community/yuvomi/mobile/SelectionGateTest.kt rename to legacy/android/SelectionGateTest.kt diff --git a/legacy/android/strings.xml b/legacy/android/strings.xml new file mode 100644 index 0000000..58a2819 --- /dev/null +++ b/legacy/android/strings.xml @@ -0,0 +1,5 @@ + + Yuvomi Mobile + Checking your server… + Cannot verify a compatible Yuvomi server. Check the URL, TLS certificate, VPN, and reverse-proxy access. + diff --git a/android/app/src/androidTest/java/community/yuvomi/mobile/BlobExportTest.kt b/legacy/android/tests/BlobExportTest.kt similarity index 100% rename from android/app/src/androidTest/java/community/yuvomi/mobile/BlobExportTest.kt rename to legacy/android/tests/BlobExportTest.kt diff --git a/android/app/src/androidTest/java/community/yuvomi/mobile/BrowserSecurityTest.kt b/legacy/android/tests/BrowserSecurityTest.kt similarity index 100% rename from android/app/src/androidTest/java/community/yuvomi/mobile/BrowserSecurityTest.kt rename to legacy/android/tests/BrowserSecurityTest.kt diff --git a/android/app/src/androidTest/java/community/yuvomi/mobile/LocalNetworkPermissionTest.kt b/legacy/android/tests/LocalNetworkPermissionTest.kt similarity index 100% rename from android/app/src/androidTest/java/community/yuvomi/mobile/LocalNetworkPermissionTest.kt rename to legacy/android/tests/LocalNetworkPermissionTest.kt diff --git a/android/app/src/androidTest/java/community/yuvomi/mobile/OnboardingTest.kt b/legacy/android/tests/OnboardingTest.kt similarity index 100% rename from android/app/src/androidTest/java/community/yuvomi/mobile/OnboardingTest.kt rename to legacy/android/tests/OnboardingTest.kt diff --git a/android/app/src/androidTestIntegration/java/community/yuvomi/mobile/UpstreamIntegrationTest.kt b/legacy/android/tests/UpstreamIntegrationTest.kt similarity index 100% rename from android/app/src/androidTestIntegration/java/community/yuvomi/mobile/UpstreamIntegrationTest.kt rename to legacy/android/tests/UpstreamIntegrationTest.kt