From d466b170ab407ea68452975dbb46486dc7b1d1ba Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Mon, 12 Oct 2026 09:49:38 +0200 Subject: [PATCH] Implement CameraX GPG QR transfer on Android (#106) --- android/README.md | 2 + android/app/gradle.lockfile | 42 ++- .../ironstorage/OnboardingScreenTest.kt | 52 +++- .../rfc1437/ironstorage/SettingsScreenTest.kt | 102 ++++++- android/app/src/main/AndroidManifest.xml | 6 +- .../ironstorage/core/NativeEntryViewerTest.kt | 26 +- .../core/NativeSettingsRepository.kt | 122 +++++++- .../ironstorage/core/SettingsRepository.kt | 21 +- android/core/src/main/res/values/strings.xml | 1 + android/dependency-policy.json | 26 +- .../ironstorage/features/OnboardingScreen.kt | 12 + .../ironstorage/features/QrCameraPane.kt | 95 +++++++ .../ironstorage/features/SettingsScreen.kt | 260 +++++++++++------- .../ironstorage/features/ShellScreen.kt | 8 +- .../features/src/main/res/values/strings.xml | 11 + android/gradle/libs.versions.toml | 4 + android/gradle/verification-metadata.xml | 260 ++++++++++++++++++ android/performance/gradle.lockfile | 31 ++- android/platform/build.gradle.kts | 3 + android/platform/gradle.lockfile | 41 ++- .../src/androidTest/AndroidManifest.xml | 1 + .../platform/AndroidQrCameraTest.kt | 50 ++++ .../ironstorage/platform/AndroidQrCamera.kt | 113 ++++++++ .../platform/AndroidSettingsPlatform.kt | 7 + .../mobile-bridge/src/android_onboarding.rs | 18 ++ crates/mobile-bridge/src/lib.rs | 29 ++ crates/storage/src/mobile_key_transfer.rs | 203 ++++++++++++-- .../storage/src/mobile_onboarding/android.rs | 43 +++ crates/storage/src/mobile_platform.rs | 75 ++++- docs/android/key-transfer.md | 96 +++++++ docs/android/parity.md | 6 +- tools/android-build/src/gates.rs | 10 +- tools/android-build/src/native.rs | 2 + 33 files changed, 1616 insertions(+), 162 deletions(-) create mode 100644 android/features/src/main/kotlin/de/rfc1437/ironstorage/features/QrCameraPane.kt create mode 100644 android/platform/src/androidTest/kotlin/de/rfc1437/ironstorage/platform/AndroidQrCameraTest.kt create mode 100644 android/platform/src/main/kotlin/de/rfc1437/ironstorage/platform/AndroidQrCamera.kt create mode 100644 docs/android/key-transfer.md diff --git a/android/README.md b/android/README.md index 00dcd06..29e86ca 100644 --- a/android/README.md +++ b/android/README.md @@ -8,6 +8,8 @@ UniFFI API. Local-first and HTTPS onboarding is described in revision-checked [adaptive navigation](../docs/android/password-navigation.md); Home renders Rust-owned [Git activity and HTTPS synchronization](../docs/android/home-sync.md). Passwords and Preferences share [Rust-owned biometric authentication](../docs/android/authentication.md). +CameraX GPG scanning and QR export are described in +[key transfer](../docs/android/key-transfer.md). Remaining feature screens retain their milestone issues. The full [CI and emulator workflow](../docs/android/ci.md) documents the ARM-only runner/toolchain policy and artifact audits. diff --git a/android/app/gradle.lockfile b/android/app/gradle.lockfile index 9c5b505..07bc964 100644 --- a/android/app/gradle.lockfile +++ b/android/app/gradle.lockfile @@ -16,6 +16,14 @@ androidx.arch.core:core-common:2.2.0=benchmarkCompileClasspath,benchmarkLintChec androidx.arch.core:core-runtime:2.2.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.autofill:autofill:1.0.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.biometric:biometric:1.1.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.camera.featurecombinationquery:featurecombinationquery:1.6.2=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.camera.viewfinder:viewfinder-core:1.5.1=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.camera:camera-camera2-pipe:1.6.2=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.camera:camera-camera2:1.6.2=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.camera:camera-core:1.6.2=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.camera:camera-lifecycle:1.6.2=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.camera:camera-video:1.6.2=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.camera:camera-view:1.6.2=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.cardview:cardview:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath androidx.collection:collection-jvm:1.5.0=benchmarkCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,profileCompileClasspath,releaseCompileClasspath androidx.collection:collection-jvm:1.6.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -67,10 +75,12 @@ androidx.compose.ui:ui-util-android:1.12.1=benchmarkCompileClasspath,benchmarkLi androidx.compose.ui:ui-util:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.ui:ui:1.12.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose:compose-bom:2026.09.00=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.concurrent:concurrent-futures-ktx:1.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.concurrent:concurrent-futures-ktx:1.1.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.concurrent:concurrent-futures-ktx:1.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath androidx.concurrent:concurrent-futures:1.1.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.concurrent:concurrent-futures:1.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath androidx.coordinatorlayout:coordinatorlayout:1.1.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.core:core-backported-fixes:1.0.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.core:core-ktx:1.18.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.core:core-viewtree:1.0.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.core:core:1.18.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -79,6 +89,7 @@ androidx.customview:customview-poolingcontainer:1.0.0=benchmarkLintChecksClasspa androidx.customview:customview:1.0.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.drawerlayout:drawerlayout:1.0.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.emoji2:emoji2:1.4.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.exifinterface:exifinterface:1.4.2=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.fragment:fragment:1.9.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.graphics:graphics-path:1.0.1=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.interpolator:interpolator:1.0.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -103,6 +114,9 @@ androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.11.0=benchmarkCompil androidx.lifecycle:lifecycle-viewmodel-savedstate:2.11.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-viewmodel:2.11.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.loader:loader:1.0.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.media3:media3-common:1.9.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.media3:media3-container:1.9.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.media3:media3-muxer:1.9.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.navigation3:navigation3-runtime-android:1.2.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.navigation3:navigation3-runtime:1.2.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.navigation3:navigation3-ui-android:1.2.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -128,6 +142,7 @@ androidx.test:monitor:1.8.0=debugAndroidTestCompileClasspath,debugAndroidTestLin androidx.test:rules:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath androidx.test:runner:1.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath androidx.tracing:tracing-android:2.0.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.tracing:tracing-ktx:2.0.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.tracing:tracing:1.1.0=debugAndroidTestCompileClasspath androidx.tracing:tracing:2.0.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.transition:transition:1.2.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath @@ -174,6 +189,7 @@ com.github.ajalt.clikt:clikt-jvm:5.0.3=ktlint com.github.ajalt.clikt:clikt:5.0.3=ktlint com.google.android.apps.common.testing.accessibility.framework:accessibility-test-framework:4.1.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath com.google.android.material:material:1.2.0-rc01=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +com.google.auto.value:auto-value-annotations:1.6.3=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.code.findbugs:jsr305:3.0.2=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,androidLintTool,benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.code.gson:gson:2.11.0=androidLintTool com.google.dagger:dagger-compiler:2.60.1=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath @@ -185,20 +201,19 @@ com.google.dagger:hilt-compiler:2.60.1=_agp_internal_benchmark_kspClasspath,_agp com.google.dagger:hilt-core:2.60.1=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.devtools.ksp:symbol-processing-api:2.3.12=kotlinCompilerPluginClasspathBenchmark,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathProfile,kotlinCompilerPluginClasspathRelease,kspPluginClasspath,kspPluginClasspathNonEmbeddable com.google.devtools.ksp:symbol-processing-api:2.3.7=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath -com.google.errorprone:error_prone_annotations:2.30.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.28.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.30.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath com.google.errorprone:error_prone_annotations:2.36.0=androidLintTool com.google.errorprone:error_prone_annotations:2.47.0=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath com.google.googlejavaformat:google-java-format:1.33.0=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath -com.google.guava:failureaccess:1.0.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -com.google.guava:failureaccess:1.0.2=androidLintTool +com.google.guava:failureaccess:1.0.2=androidLintTool,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.guava:failureaccess:1.0.3=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath -com.google.guava:guava:31.0.1-android=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +com.google.guava:guava:33.3.1-android=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.guava:guava:33.4.0-jre=androidLintTool com.google.guava:guava:33.6.0-jre=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath -com.google.guava:listenablefuture:1.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,androidLintTool,debugAndroidTestLintChecksClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath -com.google.j2objc:j2objc-annotations:1.3=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -com.google.j2objc:j2objc-annotations:3.0.0=androidLintTool +com.google.guava:listenablefuture:1.0=benchmarkCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,profileCompileClasspath,releaseCompileClasspath +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,androidLintTool,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.j2objc:j2objc-annotations:3.0.0=androidLintTool,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.j2objc:j2objc-annotations:3.1=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath com.google.jimfs:jimfs:1.1=androidLintTool com.google.protobuf:protobuf-java:3.25.5=androidLintTool,unified-test-platform-gradle-work-action @@ -252,15 +267,14 @@ org.bouncycastle:bcpkix-jdk18on:1.80.2=androidLintTool org.bouncycastle:bcprov-jdk18on:1.80.2=androidLintTool org.bouncycastle:bcutil-jdk18on:1.80.2=androidLintTool org.checkerframework:checker-compat-qual:2.5.3=_agp_internal_benchmark_kspClasspath,_agp_internal_debug_kspClasspath,_agp_internal_profile_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorBenchmark,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorProfile,hiltAnnotationProcessorRelease,kspBenchmarkKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspProfileKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath -org.checkerframework:checker-compat-qual:2.5.5=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -org.checkerframework:checker-qual:3.22.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -org.checkerframework:checker-qual:3.43.0=androidLintTool +org.checkerframework:checker-qual:3.43.0=androidLintTool,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.ec4j.core:ec4j-core:1.1.1=ktlint,ktlintBaselineReporter,ktlintRuleset org.glassfish.jaxb:jaxb-runtime:2.3.2=androidLintTool org.glassfish.jaxb:txw2:2.3.2=androidLintTool org.hamcrest:hamcrest-core:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath org.hamcrest:hamcrest-library:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath org.jetbrains.kotlin:compose-group-mapping:2.4.21=composeMappingProducerClasspath +org.jetbrains.kotlin:kotlin-bom:1.8.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlin:kotlin-build-tools-api:2.4.0=kotlinAbiValidationCompatClasspath org.jetbrains.kotlin:kotlin-build-tools-api:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.21=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath @@ -298,6 +312,8 @@ org.jetbrains.kotlin:kotlin-stdlib:2.3.21=_agp_internal_benchmark_kspClasspath,_ org.jetbrains.kotlin:kotlin-stdlib:2.4.0=kotlinAbiValidationCompatClasspath org.jetbrains.kotlin:kotlin-stdlib:2.4.21=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlin:kotlin-tooling-core:2.4.21=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlinx:atomicfu-jvm:0.28.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:atomicfu:0.28.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileCompileClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=androidLintTool,unified-test-platform-gradle-work-action @@ -333,4 +349,4 @@ org.ow2.asm:asm:9.9=androidLintTool org.ow2.asm:asm:9.9.1=composeMappingProducerClasspath org.slf4j:slf4j-api:2.0.7=ktlint rustls:rustls-platform-verifier:0.1.1=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,profileLintChecksClasspath,profileRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -empty=_agp_internal_debugAndroidTest_kspClasspath,_agp_internal_debugUnitTest_kspClasspath,androidApis,androidJdkImage,androidTestUtil,benchmarkAnnotationProcessorClasspath,benchmarkImplementationDependenciesMetadata,benchmarkReverseMetadataValues,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugReverseMetadataValues,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,hiltCompileOnlyBenchmark,hiltCompileOnlyDebug,hiltCompileOnlyDebugAndroidTest,hiltCompileOnlyDebugUnitTest,hiltCompileOnlyProfile,hiltCompileOnlyRelease,kotlinCompilerPluginClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,lintChecks,lintPublish,profileAnnotationProcessorClasspath,profileReverseMetadataValues,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata,releaseReverseMetadataValues +empty=_agp_internal_debugAndroidTest_kspClasspath,_agp_internal_debugUnitTest_kspClasspath,androidApis,androidJdkImage,androidTestUtil,benchmarkAnnotationProcessorClasspath,benchmarkImplementationDependenciesMetadata,benchmarkReverseMetadataValues,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugReverseMetadataValues,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,hiltCompileOnlyBenchmark,hiltCompileOnlyDebug,hiltCompileOnlyDebugAndroidTest,hiltCompileOnlyDebugUnitTest,hiltCompileOnlyProfile,hiltCompileOnlyRelease,kotlinCompilerPluginClasspath,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,lintChecks,lintPublish,profileAnnotationProcessorClasspath,profileImplementationDependenciesMetadata,profileReverseMetadataValues,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata,releaseReverseMetadataValues diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/OnboardingScreenTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/OnboardingScreenTest.kt index fa564bc..ad48b83 100644 --- a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/OnboardingScreenTest.kt +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/OnboardingScreenTest.kt @@ -8,11 +8,13 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput +import androidx.lifecycle.ViewModelProvider import de.rfc1437.ironstorage.core.OnboardingRepository import de.rfc1437.ironstorage.core.SetupKey import de.rfc1437.ironstorage.core.SetupStatus import de.rfc1437.ironstorage.features.OnboardingScreen import de.rfc1437.ironstorage.features.SetupFormViewModel +import de.rfc1437.ironstorage.features.ShellViewModel import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -32,7 +34,16 @@ class OnboardingScreenTest { val secrets = SetupFormViewModel() compose.activity.runOnUiThread { compose.activity.setProtectedTestContent { - MaterialTheme { OnboardingScreen(repository, secrets) } + MaterialTheme { + OnboardingScreen( + repository, + secrets, + SettingsScreenTest.Fixture(), + androidx.lifecycle.ViewModelProvider( + compose.activity + )[de.rfc1437.ironstorage.features.ShellViewModel::class.java].authentication + ) + } } } compose.waitForIdle() @@ -60,6 +71,45 @@ class OnboardingScreenTest { ) } + @Test fun closingQrReviewReloadsPreparedOnboardingKeysWithoutCreatingAStore() { + val original = FixtureRepository() + val settings = SettingsScreenTest.Fixture() + val secrets = SetupFormViewModel() + var staged = false + val repository = object : OnboardingRepository by original { + override fun refresh() { + if (staged) { + original.setup.value = original.setup.value.copy( + keys = listOf( + SetupKey("SCANNED-SETUP-KEY", listOf("Scanned setup fixture")) + ), + selectedKey = "SCANNED-SETUP-KEY" + ) + } + } + } + val model = ViewModelProvider(compose.activity)[ShellViewModel::class.java] + val authentication = model.authentication + compose.activity.runOnUiThread { + compose.activity.setProtectedTestContent { + MaterialTheme { + OnboardingScreen(repository, secrets, settings, authentication) + } + } + } + compose.onNodeWithText("Create local store").performClick() + compose.onNodeWithText("Import private GPG key").performScrollTo().performClick() + compose.onNodeWithText("Scan GPG QR codes").performScrollTo().performClick() + compose.runOnIdle { staged = true } + compose.onNodeWithText("Done").performClick() + compose.runOnIdle { assertEquals("SCANNED-SETUP-KEY", original.setup.value.selectedKey) } + compose.onNodeWithText("Back to setup choices").performScrollTo().performClick() + compose.onNodeWithText("Create local store").performClick() + compose.onNodeWithText("SCANNED-SETUP-KEY").performScrollTo().assertIsDisplayed() + assertTrue(!original.setup.value.configured) + assertTrue(original.calls.isEmpty()) + } + private class FixtureRepository : OnboardingRepository { override val setup = MutableStateFlow(SetupStatus()) val calls = mutableListOf() diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/SettingsScreenTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/SettingsScreenTest.kt index 8c230e2..556d4aa 100644 --- a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/SettingsScreenTest.kt +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/SettingsScreenTest.kt @@ -6,6 +6,8 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.test.assertHeightIsAtLeast import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertTextContains import androidx.compose.ui.test.assertWidthIsAtLeast import androidx.compose.ui.test.hasText @@ -24,6 +26,7 @@ import androidx.lifecycle.ViewModelProvider import androidx.test.platform.app.InstrumentationRegistry import de.rfc1437.ironstorage.core.AuthenticationRepository import de.rfc1437.ironstorage.core.AuthenticationState +import de.rfc1437.ironstorage.core.CameraPreview import de.rfc1437.ironstorage.core.KeyTransferState import de.rfc1437.ironstorage.core.ProtectedPresentation import de.rfc1437.ironstorage.core.ProviderStatus @@ -31,7 +34,9 @@ import de.rfc1437.ironstorage.core.SettingsPage import de.rfc1437.ironstorage.core.SettingsRepository import de.rfc1437.ironstorage.core.SettingsState import de.rfc1437.ironstorage.core.TransferFrame +import de.rfc1437.ironstorage.core.TransferKey import de.rfc1437.ironstorage.features.ApplicationTheme +import de.rfc1437.ironstorage.features.R as FeatureR import de.rfc1437.ironstorage.features.SettingsScreen import de.rfc1437.ironstorage.features.SetupFormViewModel import de.rfc1437.ironstorage.features.ShellViewModel @@ -164,6 +169,69 @@ class SettingsScreenTest { authentication.presentation.clear() } } + + @Test fun privateQrActionsRequireFingerprintConfirmationAndExplicitClosing() { + val original = ViewModelProvider(compose.activity)[ShellViewModel::class.java] + val repository = Fixture() + val authentication = object : AuthenticationRepository by original.authentication { + override val presentation = ProtectedPresentation() + } + compose.activity.runOnUiThread { + compose.activity.setProtectedTestContent(authentication.presentation) { + ApplicationTheme(authentication) { + de.rfc1437.ironstorage.features.SettingsScreen( + repository, + authentication, + original.onboarding, + ViewModelProvider(compose.activity)[SetupFormViewModel::class.java] + ) {} + } + } + } + compose.onNodeWithTag( + "settings-list" + ).performScrollToNode(hasText("Import or export GPG QR codes")) + compose.onNodeWithText("Import or export GPG QR codes").performClick() + compose.onNodeWithText("Export private key").performScrollTo().performClick() + compose.onNodeWithText( + "Confirm and export selected key" + ).performScrollTo().assertIsNotEnabled() + compose.onNodeWithText( + "I checked this fingerprint and authorize displaying its private QR codes." + ) + .performScrollTo().performClick() + compose.onNodeWithText( + "Confirm and export selected key" + ).performScrollTo().assertIsEnabled().performClick() + assertEquals(1, repository.exports) + compose.runOnIdle { + repository.state.value = repository.state.value.copy( + transfer = repository.state.value.transfer.copy( + ready = TransferKey( + "SCANNED-FINGERPRINT", + "Scanned fixture", + true + ), + received = 2u, + total = 2u + ) + ) + } + compose.onNodeWithText( + "Confirm and import reconstructed key" + ).performScrollTo().assertIsNotEnabled() + compose.onNodeWithText("SCANNED-FINGERPRINT").performScrollTo().assertIsDisplayed() + compose.onNodeWithText( + "I checked this fingerprint and authorize importing its private key material." + ) + .performScrollTo().performClick() + compose.onNodeWithText( + "Confirm and import reconstructed key" + ).performScrollTo().assertIsEnabled().performClick() + assertEquals(1, repository.imports) + compose.onNodeWithText("Done").performClick() + assertFalse(repository.state.value.transfer.open) + } internal class Fixture : SettingsRepository { override val state = MutableStateFlow( SettingsState( @@ -193,6 +261,8 @@ class SettingsScreenTest { var removals = 0 var reloads = 0 var choseImages = false + var exports = 0 + var imports = 0 override fun reload() { reloads += 1 } @@ -211,15 +281,41 @@ class SettingsScreenTest { override fun openCredentials() = Unit override fun beginTransfer() { state.value = - state.value.copy(error = null, transfer = KeyTransferState(open = true)) + state.value.copy( + error = null, + transfer = KeyTransferState( + open = true, + keys = listOf( + TransferKey( + "PUBLIC-FINGERPRINT", + "Fixture GPG key", + true + ) + ) + ) + ) + } + override fun beginSetupTransfer() { + beginTransfer() + state.value = + state.value.copy( + transfer = state.value.transfer.copy(setup = true, keys = emptyList()) + ) + } + override fun cameraPreview(): CameraPreview? = null + override fun exportKey(fingerprint: String, privateKey: Boolean, passphrase: String) { + exports += + 1 } - override fun exportKey(fingerprint: String, privateKey: Boolean, passphrase: String) = Unit override fun chooseQrImages() { choseImages = true } override fun addQrImages(uris: List) = Unit override fun addQrPayload(payload: String) = Unit - override fun importKey(passphrase: String, makeDefault: Boolean, overwrite: Boolean) = Unit + override fun importKey(passphrase: String, makeDefault: Boolean, overwrite: Boolean) { + imports += + 1 + } override fun closeTransfer() { state.value = state.value.copy(transfer = KeyTransferState()) } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5cbd1d8..0ccb4e4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,10 @@ - + + + + Unit)? = null val platform = object : SettingsPlatform { override fun autofill() = ProviderStatus(true, false, false) override fun credentials() = @@ -446,6 +447,13 @@ class NativeEntryViewerTest { override fun version() = "native-settings-fixture" override fun openAutofill() = true override fun openCredentials() = true + override fun cameraPreview( + frame: (CameraFrame) -> Unit, + failure: () -> Unit + ): CameraPreview? { + cameraReceiver = frame + return null + } override fun chooseQrImages() = true override suspend fun readQrImage(uri: String) = image.copyOf() } @@ -533,6 +541,18 @@ class NativeEntryViewerTest { image = output.toByteArray() request { settings.closeTransfer() } assertTrue(frame.modules.all { it.toInt() == 0 }) + assertNull(request { settings.beginTransfer() }.error) + withContext(Dispatchers.Main.immediate) { settings.cameraPreview() } + val luminance = ByteArray(size * size) { index -> + if (pixels[index] == android.graphics.Color.BLACK) 0 else 255.toByte() + } + requireNotNull( + cameraReceiver + )(CameraFrame(luminance, size.toUInt(), size.toUInt(), size.toUInt(), 1u, 90u)) + withTimeout(30000) { settings.state.first { it.transfer.ready != null } } + assertEquals(fingerprint, settings.state.value.transfer.ready!!.fingerprint) + assertTrue(luminance.all { it.toInt() == 0 }) + request { settings.closeTransfer() } val qrStart = android.os.SystemClock.elapsedRealtime() assertNull(request { settings.addQrImages(listOf("content://public-fixture/qr")) }.error) val qrMs = android.os.SystemClock.elapsedRealtime() - qrStart @@ -559,10 +579,14 @@ class NativeEntryViewerTest { assertNotNull(request { settings.setDefaultKey(bob.fingerprint) }.error) assertEquals(fingerprint, settings.state.value.page!!.defaultKeyFingerprint) assertNull(request { settings.beginTransfer() }.error) - assertNull(request { settings.exportKey(fingerprint, false, "") }.error) + assertNull(request { settings.exportKey(fingerprint, true, passphrase) }.error) + assertTrue(settings.state.value.transfer.exportedKey!!.privateKey) + val privateFrames = settings.state.value.transfer.frames + assertTrue(privateFrames.isNotEmpty()) withContext(Dispatchers.Main.immediate) { authentication.presentation.clear() } assertFalse(settings.state.value.transfer.open) assertTrue(settings.state.value.transfer.frames.isEmpty()) + assertTrue(privateFrames.all { frame -> frame.modules.all { it.toInt() == 0 } }) assertNull(request { settings.setDefaultKey(fingerprint) }.error) assertFalse(authentication.state.value.unlocked) assertEquals(fingerprint, settings.state.value.page!!.defaultKeyFingerprint) diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeSettingsRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeSettingsRepository.kt index 7a6774c..e445617 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeSettingsRepository.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeSettingsRepository.kt @@ -2,6 +2,7 @@ package de.rfc1437.ironstorage.core import de.rfc1437.ironstorage.bridge.NativeBridgeException import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException +import de.rfc1437.ironstorage.bridge.generated.MobileCameraFrame import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransfer import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransferFfiException import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransferImport @@ -10,7 +11,9 @@ import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransferProgress import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException import de.rfc1437.ironstorage.bridge.generated.MobileTotpOperation import de.rfc1437.ironstorage.bridge.generated.mobileKeyTransfer +import de.rfc1437.ironstorage.bridge.generated.mobileLocalKeyTransferImporter import de.rfc1437.ironstorage.bridge.generated.mobileTotpOperation +import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -31,10 +34,12 @@ class NativeSettingsRepository( private val mutable = MutableStateFlow(SettingsState()) override val state: StateFlow = mutable private val operation = Mutex() + private val cameraBusy = AtomicBoolean() @Volatile private var epoch = 0L @Volatile private var discovery: MobileTotpOperation? = null + private var visibility: Job? = null private var transfer: MobileKeyTransfer? = null private var importer: MobileKeyTransferImport? = null init { @@ -126,19 +131,27 @@ class NativeSettingsRepository( ) } } - override fun beginTransfer() = run { ticket -> + override fun beginTransfer() = begin(false) + override fun beginSetupTransfer() = begin(true) + private fun begin(setup: Boolean) = run { ticket -> withContext(Dispatchers.IO) { session.storage() - transfer = mobileKeyTransfer() - importer = requireNotNull(transfer).importer() + releaseTransfer() + if (setup) { + importer = mobileLocalKeyTransferImporter() + } else { + transfer = mobileKeyTransfer() + importer = requireNotNull(transfer).importer() + } } - val keys = withContext(Dispatchers.IO) { requireNotNull(transfer).keys() } + val keys = withContext(Dispatchers.IO) { transfer?.keys().orEmpty() } if (ticket == epoch ) { mutable.value = mutable.value.copy( transfer = KeyTransferState( open = true, + setup = setup, keys = keys.map { TransferKey( it.fingerprint, @@ -152,6 +165,12 @@ class NativeSettingsRepository( } override fun exportKey(fingerprint: String, privateKey: Boolean, passphrase: String) = run { ticket -> + visibility?.cancel() + mutable.value.transfer.frames.forEach { it.modules.fill(0) } + mutable.value = + mutable.value.copy( + transfer = mutable.value.transfer.copy(frames = emptyList(), exportedKey = null) + ) val result = withContext(Dispatchers.IO) { requireNotNull(transfer).export( fingerprint, @@ -165,11 +184,28 @@ class NativeSettingsRepository( mutable.value.transfer.frames.forEach { it.modules.fill(0) } mutable.value = mutable.value.copy( transfer = mutable.value.transfer.copy( + exportedKey = TransferKey( + result.key.fingerprint, + result.key.title, + result.key.kind == MobileKeyTransferKind.PRIVATE + ), frames = result.frames.map { TransferFrame(it.sequence, it.total, it.width, it.modules) } ) ) + visibility = scope.launch { + while (ticket == epoch) { + kotlinx.coroutines.delay(250) + val visible = operation.withLock { + withContext(Dispatchers.IO) { transfer?.exportVisible() == true } + } + if (ticket == epoch && !visible) { + closeTransfer() + break + } + } + } } else { result.frames.forEach { it.modules.fill(0) } } @@ -177,9 +213,11 @@ class NativeSettingsRepository( private fun progress(ticket: Long, progress: MobileKeyTransferProgress) { if (ticket != epoch) return mutable.value = mutable.value.copy( + error = null, transfer = mutable.value.transfer.copy( received = progress.received, total = progress.total, + duplicate = progress.duplicate, ready = progress.key?.let { TransferKey( it.fingerprint, @@ -190,6 +228,65 @@ class NativeSettingsRepository( ) ) } + override fun cameraPreview(): CameraPreview? { + val ticket = epoch + return platform.cameraPreview({ frame -> + if (!cameraBusy.compareAndSet(false, true)) { + frame.pixels.fill(0) + } else { + scope.launch { + try { + operation.withLock { + if (ticket != epoch || !mutable.value.transfer.open || + mutable.value.transfer.ready != null || mutable.value.busy + ) { + return@withLock + } + val result = withContext(Dispatchers.IO) { + requireNotNull(importer).addCamera( + MobileCameraFrame( + frame.width, + frame.height, + frame.rowStride, + frame.pixelStride, + frame.pixels + ), + frame.rotationDegrees + ) + } + frame.pixels.fill(0) + if (result != null) progress(ticket, result) + } + } catch (error: MobileKeyTransferFfiException.Failed) { + if (ticket == + epoch + ) { + mutable.value = mutable.value.copy(error = error.rustMessage) + } + } catch (_: NativeBridgeException) { + if (ticket == + epoch + ) { + mutable.value = + mutable.value.copy(error = session.text(R.string.core_update_retry)) + } + } finally { + frame.pixels.fill(0) + cameraBusy.set(false) + } + } + } + }, { + scope.launch { + if (ticket == + epoch + ) { + mutable.value = + mutable.value.copy(error = session.text(R.string.core_camera_unavailable)) + } + } + }) + } override fun chooseQrImages() { if (!platform.chooseQrImages()) { mutable.value = @@ -246,6 +343,15 @@ class NativeSettingsRepository( progress(ticket, withContext(Dispatchers.IO) { requireNotNull(importer).addFrame(payload) }) } override fun importKey(passphrase: String, makeDefault: Boolean, overwrite: Boolean) = run { + if (mutable.value.transfer.setup) { + withContext(Dispatchers.IO) { + session.storage().androidOnboarding(session.backend()).use { + it.importScanned(requireNotNull(importer), passphrase.ifEmpty { null }) + } + } + closeTransfer() + return@run + } val result = withContext(Dispatchers.IO) { requireNotNull(importer).import(passphrase.ifEmpty { null }, makeDefault, overwrite) } @@ -264,6 +370,8 @@ class NativeSettingsRepository( transfer = null } override fun closeTransfer() { + visibility?.cancel() + visibility = null epoch += 1 mutable.value.transfer.frames.forEach { it.modules.fill(0) } mutable.value = mutable.value.copy(transfer = KeyTransferState(), busy = false) @@ -295,7 +403,11 @@ class NativeSettingsRepository( mutable.value.copy(error = error.title + "\n" + error.detail) } } catch (error: MobileKeyTransferFfiException.Failed) { - if (ticket == epoch) mutable.value = mutable.value.copy(error = error.message) + if (ticket == + epoch + ) { + mutable.value = mutable.value.copy(error = error.rustMessage) + } } catch (_: MobilePrivateStorageFfiException) { if (ticket == epoch diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/SettingsRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/SettingsRepository.kt index 0be66c4..7c1138f 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/SettingsRepository.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/SettingsRepository.kt @@ -12,6 +12,20 @@ interface SettingsPlatform { fun openCredentials(): Boolean fun chooseQrImages(): Boolean suspend fun readQrImage(uri: String): ByteArray + fun cameraPreview(frame: (CameraFrame) -> Unit, failure: () -> Unit): CameraPreview? +} +data class CameraFrame( + val pixels: ByteArray, + val width: UInt, + val height: UInt, + val rowStride: UInt, + val pixelStride: UInt, + val rotationDegrees: UInt +) { + override fun toString() = "CameraFrame([REDACTED])" +} +interface CameraPreview : AutoCloseable { + val view: android.view.View } data class SettingsPage( val repositoryTitle: String, @@ -45,7 +59,10 @@ data class KeyTransferState( val frames: List = emptyList(), val received: UInt = 0u, val total: UInt = 0u, - val ready: TransferKey? = null + val ready: TransferKey? = null, + val duplicate: Boolean = false, + val setup: Boolean = false, + val exportedKey: TransferKey? = null ) { override fun toString() = "KeyTransferState([REDACTED])" } @@ -69,6 +86,8 @@ interface SettingsRepository { fun openAutofill() fun openCredentials() fun beginTransfer() + fun beginSetupTransfer() + fun cameraPreview(): CameraPreview? fun exportKey(fingerprint: String, privateKey: Boolean, passphrase: String) fun chooseQrImages() fun addQrImages(uris: List) diff --git a/android/core/src/main/res/values/strings.xml b/android/core/src/main/res/values/strings.xml index 36aad5d..4e4ec26 100644 --- a/android/core/src/main/res/values/strings.xml +++ b/android/core/src/main/res/values/strings.xml @@ -38,4 +38,5 @@ Copied. The clipboard will be cleared automatically. Unlock the device or recover the private store and refresh. Private storage is unavailable. Unlock the device and refresh. + The camera is unavailable. Pause scanning and retry, or choose QR images. diff --git a/android/dependency-policy.json b/android/dependency-policy.json index a91b48a..65395e7 100644 --- a/android/dependency-policy.json +++ b/android/dependency-policy.json @@ -176,7 +176,31 @@ "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0": "Apache-2.0", "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0": "Apache-2.0", "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0": "Apache-2.0", - "org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0": "Apache-2.0" + "org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0": "Apache-2.0", + "androidx.camera.featurecombinationquery:featurecombinationquery:1.6.2": "Apache-2.0", + "androidx.camera.viewfinder:viewfinder-core:1.5.1": "Apache-2.0", + "androidx.camera:camera-camera2-pipe:1.6.2": "Apache-2.0", + "androidx.camera:camera-camera2:1.6.2": "Apache-2.0", + "androidx.camera:camera-core:1.6.2": "Apache-2.0 AND BSD-3-Clause", + "androidx.camera:camera-lifecycle:1.6.2": "Apache-2.0", + "androidx.camera:camera-video:1.6.2": "Apache-2.0", + "androidx.camera:camera-view:1.6.2": "Apache-2.0", + "androidx.concurrent:concurrent-futures-ktx:1.1.0": "Apache-2.0", + "androidx.core:core-backported-fixes:1.0.0": "Apache-2.0", + "androidx.exifinterface:exifinterface:1.4.2": "Apache-2.0", + "androidx.media3:media3-common:1.9.0": "Apache-2.0", + "androidx.media3:media3-container:1.9.0": "Apache-2.0", + "androidx.media3:media3-muxer:1.9.0": "Apache-2.0", + "androidx.tracing:tracing-ktx:2.0.0": "Apache-2.0", + "com.google.auto.value:auto-value-annotations:1.6.3": "Apache-2.0", + "com.google.dagger:dagger:2.59": "Apache-2.0", + "com.google.errorprone:error_prone_annotations:2.28.0": "Apache-2.0", + "com.google.guava:failureaccess:1.0.2": "Apache-2.0", + "com.google.guava:guava:33.3.1-android": "Apache-2.0", + "com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava": "Apache-2.0", + "com.google.j2objc:j2objc-annotations:3.0.0": "Apache-2.0", + "org.checkerframework:checker-qual:3.43.0": "MIT", + "org.jetbrains.kotlinx:atomicfu-jvm:0.28.0": "Apache-2.0" }, "rust_licenses": [ "(Apache-2.0 OR MIT) AND BSD-3-Clause", diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/OnboardingScreen.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/OnboardingScreen.kt index 8c0278c..a4b16aa 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/OnboardingScreen.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/OnboardingScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -54,9 +55,17 @@ class SetupFormViewModel @Inject constructor() : ViewModel() { fun OnboardingScreen( repository: OnboardingRepository, secrets: SetupFormViewModel, + settings: de.rfc1437.ironstorage.core.SettingsRepository, + authentication: de.rfc1437.ironstorage.core.AuthenticationRepository, modifier: Modifier = Modifier, scroll: Boolean = true ) { + val transfer by settings.state.collectAsStateWithLifecycle() + DisposableEffect(transfer.transfer.open) { + val wasOpen = transfer.transfer.open + onDispose { if (wasOpen) repository.refresh() } + } + if (transfer.transfer.open) KeyTransferDialog(settings, authentication) ProtectedInputSurface() val state by repository.setup.collectAsStateWithLifecycle() var step by rememberSaveable { mutableStateOf("choice") } @@ -183,6 +192,9 @@ fun OnboardingScreen( } "import" -> { Text(stringResource(R.string.setup_import_help)) + TextButton(onClick = settings::beginSetupTransfer) { + Text(stringResource(R.string.qr_scan)) + } SecretField( stringResource(R.string.setup_armor), secrets.armor, diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/QrCameraPane.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/QrCameraPane.kt new file mode 100644 index 0000000..e0edcb2 --- /dev/null +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/QrCameraPane.kt @@ -0,0 +1,95 @@ +package de.rfc1437.ironstorage.features + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import de.rfc1437.ironstorage.core.SettingsRepository + +@Composable +internal fun QrCameraPane(repository: SettingsRepository) { + val context = LocalContext.current + fun allowed() = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + var permitted by remember { mutableStateOf(allowed()) } + var scanning by remember { mutableStateOf(false) } + val permission = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { + permitted = it + scanning = it + } + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { permitted = allowed() } + if (permitted && scanning) { + val camera = remember(repository) { repository.cameraPreview() } + DisposableEffect(camera) { onDispose { camera?.close() } } + if (camera == null) { + Text(stringResource(R.string.qr_camera_missing)) + } else { + AndroidView(factory = { + camera.view + }, modifier = Modifier.fillMaxWidth().height(280.dp).clipToBounds()) + } + TextButton(onClick = { scanning = false }) { Text(stringResource(R.string.qr_pause)) } + } else { + TextButton(onClick = { + if (permitted) { + scanning = true + } else { + permission.launch(Manifest.permission.CAMERA) + } + }) { Text(stringResource(if (permitted) R.string.qr_resume else R.string.qr_scan)) } + if (!permitted) { + Text(stringResource(R.string.qr_camera_denied)) + TextButton(onClick = { + context.startActivity( + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + "package:${context.packageName}".toUri() + ) + ) + }) { Text(stringResource(R.string.qr_camera_settings)) } + } + } +} + +@Composable +internal fun QrDisplayWindow() { + val view = androidx.compose.ui.platform.LocalView.current + val window = (view.parent as? androidx.compose.ui.window.DialogWindowProvider)?.window + DisposableEffect(window, view) { + val brightness = window?.attributes?.screenBrightness + val keep = view.keepScreenOn + view.keepScreenOn = true + window?.let { it.attributes = it.attributes.apply { screenBrightness = 1f } } + onDispose { + view.keepScreenOn = keep + if (brightness != + null + ) { + window.attributes = window.attributes.apply { screenBrightness = brightness } + } + } + } +} diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/SettingsScreen.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/SettingsScreen.kt index cb61290..ca7afca 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/SettingsScreen.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/SettingsScreen.kt @@ -169,6 +169,8 @@ fun SettingsScreen( OnboardingScreen( onboarding, setup, + repository, + authentication, scroll = false ) } @@ -290,7 +292,7 @@ fun SettingsScreen( SettingsEditDialog(repository, authentication, action) { edit = null } } } - if (state.transfer.open) KeyTransferDialog(repository, authentication) + if (state.transfer.open && !showSetup) KeyTransferDialog(repository, authentication) if (licenses) { val resources = LocalResources.current val loading = stringResource(R.string.settings_license_loading) @@ -422,7 +424,7 @@ private fun SettingsEditDialog( } @Composable -private fun KeyTransferDialog( +internal fun KeyTransferDialog( repository: SettingsRepository, authentication: AuthenticationRepository ) { @@ -436,6 +438,15 @@ private fun KeyTransferDialog( var frame by remember { mutableStateOf(0) } var passphrase by rememberProtectedInput(authentication.presentation) var payload by rememberProtectedInput(authentication.presentation) + var exportConfirmed by remember(fingerprint, privateKey) { mutableStateOf(false) } + var importConfirmed by remember(state.transfer.ready) { mutableStateOf(false) } + var animate by remember { mutableStateOf(false) } + LaunchedEffect(animate, state.transfer.frames) { + while (animate && state.transfer.frames.size > 1) { + kotlinx.coroutines.delay(1200) + frame = (frame + 1) % state.transfer.frames.size + } + } ProtectedWindow() AlertDialog( properties = DialogProperties( @@ -454,91 +465,126 @@ private fun KeyTransferDialog( ProtectedInputSurface(authentication.presentation) state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } if (state.busy) CircularProgressIndicator() - state.transfer.keys.forEach { key -> - Row( - Modifier.fillMaxWidth().selectable( - selected = fingerprint == key.fingerprint, - enabled = !state.busy, - role = Role.RadioButton, - onClick = { fingerprint = key.fingerprint } - ) - ) { - RadioButton(selected = fingerprint == key.fingerprint, onClick = null) - Column(Modifier.weight(1f)) { - Text(key.title) - Text(key.fingerprint) - } - } - } - TextButton( - enabled = - !state.busy && - state.transfer.keys.any { - it.fingerprint == fingerprint && it.privateKey - }, - onClick = { repository.setDefaultKey(fingerprint) } - ) { Text(stringResource(R.string.settings_default_key_select)) } - Text(stringResource(R.string.settings_private_warning)) - SettingsToggle(stringResource(R.string.settings_private_export), privateKey) { - privateKey = - it - } - SecretField(stringResource(R.string.setup_passphrase), passphrase) { - passphrase = it - } - TextButton(enabled = !state.busy && fingerprint.isNotEmpty(), onClick = { - repository.exportKey(fingerprint, privateKey, passphrase) - passphrase = "" - frame = - 0 - }) { Text(stringResource(R.string.settings_export_confirm)) } - state.transfer.frames.getOrNull(frame)?.let { qr -> - val description = - stringResource( - R.string.settings_qr_frame, - qr.sequence.toInt(), - qr.total.toInt() - ) - Text(description) - Canvas( - Modifier.fillMaxWidth().height(280.dp).semantics { - contentDescription = - description - } - ) { - drawRect(Color.White) - val width = qr.width.toInt() - val scale = minOf(size.width, size.height) / (width + 8) - val x = (size.width - scale * width) / 2 - val y = (size.height - scale * width) / 2 - for (row in 0 until width) { - for (column in 0 until width) { - if (qr.modules[ - row * - width + - column - ].toInt() == - 1 - ) { - drawRect( - Color.Black, - Offset(x + column * scale, y + row * scale), - Size(scale, scale) - ) - } + if (!state.transfer.setup) { + state.transfer.keys.forEach { key -> + Row( + Modifier.fillMaxWidth().selectable( + selected = fingerprint == key.fingerprint, + enabled = !state.busy, + role = Role.RadioButton, + onClick = { fingerprint = key.fingerprint } + ) + ) { + RadioButton(selected = fingerprint == key.fingerprint, onClick = null) + Column(Modifier.weight(1f)) { + Text(key.title) + Text(key.fingerprint) } } } - FlowRow { - TextButton(enabled = frame > 0, onClick = { - frame -= 1 - }) { Text(stringResource(R.string.settings_previous)) } - TextButton(enabled = frame < state.transfer.frames.lastIndex, onClick = { - frame += - 1 - }) { Text(stringResource(R.string.settings_next)) } + TextButton( + enabled = + !state.busy && + state.transfer.keys.any { + it.fingerprint == fingerprint && it.privateKey + }, + onClick = { repository.setDefaultKey(fingerprint) } + ) { Text(stringResource(R.string.settings_default_key_select)) } + Text(stringResource(R.string.settings_private_warning)) + SettingsToggle(stringResource(R.string.settings_private_export), privateKey) { + privateKey = + it + } + SecretField(stringResource(R.string.setup_passphrase), passphrase) { + passphrase = it + } + if (privateKey) { + SettingsToggle( + stringResource(R.string.qr_export_private_confirm), + exportConfirmed + ) { + exportConfirmed = + it + } + } + TextButton( + enabled = + !state.busy && fingerprint.isNotEmpty() && (!privateKey || exportConfirmed), + onClick = { + repository.exportKey(fingerprint, privateKey, passphrase) + passphrase = "" + frame = + 0 + } + ) { Text(stringResource(R.string.settings_export_confirm)) } + state.transfer.frames.getOrNull(frame)?.let { qr -> + state.transfer.exportedKey?.let { exported -> + Text(exported.title) + Text(exported.fingerprint) + } + QrDisplayWindow() + val description = + stringResource( + R.string.settings_qr_frame, + qr.sequence.toInt(), + qr.total.toInt() + ) + Text(description) + Canvas( + Modifier.fillMaxWidth().height(280.dp).semantics { + contentDescription = + description + } + ) { + drawRect(Color.White) + val width = qr.width.toInt() + val scale = minOf(size.width, size.height) / (width + 8) + val x = (size.width - scale * width) / 2 + val y = (size.height - scale * width) / 2 + for (row in 0 until width) { + for (column in 0 until width) { + if (qr.modules[ + row * + width + + column + ].toInt() == + 1 + ) { + drawRect( + Color.Black, + Offset(x + column * scale, y + row * scale), + Size(scale, scale) + ) + } + } + } + } + FlowRow { + TextButton(enabled = frame > 0, onClick = { + frame -= 1 + }) { Text(stringResource(R.string.settings_previous)) } + TextButton( + enabled = frame < state.transfer.frames.lastIndex, + onClick = { + frame += + 1 + } + ) { Text(stringResource(R.string.settings_next)) } + } + } + if (state.transfer.frames.size > 1) { + SettingsToggle(stringResource(R.string.qr_animate), animate) { + animate = it + } } } + if (state.transfer.setup) { + SecretField(stringResource(R.string.setup_passphrase), passphrase) { + passphrase = + it + } + } + if (state.transfer.ready == null && !state.busy) QrCameraPane(repository) TextButton(enabled = !state.busy, onClick = repository::chooseQrImages) { Text(stringResource(R.string.settings_qr_images)) } @@ -564,22 +610,50 @@ private fun KeyTransferDialog( state.transfer.total.toInt() ) ) + if (state.transfer.duplicate) Text(stringResource(R.string.qr_duplicate)) state.transfer.ready?.let { Text(it.title) Text(it.fingerprint) - SettingsToggle(stringResource(R.string.settings_make_default), makeDefault) { - makeDefault = - it + if (!state.transfer.setup) { + SettingsToggle( + stringResource(R.string.settings_make_default), + makeDefault + ) { + makeDefault = + it + } + SettingsToggle(stringResource(R.string.settings_replace_key), overwrite) { + overwrite = + it + } } - SettingsToggle(stringResource(R.string.settings_replace_key), overwrite) { - overwrite = - it + if (it.privateKey) { + SettingsToggle( + stringResource(R.string.qr_import_private_confirm), + importConfirmed + ) { + importConfirmed = + it + } + } + TextButton( + enabled = !state.busy && (!it.privateKey || importConfirmed), + onClick = { + repository.importKey(passphrase, makeDefault, overwrite) + passphrase = + "" + } + ) { + Text( + stringResource( + if (state.transfer.setup) { + R.string.qr_setup_import + } else { + R.string.settings_import_confirm + } + ) + ) } - TextButton(enabled = !state.busy, onClick = { - repository.importKey(passphrase, makeDefault, overwrite) - passphrase = - "" - }) { Text(stringResource(R.string.settings_import_confirm)) } } } }, diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt index eab615e..3f18697 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt @@ -171,7 +171,13 @@ private fun ShellDestination( Modifier.fillMaxSize().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - OnboardingScreen(viewModel.onboarding, setupForm, scroll = false) + OnboardingScreen( + viewModel.onboarding, + setupForm, + viewModel.settings, + viewModel.authentication, + scroll = false + ) } } ready && route == ShellRoute.Passwords -> { diff --git a/android/features/src/main/res/values/strings.xml b/android/features/src/main/res/values/strings.xml index 6ef3747..53a6055 100644 --- a/android/features/src/main/res/values/strings.xml +++ b/android/features/src/main/res/values/strings.xml @@ -224,4 +224,15 @@ %1$d results Loading license… + Scan GPG QR codes + Pause camera + Resume camera + Camera access is required to scan. If permission was denied, allow it in app settings or choose QR images. + Open camera permission settings + The camera is unavailable. Choose QR images or paste an armored key. + Duplicate frame; progress is unchanged. + I checked this fingerprint and authorize importing its private key material. + I checked this fingerprint and authorize displaying its private QR codes. + Automatically advance frames + Prepare the scanned key diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index a2579ad..de12ebc 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,4 +1,5 @@ [versions] +camera = "1.6.2" agp = "9.4.1" kotlin = "2.4.21" ksp = "2.3.12" @@ -28,6 +29,9 @@ biometric = "1.1.0" fragment = "1.9.1" [libraries] +camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } +camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } +camera-view = { module = "androidx.camera:camera-view", version.ref = "camera" } benchmark-macro = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmark" } rustls-verifier = { group = "rustls", name = "rustls-platform-verifier", version.ref = "rustlsVerifier" } compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose" } diff --git a/android/gradle/verification-metadata.xml b/android/gradle/verification-metadata.xml index e61740c..186d05d 100644 --- a/android/gradle/verification-metadata.xml +++ b/android/gradle/verification-metadata.xml @@ -183,6 +183,11 @@ + + + + + @@ -284,6 +289,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -848,6 +917,14 @@ + + + + + + + + @@ -903,6 +980,11 @@ + + + + + @@ -916,6 +998,14 @@ + + + + + + + + @@ -1042,6 +1132,14 @@ + + + + + + + + @@ -1100,6 +1198,11 @@ + + + + + @@ -1170,6 +1273,11 @@ + + + + + @@ -1186,6 +1294,11 @@ + + + + + @@ -1194,6 +1307,11 @@ + + + + + @@ -1215,6 +1333,11 @@ + + + + + @@ -1563,6 +1686,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -1989,6 +2136,19 @@ + + + + + + + + + + + + + @@ -2551,6 +2711,11 @@ + + + + + @@ -2559,11 +2724,24 @@ + + + + + + + + + + + + + @@ -2601,6 +2779,14 @@ + + + + + + + + @@ -2716,6 +2902,9 @@ + + + @@ -2725,6 +2914,14 @@ + + + + + + + + @@ -2764,6 +2961,11 @@ + + + + + @@ -2837,6 +3039,25 @@ + + + + + + + + + + + + + + + + + + + @@ -2863,6 +3084,16 @@ + + + + + + + + + + @@ -2903,6 +3134,9 @@ + + + @@ -3610,6 +3844,14 @@ + + + + + + + + @@ -3768,6 +4010,11 @@ + + + + + @@ -4400,6 +4647,19 @@ + + + + + + + + + + + + + diff --git a/android/performance/gradle.lockfile b/android/performance/gradle.lockfile index 076a7b8..bb1ddd7 100644 --- a/android/performance/gradle.lockfile +++ b/android/performance/gradle.lockfile @@ -22,6 +22,14 @@ androidx.benchmark:benchmark-macro:1.5.0=benchmarkCompileClasspath,benchmarkLint androidx.benchmark:benchmark-traceprocessor-android:1.5.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath androidx.benchmark:benchmark-traceprocessor:1.5.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath androidx.biometric:biometric:1.1.0=benchmarkTestedApks,debugTestedApks +androidx.camera.featurecombinationquery:featurecombinationquery:1.6.2=benchmarkTestedApks,debugTestedApks +androidx.camera.viewfinder:viewfinder-core:1.5.1=benchmarkTestedApks,debugTestedApks +androidx.camera:camera-camera2-pipe:1.6.2=benchmarkTestedApks,debugTestedApks +androidx.camera:camera-camera2:1.6.2=benchmarkTestedApks,debugTestedApks +androidx.camera:camera-core:1.6.2=benchmarkTestedApks,debugTestedApks +androidx.camera:camera-lifecycle:1.6.2=benchmarkTestedApks,debugTestedApks +androidx.camera:camera-video:1.6.2=benchmarkTestedApks,debugTestedApks +androidx.camera:camera-view:1.6.2=benchmarkTestedApks,debugTestedApks androidx.collection:collection-jvm:1.6.0=benchmarkTestedApks,debugTestedApks androidx.collection:collection:1.0.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath androidx.collection:collection:1.6.0=benchmarkTestedApks,debugTestedApks @@ -64,9 +72,11 @@ androidx.compose.ui:ui-util-jvmstubs:1.12.1=benchmarkTestedApks,debugTestedApks androidx.compose.ui:ui-util:1.12.1=benchmarkTestedApks,debugTestedApks androidx.compose.ui:ui:1.12.1=benchmarkTestedApks,debugTestedApks androidx.compose:compose-bom:2026.09.00=benchmarkTestedApks,debugTestedApks +androidx.concurrent:concurrent-futures-ktx:1.1.0=benchmarkTestedApks,debugTestedApks androidx.concurrent:concurrent-futures-ktx:1.2.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath androidx.concurrent:concurrent-futures:1.1.0=benchmarkTestedApks,debugTestedApks androidx.concurrent:concurrent-futures:1.2.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath +androidx.core:core-backported-fixes:1.0.0=benchmarkTestedApks,debugTestedApks androidx.core:core-ktx:1.18.0=benchmarkTestedApks,debugTestedApks androidx.core:core-viewtree:1.0.0=benchmarkTestedApks,debugTestedApks androidx.core:core:1.18.0=benchmarkTestedApks,debugTestedApks @@ -74,6 +84,7 @@ androidx.core:core:1.9.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath, androidx.cursoradapter:cursoradapter:1.0.0=benchmarkTestedApks,debugTestedApks androidx.customview:customview:1.0.0=benchmarkTestedApks,debugTestedApks androidx.drawerlayout:drawerlayout:1.0.0=benchmarkTestedApks,debugTestedApks +androidx.exifinterface:exifinterface:1.4.2=benchmarkTestedApks,debugTestedApks androidx.fragment:fragment:1.9.1=benchmarkTestedApks,debugTestedApks androidx.interpolator:interpolator:1.0.0=benchmarkTestedApks,debugTestedApks androidx.lifecycle:lifecycle-common-jvm:2.11.0=benchmarkTestedApks,debugTestedApks @@ -97,6 +108,9 @@ androidx.lifecycle:lifecycle-viewmodel-savedstate-desktop:2.11.0=benchmarkTested androidx.lifecycle:lifecycle-viewmodel-savedstate:2.11.0=benchmarkTestedApks,debugTestedApks androidx.lifecycle:lifecycle-viewmodel:2.11.0=benchmarkTestedApks,debugTestedApks androidx.loader:loader:1.0.0=benchmarkTestedApks,debugTestedApks +androidx.media3:media3-common:1.9.0=benchmarkTestedApks,debugTestedApks +androidx.media3:media3-container:1.9.0=benchmarkTestedApks,debugTestedApks +androidx.media3:media3-muxer:1.9.0=benchmarkTestedApks,debugTestedApks androidx.navigation3:navigation3-runtime-desktop:1.2.0=benchmarkTestedApks,debugTestedApks androidx.navigation3:navigation3-runtime:1.2.0=benchmarkTestedApks,debugTestedApks androidx.navigation3:navigation3-ui-jvmstubs:1.2.0=benchmarkTestedApks,debugTestedApks @@ -125,6 +139,7 @@ androidx.test:rules:1.5.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath androidx.test:runner:1.7.0=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath androidx.tracing:tracing-desktop:2.0.0=benchmarkTestedApks,debugTestedApks androidx.tracing:tracing-ktx:1.1.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath +androidx.tracing:tracing-ktx:2.0.0=benchmarkTestedApks,debugTestedApks androidx.tracing:tracing-perfetto-binary:1.0.1=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath androidx.tracing:tracing-perfetto-handshake:1.0.1=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath androidx.tracing:tracing-perfetto:1.0.1=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath @@ -169,18 +184,21 @@ com.android.tools:sdk-common:32.4.1=androidLintTool com.android.tools:sdklib:32.4.1=androidLintTool com.github.ajalt.clikt:clikt-jvm:5.0.3=ktlint com.github.ajalt.clikt:clikt:5.0.3=ktlint +com.google.auto.value:auto-value-annotations:1.6.3=benchmarkTestedApks,debugTestedApks com.google.code.findbugs:jsr305:3.0.2=androidLintTool,benchmarkTestedApks,debugTestedApks com.google.code.gson:gson:2.11.0=androidLintTool com.google.dagger:dagger-lint-aar:2.60.1=benchmarkTestedApks,debugTestedApks com.google.dagger:dagger:2.60.1=benchmarkTestedApks,debugTestedApks com.google.dagger:hilt-android:2.60.1=benchmarkTestedApks,debugTestedApks com.google.dagger:hilt-core:2.60.1=benchmarkTestedApks,debugTestedApks +com.google.errorprone:error_prone_annotations:2.28.0=benchmarkTestedApks,debugTestedApks com.google.errorprone:error_prone_annotations:2.36.0=androidLintTool -com.google.guava:failureaccess:1.0.2=androidLintTool +com.google.guava:failureaccess:1.0.2=androidLintTool,benchmarkTestedApks,debugTestedApks +com.google.guava:guava:33.3.1-android=benchmarkTestedApks,debugTestedApks com.google.guava:guava:33.4.0-jre=androidLintTool -com.google.guava:listenablefuture:1.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,benchmarkTestedApks,debugLintChecksClasspath,debugRuntimeClasspath,debugTestedApks -com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=androidLintTool -com.google.j2objc:j2objc-annotations:3.0.0=androidLintTool +com.google.guava:listenablefuture:1.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=androidLintTool,benchmarkTestedApks,debugTestedApks +com.google.j2objc:j2objc-annotations:3.0.0=androidLintTool,benchmarkTestedApks,debugTestedApks com.google.jimfs:jimfs:1.1=androidLintTool com.google.protobuf:protobuf-java:3.25.5=androidLintTool,unified-test-platform-gradle-work-action com.pinterest.ktlint:ktlint-cli-reporter-baseline:1.7.1=ktlint,ktlintBaselineReporter @@ -233,11 +251,12 @@ org.apache.httpcomponents:httpmime:4.5.6=androidLintTool org.bouncycastle:bcpkix-jdk18on:1.80.2=androidLintTool org.bouncycastle:bcprov-jdk18on:1.80.2=androidLintTool org.bouncycastle:bcutil-jdk18on:1.80.2=androidLintTool -org.checkerframework:checker-qual:3.43.0=androidLintTool +org.checkerframework:checker-qual:3.43.0=androidLintTool,benchmarkTestedApks,debugTestedApks org.ec4j.core:ec4j-core:1.1.1=ktlint,ktlintBaselineReporter,ktlintRuleset org.glassfish.jaxb:jaxb-runtime:2.3.2=androidLintTool org.glassfish.jaxb:txw2:2.3.2=androidLintTool org.hamcrest:hamcrest-core:1.3=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath +org.jetbrains.kotlin:kotlin-bom:1.8.0=benchmarkTestedApks,debugTestedApks org.jetbrains.kotlin:kotlin-build-tools-api:2.4.0=kotlinAbiValidationCompatClasspath org.jetbrains.kotlin:kotlin-build-tools-api:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.21=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath @@ -270,6 +289,8 @@ org.jetbrains.kotlin:kotlin-stdlib:2.2.10=androidLintTool,unified-test-platform- org.jetbrains.kotlin:kotlin-stdlib:2.4.0=kotlinAbiValidationCompatClasspath org.jetbrains.kotlin:kotlin-stdlib:2.4.21=benchmarkCompileClasspath,benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,benchmarkTestedApks,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugTestedApks,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-tooling-core:2.4.21=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlinx:atomicfu-jvm:0.28.0=benchmarkTestedApks,debugTestedApks +org.jetbrains.kotlinx:atomicfu:0.28.0=benchmarkTestedApks,debugTestedApks org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0=benchmarkTestedApks,debugTestedApks org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0=benchmarkLintChecksClasspath,benchmarkRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0=benchmarkTestedApks,debugTestedApks diff --git a/android/platform/build.gradle.kts b/android/platform/build.gradle.kts index 61d9646..0df0116 100644 --- a/android/platform/build.gradle.kts +++ b/android/platform/build.gradle.kts @@ -40,6 +40,9 @@ kotlin { dependencies { implementation(project(":core")) + implementation(libs.camera.camera2) + implementation(libs.camera.lifecycle) + implementation(libs.camera.view) implementation(libs.biometric) implementation(libs.fragment) testImplementation(libs.junit) diff --git a/android/platform/gradle.lockfile b/android/platform/gradle.lockfile index 64da619..58bb86f 100644 --- a/android/platform/gradle.lockfile +++ b/android/platform/gradle.lockfile @@ -14,13 +14,23 @@ androidx.appcompat:appcompat:1.2.0=debugAndroidTestLintChecksClasspath,debugAndr androidx.arch.core:core-common:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.arch.core:core-runtime:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.biometric:biometric:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.camera.featurecombinationquery:featurecombinationquery:1.6.2=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.camera.viewfinder:viewfinder-core:1.5.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.camera:camera-camera2-pipe:1.6.2=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.camera:camera-camera2:1.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.camera:camera-core:1.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.camera:camera-lifecycle:1.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.camera:camera-video:1.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.camera:camera-view:1.6.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.collection:collection-jvm:1.4.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath androidx.collection:collection-jvm:1.6.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.collection:collection:1.4.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath androidx.collection:collection:1.6.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.concurrent:concurrent-futures-ktx:1.1.0=debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.concurrent:concurrent-futures-ktx:1.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath androidx.concurrent:concurrent-futures:1.1.0=debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.concurrent:concurrent-futures:1.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.core:core-backported-fixes:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.core:core-ktx:1.13.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.core:core-ktx:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath androidx.core:core-viewtree:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -29,6 +39,7 @@ androidx.core:core:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath, androidx.cursoradapter:cursoradapter:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.customview:customview:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.drawerlayout:drawerlayout:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.exifinterface:exifinterface:1.4.2=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.fragment:fragment:1.9.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.interpolator:interpolator:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-common-jvm:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -46,6 +57,9 @@ androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.10.0=debugAndroidTes androidx.lifecycle:lifecycle-viewmodel-savedstate:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-viewmodel:2.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.loader:loader:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.media3:media3-common:1.9.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.media3:media3-container:1.9.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.media3:media3-muxer:1.9.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.profileinstaller:profileinstaller:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.savedstate:savedstate-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.savedstate:savedstate-ktx:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -56,6 +70,7 @@ androidx.test:core:1.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintCh androidx.test:monitor:1.8.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath androidx.test:runner:1.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath androidx.tracing:tracing-android:2.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.tracing:tracing-ktx:2.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.tracing:tracing:1.1.0=debugAndroidTestCompileClasspath androidx.tracing:tracing:2.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.vectordrawable:vectordrawable-animated:1.1.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -95,14 +110,21 @@ com.android.tools:sdk-common:32.4.1=androidLintTool com.android.tools:sdklib:32.4.1=androidLintTool com.github.ajalt.clikt:clikt-jvm:5.0.3=ktlint com.github.ajalt.clikt:clikt:5.0.3=ktlint -com.google.code.findbugs:jsr305:3.0.2=androidLintTool +com.google.auto.value:auto-value-annotations:1.6.3=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.code.findbugs:jsr305:3.0.2=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.code.gson:gson:2.11.0=androidLintTool +com.google.dagger:dagger:2.59=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.18.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +com.google.errorprone:error_prone_annotations:2.28.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.errorprone:error_prone_annotations:2.36.0=androidLintTool -com.google.guava:failureaccess:1.0.2=androidLintTool +com.google.guava:failureaccess:1.0.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +com.google.guava:failureaccess:1.0.2=androidLintTool,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.guava:guava:32.0.1-android=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +com.google.guava:guava:33.3.1-android=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.guava:guava:33.4.0-jre=androidLintTool -com.google.guava:listenablefuture:1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=androidLintTool -com.google.j2objc:j2objc-annotations:3.0.0=androidLintTool +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.j2objc:j2objc-annotations:2.8=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +com.google.j2objc:j2objc-annotations:3.0.0=androidLintTool,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.jimfs:jimfs:1.1=androidLintTool com.google.protobuf:protobuf-java:3.25.5=androidLintTool,unified-test-platform-gradle-work-action com.pinterest.ktlint:ktlint-cli-reporter-baseline:1.7.1=ktlint,ktlintBaselineReporter @@ -132,8 +154,9 @@ io.github.detekt.sarif4k:sarif4k:0.6.0=ktlint,ktlintReporter io.github.oshai:kotlin-logging-jvm:7.0.7=ktlint,ktlintBaselineReporter,ktlintReporter,ktlintRuleset io.github.oshai:kotlin-logging:5.1.0=ktlint,ktlintBaselineReporter,ktlintReporter jakarta.activation:jakarta.activation-api:1.2.1=androidLintTool +jakarta.inject:jakarta.inject-api:2.0.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=androidLintTool -javax.inject:javax.inject:1=androidLintTool +javax.inject:javax.inject:1=androidLintTool,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath junit:junit:4.13.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath net.java.dev.jna:jna-platform:5.6.0=androidLintTool net.java.dev.jna:jna:5.19.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -149,11 +172,13 @@ org.apache.httpcomponents:httpmime:4.5.6=androidLintTool org.bouncycastle:bcpkix-jdk18on:1.80.2=androidLintTool org.bouncycastle:bcprov-jdk18on:1.80.2=androidLintTool org.bouncycastle:bcutil-jdk18on:1.80.2=androidLintTool -org.checkerframework:checker-qual:3.43.0=androidLintTool +org.checkerframework:checker-qual:3.33.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +org.checkerframework:checker-qual:3.43.0=androidLintTool,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.ec4j.core:ec4j-core:1.1.1=ktlint,ktlintBaselineReporter,ktlintRuleset org.glassfish.jaxb:jaxb-runtime:2.3.2=androidLintTool org.glassfish.jaxb:txw2:2.3.2=androidLintTool org.hamcrest:hamcrest-core:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-bom:1.8.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlin:kotlin-build-tools-api:2.4.0=kotlinAbiValidationCompatClasspath org.jetbrains.kotlin:kotlin-build-tools-api:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.21=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath @@ -186,6 +211,8 @@ org.jetbrains.kotlin:kotlin-stdlib:2.2.10=androidLintTool,unified-test-platform- 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,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlin:kotlin-tooling-core:2.4.21=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlinx:atomicfu-jvm:0.28.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:atomicfu:0.28.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath diff --git a/android/platform/src/androidTest/AndroidManifest.xml b/android/platform/src/androidTest/AndroidManifest.xml index 0c57fdc..0fdbea2 100644 --- a/android/platform/src/androidTest/AndroidManifest.xml +++ b/android/platform/src/androidTest/AndroidManifest.xml @@ -1,4 +1,5 @@ + diff --git a/android/platform/src/androidTest/kotlin/de/rfc1437/ironstorage/platform/AndroidQrCameraTest.kt b/android/platform/src/androidTest/kotlin/de/rfc1437/ironstorage/platform/AndroidQrCameraTest.kt new file mode 100644 index 0000000..ecbdd0e --- /dev/null +++ b/android/platform/src/androidTest/kotlin/de/rfc1437/ironstorage/platform/AndroidQrCameraTest.kt @@ -0,0 +1,50 @@ +package de.rfc1437.ironstorage.platform + +import android.Manifest +import android.content.pm.PackageManager +import androidx.test.core.app.ActivityScenario +import androidx.test.platform.app.InstrumentationRegistry +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidQrCameraTest { + @Test fun livePreviewDeliversBoundedPlanesAndCanBeClosedAndReopened() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals( + PackageManager.PERMISSION_GRANTED, + context.checkSelfPermission(Manifest.permission.CAMERA) + ) + ActivityScenario.launch(KeystoreTestActivity::class.java).use { activity -> + repeat(2) { + val received = CountDownLatch(1) + val failed = CountDownLatch(1) + var camera: AndroidQrCamera? = null + activity.onActivity { host -> + camera = AndroidQrCamera(host, { frame -> + try { + assertTrue(frame.width in 1u..2048u) + assertTrue(frame.height in 1u..2048u) + assertTrue(frame.pixels.size <= 4 * 1024 * 1024) + assertTrue(frame.rotationDegrees in listOf(0u, 90u, 180u, 270u)) + received.countDown() + } finally { + frame.pixels.fill(0) + } + }, { failed.countDown() }) + host.setContentView(requireNotNull(camera).view) + assertTrue(requireNotNull(camera).view.clipToOutline) + } + assertTrue( + "The emulator camera did not deliver a frame", + received.await(15, TimeUnit.SECONDS) + ) + assertFalse("CameraX failed to bind", failed.count == 0L) + activity.onActivity { camera?.close() } + } + } + } +} diff --git a/android/platform/src/main/kotlin/de/rfc1437/ironstorage/platform/AndroidQrCamera.kt b/android/platform/src/main/kotlin/de/rfc1437/ironstorage/platform/AndroidQrCamera.kt new file mode 100644 index 0000000..c218886 --- /dev/null +++ b/android/platform/src/main/kotlin/de/rfc1437/ironstorage/platform/AndroidQrCamera.kt @@ -0,0 +1,113 @@ +package de.rfc1437.ironstorage.platform + +import android.util.Size +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.core.resolutionselector.ResolutionSelector +import androidx.camera.core.resolutionselector.ResolutionStrategy +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import de.rfc1437.ironstorage.core.CameraFrame +import de.rfc1437.ironstorage.core.CameraPreview +import java.util.concurrent.Executors + +internal class AndroidQrCamera( + private val activity: FragmentActivity, + private val frame: (CameraFrame) -> Unit, + private val failure: () -> Unit +) : CameraPreview { + override val view = PreviewView(activity).apply { + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + outlineProvider = android.view.ViewOutlineProvider.BOUNDS + clipToOutline = true + scaleType = PreviewView.ScaleType.FIT_CENTER + importantForAccessibility = android.view.View.IMPORTANT_FOR_ACCESSIBILITY_NO + } + private val executor = Executors.newSingleThreadExecutor() + private val preview = Preview.Builder().build() + private val analysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .setResolutionSelector( + ResolutionSelector.Builder().setResolutionStrategy( + ResolutionStrategy(Size(1280, 720), ResolutionStrategy.FALLBACK_RULE_CLOSEST_LOWER) + ).build() + ).build() + private var provider: ProcessCameraProvider? = null + + @Volatile private var active = true + + init { + analysis.setAnalyzer(executor) { image -> + var bytes: ByteArray? = null + try { + if (active && image.width in 1..2048 && image.height in 1..2048) { + val plane = image.planes[0] + val source = plane.buffer.duplicate() + if (source.remaining() <= 4 * 1024 * 1024) { + val pixels = ByteArray(source.remaining()) + bytes = pixels + source.get(pixels) + if (active) { + frame( + CameraFrame( + pixels, + image.width.toUInt(), + image.height.toUInt(), + plane.rowStride.toUInt(), + plane.pixelStride.toUInt(), + image.imageInfo.rotationDegrees.toUInt() + ) + ) + // The receiver clears transferred bytes after the native call. + bytes = null + } + } else { + failure() + } + } + } finally { + bytes?.fill(0) + image.close() + } + } + val pending = ProcessCameraProvider.getInstance(activity) + pending.addListener({ + if (active) { + try { + provider = pending.get() + preview.setSurfaceProvider(view.surfaceProvider) + provider!!.bindToLifecycle( + activity, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + analysis + ) + } catch (_: java.util.concurrent.ExecutionException) { + close() + failure() + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + close() + failure() + } catch (_: IllegalArgumentException) { + close() + failure() + } catch (_: SecurityException) { + close() + failure() + } + } + }, ContextCompat.getMainExecutor(activity)) + } + + override fun close() { + active = false + analysis.clearAnalyzer() + provider?.unbind(preview, analysis) + provider = null + executor.shutdown() + } +} diff --git a/android/platform/src/main/kotlin/de/rfc1437/ironstorage/platform/AndroidSettingsPlatform.kt b/android/platform/src/main/kotlin/de/rfc1437/ironstorage/platform/AndroidSettingsPlatform.kt index 0ffd395..8c6c383 100644 --- a/android/platform/src/main/kotlin/de/rfc1437/ironstorage/platform/AndroidSettingsPlatform.kt +++ b/android/platform/src/main/kotlin/de/rfc1437/ironstorage/platform/AndroidSettingsPlatform.kt @@ -40,6 +40,13 @@ class AndroidSettingsPlatform( host.clear() } } + override fun cameraPreview( + frame: (de.rfc1437.ironstorage.core.CameraFrame) -> Unit, + failure: () -> Unit + ): de.rfc1437.ironstorage.core.CameraPreview? { + val activity = host.get() ?: return null + return AndroidQrCamera(activity, frame, failure) + } override fun chooseQrImages(): Boolean { val picker = picker ?: return false return try { diff --git a/crates/mobile-bridge/src/android_onboarding.rs b/crates/mobile-bridge/src/android_onboarding.rs index 12a38b5..480c7fc 100644 --- a/crates/mobile-bridge/src/android_onboarding.rs +++ b/crates/mobile-bridge/src/android_onboarding.rs @@ -101,6 +101,24 @@ impl MobileAndroidOnboarding { .import(SecretBytes::new(armor.into_bytes()), secret(passphrase))? .into()) } + pub fn import_scanned( + &self, + transfer: Arc, + passphrase: Option, + ) -> Result { + let importer = + transfer + .importer + .lock() + .map_err(|_| crate::MobileKeyTransferFfiError::Failed { + kind: crate::MobileKeyTransferErrorKind::Failed, + message: "the key-transfer session is unavailable".to_owned(), + })?; + Ok(self + .setup + .import_scanned(&importer, secret(passphrase))? + .into()) + } pub fn setup_local( &self, fingerprint: String, diff --git a/crates/mobile-bridge/src/lib.rs b/crates/mobile-bridge/src/lib.rs index 12c3cc3..10292eb 100644 --- a/crates/mobile-bridge/src/lib.rs +++ b/crates/mobile-bridge/src/lib.rs @@ -1581,6 +1581,10 @@ pub struct MobileKeyTransfer { #[uniffi::export] impl MobileKeyTransfer { + pub fn export_visible(&self) -> bool { + self.service.export_visible() + } + pub fn keys(&self) -> Vec { self.service.keys().iter().map(Into::into).collect() } @@ -1635,6 +1639,31 @@ pub struct MobileKeyTransferImport { #[uniffi::export] impl MobileKeyTransferImport { + pub fn add_camera( + &self, + frame: platform::MobileCameraFrame, + rotation_degrees: u32, + ) -> Result, MobileKeyTransferFfiError> { + self.importer + .lock() + .map_err(|_| MobileKeyTransferFfiError::Failed { + kind: MobileKeyTransferErrorKind::Failed, + message: "the key-transfer session is unavailable".to_owned(), + })? + .add_camera( + ironstorage::mobile_platform::MobileCameraFrame { + luminance: ironstorage::repository::SecretBytes::new(frame.luminance), + width: frame.width, + height: frame.height, + row_stride: frame.row_stride, + pixel_stride: frame.pixel_stride, + }, + rotation_degrees, + ) + .map(|progress| progress.map(Into::into)) + .map_err(Into::into) + } + pub fn add_image( &self, image: Vec, diff --git a/crates/storage/src/mobile_key_transfer.rs b/crates/storage/src/mobile_key_transfer.rs index 82e5cd0..95f0b1e 100644 --- a/crates/storage/src/mobile_key_transfer.rs +++ b/crates/storage/src/mobile_key_transfer.rs @@ -1,6 +1,9 @@ //! Storage-owned OpenPGP key transfer for native mobile QR adapters. -use std::{collections::BTreeMap, error::Error, fmt, fs, io::Write as _, path::Path}; +use std::{ + collections::BTreeMap, error::Error, fmt, fs, io::Write as _, path::Path, sync::Mutex, + time::Instant, +}; use crate::atomic_file::TempFile; use cap_std::{ambient_authority, fs::Dir}; @@ -17,6 +20,9 @@ use crate::{ const FRAME_PREFIX: &str = "ISKT1"; const FRAME_CHUNK_BYTES: usize = 1_600; const MAX_TRANSFER_FRAMES: usize = 11_000; +const MAX_TRANSFER_BYTES: usize = MAX_TRANSFER_FRAMES * FRAME_CHUNK_BYTES; +// Bound eager QR matrices to under 8 MiB on lower-memory mobile clients. +const MAX_EXPORT_FRAMES: usize = 256; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum MobileKeyTransferKind { @@ -140,13 +146,18 @@ impl MobileKeyTransferOutcome { pub struct MobileKeyTransferService { config: Config, keys: KeyStore, + export_deadline: Mutex>, } impl MobileKeyTransferService { pub fn load() -> Result { let config = Config::load(None)?; let keys = load_keys_or_empty(config.key_material())?; - Ok(Self { config, keys }) + Ok(Self { + config, + keys, + export_deadline: Mutex::new(None), + }) } pub fn keys(&self) -> Vec { @@ -169,6 +180,11 @@ impl MobileKeyTransferService { .find(|key| key.fingerprint().as_str() == fingerprint) .ok_or(MobileKeyTransferError::KeyUnavailable)?; let frames = encode_frames(armor)?; + *self + .export_deadline + .lock() + .map_err(|_| MobileKeyTransferError::KeyUnavailable)? = + Some(Instant::now() + self.config.authentication_timeout().duration()); Ok(MobileKeyTransferExport { key: transfer_key_with_kind(&info, kind), frames, @@ -178,13 +194,20 @@ impl MobileKeyTransferService { pub fn importer(&self) -> MobileKeyTransferImport { MobileKeyTransferImport::new(self.config.clone()) } + + pub fn export_visible(&self) -> bool { + self.export_deadline + .lock() + .ok() + .is_some_and(|deadline| deadline.is_some_and(|deadline| Instant::now() < deadline)) + } } pub struct MobileKeyTransferImport { destination: MobileKeyTransferDestination, digest: Option, total: Option, - chunks: BTreeMap>, + chunks: BTreeMap, complete: Option, key: Option, } @@ -195,6 +218,31 @@ enum MobileKeyTransferDestination { } impl MobileKeyTransferImport { + pub fn add_camera( + &mut self, + frame: crate::mobile_platform::MobileCameraFrame, + rotation_degrees: u32, + ) -> Result, MobileKeyTransferError> { + if frame.width > 2048 + || frame.height > 2048 + || frame.luminance.expose().len() > 4 * 1024 * 1024 + { + return Err(MobileKeyTransferError::InvalidFrame); + } + match frame.decode_rotated(rotation_degrees) { + Ok(payload) => self.add_frame(payload).map(Some), + Err(QrError::NotFound) => Ok(None), + Err(_) => Err(MobileKeyTransferError::InvalidFrame), + } + } + + pub(crate) fn completed_armor(&self) -> Result { + self.complete + .as_ref() + .map(|armor| SecretBytes::new(armor.expose().to_vec())) + .ok_or(MobileKeyTransferError::IncompleteTransfer) + } + fn new(config: Config) -> Self { Self { destination: MobileKeyTransferDestination::Configured(Box::new(config)), @@ -230,6 +278,9 @@ impl MobileKeyTransferImport { &mut self, payload: SecretBytes, ) -> Result { + if payload.expose().len() > MAX_TRANSFER_BYTES { + return Err(MobileKeyTransferError::InvalidFrame); + } if self.complete.is_some() { return Err(MobileKeyTransferError::AlreadyComplete); } @@ -251,52 +302,58 @@ impl MobileKeyTransferImport { if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) { return Err(MobileKeyTransferError::InvalidFrame); } + let digest = digest.to_ascii_lowercase(); let sequence = parse_positive(fields.next())?; let total = parse_positive(fields.next())?; if sequence > total || total > MAX_TRANSFER_FRAMES { return Err(MobileKeyTransferError::InvalidFrame); } - let chunk = BASE64URL_NOPAD - .decode( - fields - .next() - .ok_or(MobileKeyTransferError::InvalidFrame)? - .as_bytes(), - ) - .map_err(|_| MobileKeyTransferError::InvalidFrame)?; - if chunk.is_empty() || chunk.len() > FRAME_CHUNK_BYTES { + let encoded = fields.next().ok_or(MobileKeyTransferError::InvalidFrame)?; + if encoded.len() > FRAME_CHUNK_BYTES.div_ceil(3) * 4 { + return Err(MobileKeyTransferError::InvalidFrame); + } + let chunk = SecretBytes::new( + BASE64URL_NOPAD + .decode(encoded.as_bytes()) + .map_err(|_| MobileKeyTransferError::InvalidFrame)?, + ); + if chunk.expose().is_empty() || chunk.expose().len() > FRAME_CHUNK_BYTES { return Err(MobileKeyTransferError::InvalidFrame); } - if self.digest.as_deref().is_some_and(|value| value != digest) + if self + .digest + .as_deref() + .is_some_and(|value| value != digest.as_str()) || self.total.is_some_and(|value| value != total) { return Err(MobileKeyTransferError::MixedTransfers); } - self.digest - .get_or_insert_with(|| digest.to_ascii_lowercase()); + self.digest.get_or_insert(digest); self.total.get_or_insert(total); if let Some(existing) = self.chunks.get(&sequence) { - if existing == &chunk { + if existing.expose() == chunk.expose() { return Ok(self.progress(true)); } return Err(MobileKeyTransferError::ConflictingFrame); } self.chunks.insert(sequence, chunk); if self.chunks.len() == total { - let mut armor = Vec::new(); + let mut armor = zeroize::Zeroizing::new(Vec::new()); for sequence in 1..=total { - armor.extend( + armor.extend_from_slice( self.chunks .get(&sequence) - .ok_or(MobileKeyTransferError::IncompleteTransfer)?, + .ok_or(MobileKeyTransferError::IncompleteTransfer)? + .expose(), ); } - let actual = HEXLOWER.encode(&Sha256::digest(&armor)); + let actual = HEXLOWER.encode(&Sha256::digest(armor.as_slice())); if self.digest.as_deref() != Some(actual.as_str()) { return Err(MobileKeyTransferError::ChecksumMismatch); } - self.finish(SecretBytes::new(armor))?; + self.finish(SecretBytes::new(std::mem::take(&mut *armor)))?; + self.chunks.clear(); } Ok(self.progress(false)) } @@ -435,7 +492,11 @@ impl MobileKeyTransferImport { fn progress(&self, duplicate: bool) -> MobileKeyTransferProgress { MobileKeyTransferProgress { - received: self.chunks.len().max(usize::from(self.complete.is_some())), + received: if self.complete.is_some() { + self.total.unwrap_or(1) + } else { + self.chunks.len() + }, total: self.total.unwrap_or(1), duplicate, key: self.key.clone(), @@ -576,7 +637,7 @@ fn encode_multipart( } let digest = HEXLOWER.encode(&Sha256::digest(armor.expose())); let total = armor.expose().len().div_ceil(chunk_bytes); - if total == 0 || total > MAX_TRANSFER_FRAMES { + if total == 0 || total > MAX_EXPORT_FRAMES { return Err(MobileKeyTransferError::QrPayload); } let mut frames = Vec::with_capacity(total); @@ -706,6 +767,7 @@ mod tests { MobileKeyTransferService, encode_multipart, load_keys_or_empty, }; use crate::{config::Config, crypto::KeyStore, repository::SecretBytes}; + use std::{sync::Mutex, time::Instant}; use tempfile::tempdir; const ALICE_PUBLIC: &[u8] = @@ -714,6 +776,89 @@ mod tests { include_bytes!("../tests/fixtures/compatibility/keys/alice-secret.asc"); const ALICE_FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30"; + #[test] + fn export_rejects_oversized_matrix_sets_before_rendering() { + assert!(matches!( + encode_multipart(SecretBytes::new(vec![0; 257]), 1), + Err(MobileKeyTransferError::QrPayload) + )); + } + + #[test] + fn export_visibility_is_bounded_and_does_not_renew_while_reading() { + let fixture = configured_secret_key().unwrap(); + let service = MobileKeyTransferService { + keys: load_keys_or_empty(fixture.config.key_material()).unwrap(), + config: fixture.config, + export_deadline: Mutex::new(None), + }; + assert!(!service.export_visible()); + service + .export(ALICE_FINGERPRINT, MobileKeyTransferKind::Public, None) + .unwrap(); + assert!(service.export_visible()); + *service.export_deadline.lock().unwrap() = Some(Instant::now()); + assert!(!service.export_visible()); + assert!(!service.export_visible()); + } + + #[test] + fn camera_multipart_is_exact_duplicate_safe_and_transactional() + -> Result<(), Box> { + let fixture = configured_public_key()?; + let mut importer = MobileKeyTransferImport::new(fixture.config.clone()); + let mut frames = encode_multipart(SecretBytes::new(ALICE_SECRET.to_vec()), 128)?; + frames.reverse(); + for (index, frame) in frames.iter().enumerate() { + let matrix = frame.matrix(); + let width = (matrix.width() + 8) * 4; + let height = width + 8; + let row = width * 2 + 13; + let mut pixels = vec![255; height * row]; + for y in 0..height { + for x in 0..width { + let dark = x / 4 >= 4 + && y / 4 >= 4 + && matrix.is_dark(x / 4 - 4, y / 4 - 4).unwrap_or(false); + pixels[y * row + x * 2] = if dark { 0 } else { 255 }; + } + } + let camera = || crate::mobile_platform::MobileCameraFrame { + width: width as u32, + height: height as u32, + row_stride: row as u32, + pixel_stride: 2, + luminance: SecretBytes::new(pixels.clone()), + }; + let progress = importer + .add_camera(camera(), [0, 90, 180, 270][index % 4])? + .unwrap(); + assert_eq!(progress.received(), index + 1); + if index + 1 < frames.len() { + assert!(importer.add_camera(camera(), 0)?.unwrap().duplicate()); + assert_eq!( + importer.import(None, true, false), + Err(MobileKeyTransferError::IncompleteTransfer) + ); + } + } + assert_eq!(importer.completed_armor()?.expose(), ALICE_SECRET); + assert_eq!( + importer.import(Some(SecretBytes::new(b"wrong".to_vec())), true, false), + Err(MobileKeyTransferError::IncorrectPassphrase) + ); + let before = std::fs::read(fixture.key_path.join("alice-public.asc"))?; + importer.import( + Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())), + true, + false, + )?; + assert_eq!(before, ALICE_PUBLIC); + let keys = KeyStore::load(fixture.config.key_material())?; + assert!(keys.infos().any(|key| key.has_secret())); + Ok(()) + } + #[test] fn qr_images_reconstruct_the_same_key_and_reject_invalid_images() -> Result<(), Box> { @@ -750,8 +895,14 @@ mod tests { let mut importer = MobileKeyTransferImport::new(fixture.config); let mut frames = encode_multipart(SecretBytes::new(ALICE_SECRET.to_vec()), 128)?; frames.reverse(); - for frame in frames { - importer.add_frame(SecretBytes::new(frame.payload().to_vec()))?; + for (index, frame) in frames.into_iter().enumerate() { + let mut payload = frame.payload().to_vec(); + if index % 2 == 0 { + for byte in &mut payload[6..70] { + byte.make_ascii_uppercase(); + } + } + importer.add_frame(SecretBytes::new(payload))?; } assert_eq!( importer.complete.as_ref().map(SecretBytes::expose), @@ -769,6 +920,7 @@ mod tests { -> Result<(), Box> { let fixture = configured_secret_key()?; let service = MobileKeyTransferService { + export_deadline: Mutex::new(None), config: fixture.config.clone(), keys: KeyStore::load(&fixture.key_path)?, }; @@ -953,6 +1105,7 @@ mod tests { let fixture = configured_key("placeholder", b"")?; std::fs::remove_file(fixture.key_path.join("placeholder"))?; let service = MobileKeyTransferService { + export_deadline: Mutex::new(None), keys: load_keys_or_empty(&fixture.key_path)?, config: fixture.config.clone(), }; diff --git a/crates/storage/src/mobile_onboarding/android.rs b/crates/storage/src/mobile_onboarding/android.rs index 89e9cf7..bea2cea 100644 --- a/crates/storage/src/mobile_onboarding/android.rs +++ b/crates/storage/src/mobile_onboarding/android.rs @@ -163,6 +163,15 @@ impl AndroidOnboarding { self.status_unlocked() } + pub fn import_scanned( + &self, + transfer: &crate::mobile_key_transfer::MobileKeyTransferImport, + passphrase: Option, + ) -> Result { + self.import(transfer.completed_armor()?, passphrase) + .map_err(crate::mobile_key_transfer::MobileKeyTransferError::LocalSetup) + } + fn stage_key( &self, fingerprint: &str, @@ -1052,6 +1061,40 @@ pub(crate) mod tests { Ok(true) } } + #[test] + fn scanned_import_stages_exact_key_without_creating_a_store() { + use crate::mobile_key_transfer::{MobileKeyTransferError, MobileKeyTransferImport}; + let temp = tempfile::tempdir().unwrap(); + let private = storage(temp.path()); + let backend = Arc::new(Backend::default()); + let setup = AndroidOnboarding::new(&private, backend).unwrap(); + let mut transfer = MobileKeyTransferImport::local_setup(); + assert!(matches!( + setup.import_scanned(&transfer, password()), + Err(MobileKeyTransferError::IncompleteTransfer) + )); + transfer + .add_frame(SecretBytes::new( + include_bytes!("../../tests/fixtures/compatibility/keys/alice-secret.asc").to_vec(), + )) + .unwrap(); + assert!( + setup + .import_scanned(&transfer, Some(SecretBytes::new(b"wrong".to_vec()))) + .is_err() + ); + assert!(setup.status().unwrap().keys.is_empty()); + let status = setup.import_scanned(&transfer, password()).unwrap(); + assert_eq!(status.keys[0].fingerprint().as_str(), ALICE); + assert!(!status.configured); + assert!(setup.configuration().unwrap().is_none()); + assert!(matches!( + setup.import_scanned(&transfer, password()), + Err(MobileKeyTransferError::LocalSetup(_)) + )); + assert_eq!(setup.status().unwrap().keys.len(), 1); + } + #[test] fn imported_key_restarts_then_creates_a_pass_compatible_local_store() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/storage/src/mobile_platform.rs b/crates/storage/src/mobile_platform.rs index d31ad92..19b9328 100644 --- a/crates/storage/src/mobile_platform.rs +++ b/crates/storage/src/mobile_platform.rs @@ -90,6 +90,10 @@ pub struct MobileCameraFrame { impl MobileCameraFrame { pub fn decode(&self) -> Result { + self.decode_rotated(0) + } + + pub fn decode_rotated(&self, rotation_degrees: u32) -> Result { let width = self.width as usize; let height = self.height as usize; let row = self.row_stride as usize; @@ -99,6 +103,7 @@ impl MobileCameraFrame { || !(1..=4096).contains(&height) || !(1..=4).contains(&pixel) || bytes.len() > 32 * 1024 * 1024 + || !matches!(rotation_degrees, 0 | 90 | 180 | 270) { return Err(QrError::InvalidImage); } @@ -112,7 +117,20 @@ impl MobileCameraFrame { if row <= last_column || last >= bytes.len() { return Err(QrError::InvalidImage); } - QrMatrix::decode_luminance(width, height, |x, y| bytes[y * row + x * pixel]) + let (output_width, output_height) = if matches!(rotation_degrees, 90 | 270) { + (height, width) + } else { + (width, height) + }; + QrMatrix::decode_luminance(output_width, output_height, |x, y| { + let (x, y) = match rotation_degrees { + 90 => (y, height - 1 - x), + 180 => (width - 1 - x, height - 1 - y), + 270 => (width - 1 - y, x), + _ => (x, y), + }; + bytes[y * row + x * pixel] + }) } } @@ -223,6 +241,61 @@ impl fmt::Debug for MobileCredentialRequest { mod tests { use super::*; + #[test] + fn camera_planes_decode_padded_pixels_at_every_orientation() { + let payload = SecretBytes::new(b"camera-qr-byte-exact".to_vec()); + let matrix = QrMatrix::encode(&payload).unwrap(); + let size = (matrix.width() + 8) * 5; + let row = size * 2 + 7; + let mut pixels = vec![255; size * row]; + for y in 0..size { + for x in 0..size { + let dark = x / 5 >= 4 + && y / 5 >= 4 + && matrix.is_dark(x / 5 - 4, y / 5 - 4).unwrap_or(false); + pixels[y * row + x * 2] = if dark { 0 } else { 255 }; + } + } + for rotation in [0, 90, 180, 270] { + let decoded = MobileCameraFrame { + luminance: SecretBytes::new(pixels.clone()), + width: size as u32, + height: size as u32, + row_stride: row as u32, + pixel_stride: 2, + } + .decode_rotated(rotation) + .unwrap(); + assert_eq!(decoded.expose(), payload.expose()); + } + } + + #[test] + fn camera_rejects_truncated_oversized_and_invalid_planes() { + for (width, height, row, step, rotation, length) in [ + (0, 1, 1, 1, 0, 1), + (4097, 1, 4097, 1, 0, 4097), + (2, 2, 2, 1, 0, 3), + (2, 2, 1, 1, 0, 4), + (2, 2, 2, 0, 0, 4), + (2, 2, 2, 1, 45, 4), + (2, 2, u32::MAX, 1, 0, 4), + (1, 1, 1, 1, 0, 32 * 1024 * 1024 + 1), + ] { + assert!(matches!( + MobileCameraFrame { + luminance: SecretBytes::new(vec![255; length]), + width, + height, + row_stride: row, + pixel_stride: step, + } + .decode_rotated(rotation), + Err(QrError::InvalidImage) + )); + } + } + #[test] fn roots_and_caller_facts_are_validated_without_creating_data_or_grants() { let paths = diff --git a/docs/android/key-transfer.md b/docs/android/key-transfer.md new file mode 100644 index 0000000..d32df6d --- /dev/null +++ b/docs/android/key-transfer.md @@ -0,0 +1,96 @@ +# Android GPG QR transfer + +Issue [#106](https://git.rfc1437.de/hugo/IronStorage/issues/106) uses the shared +ASCII-armored OpenPGP and `ISKT1` multipart format. `crates/storage` owns frame +bounds/orientation, QR decoding, checksum/reassembly, identity, passphrase +validation, duplicate handling and transactional key installation. Kotlin only +supplies camera facts and renders storage-provided QR matrices. + +## Import and export + +Open Preferences → Import or export GPG QR codes. Scanning requests camera +permission, binds CameraX Preview and ImageAnalysis to the Activity lifecycle, +and uses only the latest Y plane. Pause closes the camera while retaining the +in-memory multipart session; resume opens a fresh camera. Frames can arrive in +any order. Duplicate frames leave the received count unchanged. The complete +key's Rust-derived identity and fingerprint appear before import. Private import +requires a separate fingerprint/security confirmation. Default-key selection +and replacement are explicit controls; incorrect passphrases, incomplete, +conflicting or mismatched transfers cannot replace valid keys/configuration. + +Onboarding → Import existing GPG key → Scan GPG QR codes uses the same scanner +and review, then stages the key through Android's existing onboarding transaction. +It does not create a store or bypass the later local/HTTPS setup choice. Public +keys cannot become onboarding/default decryption keys; Rust rejects them. + +Export selects a storage-provided key. Private export requires a GPG passphrase +and a separate fingerprint/security confirmation. Every displayed export remains +labeled with the identity that produced it, even if selection changes. Previous +matrices are erased before a new export. Multipart exports support previous/next +and optional automatic frame advancement. Brightness and keep-screen-on apply +only while QR display is composed and are restored on disposal. Frame ticks do +not renew the storage-owned visibility deadline (the configured authentication +timeout). + +## Privacy and interruption + +The Activity and transfer dialog use `FLAG_SECURE`. Lock/background/focus loss, +process recreation and dismissal remove transfer state, clear Kotlin matrices +and inputs, and drop the Rust importer/exporter. Rust multipart chunks and +reassembly buffers zeroize on drop. No camera frame, armor or QR payload is +written to Android persistence, photos, clipboard, saved instance state or logs. +Only an explicitly confirmed successful import persists GPG-compatible keys. + +Camera permission prompts, an external image picker, device rotation and app +backgrounding follow the existing fail-closed lifecycle policy: an interrupted +transfer is discarded; reopen and rescan rather than retaining private bytes +across an OS interruption. A camera pause within the active dialog retains +progress. Denial (including permanent denial) presents a settings link and image/ +manual alternatives. Missing/busy camera reports an actionable recovery message. + +Camera decoding accepts at most 2048 × 2048 pixels and a 4 MiB Y plane, validates +row/pixel strides and the four quarter-turn rotations before accessing pixels, +and allows one native decode in flight. Kotlin clears the input plane before +publishing a completed key to state observers, with a final cleanup on every +error/cancellation path. CameraX drops obsolete frames rather +than queueing them. Imported `ISKT1` envelopes retain the existing 11,000-frame/ +17.6 MB armor bound and 1,600-byte chunk bound. Export rejects more than 256 +frames before allocating matrices, keeping the matrix set below 8 MiB. + +## Verification + +Rust tests use upstream-compatible Alice public/private armor to prove byte-exact +single/multipart reconstruction, rotated/padded camera planes, out-of-order and +duplicate delivery, incomplete/corrupt/mismatched rejection, wrong-passphrase +retention, import rollback, export size bounds and deadline polling without renewal. +Native Android settings tests also exercise camera planes through generated UniFFI +and verify Kotlin frame bytes and QR matrices are cleared. Compose tests check +private import/export confirmation and hidden-input saved-state exclusions. +`AndroidQrCameraTest` exercises actual emulator CameraX frame delivery and repeated +close/reopen; the preview is clipped in both Android View and Compose layout. + +Build gates include both ARM ABIs, debug/release/AAB, JVM tests, lint/ktlint, +architecture/source checks, exact dependency locks/checksums, the license/SBOM +gate, every native ELF/APK 16 KB alignment check, and matching FULL release symbols. +CameraX 1.6.2 and its exact transitives are reviewed against official Maven POM +licenses; camera-core includes Apache-2.0 and BSD-3-Clause portions. The merged +manifest removes Media3's unused ACCESS_NETWORK_STATE permission; only CAMERA +is added for this feature. + +Validation is emulator-only under the milestone's ARM support policy. The +Keystore enrollment/invalidation tests require separate interactive device setup; +their setup failures are not camera test results and do not establish biometric +coverage for this change. + +Recorded for #106 on 2026-10-12: + +| Environment | Evidence | +| --- | --- | +| API 26 ARM, 4 KB | Native settings/viewer fixture including private export and relock erasure; 14 bridge tests; app runner OK (32 reported, including four opt-in/biometric methods not enabled); final onboarding/settings regression group 4 passed; CameraX close/reopen passed | +| API 37 ARM, strict 16 KB | Native fixture including private export and relock erasure; 14 bridge tests; CameraX close/reopen passed; original app run included both passing QR settings tests | +| API 37 ARM, 4 KB, computer use | Update retained existing store/preferences; camera permission interruption discarded transfer; live CameraX preview and clipped scrolling controls inspected; private export stayed disabled until fingerprint confirmation | +| Rust 1.97.1 and Android packages | Required workspace gates, androidVerification and release lint, license/SBOM, debug/release manifests/backup exclusions, ARM native alignment and release FULL-symbol checks passed | + +Private export from the retained simulator profile awaits optional user input. +Successful private export/import and erasure evidence above uses isolated, +encrypted compatibility fixtures rather than a user's passphrase. diff --git a/docs/android/parity.md b/docs/android/parity.md index 1c842e7..6ad6dc7 100644 --- a/docs/android/parity.md +++ b/docs/android/parity.md @@ -46,9 +46,9 @@ shared core's recipient/signing rules, pass/pass-otp format, HOTP counter safety Git failure semantics and secret-redaction requirements remain binding wherever an Android workflow invokes them; platform presentation may not replace them. -Current iPhone camera adapters pass OS-decoded QR bytes. Android #106/#91 must -also supply bounded image/frame facts for Rust decoding as required by the -Android contract; that additional adapter is pending, not an existing API. +Current iPhone camera adapters pass OS-decoded QR bytes. Android #106 supplies bounded Y-plane/stride/orientation facts through the +shared native contract; storage decodes and reassembles the transfer. See +[key-transfer.md](key-transfer.md) for interruption/security behavior and evidence. The external-credential matching/grant API required by #109 must be implemented in storage, rather than copying frontend selection assumptions from AutoFill. diff --git a/tools/android-build/src/gates.rs b/tools/android-build/src/gates.rs index f9f6e07..861b1bb 100644 --- a/tools/android-build/src/gates.rs +++ b/tools/android-build/src/gates.rs @@ -60,6 +60,7 @@ fn manifest_text(source: &str) -> Result<(), Box> { "android.permission.USE_FINGERPRINT", "de.rfc1437.ironstorage.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION", "android.permission.INTERNET", + "android.permission.CAMERA", ]; for node in root.children().filter(|node| node.is_element()) { if node.tag_name().name().starts_with("uses-permission") @@ -448,6 +449,13 @@ mod tests { )) .is_ok() ); + assert!( + manifest_text(&source.replace( + " Result<(), Box> if ![ "libironstorage_android.so", "libjnidispatch.so", + "libimage_processing_util_jni.so", + "libsurface_util_jni.so", "libandroidx.graphics.path.so", ] .contains(&library)