diff --git a/Cargo.lock b/Cargo.lock index 117d861..291cee8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5433,12 +5433,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ironstorage-android" +version = "1.0.1" +dependencies = [ + "ironstorage-apple", +] + [[package]] name = "ironstorage-android-build" version = "1.0.1" dependencies = [ "ironstorage", + "object", "regex", + "uniffi", + "zip", ] [[package]] @@ -10550,7 +10560,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a782a48d72cfd7a2d65cfc7c691dbf5375c43104b3c195f7eccc716dcc3540c8" dependencies = [ "anyhow", + "camino", "cargo_metadata", + "clap", "uniffi_bindgen", "uniffi_core", "uniffi_macros", diff --git a/Cargo.toml b/Cargo.toml index 203defa..5287b06 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "apps/desktop", "apps/tui", "crates/apple", + "crates/android", "crates/storage", "crates/watch-apple", "tools/android-build", diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index 3da58c0..c91c42e 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -31,7 +31,7 @@ The current direct dependencies are: | [regex 1.13](https://crates.io/crates/regex/1.13.1) | Linear-time byte-oriented decrypted grep matching | MIT OR Apache-2.0 | | [tempfile 3](https://crates.io/crates/tempfile) | Permission-restricted CLI editor session directories and cleanup | MIT OR Apache-2.0 | | [Serde 1](https://crates.io/crates/serde), [TOML 0.9](https://crates.io/crates/toml), [shlex 1.3](https://crates.io/crates/shlex), [url 2.5](https://crates.io/crates/url) | Strict configuration and command values | MIT OR Apache-2.0 | -| [UniFFI 0.32](https://crates.io/crates/uniffi/0.32.0) | Swift bridge | MPL-2.0 | +| [UniFFI 0.32](https://crates.io/crates/uniffi/0.32.0) | Swift and Kotlin bridge | MPL-2.0 | | [zeroize 1.9](https://crates.io/crates/zeroize/1.9.0) | Clear decrypted bytes on drop | MIT OR Apache-2.0 | The activated transitive graph has no dependency that forces a GPL or LGPL @@ -115,4 +115,16 @@ version catalog pins stable AndroidX, Kotlin, Hilt/KSP and formatting tools. Espresso 3.7.0 is pinned explicitly because Compose's older transitive Espresso cannot initialize input injection on API 37. AndroidX Test is Apache-2.0; JUnit 4.13.2 is EPL-1.0 and test-only. ktlint and its Gradle plugin are MIT, -build-only tools. Native runtime dependency/ABI review belongs to #92. +build-only tools. + +#92 packages [JNA 5.19.1](https://repo.maven.apache.org/maven2/net/java/dev/jna/jna/5.19.1/jna-5.19.1.pom) +as the Android AAR under its Apache-2.0 option (dual-licensed with LGPL-2.1-or-later). +It is the OS FFI runtime required by generated UniFFI Kotlin, not a competing +storage implementation. Its Android native libraries are subject to the same +three-ABI, ELF and 16 KB APK alignment checks as Rust. Exact resolved AAR/POM +checksums and locks are committed. cargo-ndk 4.1.2 (MIT OR Apache-2.0) is build-only; +NDK 30.0.16248370 and Rust 1.97.1 are pinned and checked. The build-only ELF/ZIP +auditor reuses locked object 0.36.7 (Apache-2.0 OR MIT) and zip 2.4.2 (MIT) +versions already present in the workspace. UniFFI's MPL-2.0 source remains available +at its recorded upstream crate version. Release attribution assembly is owned by +#112; the dependency policy and runtime matrix are in android/README.md. diff --git a/android/README.md b/android/README.md index a8deb5f..f32026e 100644 --- a/android/README.md +++ b/android/README.md @@ -1,13 +1,13 @@ # Native Android build Architecture and issue ownership: [ADR](../docs/android/architecture.md) and -[parity matrix](../docs/android/parity.md). This scaffold implements #89. It is -an installable native Compose foundation; onboarding, secure storage and native -runtime packaging are implemented by their milestone issues. +[parity matrix](../docs/android/parity.md). The native foundation implements #89 and #92. Onboarding and secure storage +are implemented by their milestone issues. Both variants now invoke Rust through +the generated UniFFI API. ## Reproducible setup -Use a stable workspace Rust toolchain satisfying Cargo.toml, a JDK 25 installation +Use Rust 1.97.1 (pinned in rust-toolchain.toml), cargo-ndk 4.1.2, a JDK 25 installation (Android Studio's JetBrains Runtime 25 is suitable), Android SDK Platform 37, build-tools 36.0.0 and NDK 30.0.16248370. The app emits JVM 17-compatible code and supports minSdk 26. Set JAVA_HOME and ANDROID_HOME/ANDROID_SDK_ROOT or create @@ -19,14 +19,14 @@ The committed Gradle 9.8.1 wrapper verifies the distribution SHA-256. Its JAR SHA-256 is 3b8a25775a69158b5ad2b1d17a80a88dc7b40a352a73eb27d06c612a7ce68e98, verified against the official Gradle version metadata. AGP 9.4.1 includes Kotlin support; the Kotlin JVM/Compose plugin pin upgrades its classpath consistently -to 2.4.20. Do not additionally apply kotlin-android with built-in Kotlin enabled. +to 2.4.21. Do not additionally apply kotlin-android with built-in Kotlin enabled. The version catalog is the single source for Android dependency/SDK/NDK pins. From this directory: ```sh -./gradlew :app:assembleDebug :app:assembleRelease :app:testDebugUnitTest :app:lintDebug ktlintCheck -./gradlew :app:connectedDebugAndroidTest +./gradlew auditNativePackages :core:testDebugUnitTest :app:testDebugUnitTest :app:lintDebug :app:lintRelease ktlintCheck +./gradlew :app:connectedDebugAndroidTest :rust-bridge:connectedDebugAndroidTest ``` Repeat the first command to verify configuration-cache reuse. Dependency @@ -50,12 +50,11 @@ source/import boundaries and subprocess paths before every Android build. These static checks accompany code review; they do not prove arbitrary Kotlin code cannot conceal a domain implementation. -Debug-only Loading/Empty/Ready/Locked/Error fixtures are generated from the -storage-owned MobileShell::fixture by the Rust build tool. Debug controls select -the fixture and native tabs; these controls and generated fake states are absent -from release. Release displays the scaffold state and does not pretend to have -opened/decrypted a repository. Future #92 replaces scaffold wiring with generated -UniFFI calls; feature code continues to consume the facade. +Debug controls invoke the storage-owned MobileShell::fixture through the native +API for Loading/Empty/Ready/Locked/Error. A build-generated fixture inventory is +used only as the test oracle. Release calls the same Rust Empty fixture without +debug controls; repository initialization is owned by #90. Feature code consumes +only the facade, including typed native recovery states. ## Identity, signing and update @@ -64,8 +63,8 @@ Version code 1 and name 1.0.1 are deterministic initial metadata. Debug signing uses the standard local debug key. Release has no configured signing key and produces an unsigned APK; #112 configures protected upload signing. Do not add signing secrets or store passwords to the repository. Native symbol level FULL -and the selected three ABIs are declared for later Rust packaging; the scaffold -ships no native secret runtime or development network endpoint. +and the selected three ABIs are packaged. Native runtime packaging is described +below; no development network endpoint is included. The manifest has only the exported launcher activity. Backup/cloud/device-transfer exclusions and cleartext denial are explicit. Feature-specific services/permissions @@ -87,3 +86,84 @@ app-data package as an update. After closing an issue, clean its project build outputs and Cargo target; preserve the emulator and shared SDK/toolchain caches. AGP's leaveApksInstalledAfterRun option is enabled so instrumented checks do not uninstall the app after running. Keep it enabled for this persistent test device. + +## Rust packaging and integrity + +Install the pinned build tools once: + +```sh +rustup toolchain install 1.97.1 --profile minimal +rustup target add --toolchain 1.97.1 aarch64-linux-android armv7-linux-androideabi x86_64-linux-android +cargo install cargo-ndk --version 4.1.2 --locked +``` + +Gradle checks the actual Cargo/cargo-ndk/NDK revisions before building. Each +variant compiles crates/android with cargo-ndk into its own generated jniLibs, +then verifies ELF architecture, 16 KB LOAD alignment, and byte-identical UniFFI +metadata across all three ABIs. A packaging step selects only the Android library +and rejects accidental inclusion of the dependency Apple cdylib. The thin Android crate re-exports the existing +UniFFI component; it duplicates no storage rules or converters. #91 owns the +platform-neutral contract extraction. No handwritten JNI or unsafe Rust is added. + +The workspace's UniFFI 0.32.0 CLI generates Android-mode immutable Kotlin +records from the arm64 library, using the global defaults in uniffi.toml. +A version-checked preparation step redacts record toString, typed/internal +exception diagnostics and unexpected callback stack traces, mechanically renames +Rust error message fields to rustMessage in Kotlin, +and translates every integrity guard to a typed failure. Checksums remain enabled +and retain every native function and expected value. UniFFI checksums are u16: +JNA's generated Int return is normalized to its lower 16 bits before comparison; +AArch64 may leave unrelated upper bits in the return register. Contract versions +are compared at their full width. A failed initializer remains failed until an +app restart/update, rather than reporting a different error on each retry. + +NativeRuntime performs OS library/ABI preflight, loads the library and initializes +UniFFI before entering Rust. Missing/unsupported libraries, broken APKs, contract +or checksum mismatch, and load failures yield typed, non-secret recovery states. +It rejects main-thread calls. The core facade schedules blocking native calls on +Dispatchers.IO; cancellation propagates and a superseded result cannot overwrite +newer state. Blocking fixture calls finish in Rust when a coroutine is cancelled; +no thread is forcibly stopped. Long storage operations must use their Rust-owned +cancellation methods when their feature is integrated. + +No Rust-to-Kotlin platform callbacks are installed by #92. When #93 adds them, +use generated UniFFI/JNA callback adapters only: JNA attaches a calling native +thread to the JVM; retain the callback for the full Rust owner lifetime, release +it after unregistering, never assume the callback runs on the Android main thread, +and marshal UI/Activity work onto Main. Keep storage decisions in Rust and avoid +blocking the main thread or calling back into a held Rust lock. + +JNA 5.19.1 is packaged as its Android AAR, filtered to arm64-v8a, armeabi-v7a and +x86_64. The release set may only be narrowed by changing the support decision in +#88, not by silently omitting an ABI. All source/lock/config files are task inputs; +outputs live only under build/. Debug and release compile independently, and +unchanged builds reuse their outputs. R8 keeps generated/JNA reflection entry +points. AGP strips release libraries and exports FULL native symbols to +app/build/outputs/native-debug-symbols/release/native-debug-symbols.zip; retain +that file and outputs/mapping/release/mapping.txt with the matching release. +AAB base/lib entries let Play produce per-ABI splits. + +The auditNativePackages gate inspects every native dependency in both APKs and +the AAB, checks all three ABI directories and both Rust/JNA libraries, and rejects +ELF segments below 16 KB alignment or APK native entries that are compressed or +not ZIP-aligned to 16 KB. Follow the official [16 KB runtime validation](https://developer.android.com/guide/practices/page-sizes): +use an actual 16 KB system image, check adb shell getconf PAGE_SIZE is 16384, +disable page-size compatibility mode, and run both instrumented test suites. +An ELF/ZIP audit is not a substitute for that runtime test. Milestone validation +is emulator-only per the user instruction of 2026-10-08; physical devices are +outside the test scope for every issue. Keep x86_64 emulator evidence separate +from the Apple Silicon arm64 emulator run. + +Validation recorded on 2026-10-08 for #92: + +| Environment | Verified | +| --- | --- | +| API 37 arm64 emulator, 4 KB | Generated API fixture/error/redaction tests; 25 UI states; release APK and AAB split launch; update-state preservation | +| API 37 arm64 emulator, 16 KB, compatibility disabled | Same native/UI tests, release APK/AAB launch and update-state preservation | +| Fresh source export | Three ABI debug builds with pinned tools, locked dependencies and strict artifact verification | +| All three ABI release outputs | ELF alignment and identical UniFFI metadata; stripped APK libraries and separate FULL Rust symbols; nine bundletool ABI split APKs audited | +| Physical devices | Excluded from milestone testing by user instruction | +| x86_64 runtime | System image installed; QEMU2 rejects x86_64 on this aarch64 Mac, including with acceleration disabled | + +The outstanding x86_64 emulator check needs a compatible host; cross-compilation +and static package checks do not establish x86_64 runtime coverage. diff --git a/android/app/gradle.lockfile b/android/app/gradle.lockfile index 6283d88..74ce385 100644 --- a/android/app/gradle.lockfile +++ b/android/app/gradle.lockfile @@ -6,8 +6,10 @@ androidx.activity:activity-compose:1.13.0=debugAndroidTestCompileClasspath,debug androidx.activity:activity-ktx:1.13.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.activity:activity:1.13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.annotation:annotation-experimental:1.5.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.annotation:annotation-jvm:1.9.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.annotation:annotation:1.9.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation-jvm:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation-jvm:1.9.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +androidx.annotation:annotation:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation:1.9.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath 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.autofill:autofill:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -208,6 +210,7 @@ jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=androidLintTool javax.inject:javax.inject:1=_agp_internal_debug_kspClasspath,_agp_internal_release_kspClasspath,androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,releaseCompileClasspath,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,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath net.java.dev.jna:jna:5.6.0=androidLintTool net.ltgt.gradle.incap:incap:0.2=_agp_internal_debug_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath net.sf.kxml:kxml2:2.3.0=androidLintTool @@ -228,31 +231,31 @@ 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.20=composeMappingProducerClasspath +org.jetbrains.kotlin:compose-group-mapping:2.4.21=composeMappingProducerClasspath org.jetbrains.kotlin:kotlin-build-tools-api:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-build-tools-api:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath -org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.20=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-api:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.21=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-compiler-embeddable:2.2.0=ktlint,ktlintBaselineReporter,ktlintRuleset org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-compiler-runner:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-compiler-runner:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath -org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable:2.4.20=kotlin-extension,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease +org.jetbrains.kotlin:kotlin-compiler-runner:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable:2.4.21=kotlin-extension,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease org.jetbrains.kotlin:kotlin-daemon-client:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-daemon-client:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-client:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-daemon-embeddable:2.2.0=ktlint,ktlintBaselineReporter,ktlintRuleset org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-metadata-jvm:2.3.21=_agp_internal_debug_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath org.jetbrains.kotlin:kotlin-reflect:1.6.10=_agp_internal_debug_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,ktlint,ktlintBaselineReporter,ktlintRuleset org.jetbrains.kotlin:kotlin-reflect:2.2.10=androidLintTool org.jetbrains.kotlin:kotlin-script-runtime:2.2.0=ktlint,ktlintBaselineReporter,ktlintRuleset org.jetbrains.kotlin:kotlin-script-runtime:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-script-runtime:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-script-runtime:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-stdlib-common:2.2.0=ktlintReporter org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0=_agp_internal_debug_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,ktlintReporter org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.10=androidLintTool,unified-test-platform-gradle-work-action @@ -264,8 +267,8 @@ org.jetbrains.kotlin:kotlin-stdlib:2.2.21=composeMappingProducerClasspath org.jetbrains.kotlin:kotlin-stdlib:2.3.20=kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kspPluginClasspath,kspPluginClasspathNonEmbeddable org.jetbrains.kotlin:kotlin-stdlib:2.3.21=_agp_internal_debug_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath org.jetbrains.kotlin:kotlin-stdlib:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-stdlib:2.4.20=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.kotlin:kotlin-tooling-core:2.4.20=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +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:kotlinx-coroutines-android:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=androidLintTool,unified-test-platform-gradle-work-action diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/ShellScreenTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/ShellScreenTest.kt index 460e921..7b6d406 100644 --- a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/ShellScreenTest.kt +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/ShellScreenTest.kt @@ -25,6 +25,10 @@ class ShellScreenTest { hasText(tab.name) and androidx.compose.ui.test.isSelectable() ).performClick() val page = ShellFixtures.all.getValue(state).first { it.tab == tab } + compose.waitUntil(timeoutMillis = 10_000) { + compose.onAllNodes(hasText(page.stateTitle) and isHeading()) + .fetchSemanticsNodes().size == 1 + } compose.onNode(hasText(page.stateTitle) and isHeading()).assertIsDisplayed() } } diff --git a/android/app/src/debug/kotlin/de/rfc1437/ironstorage/DebugShellModule.kt b/android/app/src/debug/kotlin/de/rfc1437/ironstorage/DebugShellModule.kt deleted file mode 100644 index 3a17e5e..0000000 --- a/android/app/src/debug/kotlin/de/rfc1437/ironstorage/DebugShellModule.kt +++ /dev/null @@ -1,39 +0,0 @@ -package de.rfc1437.ironstorage - -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import de.rfc1437.ironstorage.core.ShellFixtures -import de.rfc1437.ironstorage.core.ShellRepository -import de.rfc1437.ironstorage.core.ShellSnapshot -import de.rfc1437.ironstorage.core.ShellState -import de.rfc1437.ironstorage.core.ShellTab -import javax.inject.Singleton -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - -class DebugShellRepository : ShellRepository { - private val mutableSnapshot = - MutableStateFlow( - ShellSnapshot(ShellState.Empty, ShellFixtures.all.getValue(ShellState.Empty)) - ) - override val snapshot: StateFlow = mutableSnapshot - - override fun selectTab(tab: ShellTab) { - mutableSnapshot.value = mutableSnapshot.value.copy(selectedTab = tab) - } - - fun showFixture(state: ShellState) { - mutableSnapshot.value = - mutableSnapshot.value.copy(state = state, pages = ShellFixtures.all.getValue(state)) - } -} - -@Module -@InstallIn(SingletonComponent::class) -object DebugShellModule { - @Provides - @Singleton - fun shellRepository(): ShellRepository = DebugShellRepository() -} diff --git a/android/app/src/debug/kotlin/de/rfc1437/ironstorage/VariantContent.kt b/android/app/src/debug/kotlin/de/rfc1437/ironstorage/VariantContent.kt index 847fa01..da625c1 100644 --- a/android/app/src/debug/kotlin/de/rfc1437/ironstorage/VariantContent.kt +++ b/android/app/src/debug/kotlin/de/rfc1437/ironstorage/VariantContent.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import de.rfc1437.ironstorage.core.NativeFixtureShellRepository import de.rfc1437.ironstorage.core.ShellRepository import de.rfc1437.ironstorage.core.ShellState import de.rfc1437.ironstorage.features.ShellScreen @@ -19,7 +20,9 @@ fun VariantContent(viewModel: ShellViewModel, repository: ShellRepository) { Column(Modifier.statusBarsPadding()) { Row(Modifier.horizontalScroll(rememberScrollState())) { ShellState.entries.forEach { state -> - TextButton(onClick = { (repository as DebugShellRepository).showFixture(state) }) { + TextButton(onClick = { + (repository as NativeFixtureShellRepository).showFixture(state) + }) { Text("Fixture: ${state.name}") } } diff --git a/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt b/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt new file mode 100644 index 0000000..0ef94b1 --- /dev/null +++ b/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt @@ -0,0 +1,26 @@ +package de.rfc1437.ironstorage + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import de.rfc1437.ironstorage.core.NativeFixtureShellRepository +import de.rfc1437.ironstorage.core.ShellRepository +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +@Module +@InstallIn(SingletonComponent::class) +object ShellModule { + @Provides + @Singleton + fun shellRepository(@ApplicationContext context: Context): ShellRepository = + NativeFixtureShellRepository( + context, + CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + ) +} diff --git a/android/app/src/release/kotlin/de/rfc1437/ironstorage/ReleaseShellModule.kt b/android/app/src/release/kotlin/de/rfc1437/ironstorage/ReleaseShellModule.kt deleted file mode 100644 index bdfcfdf..0000000 --- a/android/app/src/release/kotlin/de/rfc1437/ironstorage/ReleaseShellModule.kt +++ /dev/null @@ -1,30 +0,0 @@ -package de.rfc1437.ironstorage - -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import de.rfc1437.ironstorage.core.ShellRepository -import de.rfc1437.ironstorage.core.ShellSnapshot -import de.rfc1437.ironstorage.core.ShellState -import de.rfc1437.ironstorage.core.ShellTab -import javax.inject.Singleton -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - -class ScaffoldShellRepository : ShellRepository { - private val mutableSnapshot = MutableStateFlow(ShellSnapshot(ShellState.Empty, emptyList())) - override val snapshot: StateFlow = mutableSnapshot - - override fun selectTab(tab: ShellTab) { - mutableSnapshot.value = mutableSnapshot.value.copy(selectedTab = tab) - } -} - -@Module -@InstallIn(SingletonComponent::class) -object ReleaseShellModule { - @Provides - @Singleton - fun shellRepository(): ShellRepository = ScaffoldShellRepository() -} diff --git a/android/app/src/testDebug/kotlin/de/rfc1437/ironstorage/DebugShellRepositoryTest.kt b/android/app/src/testDebug/kotlin/de/rfc1437/ironstorage/DebugShellRepositoryTest.kt index 5b7773a..dedee0a 100644 --- a/android/app/src/testDebug/kotlin/de/rfc1437/ironstorage/DebugShellRepositoryTest.kt +++ b/android/app/src/testDebug/kotlin/de/rfc1437/ironstorage/DebugShellRepositoryTest.kt @@ -1,5 +1,6 @@ package de.rfc1437.ironstorage +import de.rfc1437.ironstorage.core.ShellFixtures import de.rfc1437.ironstorage.core.ShellState import de.rfc1437.ironstorage.core.ShellTab import org.junit.Assert.assertEquals @@ -7,14 +8,12 @@ import org.junit.Test class DebugShellRepositoryTest { @Test - fun everyFixtureHasFivePagesAndSelectionSurvivesStateChanges() { - val repository = DebugShellRepository() - repository.selectTab(ShellTab.Totp) + fun fixtureInventorySupportsEveryNativeTab() { + val selected = ShellTab.Totp ShellState.entries.forEach { state -> - repository.showFixture(state) - assertEquals(state, repository.snapshot.value.state) - assertEquals(ShellTab.entries, repository.snapshot.value.pages.map { it.tab }) - assertEquals(ShellTab.Totp, repository.snapshot.value.selectedTab) + val pages = ShellFixtures.all.getValue(state) + assertEquals(ShellTab.entries, pages.map { it.tab }) + assertEquals(selected, pages.first { it.tab == selected }.tab) } } } diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 8d1a3df..18c4a75 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -62,3 +62,20 @@ val checkArchitecture = tasks.register("checkArchitecture") { subprojects { tasks.matching { it.name == "preBuild" || it.name == "check" }.configureEach { dependsOn(rootProject.tasks.named("checkArchitecture")) } } + +tasks.register("auditNativePackages") { + dependsOn(":app:assembleDebug", ":app:assembleRelease", ":app:bundleRelease") + workingDir = workspace + val packages = listOf( + layout.projectDirectory.file("app/build/outputs/apk/debug/app-debug.apk"), + layout.projectDirectory.file("app/build/outputs/apk/release/app-release-unsigned.apk"), + layout.projectDirectory.file("app/build/outputs/bundle/release/app-release.aab") + ) + inputs.files(packages) + commandLine( + listOf( + cargoExecutable.get(), "+${libs.versions.rust.get()}", "run", "--locked", + "--package", "ironstorage-android-build", "--", "audit-package" + ) + packages.map { it.asFile.absolutePath } + ) +} diff --git a/android/core/gradle.lockfile b/android/core/gradle.lockfile index 823d231..31c6d25 100644 --- a/android/core/gradle.lockfile +++ b/android/core/gradle.lockfile @@ -2,6 +2,8 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. # To regenerate this file, run: ./gradlew :core:dependencies --write-locks +androidx.annotation:annotation-jvm:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath ch.qos.logback:logback-classic:1.3.15=ktlint ch.qos.logback:logback-core:1.3.15=ktlint com.android.tools.analytics-library:protos:32.4.1=androidLintTool @@ -75,6 +77,7 @@ jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=androidLintTool javax.inject:javax.inject:1=androidLintTool junit:junit:4.13.2=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 net.java.dev.jna:jna:5.6.0=androidLintTool net.sf.kxml:kxml2:2.3.0=androidLintTool org.apache.commons:commons-compress:1.27.1=androidLintTool @@ -93,27 +96,27 @@ org.glassfish.jaxb:jaxb-runtime:2.3.2=androidLintTool org.glassfish.jaxb:txw2:2.3.2=androidLintTool org.hamcrest:hamcrest-core:1.3=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath org.jetbrains.kotlin:kotlin-build-tools-api:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-build-tools-api:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath -org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.20=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-api:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.21=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-compiler-embeddable:2.2.0=ktlint,ktlintBaselineReporter,ktlintRuleset org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-compiler-runner:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-compiler-runner:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-compiler-runner:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-daemon-client:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-daemon-client:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-client:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-daemon-embeddable:2.2.0=ktlint,ktlintBaselineReporter,ktlintRuleset org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,ktlint,ktlintBaselineReporter,ktlintRuleset org.jetbrains.kotlin:kotlin-reflect:2.2.10=androidLintTool org.jetbrains.kotlin:kotlin-script-runtime:2.2.0=ktlint,ktlintBaselineReporter,ktlintRuleset org.jetbrains.kotlin:kotlin-script-runtime:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-script-runtime:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-script-runtime:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-stdlib-common:2.2.0=ktlintReporter org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0=ktlintReporter org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.10=androidLintTool,unified-test-platform-gradle-work-action @@ -122,8 +125,8 @@ org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.10=androidLintTool,unified-test-plat org.jetbrains.kotlin:kotlin-stdlib:2.2.0=ktlint,ktlintBaselineReporter,ktlintReporter,ktlintRuleset org.jetbrains.kotlin:kotlin-stdlib:2.2.10=androidLintTool,unified-test-platform-gradle-work-action org.jetbrains.kotlin:kotlin-stdlib:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-stdlib:2.4.20=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.kotlin:kotlin-tooling-core:2.4.20=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +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:kotlinx-coroutines-android:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=androidLintTool,unified-test-platform-gradle-work-action diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeFixtureShellRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeFixtureShellRepository.kt new file mode 100644 index 0000000..38de42d --- /dev/null +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeFixtureShellRepository.kt @@ -0,0 +1,101 @@ +package de.rfc1437.ironstorage.core + +import android.content.Context +import de.rfc1437.ironstorage.bridge.NativeBridgeException +import de.rfc1437.ironstorage.bridge.NativeFailureKind +import de.rfc1437.ironstorage.bridge.NativeRuntime +import de.rfc1437.ironstorage.bridge.generated.MobileShellState +import de.rfc1437.ironstorage.bridge.generated.MobileTab +import java.util.concurrent.atomic.AtomicLong +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** #92's native fixture integration; repository initialization is owned by #90. */ +class NativeFixtureShellRepository internal constructor( + private val scope: CoroutineScope, + private val load: suspend (ShellState) -> ShellSnapshot +) : ShellRepository { + constructor(context: Context, scope: CoroutineScope) : this(scope, nativeLoader(context)) + private val mutableSnapshot = MutableStateFlow(ShellSnapshot(ShellState.Loading, emptyList())) + override val snapshot: StateFlow = mutableSnapshot + private val revision = AtomicLong() + private var job: Job? = null + + init { + showFixture(ShellState.Empty) + } + + override fun selectTab(tab: ShellTab) { + mutableSnapshot.update { it.copy(selectedTab = tab) } + } + + fun showFixture(state: ShellState) { + val operation = revision.incrementAndGet() + job?.cancel() + job = scope.launch { + val result = try { + load(state) + } catch (error: NativeBridgeException) { + ShellSnapshot(ShellState.Error, emptyList(), nativeFailure = error.kind.toFacade()) + } + if (revision.get() == operation) { + mutableSnapshot.update { result.copy(selectedTab = it.selectedTab) } + } + } + } +} + +private fun nativeLoader(context: Context): suspend (ShellState) -> ShellSnapshot { + val runtime = NativeRuntime(context) + return { state -> + withContext(Dispatchers.IO) { + val shell = runtime.fixture(state.toNative()) + val pages = shell.pages.map { page -> + ShellPage(page.tab.toFacade(), page.title, page.stateTitle, page.stateDetail) + } + val actual = shell.pages.firstOrNull()?.state + ?: throw NativeBridgeException(NativeFailureKind.RustFailure) + ShellSnapshot(actual.toFacade(), pages) + } + } +} + +private fun MobileShellState.toFacade(): ShellState = when (this) { + MobileShellState.LOADING -> ShellState.Loading + MobileShellState.EMPTY -> ShellState.Empty + MobileShellState.READY -> ShellState.Ready + MobileShellState.LOCKED -> ShellState.Locked + MobileShellState.ERROR -> ShellState.Error +} + +private fun ShellState.toNative(): MobileShellState = when (this) { + ShellState.Loading -> MobileShellState.LOADING + ShellState.Empty -> MobileShellState.EMPTY + ShellState.Ready -> MobileShellState.READY + ShellState.Locked -> MobileShellState.LOCKED + ShellState.Error -> MobileShellState.ERROR +} + +private fun MobileTab.toFacade(): ShellTab = when (this) { + MobileTab.HOME -> ShellTab.Home + MobileTab.PASSWORDS -> ShellTab.Passwords + MobileTab.SEARCH -> ShellTab.Search + MobileTab.TOTP -> ShellTab.Totp + MobileTab.PREFERENCES -> ShellTab.Preferences +} + +private fun NativeFailureKind.toFacade(): NativeFailure = when (this) { + NativeFailureKind.UnsupportedAbi -> NativeFailure.UnsupportedAbi + NativeFailureKind.MissingLibrary -> NativeFailure.MissingLibrary + NativeFailureKind.ContractMismatch -> NativeFailure.ContractMismatch + NativeFailureKind.ChecksumMismatch -> NativeFailure.ChecksumMismatch + NativeFailureKind.LoadFailure -> NativeFailure.LoadFailure + NativeFailureKind.RustFailure -> NativeFailure.RustFailure + NativeFailureKind.MainThreadCall -> NativeFailure.MainThreadCall +} diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/ShellRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/ShellRepository.kt index c028d5f..0401cfd 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/ShellRepository.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/ShellRepository.kt @@ -14,9 +14,20 @@ data class ShellPage( data class ShellSnapshot( val state: ShellState, val pages: List, - val selectedTab: ShellTab = ShellTab.Home + val selectedTab: ShellTab = ShellTab.Home, + val nativeFailure: NativeFailure? = null ) +enum class NativeFailure { + UnsupportedAbi, + MissingLibrary, + ContractMismatch, + ChecksumMismatch, + LoadFailure, + RustFailure, + MainThreadCall +} + interface ShellRepository { val snapshot: StateFlow fun selectTab(tab: ShellTab) diff --git a/android/core/src/test/kotlin/de/rfc1437/ironstorage/core/NativeFixtureShellRepositoryTest.kt b/android/core/src/test/kotlin/de/rfc1437/ironstorage/core/NativeFixtureShellRepositoryTest.kt new file mode 100644 index 0000000..1a79e47 --- /dev/null +++ b/android/core/src/test/kotlin/de/rfc1437/ironstorage/core/NativeFixtureShellRepositoryTest.kt @@ -0,0 +1,31 @@ +package de.rfc1437.ironstorage.core + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class NativeFixtureShellRepositoryTest { + @Test + fun cancelledNativeCompletionCannotReplaceNewerStateOrTabSelection() = runTest { + val pending = CompletableDeferred() + val repository = NativeFixtureShellRepository(backgroundScope) { state -> + if (state == ShellState.Ready) withContext(NonCancellable) { pending.await() } + ShellSnapshot(state, listOf(ShellPage(ShellTab.Home, "Home", state.name, "Fixture"))) + } + runCurrent() + repository.showFixture(ShellState.Ready) + runCurrent() + repository.selectTab(ShellTab.Search) + repository.showFixture(ShellState.Locked) + runCurrent() + pending.complete(Unit) + runCurrent() + assertEquals(ShellState.Locked, repository.snapshot.value.state) + assertEquals(ShellTab.Search, repository.snapshot.value.selectedTab) + } +} diff --git a/android/features/gradle.lockfile b/android/features/gradle.lockfile index 8021a3c..a40b5a2 100644 --- a/android/features/gradle.lockfile +++ b/android/features/gradle.lockfile @@ -6,8 +6,10 @@ androidx.activity:activity-compose:1.12.0=debugAndroidTestCompileClasspath,debug androidx.activity:activity-ktx:1.12.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.activity:activity:1.12.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.annotation:annotation-experimental:1.5.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.annotation:annotation-jvm:1.9.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.annotation:annotation:1.9.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation-jvm:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation-jvm:1.9.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath +androidx.annotation:annotation:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation:1.9.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath 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.autofill:autofill:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -192,6 +194,7 @@ jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=androidLintTool javax.inject:javax.inject:1=_agp_internal_debug_kspClasspath,_agp_internal_release_kspClasspath,androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath junit:junit:4.13.2=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 net.java.dev.jna:jna:5.6.0=androidLintTool net.ltgt.gradle.incap:incap:0.2=_agp_internal_debug_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath net.sf.kxml:kxml2:2.3.0=androidLintTool @@ -212,29 +215,29 @@ org.glassfish.jaxb:jaxb-runtime:2.3.2=androidLintTool org.glassfish.jaxb:txw2:2.3.2=androidLintTool org.hamcrest:hamcrest-core:1.3=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath org.jetbrains.kotlin:kotlin-build-tools-api:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-build-tools-api:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath -org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.20=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-api:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.21=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-compiler-embeddable:2.2.0=ktlint,ktlintBaselineReporter,ktlintRuleset org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-compiler-runner:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-compiler-runner:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath -org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable:2.4.20=kotlin-extension,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease +org.jetbrains.kotlin:kotlin-compiler-runner:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable:2.4.21=kotlin-extension,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease org.jetbrains.kotlin:kotlin-daemon-client:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-daemon-client:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-client:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-daemon-embeddable:2.2.0=ktlint,ktlintBaselineReporter,ktlintRuleset org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-metadata-jvm:2.3.21=_agp_internal_debug_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath org.jetbrains.kotlin:kotlin-reflect:1.6.10=_agp_internal_debug_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,ktlint,ktlintBaselineReporter,ktlintRuleset org.jetbrains.kotlin:kotlin-reflect:2.2.10=androidLintTool org.jetbrains.kotlin:kotlin-script-runtime:2.2.0=ktlint,ktlintBaselineReporter,ktlintRuleset org.jetbrains.kotlin:kotlin-script-runtime:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-script-runtime:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-script-runtime:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-stdlib-common:2.2.0=ktlintReporter org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0=_agp_internal_debug_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,ktlintReporter org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.10=androidLintTool,unified-test-platform-gradle-work-action @@ -245,8 +248,8 @@ org.jetbrains.kotlin:kotlin-stdlib:2.2.10=androidLintTool,unified-test-platform- org.jetbrains.kotlin:kotlin-stdlib:2.3.20=kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kspPluginClasspath,kspPluginClasspathNonEmbeddable org.jetbrains.kotlin:kotlin-stdlib:2.3.21=_agp_internal_debug_kspClasspath,_agp_internal_release_kspClasspath,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath org.jetbrains.kotlin:kotlin-stdlib:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-stdlib:2.4.20=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.kotlin:kotlin-tooling-core:2.4.20=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +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:kotlinx-coroutines-android:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=androidLintTool,unified-test-platform-gradle-work-action 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 7232186..1d35d97 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 @@ -18,6 +18,7 @@ import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.rfc1437.ironstorage.core.NativeFailure import de.rfc1437.ironstorage.core.ShellState import de.rfc1437.ironstorage.core.ShellTab @@ -44,12 +45,33 @@ fun ShellScreen(viewModel: ShellViewModel) { ) { Text(stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge) val page = snapshot.pages.firstOrNull { it.tab == snapshot.selectedTab } + val nativeFailure = snapshot.nativeFailure + val title = if (nativeFailure != null) { + stringResource(R.string.native_failure_title) + } else { + page?.stateTitle ?: stringResource(R.string.scaffold_title) + } + val detail = if (nativeFailure == null) { + page?.detail ?: stringResource(R.string.scaffold_detail) + } else { + stringResource( + when (nativeFailure) { + NativeFailure.UnsupportedAbi -> R.string.native_unsupported_device + NativeFailure.MissingLibrary, + NativeFailure.ContractMismatch, + NativeFailure.ChecksumMismatch, + NativeFailure.LoadFailure -> R.string.native_update_required + NativeFailure.RustFailure, + NativeFailure.MainThreadCall -> R.string.native_operation_failed + } + ) + } Text( - page?.stateTitle ?: stringResource(R.string.scaffold_title), + title, modifier = Modifier.semantics { heading() }, style = MaterialTheme.typography.headlineSmall ) - Text(page?.detail ?: stringResource(R.string.scaffold_detail)) + Text(detail) if (snapshot.state == ShellState.Loading) CircularProgressIndicator() } } diff --git a/android/features/src/main/res/values/strings.xml b/android/features/src/main/res/values/strings.xml index 27dc612..8b78fe0 100644 --- a/android/features/src/main/res/values/strings.xml +++ b/android/features/src/main/res/values/strings.xml @@ -2,4 +2,8 @@ IronStorage No Password Store Your password store has not been configured. + IronStorage Is Unavailable + This device is not supported by this app build. + The installed app components could not be loaded or do not match. Install a complete update without clearing app data. + The app could not complete this operation. Try again. diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index f76d3e7..6ffec98 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] agp = "9.4.1" -kotlin = "2.4.20" +kotlin = "2.4.21" ksp = "2.3.12" hilt = "2.60.1" compose = "2026.09.00" @@ -17,6 +17,10 @@ ktlint = "1.7.1" compileSdk = "37" minSdk = "26" ndk = "30.0.16248370" +rust = "1.97.1" +cargoNdk = "4.1.2" +jna = "5.19.1" +annotation = "1.11.0" [libraries] compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose" } @@ -37,6 +41,8 @@ hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hil junit = { module = "junit:junit", version.ref = "junit" } android-test-runner = { module = "androidx.test:runner", version.ref = "androidTest" } android-test-espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } +jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } +annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/android/gradle/verification-metadata.xml b/android/gradle/verification-metadata.xml index 9b2bf71..6c35cf5 100644 --- a/android/gradle/verification-metadata.xml +++ b/android/gradle/verification-metadata.xml @@ -63,6 +63,11 @@ + + + + + @@ -91,6 +96,22 @@ + + + + + + + + + + + + + + + + @@ -2180,6 +2201,14 @@ + + + + + + + + @@ -2522,6 +2551,14 @@ + + + + + + + + @@ -2530,6 +2567,14 @@ + + + + + + + + @@ -2538,6 +2583,14 @@ + + + + + + + + @@ -2551,6 +2604,14 @@ + + + + + + + + @@ -2567,6 +2628,14 @@ + + + + + + + + @@ -2575,6 +2644,14 @@ + + + + + + + + @@ -2591,6 +2668,14 @@ + + + + + + + + @@ -2607,6 +2692,14 @@ + + + + + + + + @@ -2631,6 +2724,14 @@ + + + + + + + + @@ -2647,6 +2748,14 @@ + + + + + + + + @@ -2655,6 +2764,14 @@ + + + + + + + + @@ -2671,6 +2788,14 @@ + + + + + + + + @@ -2695,6 +2820,14 @@ + + + + + + + + @@ -2703,6 +2836,14 @@ + + + + + + + + @@ -2711,6 +2852,14 @@ + + + + + + + + @@ -2719,6 +2868,14 @@ + + + + + + + + @@ -2727,6 +2884,14 @@ + + + + + + + + @@ -2735,6 +2900,14 @@ + + + + + + + + @@ -2743,6 +2916,14 @@ + + + + + + + + @@ -2751,6 +2932,14 @@ + + + + + + + + @@ -2767,6 +2956,14 @@ + + + + + + + + @@ -2815,6 +3012,14 @@ + + + + + + + + @@ -2879,6 +3084,14 @@ + + + + + + + + @@ -2924,6 +3137,14 @@ + + + + + + + + @@ -2932,6 +3153,14 @@ + + + + + + + + @@ -2940,6 +3169,14 @@ + + + + + + + + @@ -2948,16 +3185,34 @@ + + + + + + + + + + + + + + + + + + diff --git a/android/platform/gradle.lockfile b/android/platform/gradle.lockfile index 8b1711f..3039288 100644 --- a/android/platform/gradle.lockfile +++ b/android/platform/gradle.lockfile @@ -2,6 +2,8 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. # To regenerate this file, run: ./gradlew :platform:dependencies --write-locks +androidx.annotation:annotation-jvm:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath ch.qos.logback:logback-classic:1.3.15=ktlint ch.qos.logback:logback-core:1.3.15=ktlint com.android.tools.analytics-library:protos:32.4.1=androidLintTool @@ -75,6 +77,7 @@ jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=androidLintTool javax.inject:javax.inject:1=androidLintTool junit:junit:4.13.2=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 net.java.dev.jna:jna:5.6.0=androidLintTool net.sf.kxml:kxml2:2.3.0=androidLintTool org.apache.commons:commons-compress:1.27.1=androidLintTool @@ -93,27 +96,27 @@ org.glassfish.jaxb:jaxb-runtime:2.3.2=androidLintTool org.glassfish.jaxb:txw2:2.3.2=androidLintTool org.hamcrest:hamcrest-core:1.3=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath org.jetbrains.kotlin:kotlin-build-tools-api:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-build-tools-api:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath -org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.20=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-api:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.21=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-compiler-embeddable:2.2.0=ktlint,ktlintBaselineReporter,ktlintRuleset org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-compiler-runner:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-compiler-runner:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-compiler-runner:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-daemon-client:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-daemon-client:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-client:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-daemon-embeddable:2.2.0=ktlint,ktlintBaselineReporter,ktlintRuleset org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,ktlint,ktlintBaselineReporter,ktlintRuleset org.jetbrains.kotlin:kotlin-reflect:2.2.10=androidLintTool org.jetbrains.kotlin:kotlin-script-runtime:2.2.0=ktlint,ktlintBaselineReporter,ktlintRuleset org.jetbrains.kotlin:kotlin-script-runtime:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-script-runtime:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-script-runtime:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-stdlib-common:2.2.0=ktlintReporter org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0=ktlintReporter org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.10=androidLintTool,unified-test-platform-gradle-work-action @@ -122,8 +125,8 @@ org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.10=androidLintTool,unified-test-plat org.jetbrains.kotlin:kotlin-stdlib:2.2.0=ktlint,ktlintBaselineReporter,ktlintReporter,ktlintRuleset org.jetbrains.kotlin:kotlin-stdlib:2.2.10=androidLintTool,unified-test-platform-gradle-work-action org.jetbrains.kotlin:kotlin-stdlib:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-stdlib:2.4.20=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.kotlin:kotlin-tooling-core:2.4.20=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +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:kotlinx-coroutines-android:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=androidLintTool,unified-test-platform-gradle-work-action diff --git a/android/rust-bridge/build.gradle.kts b/android/rust-bridge/build.gradle.kts index fa67947..e00380f 100644 --- a/android/rust-bridge/build.gradle.kts +++ b/android/rust-bridge/build.gradle.kts @@ -1,3 +1,4 @@ +import java.io.File import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { @@ -10,6 +11,8 @@ android { ndkVersion = libs.versions.ndk.get() defaultConfig { minSdk = libs.versions.minSdk.get().toInt() + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") } } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 @@ -28,5 +31,134 @@ kotlin { } dependencies { + implementation("${libs.jna.get().module}:${libs.versions.jna.get()}@aar") + implementation(libs.annotation) testImplementation(libs.junit) + androidTestImplementation(libs.android.test.runner) +} + +val workspace = rootProject.layout.projectDirectory.dir("..").asFile +val cargoExecutable = providers.environmentVariable("CARGO").orElse( + providers.environmentVariable("PATH").map { path -> + path.split(File.pathSeparatorChar).map { File(it, "cargo") } + .firstOrNull { it.isFile && it.canExecute() }?.absolutePath + ?: error("Cargo must be installed and in PATH") + } +) +val rustVersion = libs.versions.rust.get() +val ndkDirectory = androidComponents.sdkComponents.ndkDirectory +val sdkDirectory = androidComponents.sdkComponents.sdkDirectory +val rustInputs = files( + workspace.resolve("Cargo.toml"), + workspace.resolve("Cargo.lock"), + rootProject.file("rust-toolchain.toml"), + fileTree(workspace.resolve("crates")) { include("**/*.rs", "**/Cargo.toml", "**/uniffi.toml") } +) +val checkRustToolchain = tasks.register("checkRustToolchain") { + workingDir = workspace + commandLine( + cargoExecutable.get(), "+$rustVersion", "run", "--locked", + "--package", "ironstorage-android-build", "--", "check-toolchain", + cargoExecutable.get(), rustVersion, ndkDirectory.get().asFile.absolutePath, + libs.versions.ndk.get(), libs.versions.cargoNdk.get() + ) +} +androidComponents.onVariants { variant -> + val name = variant.name + val title = name.replaceFirstChar { it.uppercaseChar() } + val rawNative = layout.buildDirectory.dir("generated/native-raw/$name/jniLibs") + val nativeOutput = layout.buildDirectory.dir("generated/native/$name/jniLibs") + val rawBindings = layout.buildDirectory.dir("generated/bindings-raw/$name") + val bindings = layout.buildDirectory.dir("generated/bindings/$name") + val native = tasks.register("build${title}Rust") { + dependsOn(checkRustToolchain) + workingDir = workspace + inputs.files(rustInputs) + inputs.property("rustVersion", rustVersion) + inputs.property("cargoNdk", libs.versions.cargoNdk.get()) + inputs.property("ndkVersion", libs.versions.ndk.get()) + inputs.property("minSdk", libs.versions.minSdk.get()) + outputs.dir(rawNative) + environment("ANDROID_NDK_HOME", ndkDirectory.get().asFile.absolutePath) + environment("ANDROID_HOME", sdkDirectory.get().asFile.absolutePath) + environment( + "RUSTFLAGS", + "-D warnings -C link-arg=-Wl,-z,max-page-size=16384 -C link-arg=-Wl,-z,common-page-size=16384" + ) + environment("CARGO_PROFILE_RELEASE_DEBUG", "2") + commandLine( + listOf( + cargoExecutable.get(), "+$rustVersion", "ndk", + "--target", "arm64-v8a", + "--target", "armeabi-v7a", + "--target", "x86_64", + "--platform", libs.versions.minSdk.get(), + "--output-dir", rawNative.get().asFile.absolutePath, + "build", "--locked", "--package", "ironstorage-android", "--lib" + ) + + if (variant.buildType == "release") listOf("--release") else emptyList() + ) + } + val audit = tasks.register("package${title}Rust") { + dependsOn(native) + workingDir = workspace + inputs.dir(rawNative) + inputs.files( + fileTree(workspace.resolve("tools/android-build")) { + include("**/*.rs", "Cargo.toml") + } + ) + outputs.dir(nativeOutput) + commandLine( + cargoExecutable.get(), "+$rustVersion", "run", "--locked", + "--package", "ironstorage-android-build", "--", "package-native", + rawNative.get().asFile.absolutePath, nativeOutput.get().asFile.absolutePath + ) + } + val generate = tasks.register("generate${title}RustBindings") { + dependsOn(audit) + workingDir = workspace + inputs.dir(nativeOutput) + inputs.file(rootProject.file("uniffi.toml")) + inputs.files( + fileTree(workspace.resolve("tools/android-build")) { + include("**/*.rs", "Cargo.toml") + } + ) + outputs.dir(rawBindings) + commandLine( + cargoExecutable.get(), "+$rustVersion", "run", "--locked", + "--package", "ironstorage-android-build", "--bin", "ironstorage-uniffi-bindgen", + "--", "generate", "--library", + nativeOutput.get().file( + "arm64-v8a/libironstorage_android.so" + ).asFile.absolutePath, + "--language", "kotlin", "--config", + rootProject.file( + "uniffi.toml" + ).absolutePath, + "--out-dir", rawBindings.get().asFile.absolutePath, "--no-format" + ) + } + val prepare = tasks.register("prepare${title}RustBindings") { + dependsOn(generate) + workingDir = workspace + inputs.dir(rawBindings) + inputs.files( + fileTree(workspace.resolve("tools/android-build")) { + include("**/*.rs", "Cargo.toml") + } + ) + outputs.dir(bindings) + commandLine( + cargoExecutable.get(), "+$rustVersion", "run", "--locked", + "--package", "ironstorage-android-build", "--", "prepare-bindings", + rawBindings.get().asFile.absolutePath, bindings.get().asFile.absolutePath + ) + } + variant.sources.kotlin!!.addStaticSourceDirectory(bindings.get().asFile.absolutePath) + variant.sources.jniLibs!!.addStaticSourceDirectory(nativeOutput.get().asFile.absolutePath) + tasks.matching { + it.name == "pre${title}Build" || it.name == "compile${title}Kotlin" + }.configureEach { dependsOn(prepare) } } diff --git a/android/rust-bridge/consumer-rules.pro b/android/rust-bridge/consumer-rules.pro index 5b10b7c..9957897 100644 --- a/android/rust-bridge/consumer-rules.pro +++ b/android/rust-bridge/consumer-rules.pro @@ -1 +1,7 @@ -# Native/generated keep rules are added with the UniFFI packaging in #92. +-keep class com.sun.jna.** { *; } +-keep class de.rfc1437.ironstorage.bridge.generated.** { *; } +# JNA includes optional desktop AWT helpers that Android never invokes. +-dontwarn java.awt.Component +-dontwarn java.awt.GraphicsEnvironment +-dontwarn java.awt.HeadlessException +-dontwarn java.awt.Window diff --git a/android/rust-bridge/gradle.lockfile b/android/rust-bridge/gradle.lockfile index ff53fdd..3308a42 100644 --- a/android/rust-bridge/gradle.lockfile +++ b/android/rust-bridge/gradle.lockfile @@ -2,6 +2,12 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. # To regenerate this file, run: ./gradlew :rust-bridge:dependencies --write-locks +androidx.annotation:annotation-jvm:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.test.services:storage:1.6.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.test:monitor:1.8.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.test:runner:1.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.tracing:tracing:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath ch.qos.logback:logback-classic:1.3.15=ktlint ch.qos.logback:logback-core:1.3.15=ktlint com.android.tools.analytics-library:protos:32.4.1=androidLintTool @@ -73,8 +79,9 @@ io.github.oshai:kotlin-logging:5.1.0=ktlint,ktlintBaselineReporter,ktlintReporte jakarta.activation:jakarta.activation-api:1.2.1=androidLintTool jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=androidLintTool javax.inject:javax.inject:1=androidLintTool -junit:junit:4.13.2=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +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=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath net.java.dev.jna:jna:5.6.0=androidLintTool net.sf.kxml:kxml2:2.3.0=androidLintTool org.apache.commons:commons-compress:1.27.1=androidLintTool @@ -91,29 +98,29 @@ org.checkerframework:checker-qual:3.43.0=androidLintTool 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=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.hamcrest:hamcrest-core:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath org.jetbrains.kotlin:kotlin-build-tools-api:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-build-tools-api:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath -org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.20=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-api:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.21=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-compiler-embeddable:2.2.0=ktlint,ktlintBaselineReporter,ktlintRuleset org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-compiler-runner:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-compiler-runner:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-compiler-runner:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-daemon-client:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-daemon-client:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-client:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-daemon-embeddable:2.2.0=ktlint,ktlintBaselineReporter,ktlintRuleset org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,ktlint,ktlintBaselineReporter,ktlintRuleset org.jetbrains.kotlin:kotlin-reflect:2.2.10=androidLintTool org.jetbrains.kotlin:kotlin-script-runtime:2.2.0=ktlint,ktlintBaselineReporter,ktlintRuleset org.jetbrains.kotlin:kotlin-script-runtime:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-script-runtime:2.4.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-script-runtime:2.4.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-stdlib-common:2.2.0=ktlintReporter org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0=ktlintReporter org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.10=androidLintTool,unified-test-platform-gradle-work-action @@ -122,8 +129,8 @@ org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.10=androidLintTool,unified-test-plat org.jetbrains.kotlin:kotlin-stdlib:2.2.0=ktlint,ktlintBaselineReporter,ktlintReporter,ktlintRuleset org.jetbrains.kotlin:kotlin-stdlib:2.2.10=androidLintTool,unified-test-platform-gradle-work-action org.jetbrains.kotlin:kotlin-stdlib:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-stdlib:2.4.20=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.kotlin:kotlin-tooling-core:2.4.20=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +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:kotlinx-coroutines-bom:1.9.0=androidLintTool,unified-test-platform-gradle-work-action org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,ktlint,ktlintBaselineReporter,ktlintRuleset org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0=androidLintTool,unified-test-platform-gradle-work-action diff --git a/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/NativeRuntimeTest.kt b/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/NativeRuntimeTest.kt new file mode 100644 index 0000000..34c40a1 --- /dev/null +++ b/android/rust-bridge/src/androidTest/kotlin/de/rfc1437/ironstorage/bridge/NativeRuntimeTest.kt @@ -0,0 +1,128 @@ +package de.rfc1437.ironstorage.bridge + +import android.content.ContextWrapper +import android.content.pm.ApplicationInfo +import androidx.test.platform.app.InstrumentationRegistry +import de.rfc1437.ironstorage.bridge.generated.FfiConverterString +import de.rfc1437.ironstorage.bridge.generated.IntegrityCheckingUniffiLib +import de.rfc1437.ironstorage.bridge.generated.InternalException +import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationErrorKind +import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException +import de.rfc1437.ironstorage.bridge.generated.MobileEntryCopy +import de.rfc1437.ironstorage.bridge.generated.MobileShellState +import de.rfc1437.ironstorage.bridge.generated.MobileTab +import de.rfc1437.ironstorage.bridge.generated.UniffiRustCallStatus +import de.rfc1437.ironstorage.bridge.generated.uniffiTraitInterfaceCall +import java.io.File +import java.util.zip.ZipOutputStream +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Test + +class NativeRuntimeTest { + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun generatedBindingsCallEveryRustFixtureAndRedactDiagnostics() { + val runtime = NativeRuntime(context) + MobileShellState.entries.forEach { state -> + val shell = runtime.fixture(state) + assertEquals(MobileTab.entries, shell.pages.map { it.tab }) + assertEquals(setOf(state), shell.pages.map { it.state }.toSet()) + } + val secret = "NativeFixtureSecretMustNeverAppearInDiagnostics" + val copy = MobileEntryCopy(secret, 60uL) + assertEquals(secret, copy.value) + assertFalse(copy.toString().contains(secret)) + val error = MobileAuthenticationFfiException.Failed( + MobileAuthenticationErrorKind.entries.first(), + secret, + secret + ) + assertFalse(error.toString().contains(secret)) + assertFalse(error.message.contains(secret)) + assertFalse(InternalException(secret).toString().contains(secret)) + val status = UniffiRustCallStatus() + uniffiTraitInterfaceCall(status, { throw IllegalStateException(secret) }, {}) + assertEquals( + "Native callback failed ([REDACTED])", + FfiConverterString.lift(status.error_buf) + ) + } + + @Test + fun wrongNativeContractAndChecksumFailWithTypedRedactedErrors() { + NativeRuntime(context).fixture(MobileShellState.EMPTY) + val version = IntegrityCheckingUniffiLib + .ffi_ironstorage_apple_uniffi_contract_version().toLong() + val checksum = IntegrityCheckingUniffiLib + .uniffi_ironstorage_apple_checksum_func_mobile_shell_fixture().toLong() and 0xffffL + NativeBindingChecks.requireMatch(-27278, 38258, NativeFailureKind.ChecksumMismatch) + for ((actual, kind) in listOf( + version to NativeFailureKind.ContractMismatch, + checksum to NativeFailureKind.ChecksumMismatch + )) { + val failure = assertThrows(NativeBindingMismatchException::class.java) { + NativeBindingChecks.requireMatch(actual, actual + 1, kind) + } + assertEquals(kind, failure.kind) + assertEquals("Rust bindings do not match the packaged native library", failure.message) + } + } + + @Test + fun mainThreadAndUnreadablePackagesFailWithTypedErrors() { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + val failure = assertThrows(NativeBridgeException::class.java) { + NativeRuntime(context).fixture(MobileShellState.EMPTY) + } + assertEquals(NativeFailureKind.MainThreadCall, failure.kind) + } + val packageFile = File.createTempFile("native-corrupt-package", ".apk", context.cacheDir) + try { + val info = ApplicationInfo(context.applicationInfo).apply { + sourceDir = packageFile.absolutePath + splitSourceDirs = null + nativeLibraryDir = File(context.cacheDir, "missing-native-fixture").absolutePath + } + val invalid = object : ContextWrapper(context) { + override fun getApplicationContext() = this + override fun getApplicationInfo() = info + } + val failure = assertThrows(NativeBridgeException::class.java) { + NativeRuntime(invalid).fixture(MobileShellState.EMPTY) + } + assertEquals(NativeFailureKind.LoadFailure, failure.kind) + } finally { + packageFile.delete() + } + } + + @Test + fun missingLibraryAndUnsupportedAbiFailBeforeEnteringRust() { + val abiFailure = assertThrows(NativeBridgeException::class.java) { + NativeRuntime.supportedAbi(listOf("unsupported-fixture-abi")) + } + assertEquals(NativeFailureKind.UnsupportedAbi, abiFailure.kind) + val packageFile = File.createTempFile("native-empty-package", ".apk", context.cacheDir) + try { + ZipOutputStream(packageFile.outputStream()).close() + val info = ApplicationInfo(context.applicationInfo).apply { + sourceDir = packageFile.absolutePath + splitSourceDirs = null + nativeLibraryDir = File(context.cacheDir, "missing-native-fixture").absolutePath + } + val missing = object : ContextWrapper(context) { + override fun getApplicationContext() = this + override fun getApplicationInfo() = info + } + val failure = assertThrows(NativeBridgeException::class.java) { + NativeRuntime(missing).fixture(MobileShellState.EMPTY) + } + assertEquals(NativeFailureKind.MissingLibrary, failure.kind) + } finally { + packageFile.delete() + } + } +} diff --git a/android/rust-bridge/src/main/kotlin/de/rfc1437/ironstorage/bridge/NativeBindingChecks.kt b/android/rust-bridge/src/main/kotlin/de/rfc1437/ironstorage/bridge/NativeBindingChecks.kt new file mode 100644 index 0000000..4a29671 --- /dev/null +++ b/android/rust-bridge/src/main/kotlin/de/rfc1437/ironstorage/bridge/NativeBindingChecks.kt @@ -0,0 +1,9 @@ +package de.rfc1437.ironstorage.bridge + +internal object NativeBindingChecks { + fun requireMatch(actual: Long, expected: Long, kind: NativeFailureKind) { + // UniFFI checksums are u16; JNA's Int return can include unspecified upper bits. + val value = if (kind == NativeFailureKind.ChecksumMismatch) actual and 0xffffL else actual + if (value != expected) throw NativeBindingMismatchException(kind) + } +} diff --git a/android/rust-bridge/src/main/kotlin/de/rfc1437/ironstorage/bridge/NativeFailure.kt b/android/rust-bridge/src/main/kotlin/de/rfc1437/ironstorage/bridge/NativeFailure.kt new file mode 100644 index 0000000..4c511b9 --- /dev/null +++ b/android/rust-bridge/src/main/kotlin/de/rfc1437/ironstorage/bridge/NativeFailure.kt @@ -0,0 +1,17 @@ +package de.rfc1437.ironstorage.bridge + +enum class NativeFailureKind { + UnsupportedAbi, + MissingLibrary, + ContractMismatch, + ChecksumMismatch, + LoadFailure, + RustFailure, + MainThreadCall +} + +class NativeBindingMismatchException(val kind: NativeFailureKind) : + LinkageError("Rust bindings do not match the packaged native library") + +class NativeBridgeException(val kind: NativeFailureKind) : + Exception("Native Rust runtime unavailable: $kind") diff --git a/android/rust-bridge/src/main/kotlin/de/rfc1437/ironstorage/bridge/NativeRuntime.kt b/android/rust-bridge/src/main/kotlin/de/rfc1437/ironstorage/bridge/NativeRuntime.kt new file mode 100644 index 0000000..53090e4 --- /dev/null +++ b/android/rust-bridge/src/main/kotlin/de/rfc1437/ironstorage/bridge/NativeRuntime.kt @@ -0,0 +1,80 @@ +package de.rfc1437.ironstorage.bridge + +import android.content.Context +import android.os.Build +import android.os.Looper +import de.rfc1437.ironstorage.bridge.generated.MobileShell +import de.rfc1437.ironstorage.bridge.generated.MobileShellState +import de.rfc1437.ironstorage.bridge.generated.mobileShellFixture +import de.rfc1437.ironstorage.bridge.generated.uniffiEnsureInitialized +import java.io.File +import java.io.IOException +import java.util.concurrent.CancellationException +import java.util.zip.ZipFile + +/** OS library loading only. Callers schedule all native calls off the main thread. */ +class NativeRuntime(context: Context) { + private val applicationInfo = context.applicationContext.applicationInfo + private val lock = Any() + private var initialized = false + private var initializationFailure: NativeFailureKind? = null + + fun fixture(state: MobileShellState): MobileShell = call { + initialize() + mobileShellFixture(state) + } + + private fun initialize() = synchronized(lock) { + initializationFailure?.let { throw NativeBridgeException(it) } + if (!initialized) { + val abi = supportedAbi(Build.SUPPORTED_ABIS.toList()) + val extracted = File(applicationInfo.nativeLibraryDir, LIBRARY_FILE).isFile + val packages = + listOf(applicationInfo.sourceDir) + applicationInfo.splitSourceDirs.orEmpty() + val packaged = extracted || packages.any { path -> + ZipFile(path).use { it.getEntry("lib/$abi/$LIBRARY_FILE") != null } + } + if (!packaged) throw NativeBridgeException(NativeFailureKind.MissingLibrary) + System.loadLibrary(LIBRARY) + uniffiEnsureInitialized() + initialized = true + } + } + + private fun call(operation: () -> T): T { + if (Looper.myLooper() == Looper.getMainLooper()) { + throw NativeBridgeException(NativeFailureKind.MainThreadCall) + } + try { + return operation() + } catch (error: NativeBridgeException) { + fail(error.kind) + } catch (error: CancellationException) { + throw error + } catch (error: NativeBindingMismatchException) { + fail(error.kind) + } catch (_: IOException) { + fail(NativeFailureKind.LoadFailure) + } catch (_: LinkageError) { + fail(NativeFailureKind.LoadFailure) + } catch (_: Exception) { + fail(NativeFailureKind.RustFailure) + } + } + + private fun fail(kind: NativeFailureKind): Nothing { + synchronized(lock) { + if (!initialized) initializationFailure = kind + } + throw NativeBridgeException(kind) + } + + companion object { + private const val LIBRARY = "ironstorage_android" + private const val LIBRARY_FILE = "libironstorage_android.so" + + internal fun supportedAbi(deviceAbis: List): String = + deviceAbis.firstOrNull { it in setOf("arm64-v8a", "armeabi-v7a", "x86_64") } + ?: throw NativeBridgeException(NativeFailureKind.UnsupportedAbi) + } +} diff --git a/android/rust-toolchain.toml b/android/rust-toolchain.toml new file mode 100644 index 0000000..739e30f --- /dev/null +++ b/android/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.97.1" +profile = "minimal" +targets = ["aarch64-linux-android", "armv7-linux-androideabi", "x86_64-linux-android"] diff --git a/android/uniffi.toml b/android/uniffi.toml new file mode 100644 index 0000000..4bd9dbf --- /dev/null +++ b/android/uniffi.toml @@ -0,0 +1,6 @@ +[defaults.bindings.kotlin] +package_name = "de.rfc1437.ironstorage.bridge.generated" +cdylib_name = "ironstorage_android" +android = true +generate_immutable_records = true +omit_checksums = false diff --git a/crates/android/Cargo.toml b/crates/android/Cargo.toml new file mode 100644 index 0000000..0aba70f --- /dev/null +++ b/crates/android/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "ironstorage-android" +description = "Android native library reusing the shared mobile UniFFI exports" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[lib] +name = "ironstorage_android" +crate-type = ["lib", "cdylib"] + +[dependencies] +ironstorage-apple = { path = "../apple" } diff --git a/crates/android/src/lib.rs b/crates/android/src/lib.rs new file mode 100644 index 0000000..ce9dd37 --- /dev/null +++ b/crates/android/src/lib.rs @@ -0,0 +1,6 @@ +#![forbid(unsafe_code)] + +//! Android packaging of the existing mobile UniFFI component. +//! The shared component owns every conversion; no interface is copied here. + +ironstorage_apple::uniffi_reexport_scaffolding!(); diff --git a/crates/storage/src/secret_store/platform.rs b/crates/storage/src/secret_store/platform.rs index 1d8a1b4..c42c3e0 100644 --- a/crates/storage/src/secret_store/platform.rs +++ b/crates/storage/src/secret_store/platform.rs @@ -6,6 +6,12 @@ //! Credential Manager, and Secret Service boundaries. Operations are serialized //! by the parent `SecretStore`, as required by the Windows adapter contract. +#[cfg(any( + target_os = "ios", + target_os = "linux", + target_os = "macos", + target_os = "windows" +))] use std::sync::Arc; #[cfg(any(target_os = "ios", target_os = "macos"))] @@ -29,7 +35,14 @@ use security_framework::{ }, }; -use keyring_core::{CredentialStore, Entry}; +#[cfg(any( + target_os = "ios", + target_os = "linux", + target_os = "macos", + target_os = "windows" +))] +use keyring_core::CredentialStore; +use keyring_core::Entry; use super::{SecretLocator, SecretProtection, SecretStoreBackend, SecretStoreError}; use crate::repository::SecretBytes; diff --git a/docs/android/architecture.md b/docs/android/architecture.md index 28009d6..e95dd10 100644 --- a/docs/android/architecture.md +++ b/docs/android/architecture.md @@ -205,11 +205,13 @@ Every issue runs the AGENTS.md Rust gates before commit. Android changes also run build/lint/unit/instrumented checks appropriate to the issue and computer-use validation on the existing emulator. Update by installation over the existing app, never uninstall/reset. If unlocking needs an unavailable user, label the -issue untested and document exactly what could not be checked. Emulator evidence -cannot satisfy physical biometric/camera/Keystore/autofill or Play acceptance. +issue untested and document exactly what could not be checked. The user clarified on 2026-10-08 that validation for every milestone issue is +emulator-only; no physical devices are included in testing. Record emulator/API +limitations explicitly and use untested for an unavailable required emulator +check. This does not change production security behavior or claim Play approval. Apple changes require XcodeGen and affected simulator builds and UI checks; never re-enroll an already enrolled iPhone simulator in ordinary validation. #94 owns reproducible CI and test boundaries, #105 accessibility/performance, -#111 the complete architecture/security/parity audit, and #112 physical production -install/update/Play release evidence. No release approval is implied by this ADR. +#111 the complete architecture/security/parity audit, and #112 emulator +install/update evidence and Play release preparation. No release approval is implied by this ADR. diff --git a/docs/android/parity.md b/docs/android/parity.md index 7754b13..2b00f05 100644 --- a/docs/android/parity.md +++ b/docs/android/parity.md @@ -81,8 +81,8 @@ milestone. This list gives ownership, not permission to skip acceptance criteria | [#108 Move/copy/delete](https://git.rfc1437.de/hugo/IronStorage/issues/108) | storage mobile_mutation/mutation plus Compose features | Rust plans/tokens/overwrite/reencryption, destination and confirmation UI | | [#109 Credential Manager/Autofill](https://git.rfc1437.de/hugo/IronStorage/issues/109) | storage identity/permission/credential contract plus OS services | AndroidX provider and framework AutofillService, explicit selection/lease before secrets | | [#110 TOTP catalog/discovery/search](https://git.rfc1437.de/hugo/IronStorage/issues/110) | storage mobile_totp plus Compose features | Existing hash/path/is_totp catalog, Rust progress/checkpoints/filter, no Watch | -| [#111 Parity/security/reliability audit](https://git.rfc1437.de/hugo/IronStorage/issues/111) | All above plus independent acceptance review | Completed matrix evidence, Rust/Android release gates and physical-device threat tests | -| [#112 Google Play distribution](https://git.rfc1437.de/hugo/IronStorage/issues/112) | Release tooling/listing and production app | Protected signing/Play AAB, audited manifest/licenses, physical clean install and update-preservation tests | +| [#111 Parity/security/reliability audit](https://git.rfc1437.de/hugo/IronStorage/issues/111) | All above plus independent acceptance review | Completed matrix evidence, Rust/Android release gates and emulator threat tests | +| [#112 Google Play distribution](https://git.rfc1437.de/hugo/IronStorage/issues/112) | Release tooling/listing and production app | Protected signing/Play AAB, audited manifest/licenses, emulator install and update-preservation tests | #87 is the closed umbrella issue, not an open implementation candidate. Verify live issues after each closure; newly created bug/critical issues may change the diff --git a/tools/android-build/Cargo.toml b/tools/android-build/Cargo.toml index ce53ef0..a14fed6 100644 --- a/tools/android-build/Cargo.toml +++ b/tools/android-build/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "ironstorage-android-build" -description = "Android fixture generation and architecture boundary checks" +description = "Android native packaging, binding generation and architecture gates" +default-run = "ironstorage-android-build" version.workspace = true edition.workspace = true rust-version.workspace = true @@ -11,3 +12,10 @@ publish = false [dependencies] ironstorage.workspace = true regex.workspace = true +uniffi = { workspace = true, features = ["cli"] } +object = { version = "0.36.7", default-features = false, features = ["read", "elf"] } +zip = { version = "2.4.2", default-features = false, features = ["deflate"] } + +[[bin]] +name = "ironstorage-uniffi-bindgen" +path = "src/bindgen.rs" diff --git a/tools/android-build/src/bindgen.rs b/tools/android-build/src/bindgen.rs new file mode 100644 index 0000000..bc41de4 --- /dev/null +++ b/tools/android-build/src/bindgen.rs @@ -0,0 +1,5 @@ +#![forbid(unsafe_code)] + +fn main() { + uniffi::uniffi_bindgen_main(); +} diff --git a/tools/android-build/src/bindings.rs b/tools/android-build/src/bindings.rs new file mode 100644 index 0000000..71b5522 --- /dev/null +++ b/tools/android-build/src/bindings.rs @@ -0,0 +1,146 @@ +//! Narrow, version-checked hardening of UniFFI 0.32's Kotlin diagnostics. +//! Records remain immutable data classes with unchanged wire layouts/checksums. + +use std::{error::Error, fs, path::Path}; + +use regex::Regex; + +pub fn prepare(input: &Path, output: &Path) -> Result<(), Box> { + if !input.is_dir() || input.is_symlink() || output.is_symlink() { + return Err("binding input and output must be distinct directories".into()); + } + let input = input.canonicalize()?; + let destination = if output.exists() { + output.canonicalize()? + } else { + output + .parent() + .ok_or("output needs a parent")? + .canonicalize()? + .join(output.file_name().ok_or("output needs a name")?) + }; + if input.starts_with(&destination) || destination.starts_with(&input) { + return Err("binding input/output directories must not overlap".into()); + } + if output.exists() { + fs::remove_dir_all(output)?; + } + copy(&input, output) +} + +fn copy(input: &Path, output: &Path) -> Result<(), Box> { + fs::create_dir_all(output)?; + let mut found = false; + for entry in fs::read_dir(input)? { + let entry = entry?; + let destination = output.join(entry.file_name()); + if entry.file_type()?.is_dir() { + copy(&entry.path(), &destination)?; + found = true; + } else if entry.file_type()?.is_file() + && entry.path().extension().is_some_and(|ext| ext == "kt") + { + let source = fs::read_to_string(entry.path())?; + fs::write(destination, harden_source(&source)?)?; + found = true; + } else { + return Err("unexpected file in generated Kotlin bindings".into()); + } + } + if !found { + return Err("UniFFI produced no Kotlin bindings".into()); + } + Ok(()) +} + +fn harden_source(source: &str) -> Result> { + let internal = "class InternalException(message: String) : kotlin.Exception(message)"; + let callback = "val err = try { e.stackTraceToString() } catch(_: Throwable) { \"\" }"; + if !source.contains(internal) || source.matches(callback).count() != 2 { + return Err("UniFFI unexpected-error template changed; review secret redaction".into()); + } + let source = source.replace(internal, "class InternalException(@Suppress(\"UNUSED_PARAMETER\") message: String) : kotlin.Exception(\"Native Rust failure ([REDACTED])\")") + .replace(callback, "val err = \"Native callback failed ([REDACTED])\""); + let records = Regex::new(r"(?ms)^data class (\w+) \((.*?)\n\)([^\{]*)\{\n")?; + if !records.is_match(&source) { + return Err("UniFFI record template changed; review diagnostic hardening".into()); + } + let source = records.replace_all(&source, |captures: ®ex::Captures<'_>| { + format!( + "{} override fun toString(): String = \"{}([REDACTED])\"\n", + &captures[0], &captures[1] + ) + }); + let errors = Regex::new(r#"(?m)(override val message\n\s*)get\(\) = ".*"$"#)?; + // Kotlin Exception.message is reserved; keep the Rust field/wire checksum unchanged. + let source = source + .replace( + "val `message`: kotlin.String", + "val `rustMessage`: kotlin.String", + ) + .replace("value.`message`", "value.`rustMessage`"); + let source = errors.replace_all( + &source, + "${1}get() = \"Rust operation failed ([REDACTED])\"", + ); + let contract = "throw RuntimeException(\"UniFFI contract version mismatch: try cleaning and rebuilding your project\")"; + let checksum = "throw RuntimeException(\"UniFFI API checksum mismatch: try cleaning and rebuilding your project\")"; + if !source.contains(contract) || !source.contains(checksum) { + return Err( + "UniFFI integrity template changed; checksum checks must remain enabled".into(), + ); + } + let checksum_guard = Regex::new( + r#"if \(lib\.(\w+)\(\) != (-?\d+)\) \{\s*throw RuntimeException\("UniFFI API checksum mismatch: try cleaning and rebuilding your project"\)\s*\}"#, + )?; + let contract_guard = Regex::new( + r#"if \(bindings_contract_version != scaffolding_contract_version\) \{\s*throw RuntimeException\("UniFFI contract version mismatch: try cleaning and rebuilding your project"\)\s*\}"#, + )?; + if !checksum_guard.is_match(&source) || !contract_guard.is_match(&source) { + return Err( + "UniFFI integrity guard changed; refusing to generate unchecked bindings".into(), + ); + } + let output = checksum_guard.replace_all(&source, "de.rfc1437.ironstorage.bridge.NativeBindingChecks.requireMatch(lib.${1}().toLong(), ${2}.toLong(), de.rfc1437.ironstorage.bridge.NativeFailureKind.ChecksumMismatch)"); + let output = contract_guard.replace_all(&output, "de.rfc1437.ironstorage.bridge.NativeBindingChecks.requireMatch(scaffolding_contract_version.toLong(), bindings_contract_version.toLong(), de.rfc1437.ironstorage.bridge.NativeFailureKind.ContractMismatch)").into_owned(); + if output.contains(contract) || output.contains(checksum) { + return Err("UniFFI integrity guard was not translated completely".into()); + } + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SOURCE: &str = "data class Secret (\n val value: String\n)\n{\n companion object\n}\nclass Failure {\n override val message\n get() = \"value=${value}\"\n}\nif (bindings_contract_version != scaffolding_contract_version) { throw RuntimeException(\"UniFFI contract version mismatch: try cleaning and rebuilding your project\") }\nif (lib.checksum() != 1234) { throw RuntimeException(\"UniFFI API checksum mismatch: try cleaning and rebuilding your project\") }\nclass InternalException(message: String) : kotlin.Exception(message)\nval err = try { e.stackTraceToString() } catch(_: Throwable) { \"\" }\nval err = try { e.stackTraceToString() } catch(_: Throwable) { \"\" }\n"; + + #[test] + fn redacts_record_and_error_diagnostics_without_changing_fields_or_checksums() { + let hardened = harden_source(SOURCE).unwrap(); + assert!(hardened.contains("val value: String")); + assert!(hardened.contains("Secret([REDACTED])")); + assert!(!hardened.contains("get() = \"value=")); + assert!(hardened.contains("requireMatch(lib.checksum().toLong(), 1234.toLong()")); + assert!(hardened.contains("NativeFailureKind.ChecksumMismatch")); + assert!(hardened.contains("NativeFailureKind.ContractMismatch")); + assert!(!hardened.contains("stackTraceToString")); + assert!(!hardened.contains("kotlin.Exception(message)")); + } + + #[test] + fn fails_closed_if_upstream_templates_or_integrity_checks_change() { + assert!(harden_source("data class Secret(val value: String)").is_err()); + assert!(harden_source(&SOURCE.replace("UniFFI API checksum mismatch", "changed")).is_err()); + } + + #[test] + fn preserves_rust_message_fields_without_colliding_with_kotlin_exception_message() { + let source = SOURCE.replace("val value: String", "val `message`: kotlin.String") + + "FfiConverterString.write(value.`message`, buf)"; + let hardened = harden_source(&source).unwrap(); + assert!(hardened.contains("val `rustMessage`: kotlin.String")); + assert!(hardened.contains("FfiConverterString.write(value.`rustMessage`, buf)")); + assert!(hardened.contains("override val message")); + } +} diff --git a/tools/android-build/src/main.rs b/tools/android-build/src/main.rs index 050811b..87eb2db 100644 --- a/tools/android-build/src/main.rs +++ b/tools/android-build/src/main.rs @@ -3,6 +3,10 @@ use ironstorage::mobile::{MobileShell, MobileShellState}; use std::{env, error::Error, fs, path::Path}; +mod bindings; +mod native; +mod toolchain; + fn main() -> Result<(), Box> { let args: Vec<_> = env::args_os().collect(); match args.as_slice() { @@ -12,9 +16,38 @@ fn main() -> Result<(), Box> { fs::write(path, fixtures())?; } [_, command, path] if command == "check" => check(Path::new(path))?, + [_, command, input, output] if command == "prepare-bindings" => { + bindings::prepare(Path::new(input), Path::new(output))?; + } + [_, command, input, output] if command == "package-native" => { + native::package_directory(Path::new(input), Path::new(output))?; + } + [_, command, path, abi] if command == "audit-elf" => { + native::audit_elf(&fs::read(path)?, abi.to_str().ok_or("invalid ABI")?)?; + } + [_, command, path, abi] if command == "audit-apk-split" => { + native::audit_split(Path::new(path), abi.to_str().ok_or("invalid ABI")?)?; + } + [_, command, paths @ ..] if command == "audit-package" && !paths.is_empty() => { + for path in paths { + native::audit_package(Path::new(path))?; + } + } + [_, command, directory] if command == "audit-native" => { + native::audit_directory(Path::new(directory))? + } + [_, command, cargo, rust, ndk, ndk_version, cargo_ndk] if command == "check-toolchain" => { + toolchain::check( + Path::new(cargo), + rust.to_str().ok_or("invalid Rust version")?, + Path::new(ndk), + ndk_version.to_str().ok_or("invalid NDK version")?, + cargo_ndk.to_str().ok_or("invalid cargo-ndk version")?, + )?; + } _ => { return Err( - "usage: ironstorage-android-build ".into(), + "usage: ironstorage-android-build ".into(), ); } } diff --git a/tools/android-build/src/native.rs b/tools/android-build/src/native.rs new file mode 100644 index 0000000..18edd99 --- /dev/null +++ b/tools/android-build/src/native.rs @@ -0,0 +1,208 @@ +//! Verify every packaged native library, including JNA, against the ABI/page contract. + +use std::{collections::BTreeMap, error::Error, fs::File, io::Read, path::Path}; + +use object::{Architecture, BinaryFormat, Object, ObjectSection, ObjectSegment, ObjectSymbol}; + +const PAGE_SIZE: u64 = 16_384; +const ABIS: [&str; 3] = ["arm64-v8a", "armeabi-v7a", "x86_64"]; + +pub fn package_directory(input: &Path, output: &Path) -> Result<(), Box> { + audit_directory(input)?; + let input = input.canonicalize()?; + let output = if output.exists() { + if output.is_symlink() { + return Err("native output must not be a symlink".into()); + } + output.canonicalize()? + } else { + output + .parent() + .ok_or("output needs a parent")? + .canonicalize()? + .join(output.file_name().ok_or("output needs a name")?) + }; + if input.starts_with(&output) || output.starts_with(&input) { + return Err("native input/output must not overlap".into()); + } + if output.exists() { + std::fs::remove_dir_all(&output)?; + } + for abi in ABIS { + std::fs::create_dir_all(output.join(abi))?; + // cargo-ndk also emits dependency cdylibs; ship only the Android boundary. + std::fs::copy( + input.join(abi).join("libironstorage_android.so"), + output.join(abi).join("libironstorage_android.so"), + )?; + } + Ok(()) +} + +pub fn audit_directory(directory: &Path) -> Result<(), Box> { + let mut expected = None; + for abi in ABIS { + let bytes = std::fs::read(directory.join(abi).join("libironstorage_android.so"))?; + audit_elf(&bytes, abi)?; + let library = object::File::parse(bytes.as_slice())?; + let mut metadata = BTreeMap::new(); + for symbol in library.symbols() { + let name = symbol.name()?; + if name.starts_with("UNIFFI_META_") { + let section = library.section_by_index( + symbol + .section_index() + .ok_or("UniFFI metadata has no section")?, + )?; + let value = section + .data_range(symbol.address(), symbol.size())? + .ok_or("UniFFI metadata is outside its section")?; + metadata.insert(name.to_owned(), value.to_vec()); + } + } + if metadata.is_empty() { + return Err("native library has no UniFFI metadata".into()); + } + if let Some(expected) = &expected { + if expected != &metadata { + return Err("UniFFI metadata differs across Android ABIs".into()); + } + } else { + expected = Some(metadata); + } + } + println!("Verified three native ABIs, 16 KB ELF alignment and identical UniFFI metadata"); + Ok(()) +} + +pub fn audit_elf(bytes: &[u8], abi: &str) -> Result<(), Box> { + let expected = match abi { + "arm64-v8a" => Architecture::Aarch64, + "armeabi-v7a" => Architecture::Arm, + "x86_64" => Architecture::X86_64, + _ => return Err("unapproved native ABI".into()), + }; + let library = object::File::parse(bytes)?; + if library.format() != BinaryFormat::Elf || library.architecture() != expected { + return Err("native library architecture does not match its ABI directory".into()); + } + let mut segments = 0; + for segment in library.segments() { + let (offset, _) = segment.file_range(); + if segment.align() < PAGE_SIZE || segment.address() % PAGE_SIZE != offset % PAGE_SIZE { + return Err("native ELF LOAD segment is not compatible with 16 KB pages".into()); + } + segments += 1; + } + if segments == 0 { + return Err("native ELF has no loadable segments".into()); + } + Ok(()) +} + +pub fn audit_package(path: &Path) -> Result<(), Box> { + audit_package_abis(path, &ABIS) +} + +pub fn audit_split(path: &Path, abi: &str) -> Result<(), Box> { + if !ABIS.contains(&abi) || !path.extension().is_some_and(|ext| ext == "apk") { + return Err("expected an APK split for an approved ABI".into()); + } + audit_package_abis(path, &[abi]) +} + +fn audit_package_abis(path: &Path, abis: &[&str]) -> Result<(), Box> { + let apk = path.extension().is_some_and(|ext| ext == "apk"); + if !apk && !path.extension().is_some_and(|ext| ext == "aab") { + return Err("expected an APK or AAB".into()); + } + let mut archive = zip::ZipArchive::new(File::open(path)?)?; + let mut libraries = BTreeMap::>::new(); + for index in 0..archive.len() { + let mut file = archive.by_index(index)?; + let name = file.name().to_owned(); + let native = name.strip_prefix(if apk { "lib/" } else { "base/lib/" }); + let Some((abi, library)) = native.and_then(|name| name.split_once('/')) else { + continue; + }; + if !library.ends_with(".so") { + continue; + } + if library == "libironstorage_apple.so" { + return Err("Android package includes the redundant Apple boundary library".into()); + } + if !abis.contains(&abi) || library.contains('/') { + return Err("package contains an unapproved native library path".into()); + } + if apk + && (file.compression() != zip::CompressionMethod::Stored + || file.data_start() % PAGE_SIZE != 0) + { + return Err(format!("{name}: APK native entry is not stored/aligned to 16 KB").into()); + } + if file.size() > 512 * 1024 * 1024 { + return Err("native library exceeds the package audit bound".into()); + } + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes)?; + audit_elf(&bytes, abi).map_err(|error| format!("{name}: {error}"))?; + libraries + .entry(abi.to_owned()) + .or_default() + .push(library.to_owned()); + } + for abi in abis { + let found = libraries + .get(*abi) + .ok_or("package is missing a supported ABI")?; + for required in ["libironstorage_android.so", "libjnidispatch.so"] { + if !found.iter().any(|name| name == required) { + return Err(format!("{abi}: missing {required}").into()); + } + } + } + println!( + "Verified all packaged native libraries for {abis:?} in {}", + path.display() + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn elf(alignment: u64, address: u64) -> Vec { + let mut bytes = vec![0; 120]; + bytes[..7].copy_from_slice(b"\x7fELF\x02\x01\x01"); + bytes[16..18].copy_from_slice(&3_u16.to_le_bytes()); + bytes[18..20].copy_from_slice(&183_u16.to_le_bytes()); + bytes[20..24].copy_from_slice(&1_u32.to_le_bytes()); + bytes[32..40].copy_from_slice(&64_u64.to_le_bytes()); + bytes[52..54].copy_from_slice(&64_u16.to_le_bytes()); + bytes[54..56].copy_from_slice(&56_u16.to_le_bytes()); + bytes[56..58].copy_from_slice(&1_u16.to_le_bytes()); + bytes[64..68].copy_from_slice(&1_u32.to_le_bytes()); + bytes[68..72].copy_from_slice(&4_u32.to_le_bytes()); + bytes[80..88].copy_from_slice(&address.to_le_bytes()); + bytes[96..104].copy_from_slice(&120_u64.to_le_bytes()); + bytes[104..112].copy_from_slice(&120_u64.to_le_bytes()); + bytes[112..120].copy_from_slice(&alignment.to_le_bytes()); + bytes + } + + #[test] + fn checks_real_load_alignment_congruence_and_directory_abi() { + assert!(audit_elf(&elf(PAGE_SIZE, 0), "arm64-v8a").is_ok()); + assert!(audit_elf(&elf(4096, 0), "arm64-v8a").is_err()); + assert!(audit_elf(&elf(PAGE_SIZE, 4), "arm64-v8a").is_err()); + assert!(audit_elf(&elf(PAGE_SIZE, 0), "x86_64").is_err()); + } + + #[test] + fn rejects_non_elf_unapproved_abi_and_truncated_headers() { + assert!(audit_elf(b"not an ELF", "arm64-v8a").is_err()); + assert!(audit_elf(b"\x7fELF", "x86_64").is_err()); + assert!(audit_elf(&[], "mips").is_err()); + } +} diff --git a/tools/android-build/src/toolchain.rs b/tools/android-build/src/toolchain.rs new file mode 100644 index 0000000..ff0b33e --- /dev/null +++ b/tools/android-build/src/toolchain.rs @@ -0,0 +1,61 @@ +//! Build-time tools only; application crates never launch these processes. +#![allow( + clippy::disallowed_types, + reason = "build-only verification of Cargo/cargo-ndk, never linked into the application" +)] +use std::{error::Error, fs, path::Path, process::Command}; + +pub fn check( + cargo: &Path, + rust: &str, + ndk: &Path, + ndk_version: &str, + cargo_ndk: &str, +) -> Result<(), Box> { + version( + cargo, + &[&format!("+{rust}"), "--version"], + &format!("cargo {rust}"), + )?; + version( + cargo, + &[&format!("+{rust}"), "ndk", "--version"], + &format!("cargo-ndk {cargo_ndk}"), + )?; + let properties = fs::read_to_string(ndk.join("source.properties"))?; + if !properties + .lines() + .any(|line| line.trim() == format!("Pkg.Revision = {ndk_version}")) + { + return Err("Android NDK revision does not match the version catalog".into()); + } + Ok(()) +} + +fn version(command: &Path, args: &[&str], expected: &str) -> Result<(), Box> { + let result = Command::new(command).args(args).output()?; + let value = std::str::from_utf8(&result.stdout)?; + if !result.status.success() || !matches_version(value, expected) { + return Err(format!("build tool must report {expected}").into()); + } + Ok(()) +} + +fn matches_version(value: &str, expected: &str) -> bool { + value + .trim() + .strip_prefix(expected) + .is_some_and(|suffix| suffix.is_empty() || suffix.starts_with(' ')) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn rejects_nearby_and_failed_version_output() { + assert!(matches_version("cargo 1.97.1 (abc)\n", "cargo 1.97.1")); + assert!(matches_version("cargo-ndk 4.1.2\n", "cargo-ndk 4.1.2")); + assert!(!matches_version("cargo-ndk 4.1.20", "cargo-ndk 4.1.2")); + assert!(!matches_version("", "cargo-ndk 4.1.2")); + } +}