SHA256
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.
This commit is contained in:
1 parent
81973c3c45
commit
8c480ddca3
28 files changed
+2628
-742
No files matched your search
@@ -16,3 +16,4 @@ max_line_length = 140
|
|||||||
ktlint_code_style = android_studio
|
ktlint_code_style = android_studio
|
||||||
ij_kotlin_allow_trailing_comma = true
|
ij_kotlin_allow_trailing_comma = true
|
||||||
ij_kotlin_allow_trailing_comma_on_call_site = true
|
ij_kotlin_allow_trailing_comma_on_call_site = true
|
||||||
|
ktlint_function_naming_ignore_when_annotated_with = Composable
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -9,8 +10,8 @@ android {
|
|||||||
applicationId = "community.yuvomi.mobile"
|
applicationId = "community.yuvomi.mobile"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 1
|
versionCode = 2
|
||||||
versionName = "0.1.0"
|
versionName = "0.2.0-native-preview"
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -33,6 +34,11 @@ android {
|
|||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
buildFeatures { compose = true }
|
||||||
|
sourceSets.getByName("androidTest").kotlin.directories.apply {
|
||||||
|
clear()
|
||||||
|
add("src/androidTestNative/java")
|
||||||
|
}
|
||||||
lint {
|
lint {
|
||||||
abortOnError = true
|
abortOnError = true
|
||||||
warningsAsErrors = true
|
warningsAsErrors = true
|
||||||
@@ -41,7 +47,7 @@ android {
|
|||||||
testBuildType = "integration"
|
testBuildType = "integration"
|
||||||
sourceSets.getByName(
|
sourceSets.getByName(
|
||||||
"androidTest",
|
"androidTest",
|
||||||
).kotlin.directories.add("src/androidTestIntegration/java")
|
).kotlin.directories.add("src/androidTestNativeIntegration/java")
|
||||||
val testResources = providers.environmentVariable("YUVOMI_TEST_RES").get()
|
val testResources = providers.environmentVariable("YUVOMI_TEST_RES").get()
|
||||||
sourceSets.getByName("integration").res.directories.add(testResources)
|
sourceSets.getByName("integration").res.directories.add(testResources)
|
||||||
defaultConfig.testInstrumentationRunnerArguments["server"] =
|
defaultConfig.testInstrumentationRunnerArguments["server"] =
|
||||||
@@ -89,9 +95,13 @@ configurations.configureEach {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation("androidx.activity:activity-ktx:1.10.1")
|
implementation("androidx.activity:activity-ktx:1.10.1")
|
||||||
implementation("androidx.core:core-ktx:1.16.0")
|
implementation("androidx.core:core-ktx:1.16.0")
|
||||||
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("junit:junit:4.13.2")
|
||||||
|
testImplementation("org.json:json:20260814")
|
||||||
androidTestImplementation("androidx.test:runner:1.6.2")
|
androidTestImplementation("androidx.test:runner:1.6.2")
|
||||||
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
||||||
|
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.9.0")
|
||||||
}
|
}
|
||||||
+99
-48
@@ -2,33 +2,86 @@
|
|||||||
# Manual edits can break the build and are not advised.
|
# Manual edits can break the build and are not advised.
|
||||||
# This file is expected to be part of source control.
|
# This file is expected to be part of source control.
|
||||||
# To regenerate this file, run: ./gradlew :app:dependencies --write-locks
|
# 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-compose: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,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.annotation:annotation-experimental:1.4.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,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-jvm:1.8.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:1.8.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.arch.core:core-common:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,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-runtime:2.2.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,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.collection:collection-jvm:1.4.2=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
androidx.arch.core:core-runtime:2.2.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
androidx.collection:collection:1.0.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,integrationCompileClasspath,releaseCompileClasspath
|
androidx.autofill:autofill:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
androidx.collection:collection:1.4.2=debugAndroidTestLintChecksClasspath,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-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.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-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,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,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.interpolator:interpolator:1.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
androidx.customview:customview-poolingcontainer:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,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.emoji2:emoji2:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,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.graphics:graphics-path:1.0.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,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.interpolator:interpolator:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,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-common-java8:2.9.4=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,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-common-jvm:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,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-common:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,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.lifecycle:lifecycle-livedata-core: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,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
androidx.lifecycle:lifecycle-process:2.9.4=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,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.lifecycle:lifecycle-runtime-android:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,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.lifecycle:lifecycle-runtime-compose-android:2.9.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,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.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-core:3.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
androidx.test.espresso:espresso-idling-resource:3.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
androidx.test.espresso:espresso-idling-resource:3.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
androidx.test.ext:junit:1.2.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
androidx.test.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.test:runner:1.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
androidx.tracing:tracing:1.1.0=debugAndroidTestCompileClasspath
|
androidx.tracing:tracing:1.1.0=debugAndroidTestCompileClasspath
|
||||||
androidx.tracing:tracing:1.2.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
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.versionedparcelable:versionedparcelable:1.1.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
androidx.webkit:webkit:1.14.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
|
||||||
com.android.tools.analytics-library:protos:32.4.1=androidLintTool
|
com.android.tools.analytics-library:protos:32.4.1=androidLintTool
|
||||||
com.android.tools.analytics-library:shared:32.4.1=androidLintTool
|
com.android.tools.analytics-library:shared:32.4.1=androidLintTool
|
||||||
com.android.tools.analytics-library:tracker:32.4.1=androidLintTool
|
com.android.tools.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.glassfish.jaxb:txw2:2.3.2=androidLintTool
|
||||||
org.hamcrest:hamcrest-core:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
org.hamcrest:hamcrest-core:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
org.hamcrest:hamcrest-library:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
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.0=kotlinAbiValidationCompatClasspath
|
||||||
org.jetbrains.kotlin:kotlin-build-tools-api:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
org.jetbrains.kotlin:kotlin-build-tools-api:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.21=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
org.jetbrains.kotlin:kotlin-build-tools-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-embeddable:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
org.jetbrains.kotlin:kotlin-compiler-runner:2.4.0=kotlinAbiValidationCompatClasspath
|
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-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.0=kotlinAbiValidationCompatClasspath
|
||||||
org.jetbrains.kotlin:kotlin-daemon-client:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
org.jetbrains.kotlin:kotlin-daemon-client:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.0=kotlinAbiValidationCompatClasspath
|
org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.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-reflect:2.2.10=androidLintTool
|
||||||
org.jetbrains.kotlin:kotlin-script-runtime:2.4.0=kotlinAbiValidationCompatClasspath
|
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-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-common:2.4.21=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0=debugCompileClasspath,debugUnitTestCompileClasspath,integrationCompileClasspath,releaseCompileClasspath
|
|
||||||
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.20=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
|
||||||
org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.10=androidLintTool,unified-test-platform-gradle-work-action
|
org.jetbrains.kotlin:kotlin-stdlib-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-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.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.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-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.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.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1=debugAndroidTestCompileClasspath
|
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-android:1.7.3=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
|
||||||
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.6.4=debugCompileClasspath,debugUnitTestCompileClasspath,integrationCompileClasspath,releaseCompileClasspath
|
|
||||||
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.1=debugAndroidTestCompileClasspath
|
|
||||||
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
|
||||||
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=androidLintTool,unified-test-platform-gradle-work-action
|
org.jetbrains.kotlinx:kotlinx-coroutines-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.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-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.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1=debugAndroidTestCompileClasspath
|
|
||||||
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
|
||||||
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0=androidLintTool,unified-test-platform-gradle-work-action
|
org.jetbrains.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.kotlinx:kotlinx-coroutines-test-jvm:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
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.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
org.jspecify:jspecify:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,integrationCompileClasspath,integrationLintChecksClasspath,integrationRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
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-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-engine:1.13.3=unified-test-platform-gradle-work-action
|
||||||
org.junit.platform:junit-platform-launcher:1.13.3=unified-test-platform-gradle-work-action
|
org.junit.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-analysis:9.9=androidLintTool
|
||||||
org.ow2.asm:asm-commons: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=androidLintTool
|
||||||
|
org.ow2.asm:asm-tree:9.9.1=composeMappingProducerClasspath
|
||||||
org.ow2.asm:asm:9.9=androidLintTool
|
org.ow2.asm:asm:9.9=androidLintTool
|
||||||
empty=androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugReverseMetadataValues,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,integrationAnnotationProcessorClasspath,integrationImplementationDependenciesMetadata,integrationReverseMetadataValues,kotlinCompilerPluginClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathIntegration,kotlinCompilerPluginClasspathRelease,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata,releaseReverseMetadataValues
|
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
|
||||||
+90
@@ -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<Context>().getSharedPreferences(
|
||||||
|
"native-server",
|
||||||
|
Context.MODE_PRIVATE,
|
||||||
|
).edit().clear().commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@get:Rule(order = 1)
|
||||||
|
val compose = createAndroidComposeRule<MainActivity>()
|
||||||
|
|
||||||
|
@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<Context>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+200
@@ -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>()
|
||||||
|
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<MainActivity>()
|
||||||
|
|
||||||
|
@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<Context>()
|
||||||
|
assertEquals(null, NativeSession(context, origin).load())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readApi(origin: String): NativeApi {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,689 +1,31 @@
|
|||||||
package community.yuvomi.mobile
|
package community.yuvomi.mobile
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.Manifest
|
||||||
import android.app.AlertDialog
|
import android.content.pm.PackageManager
|
||||||
import android.content.Intent
|
import android.os.Build
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.net.Uri
|
|
||||||
import android.net.http.SslError
|
|
||||||
import android.os.Bundle
|
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.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.ComponentActivity
|
||||||
import androidx.activity.OnBackPressedCallback
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.core.content.FileProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
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
|
|
||||||
|
|
||||||
|
/** The shipped entry point is Compose. No WebView is created or embedded. */
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private var worker = Executors.newSingleThreadExecutor()
|
private val localNetwork = registerForActivityResult(ActivityResultContracts.RequestPermission()) { recreate() }
|
||||||
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<Array<Uri>>? = 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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
if (savedInstanceState?.getBoolean("uploadOutstanding") == true) uploadSelection.restoreOutstanding()
|
|
||||||
if (savedInstanceState?.getBoolean("downloadOutstanding") == true) downloadSelection.restoreOutstanding()
|
|
||||||
// Household data is hidden from screenshots and the recent-apps preview.
|
|
||||||
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||||
WebView.setWebContentsDebuggingEnabled(false)
|
val controller = ViewModelProvider(this)[NativeController::class.java]
|
||||||
root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
|
setContent {
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
|
NativeApp(
|
||||||
val bars = insets.getInsets(
|
controller,
|
||||||
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime(),
|
needsLocalNetwork =
|
||||||
)
|
Build.VERSION.SDK_INT >= 37 &&
|
||||||
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
|
checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) != PackageManager.PERMISSION_GRANTED,
|
||||||
insets
|
requestLocalNetwork = { if (Build.VERSION.SDK_INT >= 37) localNetwork.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) },
|
||||||
}
|
|
||||||
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<Array<Uri>>, 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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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<String, SessionCookie>()
|
||||||
|
|
||||||
|
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<String>, 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<JSONObject> = 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 "—"
|
||||||
@@ -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<JSONObject?>(null) }
|
||||||
|
var editing by remember { mutableStateOf(false) }
|
||||||
|
var submitted by remember { mutableStateOf(false) }
|
||||||
|
var remove by remember { mutableStateOf<String?>(null) }
|
||||||
|
var info by remember { mutableStateOf<String?>(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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<JSONObject> = 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Yuvomi Mobile</string>
|
<string name="app_name">Yuvomi Mobile</string>
|
||||||
<string name="checking_server">Checking your server…</string>
|
|
||||||
<string name="probe_failed">Cannot verify a compatible Yuvomi server. Check the URL, TLS certificate, VPN, and reverse-proxy access.</string>
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,17 +46,4 @@ class ServerAddressTest {
|
|||||||
assertFalse(it, server.owns(it))
|
assertFalse(it, server.owns(it))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun blobDownloadsCannotCrossOriginsOrDowngradeTls() {
|
|
||||||
val server = ServerAddress.parse("https://family.example")
|
|
||||||
assertTrue(BlobTransfer.owns(server, "blob:https://family.example/unique-id"))
|
|
||||||
listOf(
|
|
||||||
"blob:https://evil.example/id",
|
|
||||||
"blob:http://family.example/id",
|
|
||||||
"blob:https://family.example.evil.test/id",
|
|
||||||
"data:text/plain,hi",
|
|
||||||
).forEach {
|
|
||||||
assertFalse(it, BlobTransfer.owns(server, it))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,7 @@ buildscript {
|
|||||||
when (requested.group) {
|
when (requested.group) {
|
||||||
"io.netty" -> useVersion("4.1.139.Final")
|
"io.netty" -> useVersion("4.1.139.Final")
|
||||||
"com.google.protobuf" -> useVersion("3.25.9")
|
"com.google.protobuf" -> useVersion("3.25.9")
|
||||||
|
"com.google.crypto.tink" -> useVersion("1.15.0")
|
||||||
"org.bouncycastle" -> useVersion("1.86")
|
"org.bouncycastle" -> useVersion("1.86")
|
||||||
"org.apache.commons" -> if (requested.name == "commons-lang3") useVersion("3.21.0")
|
"org.apache.commons" -> if (requested.name == "commons-lang3") useVersion("3.21.0")
|
||||||
"org.apache.httpcomponents" -> if (requested.name == "httpclient") useVersion("4.5.14")
|
"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.
|
// WSL mounted Windows volumes may reject Gradle's POSIX file metadata operations.
|
||||||
// Opt-in external output root; ordinary developer/CI builds retain standard paths.
|
// Opt-in external output root; ordinary developer/CI builds retain standard paths.
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ com.android:zipflinger:9.4.1=classpath
|
|||||||
com.google.auto.value:auto-value-annotations:1.6.2=classpath
|
com.google.auto.value:auto-value-annotations:1.6.2=classpath
|
||||||
com.google.code.findbugs:jsr305:3.0.2=classpath
|
com.google.code.findbugs:jsr305:3.0.2=classpath
|
||||||
com.google.code.gson:gson:2.11.0=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.dagger:dagger:2.28.3=classpath
|
||||||
com.google.errorprone:error_prone_annotations:2.36.0=classpath
|
com.google.errorprone:error_prone_annotations:2.36.0=classpath
|
||||||
com.google.flatbuffers:flatbuffers-java:1.12.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:jaxb-runtime:2.3.2=classpath
|
||||||
org.glassfish.jaxb:txw2:2.3.2=classpath
|
org.glassfish.jaxb:txw2:2.3.2=classpath
|
||||||
org.jdom:jdom2:2.0.6.1=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:fus-statistics-gradle-plugin:2.4.21=classpath
|
||||||
org.jetbrains.kotlin:kotlin-build-statistics:2.4.21=classpath
|
org.jetbrains.kotlin:kotlin-build-statistics:2.4.21=classpath
|
||||||
org.jetbrains.kotlin:kotlin-build-tools-api:2.4.21=classpath
|
org.jetbrains.kotlin:kotlin-build-tools-api:2.4.21=classpath
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
File renamed without changes.
@@ -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<Array<Uri>>? = 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<Array<Uri>>, 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,5 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">Yuvomi Mobile</string>
|
||||||
|
<string name="checking_server">Checking your server…</string>
|
||||||
|
<string name="probe_failed">Cannot verify a compatible Yuvomi server. Check the URL, TLS certificate, VPN, and reverse-proxy access.</string>
|
||||||
|
</resources>
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Reference in new issue
Block a user