From 082b54811a5f9238672994b7bc9a7a0e594080ee Mon Sep 17 00:00:00 2001 From: Jaiden Siu <82122144+jaidensiu@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:23:38 -0700 Subject: [PATCH 1/2] feat: add kmp support to kotlin --- .github/workflows/ci.yml | 92 +++- .github/workflows/publish-kotlin.yml | 148 ++---- .gitignore | 12 +- Cargo.lock | 10 + Cargo.toml | 7 + README.md | 2 +- kotlin/Examples/IDKitKmpSampleApp/README.md | 61 +++ .../androidApp/build.gradle.kts | 61 +++ .../androidApp/proguard-rules.pro | 4 + .../androidApp/src/main/AndroidManifest.xml | 35 ++ .../idkit/kmpsample/android/MainActivity.kt | 218 ++++++++ .../IDKitKmpSampleApp/build.gradle.kts | 8 + .../IDKitKmpSampleApp/gradle.properties | 6 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43739 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + kotlin/Examples/IDKitKmpSampleApp/gradlew | 251 ++++++++++ kotlin/Examples/IDKitKmpSampleApp/gradlew.bat | 94 ++++ .../IDKitKmpSampleApp/ContentView.swift | 118 +++++ .../IDKitKmpSampleApp/IDKitKmpSampleApp.swift | 10 + .../iosApp/IDKitKmpSampleApp/Info.plist | 37 ++ .../iosApp/build-shared-framework.sh | 45 ++ .../IDKitKmpSampleApp/iosApp/project.yml | 52 ++ .../IDKitKmpSampleApp/settings.gradle.kts | 22 + .../IDKitKmpSampleApp/shared/build.gradle.kts | 55 +++ .../kmpsample/shared/SampleController.kt | 253 ++++++++++ .../IDKitSampleApp/app/build.gradle.kts | 19 +- .../worldcoin/idkit/sample/MainActivity.kt | 17 +- .../Examples/IDKitSampleApp/build.gradle.kts | 8 +- .../Examples/IDKitSampleApp/gradle.properties | 6 +- .../IDKitSampleApp/settings.gradle.kts | 4 +- kotlin/README.md | 285 +++-------- kotlin/bindings/build.gradle.kts | 166 ------- .../main/kotlin/com/worldcoin/idkit/IdKit.kt | 438 ----------------- .../com/worldcoin/idkit/KotlinCompat.kt | 52 -- .../kotlin/com/worldcoin/idkit/IDKitTests.kt | 465 ------------------ kotlin/build.gradle.kts | 5 +- kotlin/gradle.properties | 12 +- kotlin/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43739 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + kotlin/gradlew | 251 ++++++++++ kotlin/gradlew.bat | 94 ++++ kotlin/idkit/build.gradle.kts | 236 +++++++++ .../idkit/internal/IoDispatcher.android.kt | 6 + .../idkit/internal/NativeBridge.android.kt | 92 ++++ .../kotlin/com/worldcoin/idkit/Config.kt | 102 ++++ .../kotlin/com/worldcoin/idkit/Constraints.kt | 134 +++++ .../kotlin/com/worldcoin/idkit/Errors.kt | 15 + .../kotlin/com/worldcoin/idkit/IDKit.kt | 77 +++ .../kotlin/com/worldcoin/idkit/Payload.kt | 81 +++ .../kotlin/com/worldcoin/idkit/Presets.kt | 186 +++++++ .../kotlin/com/worldcoin/idkit/Request.kt | 194 ++++++++ .../kotlin/com/worldcoin/idkit/Result.kt | 151 ++++++ .../kotlin/com/worldcoin/idkit/Status.kt | 77 +++ .../com/worldcoin/idkit/internal/Envelope.kt | 51 ++ .../worldcoin/idkit/internal/IoDispatcher.kt | 10 + .../com/worldcoin/idkit/internal/Json.kt | 19 + .../worldcoin/idkit/internal/NativeBridge.kt | 21 + .../worldcoin/idkit/NativeContractTests.kt | 111 +++++ .../com/worldcoin/idkit/PollLoopTests.kt | 99 ++++ .../com/worldcoin/idkit/ResultJsonTests.kt | 98 ++++ .../com/worldcoin/idkit/SerializationTests.kt | 167 +++++++ .../com/worldcoin/idkit/StatusMappingTests.kt | 80 +++ .../com/worldcoin/idkit/TestFixtures.kt | 44 ++ .../idkit/internal/IoDispatcher.ios.kt | 7 + .../idkit/internal/NativeBridge.ios.kt | 75 +++ .../src/nativeInterop/cinterop/idkit_kmp.def | 7 + kotlin/settings.gradle.kts | 2 +- rust/core/src/bridge.rs | 17 +- rust/core/src/preset.rs | 17 +- rust/kmp-ffi/Cargo.toml | 24 + rust/kmp-ffi/include/idkit_kmp.h | 77 +++ rust/kmp-ffi/src/config.rs | 170 +++++++ rust/kmp-ffi/src/envelope.rs | 93 ++++ rust/kmp-ffi/src/lib.rs | 315 ++++++++++++ rust/kmp-ffi/src/registry.rs | 62 +++ rust/kmp-ffi/tests/ffi_contract.rs | 247 ++++++++++ scripts/build-kotlin.sh | 110 +++-- scripts/package-kotlin.sh | 8 +- 78 files changed, 5167 insertions(+), 1552 deletions(-) create mode 100644 kotlin/Examples/IDKitKmpSampleApp/README.md create mode 100644 kotlin/Examples/IDKitKmpSampleApp/androidApp/build.gradle.kts create mode 100644 kotlin/Examples/IDKitKmpSampleApp/androidApp/proguard-rules.pro create mode 100644 kotlin/Examples/IDKitKmpSampleApp/androidApp/src/main/AndroidManifest.xml create mode 100644 kotlin/Examples/IDKitKmpSampleApp/androidApp/src/main/java/com/worldcoin/idkit/kmpsample/android/MainActivity.kt create mode 100644 kotlin/Examples/IDKitKmpSampleApp/build.gradle.kts create mode 100644 kotlin/Examples/IDKitKmpSampleApp/gradle.properties create mode 100644 kotlin/Examples/IDKitKmpSampleApp/gradle/wrapper/gradle-wrapper.jar create mode 100644 kotlin/Examples/IDKitKmpSampleApp/gradle/wrapper/gradle-wrapper.properties create mode 100755 kotlin/Examples/IDKitKmpSampleApp/gradlew create mode 100644 kotlin/Examples/IDKitKmpSampleApp/gradlew.bat create mode 100644 kotlin/Examples/IDKitKmpSampleApp/iosApp/IDKitKmpSampleApp/ContentView.swift create mode 100644 kotlin/Examples/IDKitKmpSampleApp/iosApp/IDKitKmpSampleApp/IDKitKmpSampleApp.swift create mode 100644 kotlin/Examples/IDKitKmpSampleApp/iosApp/IDKitKmpSampleApp/Info.plist create mode 100755 kotlin/Examples/IDKitKmpSampleApp/iosApp/build-shared-framework.sh create mode 100644 kotlin/Examples/IDKitKmpSampleApp/iosApp/project.yml create mode 100644 kotlin/Examples/IDKitKmpSampleApp/settings.gradle.kts create mode 100644 kotlin/Examples/IDKitKmpSampleApp/shared/build.gradle.kts create mode 100644 kotlin/Examples/IDKitKmpSampleApp/shared/src/commonMain/kotlin/com/worldcoin/idkit/kmpsample/shared/SampleController.kt delete mode 100644 kotlin/bindings/build.gradle.kts delete mode 100644 kotlin/bindings/src/main/kotlin/com/worldcoin/idkit/IdKit.kt delete mode 100644 kotlin/bindings/src/main/kotlin/com/worldcoin/idkit/KotlinCompat.kt delete mode 100644 kotlin/bindings/src/test/kotlin/com/worldcoin/idkit/IDKitTests.kt create mode 100644 kotlin/gradle/wrapper/gradle-wrapper.jar create mode 100644 kotlin/gradle/wrapper/gradle-wrapper.properties create mode 100755 kotlin/gradlew create mode 100644 kotlin/gradlew.bat create mode 100644 kotlin/idkit/build.gradle.kts create mode 100644 kotlin/idkit/src/androidMain/kotlin/com/worldcoin/idkit/internal/IoDispatcher.android.kt create mode 100644 kotlin/idkit/src/androidMain/kotlin/com/worldcoin/idkit/internal/NativeBridge.android.kt create mode 100644 kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Config.kt create mode 100644 kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Constraints.kt create mode 100644 kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Errors.kt create mode 100644 kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/IDKit.kt create mode 100644 kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Payload.kt create mode 100644 kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Presets.kt create mode 100644 kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Request.kt create mode 100644 kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Result.kt create mode 100644 kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Status.kt create mode 100644 kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/internal/Envelope.kt create mode 100644 kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/internal/IoDispatcher.kt create mode 100644 kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/internal/Json.kt create mode 100644 kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/internal/NativeBridge.kt create mode 100644 kotlin/idkit/src/commonTest/kotlin/com/worldcoin/idkit/NativeContractTests.kt create mode 100644 kotlin/idkit/src/commonTest/kotlin/com/worldcoin/idkit/PollLoopTests.kt create mode 100644 kotlin/idkit/src/commonTest/kotlin/com/worldcoin/idkit/ResultJsonTests.kt create mode 100644 kotlin/idkit/src/commonTest/kotlin/com/worldcoin/idkit/SerializationTests.kt create mode 100644 kotlin/idkit/src/commonTest/kotlin/com/worldcoin/idkit/StatusMappingTests.kt create mode 100644 kotlin/idkit/src/commonTest/kotlin/com/worldcoin/idkit/TestFixtures.kt create mode 100644 kotlin/idkit/src/iosMain/kotlin/com/worldcoin/idkit/internal/IoDispatcher.ios.kt create mode 100644 kotlin/idkit/src/iosMain/kotlin/com/worldcoin/idkit/internal/NativeBridge.ios.kt create mode 100644 kotlin/idkit/src/nativeInterop/cinterop/idkit_kmp.def create mode 100644 rust/kmp-ffi/Cargo.toml create mode 100644 rust/kmp-ffi/include/idkit_kmp.h create mode 100644 rust/kmp-ffi/src/config.rs create mode 100644 rust/kmp-ffi/src/envelope.rs create mode 100644 rust/kmp-ffi/src/lib.rs create mode 100644 rust/kmp-ffi/src/registry.rs create mode 100644 rust/kmp-ffi/tests/ffi_contract.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d08f38e9..d2b013bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -300,7 +300,7 @@ jobs: - name: Install cross (for Android targets, if not cached) run: command -v cross || cargo install cross --git https://github.com/cross-rs/cross --locked - - name: Build Kotlin bindings (host + Android ABIs) + - name: Build Kotlin native libraries (host + Android ABIs) run: ./scripts/build-kotlin.sh - name: Setup Android SDK @@ -308,48 +308,102 @@ jobs: with: packages: tools platform-tools platforms;android-35 build-tools;35.0.0 + # iOS targets are disabled on Linux (kotlin.native.ignoreDisabledTargets); + # this runs commonTest on the host JVM against the host libidkit_kmp. - name: Run tests working-directory: kotlin/ - run: gradle bindings:test + run: ./gradlew :idkit:testReleaseUnitTest - name: Build Kotlin sample app working-directory: kotlin/Examples/IDKitSampleApp run: ./gradlew :app:assembleDebug - - name: Validate Kotlin Maven publication + - name: Build KMP sample app (Android) + working-directory: kotlin/Examples/IDKitKmpSampleApp + run: ./gradlew :androidApp:assembleDebug + + # Validates the Android slice of the KMP publication. The iOS variants and + # complete root metadata can only be produced on macOS; the publish workflow + # runs there and the build fails remote publishing from non-Mac hosts. + - name: Validate Kotlin Maven publication (Android slice) run: | set -euo pipefail - ./kotlin/Examples/IDKitSampleApp/gradlew -p kotlin :bindings:publishToMavenLocal - ./kotlin/Examples/IDKitSampleApp/gradlew -p kotlin \ + ./kotlin/gradlew -p kotlin :idkit:publishToMavenLocal + ./kotlin/gradlew -p kotlin \ -Pidkit.publish.mavenCentral=true \ - :bindings:publishToMavenCentral --dry-run + :idkit:publishToMavenCentral --dry-run VERSION="$(grep '^version=' kotlin/gradle.properties | cut -d= -f2- | tr -d '[:space:]')" - ARTIFACT_DIR="$HOME/.m2/repository/com/worldcoin/idkit/$VERSION" - ARTIFACT_BASE="$ARTIFACT_DIR/idkit-$VERSION" + REPO="$HOME/.m2/repository/com/worldcoin" + ROOT_BASE="$REPO/idkit/$VERSION/idkit-$VERSION" + ANDROID_BASE="$REPO/idkit-android/$VERSION/idkit-android-$VERSION" for artifact in \ - "$ARTIFACT_BASE.aar" \ - "$ARTIFACT_BASE.pom" \ - "$ARTIFACT_BASE.module" \ - "$ARTIFACT_BASE-sources.jar" \ - "$ARTIFACT_BASE-javadoc.jar"; do + "$ROOT_BASE.pom" \ + "$ROOT_BASE.module" \ + "$ANDROID_BASE.aar" \ + "$ANDROID_BASE.pom" \ + "$ANDROID_BASE.module" \ + "$ANDROID_BASE-sources.jar"; do if [ ! -s "$artifact" ]; then echo "::error::Missing Maven publication artifact: $artifact" exit 1 fi done - grep -q 'com.worldcoin' "$ARTIFACT_BASE.pom" - grep -q 'idkit' "$ARTIFACT_BASE.pom" - grep -q 'aar' "$ARTIFACT_BASE.pom" + grep -q 'com.worldcoin' "$ROOT_BASE.pom" + grep -q 'idkit' "$ROOT_BASE.pom" + grep -q 'aar' "$ANDROID_BASE.pom" AAR_CONTENTS="$(mktemp)" - jar tf "$ARTIFACT_BASE.aar" > "$AAR_CONTENTS" + jar tf "$ANDROID_BASE.aar" > "$AAR_CONTENTS" for abi in arm64-v8a armeabi-v7a x86 x86_64; do - if ! grep -q "^jni/$abi/libidkit.so$" "$AAR_CONTENTS"; then - echo "::error::Missing native library in AAR: jni/$abi/libidkit.so" + if ! grep -q "^jni/$abi/libidkit_kmp.so$" "$AAR_CONTENTS"; then + echo "::error::Missing native library in AAR: jni/$abi/libidkit_kmp.so" exit 1 fi done + + kotlin-ios: + name: Kotlin SDK - iOS targets (macOS) + runs-on: macos-latest + needs: rust-core + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Read rust-toolchain + id: rust-version + run: echo "toolchain=$(yq '.toolchain.channel' rust-toolchain.toml)" >> $GITHUB_OUTPUT + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ steps.rust-version.outputs.toolchain }} + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Setup Android SDK + uses: android-actions/setup-android@v4 + with: + packages: tools platform-tools platforms;android-35 build-tools;35.0.0 + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + with: + key: kotlin-ios + + - name: Build Kotlin native libraries (host + iOS) + run: SKIP_ANDROID=1 ./scripts/build-kotlin.sh + + # commonTest on the iOS simulator: exercises the cinterop bridge and the + # statically linked Rust core on Kotlin/Native. + - name: Run iOS simulator tests + working-directory: kotlin/ + run: ./gradlew :idkit:iosSimulatorArm64Test diff --git a/.github/workflows/publish-kotlin.yml b/.github/workflows/publish-kotlin.yml index c2f63d17..b59922bc 100644 --- a/.github/workflows/publish-kotlin.yml +++ b/.github/workflows/publish-kotlin.yml @@ -114,62 +114,6 @@ jobs: echo "version=$VERSION" >> $GITHUB_OUTPUT - build-host: - name: Build Host Library & UniFFI Bindings - runs-on: ubuntu-latest - needs: prepare - if: needs.prepare.outputs.should_publish == 'true' - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - ref: ${{ needs.prepare.outputs.ref }} - - - name: Read rust-toolchain - id: rust-version - run: echo "toolchain=$(yq '.toolchain.channel' rust-toolchain.toml)" >> $GITHUB_OUTPUT - - - name: Setup Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - toolchain: ${{ steps.rust-version.outputs.toolchain }} - - - name: Setup Rust cache - uses: Swatinem/rust-cache@v2 - - - name: Build host library and generate UniFFI bindings - run: | - # Keep UniFFI metadata symbols so bindgen can introspect the shared library on Linux. - CARGO_PROFILE_RELEASE_STRIP=none cargo build --package idkit-core --release --locked --features uniffi-bindings - - CARGO_PROFILE_RELEASE_STRIP=none cargo run -p uniffi-bindgen generate \ - --library target/release/libidkit.so \ - --crate idkit \ - --language kotlin \ - --no-format \ - --out-dir kotlin/bindings/src/main/kotlin - - - name: Verify UniFFI bindings were generated - run: | - echo "Checking for generated UniFFI files..." - ls -la kotlin/bindings/src/main/kotlin/ - GENERATED_COUNT=$(find kotlin/bindings/src/main/kotlin/uniffi -type f -name "*.kt" 2>/dev/null | wc -l | tr -d '[:space:]') - if [ "$GENERATED_COUNT" -eq 0 ]; then - echo "ERROR: generated UniFFI Kotlin files were not found under kotlin/bindings/src/main/kotlin/uniffi" - exit 1 - fi - echo "Generated UniFFI files found ($GENERATED_COUNT):" - find kotlin/bindings/src/main/kotlin/uniffi -type f -name "*.kt" | sort - - - name: Upload host artifacts - uses: actions/upload-artifact@v4 - with: - name: kotlin-bindings-host - path: | - kotlin/bindings/src/main/ - retention-days: 1 - build-android: name: Build Android ${{ matrix.abi }} runs-on: ubuntu-latest @@ -231,13 +175,16 @@ jobs: - name: Install cross run: cargo install cross --git https://github.com/cross-rs/cross --locked + # kmp-android-release (panic=unwind) is required so the FFI layer's + # catch_unwind can convert panics into error envelopes instead of + # aborting the host app. Do NOT switch to android-release (panic=abort). - name: Build Android ${{ matrix.abi }} run: | echo "Building for ${{ matrix.target }} -> ${{ matrix.abi }}" - CARGO_HOME=/tmp RUSTFLAGS="-C link-arg=-Wl,-z,max-page-size=16384 -C link-arg=-Wl,-z,common-page-size=4096" CROSS_NO_WARNINGS=1 cross build --package idkit-core --target ${{ matrix.target }} --profile android-release --locked --features uniffi-bindings + CARGO_HOME=/tmp RUSTFLAGS="-C link-arg=-Wl,-z,max-page-size=16384 -C link-arg=-Wl,-z,common-page-size=4096" CROSS_NO_WARNINGS=1 cross build --package idkit-kmp-ffi --target ${{ matrix.target }} --profile kmp-android-release --locked mkdir -p android-libs/${{ matrix.abi }} - cp target/${{ matrix.target }}/android-release/libidkit.so android-libs/${{ matrix.abi }}/ + cp target/${{ matrix.target }}/kmp-android-release/libidkit_kmp.so android-libs/${{ matrix.abi }}/ echo "Cleaning up Docker resources..." docker system prune -f || true @@ -245,14 +192,17 @@ jobs: - name: Upload Android ${{ matrix.abi }} artifact uses: actions/upload-artifact@v4 with: - name: kotlin-bindings-android-${{ matrix.abi }} + name: kotlin-android-${{ matrix.abi }} path: android-libs/${{ matrix.abi }}/ retention-days: 1 publish: name: Publish - runs-on: ubuntu-latest - needs: [prepare, build-host, build-android] + # KMP publishing must run on macOS: the iOS klib variants and complete root + # module metadata can only be produced where the Apple targets are enabled. + # The Gradle build hard-fails remote publishing from non-Mac hosts. + runs-on: macos-latest + needs: [prepare, build-android] environment: ${{ needs.prepare.outputs.environment }} permissions: contents: read @@ -264,48 +214,48 @@ jobs: with: ref: ${{ needs.prepare.outputs.ref }} - - name: Download host artifacts - uses: actions/download-artifact@v4 - with: - name: kotlin-bindings-host - path: kotlin/bindings/src/main/ - - name: Download Android artifacts uses: actions/download-artifact@v4 with: - pattern: kotlin-bindings-android-* + pattern: kotlin-android-* path: android-libs-temp merge-multiple: false - name: Organize Android libraries run: | - mkdir -p kotlin/bindings/src/main/jniLibs + mkdir -p kotlin/idkit/src/androidMain/jniLibs - for abi_dir in android-libs-temp/kotlin-bindings-android-*; do - abi=$(basename "$abi_dir" | sed 's/kotlin-bindings-android-//') + for abi_dir in android-libs-temp/kotlin-android-*; do + abi=$(basename "$abi_dir" | sed 's/kotlin-android-//') echo "Processing $abi..." - mkdir -p "kotlin/bindings/src/main/jniLibs/$abi" - so_count=$(find "$abi_dir" -maxdepth 1 -type f -name "*.so" | wc -l | tr -d '[:space:]') - if [ "$so_count" -eq 0 ]; then - echo "ERROR: expected at least one .so in $abi_dir" + mkdir -p "kotlin/idkit/src/androidMain/jniLibs/$abi" + if [ ! -s "$abi_dir/libidkit_kmp.so" ]; then + echo "ERROR: expected libidkit_kmp.so in $abi_dir" ls -la "$abi_dir" || true exit 1 fi - cp "$abi_dir/"*.so "kotlin/bindings/src/main/jniLibs/$abi/" + cp "$abi_dir/libidkit_kmp.so" "kotlin/idkit/src/androidMain/jniLibs/$abi/" done echo "Final jniLibs structure:" - find kotlin/bindings/src/main/jniLibs -name "*.so" + find kotlin/idkit/src/androidMain/jniLibs -name "*.so" - - name: Verify Kotlin sources include generated UniFFI code - run: | - GENERATED_COUNT=$(find kotlin/bindings/src/main/kotlin/uniffi -type f -name "*.kt" 2>/dev/null | wc -l | tr -d '[:space:]') - echo "Generated UniFFI Kotlin source files found: $GENERATED_COUNT" - find kotlin/bindings/src/main/kotlin -type f -name "*.kt" | sort - if [ "$GENERATED_COUNT" -eq 0 ]; then - echo "ERROR: expected generated UniFFI Kotlin bindings were not downloaded" - exit 1 - fi + - name: Read rust-toolchain + id: rust-version + run: echo "toolchain=$(yq '.toolchain.channel' rust-toolchain.toml)" >> $GITHUB_OUTPUT + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ steps.rust-version.outputs.toolchain }} + + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + with: + key: publish-ios + + - name: Build iOS static libraries + run: SKIP_ANDROID=1 ./scripts/build-kotlin.sh - name: Setup Java uses: actions/setup-java@v4 @@ -313,14 +263,14 @@ jobs: distribution: temurin java-version: 17 - - name: Clean Gradle build state - run: rm -rf kotlin/.gradle kotlin/bindings/build kotlin/bindings/.gradle - - - name: Build Kotlin artifact - uses: gradle/gradle-build-action@v3 + - name: Setup Android SDK + uses: android-actions/setup-android@v4 with: - gradle-version: 8.9 - arguments: -p kotlin bindings:assembleRelease + packages: tools platform-tools platforms;android-35 build-tools;35.0.0 + + - name: Build Kotlin artifacts + working-directory: kotlin/ + run: ./gradlew :idkit:assemble env: PKG_VERSION: ${{ needs.prepare.outputs.version }} @@ -335,10 +285,8 @@ jobs: # the final gate on what actually goes live on Maven Central. - name: Publish to Maven Central if: needs.prepare.outputs.environment == 'production' - uses: gradle/gradle-build-action@v3 - with: - gradle-version: 8.9 - arguments: -p kotlin bindings:publishToMavenCentral -Pidkit.publish.mavenCentral=true + working-directory: kotlin/ + run: ./gradlew :idkit:publishToMavenCentral -Pidkit.publish.mavenCentral=true env: PKG_VERSION: ${{ needs.prepare.outputs.version }} ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} @@ -375,10 +323,8 @@ jobs: exit 1 - name: Publish to GitHub Packages - uses: gradle/gradle-build-action@v3 - with: - gradle-version: 8.9 - arguments: -p kotlin bindings:publish + working-directory: kotlin/ + run: ./gradlew :idkit:publish env: PKG_VERSION: ${{ needs.prepare.outputs.version }} GITHUB_ACTOR: ${{ github.actor }} diff --git a/.gitignore b/.gitignore index abbf7c16..902b9b3f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,13 +13,8 @@ /ios_build/ /js/packages/core/wasm/ -# Kotlin build outputs are not committed to this repo (regenerate with scripts/package-kotlin.sh) -/kotlin/lib/src/main/jniLibs/ -/kotlin/lib/src/main/java/uniffi/ -/kotlin/bindings/bin/ -/kotlin/bindings/src/main/jniLibs/ -/kotlin/bindings/src/main/kotlin/uniffi/ -/kotlin/bindings/src/main/resources/libidkit* +# Kotlin native artifacts are not committed to this repo (regenerate with scripts/build-kotlin.sh) +/kotlin/idkit/src/androidMain/jniLibs/ /kotlin/dist/ /kotlin/idkit-kotlin-*.zip @@ -49,10 +44,13 @@ DerivedData/ *.ipa *.dSYM.zip *.dSYM +xcuserdata/ +**/xcuserdata/** # Kotlin / Android *.iml .gradle/ +.kotlin/ local.properties */build/ captures/ diff --git a/Cargo.lock b/Cargo.lock index 8ae2d64c..d81a068a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3178,6 +3178,16 @@ dependencies = [ "world-id-primitives", ] +[[package]] +name = "idkit-kmp-ffi" +version = "4.0.0" +dependencies = [ + "idkit-core", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "idna" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index 65881995..c63a70a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "rust/core", + "rust/kmp-ffi", "rust/uniffi-bindgen-bin", ] @@ -84,6 +85,12 @@ codegen-units = 1 strip = true panic = "abort" +# Android profile for the KMP C ABI: panics must unwind so the FFI layer's +# catch_unwind can convert them into error envelopes instead of aborting the app. +[profile.kmp-android-release] +inherits = "android-release" +panic = "unwind" + [profile.wasm-release] inherits = "release" opt-level = "z" diff --git a/README.md b/README.md index 011c4cac..477fa610 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ IDKit is the toolkit for anonymous proof of human. Integrate the [World ID Proto - JavaScript / TypeScript: [`@worldcoin/idkit-core`](./js/packages/core) - Go (server): [`go/idkit`](./go/idkit) - Swift: [`./swift`](./swift) -- Kotlin: [`./kotlin`](./kotlin) +- Kotlin Multiplatform (Android + iOS): [`./kotlin`](./kotlin) ## Swift quick local run diff --git a/kotlin/Examples/IDKitKmpSampleApp/README.md b/kotlin/Examples/IDKitKmpSampleApp/README.md new file mode 100644 index 00000000..626e8daa --- /dev/null +++ b/kotlin/Examples/IDKitKmpSampleApp/README.md @@ -0,0 +1,61 @@ +# IDKit KMP Sample App + +Kotlin Multiplatform sample for the [IDKit KMP SDK](../../README.md): the verification flow (RP-signature fetch → request creation → polling → server-side proof verification) lives once in the [`shared`](shared) module, and each platform ships a fully native UI — Jetpack Compose on Android, SwiftUI on iOS. + +``` +shared/ SampleController: Ktor + IDKit KMP flow, StateFlow-based state +androidApp/ Compose UI, deep-link handling (idkitkmpsample://callback) +iosApp/ SwiftUI UI (XcodeGen project), same deep link via CFBundleURLTypes +``` + +The sample builds the SDK from source (`:idkit` is included via `project(":idkit").projectDir = file("../../idkit")`). + +## Prerequisites + +From the repo root, build the native Rust artifacts first: + +```bash +bash scripts/build-kotlin.sh # everything (Android ABIs need Docker or cargo-ndk) +SKIP_ANDROID=1 bash scripts/build-kotlin.sh # iOS + host only +``` + +You also need JDK 17+, the Android SDK, and (for iOS) Xcode + [XcodeGen](https://github.com/yonaskolb/XcodeGen) — the Xcode project is generated from `project.yml` and not checked in. + +## Android (Compose) + +```bash +cd kotlin/Examples/IDKitKmpSampleApp +./gradlew :androidApp:assembleDebug +./gradlew :androidApp:installDebug # with an emulator/device connected +``` + +Note: the APK only contains `libidkit_kmp.so` for the ABIs that `scripts/build-kotlin.sh` actually built — running on a device/emulator requires the Android cross-build step (Docker or cargo-ndk), not just `SKIP_ANDROID=1`. + +Deep-link smoke test: + +```bash +adb shell am start -a android.intent.action.VIEW -d "idkitkmpsample://callback" +``` + +## iOS (SwiftUI) + +```bash +cd kotlin/Examples/IDKitKmpSampleApp/iosApp +xcodegen generate # the .xcodeproj is gitignored — always generate it from project.yml +open IDKitKmpSampleApp.xcodeproj +``` + +Build and run the `IDKitKmpSampleApp` scheme. A scheme pre-action verifies the Rust static libraries exist (and tells you to run `scripts/build-kotlin.sh` if not); a build phase (`build-shared-framework.sh`) invokes Gradle's `embedAndSignAppleFrameworkForXcode` to build the shared Kotlin framework for the current configuration/SDK. + +CLI build: + +```bash +xcodebuild -project IDKitKmpSampleApp.xcodeproj -scheme IDKitKmpSampleApp \ + -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build +``` + +## Using the sample + +1. Pick an environment (production/staging) and a preset, then tap **Generate Connector URL**. This fetches an RP signature from the hosted demo backend (`idkit-js-example.vercel.app`) and creates the bridge request. +2. Open the connector URL (opens World App on a device that has it installed). +3. After confirming in World App, the app returns via the `idkitkmpsample://callback` deep link; the sample polls the bridge, and on confirmation POSTs the proof (`result.rawJson`, verbatim) to the demo verify endpoint and logs the response. diff --git a/kotlin/Examples/IDKitKmpSampleApp/androidApp/build.gradle.kts b/kotlin/Examples/IDKitKmpSampleApp/androidApp/build.gradle.kts new file mode 100644 index 00000000..1a727108 --- /dev/null +++ b/kotlin/Examples/IDKitKmpSampleApp/androidApp/build.gradle.kts @@ -0,0 +1,61 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "com.worldcoin.idkit.kmpsample.android" + compileSdk = 35 + + defaultConfig { + applicationId = "com.worldcoin.idkit.kmpsample.android" + minSdk = 23 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + implementation(project(":shared")) + + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.activity:activity-compose:1.10.1") + implementation("androidx.compose.ui:ui:1.7.8") + implementation("androidx.compose.ui:ui-tooling-preview:1.7.8") + implementation("androidx.compose.material3:material3:1.3.1") + + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") + + debugImplementation("androidx.compose.ui:ui-tooling:1.7.8") +} diff --git a/kotlin/Examples/IDKitKmpSampleApp/androidApp/proguard-rules.pro b/kotlin/Examples/IDKitKmpSampleApp/androidApp/proguard-rules.pro new file mode 100644 index 00000000..96728898 --- /dev/null +++ b/kotlin/Examples/IDKitKmpSampleApp/androidApp/proguard-rules.pro @@ -0,0 +1,4 @@ +# JNA needs its native dispatch classes and the direct-mapped bridge intact. +-keep class com.sun.jna.** { *; } +-keep class com.worldcoin.idkit.multiplatform.internal.** { *; } +-dontwarn java.awt.* diff --git a/kotlin/Examples/IDKitKmpSampleApp/androidApp/src/main/AndroidManifest.xml b/kotlin/Examples/IDKitKmpSampleApp/androidApp/src/main/AndroidManifest.xml new file mode 100644 index 00000000..5a464e76 --- /dev/null +++ b/kotlin/Examples/IDKitKmpSampleApp/androidApp/src/main/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/kotlin/Examples/IDKitKmpSampleApp/androidApp/src/main/java/com/worldcoin/idkit/kmpsample/android/MainActivity.kt b/kotlin/Examples/IDKitKmpSampleApp/androidApp/src/main/java/com/worldcoin/idkit/kmpsample/android/MainActivity.kt new file mode 100644 index 00000000..bb81f725 --- /dev/null +++ b/kotlin/Examples/IDKitKmpSampleApp/androidApp/src/main/java/com/worldcoin/idkit/kmpsample/android/MainActivity.kt @@ -0,0 +1,218 @@ +package com.worldcoin.idkit.kmpsample.android + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import com.worldcoin.idkit.kmpsample.shared.SampleController +import com.worldcoin.idkit.kmpsample.shared.SampleEnvironment +import com.worldcoin.idkit.kmpsample.shared.SamplePreset + +class MainActivity : ComponentActivity() { + private val controller = SampleController() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + handleIntent(intent) + + setContent { + SampleScreen( + controller = controller, + onOpenConnector = { url -> + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + }, + ) + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleIntent(intent) + } + + override fun onDestroy() { + super.onDestroy() + controller.dispose() + } + + private fun handleIntent(intent: Intent?) { + val callbackUrl = intent?.data?.toString() ?: return + controller.handleDeepLink(callbackUrl) + } +} + +@Composable +private fun SampleScreen( + controller: SampleController, + onOpenConnector: (String) -> Unit, +) { + val state by controller.state.collectAsState() + + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text("IDKit KMP Sample", style = MaterialTheme.typography.headlineSmall) + + Text("Request", style = MaterialTheme.typography.titleMedium) + FormField("App ID", state.appId, enabled = false) {} + FormField("RP ID", state.rpId, enabled = false) {} + FormField("Action", state.action) { controller.setAction(it) } + FormField("Signal", state.signal) { controller.setSignal(it) } + EnvironmentSelector( + selected = state.environment, + onSelect = { controller.setEnvironment(it) }, + ) + PresetSelector( + selected = state.preset, + onSelect = { controller.setPreset(it) }, + ) + + Button( + onClick = { controller.generateRequest() }, + enabled = !state.isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (state.isLoading) "Generating..." else "Generate Connector URL") + } + + state.connectorUrl?.let { connectorUrl -> + Text("Connector URL", style = MaterialTheme.typography.titleMedium) + Button( + onClick = { onOpenConnector(connectorUrl) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Open Connector URL") + } + SelectionContainer { + Text(connectorUrl, fontFamily = FontFamily.Monospace) + } + } + + Text("Logs", style = MaterialTheme.typography.titleMedium) + SelectionContainer { + Text( + text = state.logs.ifBlank { "No logs yet." }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 180.dp) + .border(1.dp, MaterialTheme.colorScheme.outline) + .padding(12.dp), + fontFamily = FontFamily.Monospace, + ) + } + } + } + } +} + +@Composable +private fun FormField( + label: String, + value: String, + enabled: Boolean = true, + onValueChange: (String) -> Unit, +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label) }, + modifier = Modifier.fillMaxWidth(), + enabled = enabled, + singleLine = true, + ) +} + +@Composable +private fun EnvironmentSelector( + selected: SampleEnvironment, + onSelect: (SampleEnvironment) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Environment", style = MaterialTheme.typography.labelLarge) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + SampleEnvironment.entries.forEach { environment -> + if (selected == environment) { + FilledTonalButton(onClick = {}, modifier = Modifier.weight(1f)) { + Text(environment.label) + } + } else { + OutlinedButton( + onClick = { onSelect(environment) }, + modifier = Modifier.weight(1f), + ) { + Text(environment.label) + } + } + } + } + } +} + +@Composable +private fun PresetSelector( + selected: SamplePreset, + onSelect: (SamplePreset) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Preset", style = MaterialTheme.typography.labelLarge) + + SamplePreset.entries + .chunked(2) + .forEach { rowItems -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + rowItems.forEach { preset -> + if (selected == preset) { + FilledTonalButton(onClick = {}, modifier = Modifier.weight(1f)) { + Text(preset.label) + } + } else { + OutlinedButton( + onClick = { onSelect(preset) }, + modifier = Modifier.weight(1f), + ) { + Text(preset.label) + } + } + } + } + } + } +} diff --git a/kotlin/Examples/IDKitKmpSampleApp/build.gradle.kts b/kotlin/Examples/IDKitKmpSampleApp/build.gradle.kts new file mode 100644 index 00000000..2cdbc869 --- /dev/null +++ b/kotlin/Examples/IDKitKmpSampleApp/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + id("org.jetbrains.kotlin.multiplatform") version "2.3.21" apply false + id("org.jetbrains.kotlin.android") version "2.3.21" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "2.3.21" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false + id("com.android.application") version "8.11.2" apply false + id("com.android.library") version "8.11.2" apply false +} diff --git a/kotlin/Examples/IDKitKmpSampleApp/gradle.properties b/kotlin/Examples/IDKitKmpSampleApp/gradle.properties new file mode 100644 index 00000000..e2723174 --- /dev/null +++ b/kotlin/Examples/IDKitKmpSampleApp/gradle.properties @@ -0,0 +1,6 @@ +# Version used when building :idkit from source (kotlin/gradle.properties owns the release version). +version=0.1.0 + +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official diff --git a/kotlin/Examples/IDKitKmpSampleApp/gradle/wrapper/gradle-wrapper.jar b/kotlin/Examples/IDKitKmpSampleApp/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..980502d167d3610f88fa03b2f717935189d9fbcf GIT binary patch literal 43739 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 z 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/kotlin/Examples/IDKitKmpSampleApp/gradlew.bat b/kotlin/Examples/IDKitKmpSampleApp/gradlew.bat new file mode 100644 index 00000000..9b42019c --- /dev/null +++ b/kotlin/Examples/IDKitKmpSampleApp/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/kotlin/Examples/IDKitKmpSampleApp/iosApp/IDKitKmpSampleApp/ContentView.swift b/kotlin/Examples/IDKitKmpSampleApp/iosApp/IDKitKmpSampleApp/ContentView.swift new file mode 100644 index 00000000..3cffe7b9 --- /dev/null +++ b/kotlin/Examples/IDKitKmpSampleApp/iosApp/IDKitKmpSampleApp/ContentView.swift @@ -0,0 +1,118 @@ +import SwiftUI +import SampleShared + +/// Bridges the shared Kotlin `SampleController` (StateFlow) into SwiftUI. +@MainActor +final class SampleViewModel: ObservableObject { + @Published private(set) var state = SampleUiState( + appId: "", rpId: "", action: "", signal: "", + environment: .production, preset: .device, + connectorUrl: nil, isLoading: false, logs: "" + ) + + private let controller = SampleController() + + init() { + state = controller.state.value as! SampleUiState + controller.watchState { [weak self] newState in + self?.state = newState + } + } + + deinit { + controller.dispose() + } + + func setAction(_ value: String) { controller.setAction(value: value) } + func setSignal(_ value: String) { controller.setSignal(value: value) } + func setEnvironment(_ value: SampleEnvironment) { controller.setEnvironment(value: value) } + func setPreset(_ value: SamplePreset) { controller.setPreset(value: value) } + func generateRequest() { controller.generateRequest() } + func handleDeepLink(_ url: URL) { controller.handleDeepLink(url: url.absoluteString) } +} + +struct ContentView: View { + @StateObject private var model = SampleViewModel() + // Qualified: the shared framework also exports IDKit's `Environment` enum. + @SwiftUI.Environment(\.openURL) private var openURL + + var body: some View { + NavigationView { + Form { + Section("Request") { + HStack { + Text("App ID") + Spacer() + Text(model.state.appId) + .font(.footnote.monospaced()) + .foregroundColor(.secondary) + } + HStack { + Text("RP ID") + Spacer() + Text(model.state.rpId) + .font(.footnote.monospaced()) + .foregroundColor(.secondary) + } + TextField("Action", text: Binding( + get: { model.state.action }, + set: { model.setAction($0) } + )) + TextField("Signal", text: Binding( + get: { model.state.signal }, + set: { model.setSignal($0) } + )) + Picker("Environment", selection: Binding( + get: { model.state.environment }, + set: { model.setEnvironment($0) } + )) { + Text("production").tag(SampleEnvironment.production) + Text("staging").tag(SampleEnvironment.staging) + } + Picker("Preset", selection: Binding( + get: { model.state.preset }, + set: { model.setPreset($0) } + )) { + Text("orb").tag(SamplePreset.orb) + Text("secure document").tag(SamplePreset.secureDocument) + Text("document").tag(SamplePreset.document) + Text("device").tag(SamplePreset.device) + Text("selfie check").tag(SamplePreset.selfieCheck) + Text("identity check").tag(SamplePreset.identityCheck) + } + } + + Section { + Button(model.state.isLoading ? "Generating..." : "Generate Connector URL") { + model.generateRequest() + } + .disabled(model.state.isLoading) + } + + if let connectorUrl = model.state.connectorUrl { + Section("Connector URL") { + Button("Open Connector URL") { + if let url = URL(string: connectorUrl) { + openURL(url) + } + } + Text(connectorUrl) + .font(.footnote.monospaced()) + .textSelection(.enabled) + } + } + + Section("Logs") { + Text(model.state.logs.isEmpty ? "No logs yet." : model.state.logs) + .font(.footnote.monospaced()) + .frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading) + .textSelection(.enabled) + } + } + .navigationTitle("IDKit KMP Sample") + } + .onOpenURL { url in + model.handleDeepLink(url) + } + } +} diff --git a/kotlin/Examples/IDKitKmpSampleApp/iosApp/IDKitKmpSampleApp/IDKitKmpSampleApp.swift b/kotlin/Examples/IDKitKmpSampleApp/iosApp/IDKitKmpSampleApp/IDKitKmpSampleApp.swift new file mode 100644 index 00000000..9767bd6c --- /dev/null +++ b/kotlin/Examples/IDKitKmpSampleApp/iosApp/IDKitKmpSampleApp/IDKitKmpSampleApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct IDKitKmpSampleApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/kotlin/Examples/IDKitKmpSampleApp/iosApp/IDKitKmpSampleApp/Info.plist b/kotlin/Examples/IDKitKmpSampleApp/iosApp/IDKitKmpSampleApp/Info.plist new file mode 100644 index 00000000..8cd93591 --- /dev/null +++ b/kotlin/Examples/IDKitKmpSampleApp/iosApp/IDKitKmpSampleApp/Info.plist @@ -0,0 +1,37 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + IDKit KMP Sample + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleURLTypes + + + CFBundleURLName + org.worldcoin.idkit.kmp.sample.callback + CFBundleURLSchemes + + idkitkmpsample + + + + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + UILaunchScreen + + + diff --git a/kotlin/Examples/IDKitKmpSampleApp/iosApp/build-shared-framework.sh b/kotlin/Examples/IDKitKmpSampleApp/iosApp/build-shared-framework.sh new file mode 100755 index 00000000..3240d9e8 --- /dev/null +++ b/kotlin/Examples/IDKitKmpSampleApp/iosApp/build-shared-framework.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Xcode build phase: builds the Kotlin shared framework for the current +# Xcode configuration/SDK via Gradle's embedAndSignAppleFrameworkForXcode. +# +# Xcode's environment has no JAVA_HOME and a minimal PATH, so locate a +# Gradle-compatible JDK (17-21) across common install locations first. +set -eu + +java_major() { + "$1/bin/java" -version 2>&1 | head -n 1 | sed -E 's/.*version "([0-9]+).*/\1/' +} + +resolve_jdk() { + for candidate in \ + "${JAVA_HOME:-}" \ + "$(/usr/libexec/java_home -v 17 2>/dev/null || true)" \ + "$(/usr/libexec/java_home -v 21 2>/dev/null || true)" \ + /opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home \ + /usr/local/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home \ + /opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home \ + /usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home \ + "$HOME/Applications/Android Studio.app/Contents/jbr/Contents/Home" \ + "/Applications/Android Studio.app/Contents/jbr/Contents/Home"; do + [ -n "$candidate" ] && [ -x "$candidate/bin/java" ] || continue + major="$(java_major "$candidate" || true)" + case "$major" in + 17|18|19|20|21) + echo "$candidate" + return 0 + ;; + esac + done + return 1 +} + +if JDK="$(resolve_jdk)"; then + export JAVA_HOME="$JDK" + echo "Using JAVA_HOME=$JAVA_HOME" +else + echo "error: No JDK 17-21 found for Gradle. Install one (e.g. brew install openjdk@17)" >&2 + exit 1 +fi + +cd "$SRCROOT/.." +exec ./gradlew :shared:embedAndSignAppleFrameworkForXcode diff --git a/kotlin/Examples/IDKitKmpSampleApp/iosApp/project.yml b/kotlin/Examples/IDKitKmpSampleApp/iosApp/project.yml new file mode 100644 index 00000000..9feef824 --- /dev/null +++ b/kotlin/Examples/IDKitKmpSampleApp/iosApp/project.yml @@ -0,0 +1,52 @@ +name: IDKitKmpSampleApp +options: + minimumXcodeGenVersion: 2.38.0 + deploymentTarget: + iOS: 15.0 +targets: + IDKitKmpSampleApp: + type: application + platform: iOS + deploymentTarget: 15.0 + sources: + - path: IDKitKmpSampleApp + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: org.worldcoin.idkit.kmp.sample + INFOPLIST_FILE: IDKitKmpSampleApp/Info.plist + SWIFT_VERSION: 5.9 + CURRENT_PROJECT_VERSION: 1 + MARKETING_VERSION: 1.0 + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: "" + FRAMEWORK_SEARCH_PATHS: $(inherited) $(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME) + OTHER_LDFLAGS: $(inherited) -framework SampleShared + ENABLE_USER_SCRIPT_SANDBOXING: "NO" + preBuildScripts: + - script: | + "$SRCROOT/build-shared-framework.sh" + name: Build Kotlin Shared Framework + basedOnDependencyAnalysis: false +schemes: + IDKitKmpSampleApp: + build: + targets: + IDKitKmpSampleApp: all + preActions: + - script: | + REPO_ROOT="${SRCROOT}/../../../.." + LIB_SIM="${REPO_ROOT}/target/aarch64-apple-ios-sim/release/libidkit_kmp.a" + LIB_DEVICE="${REPO_ROOT}/target/aarch64-apple-ios/release/libidkit_kmp.a" + + if [ ! -f "${LIB_SIM}" ] && [ ! -f "${LIB_DEVICE}" ]; then + echo "error: IDKit KMP native artifacts not found." >&2 + echo "error: Run the following from the repo root, then build again:" >&2 + echo "error: bash scripts/build-kotlin.sh" >&2 + exit 1 + fi + name: Check IDKit KMP Dependencies + settingsTarget: IDKitKmpSampleApp + run: + config: Debug + test: + config: Debug diff --git a/kotlin/Examples/IDKitKmpSampleApp/settings.gradle.kts b/kotlin/Examples/IDKitKmpSampleApp/settings.gradle.kts new file mode 100644 index 00000000..e0a6d4bb --- /dev/null +++ b/kotlin/Examples/IDKitKmpSampleApp/settings.gradle.kts @@ -0,0 +1,22 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "IDKitKmpSampleApp" + +include(":shared") +include(":androidApp") +include(":idkit") +project(":idkit").projectDir = file("../../idkit") diff --git a/kotlin/Examples/IDKitKmpSampleApp/shared/build.gradle.kts b/kotlin/Examples/IDKitKmpSampleApp/shared/build.gradle.kts new file mode 100644 index 00000000..daf1d65e --- /dev/null +++ b/kotlin/Examples/IDKitKmpSampleApp/shared/build.gradle.kts @@ -0,0 +1,55 @@ +plugins { + id("org.jetbrains.kotlin.multiplatform") + id("org.jetbrains.kotlin.plugin.serialization") + id("com.android.library") +} + +kotlin { + jvmToolchain(17) + + androidTarget() + + listOf(iosArm64(), iosSimulatorArm64(), iosX64()).forEach { target -> + target.binaries.framework { + baseName = "SampleShared" + isStatic = true + // Export the SDK so Swift sees IDKit types through this framework. + export(project(":idkit")) + } + } + + sourceSets { + commonMain { + dependencies { + api(project(":idkit")) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + implementation("io.ktor:ktor-client-core:3.5.1") + } + } + androidMain { + dependencies { + implementation("io.ktor:ktor-client-okhttp:3.5.1") + } + } + iosMain { + dependencies { + implementation("io.ktor:ktor-client-darwin:3.5.1") + } + } + } +} + +android { + namespace = "com.worldcoin.idkit.kmpsample.shared" + compileSdk = 35 + + defaultConfig { + minSdk = 23 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} diff --git a/kotlin/Examples/IDKitKmpSampleApp/shared/src/commonMain/kotlin/com/worldcoin/idkit/kmpsample/shared/SampleController.kt b/kotlin/Examples/IDKitKmpSampleApp/shared/src/commonMain/kotlin/com/worldcoin/idkit/kmpsample/shared/SampleController.kt new file mode 100644 index 00000000..63a76804 --- /dev/null +++ b/kotlin/Examples/IDKitKmpSampleApp/shared/src/commonMain/kotlin/com/worldcoin/idkit/kmpsample/shared/SampleController.kt @@ -0,0 +1,253 @@ +package com.worldcoin.idkit.kmpsample.shared + +import com.worldcoin.idkit.DocumentType +import com.worldcoin.idkit.Environment +import com.worldcoin.idkit.IDKit +import com.worldcoin.idkit.IDKitRequest +import com.worldcoin.idkit.IDKitRequestConfig +import com.worldcoin.idkit.IDKitStatus +import com.worldcoin.idkit.IdentityAttribute +import com.worldcoin.idkit.Preset +import com.worldcoin.idkit.RpContext +import com.worldcoin.idkit.deviceLegacy +import com.worldcoin.idkit.documentLegacy +import com.worldcoin.idkit.identityCheck +import com.worldcoin.idkit.orbLegacy +import com.worldcoin.idkit.secureDocumentLegacy +import com.worldcoin.idkit.selfieCheckLegacy +import com.worldcoin.idkit.statusFlow +import io.ktor.client.HttpClient +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlin.time.Duration.Companion.milliseconds + +enum class SampleEnvironment(val label: String) { + PRODUCTION(label = "production"), + STAGING(label = "staging"), +} + +enum class SamplePreset(val label: String) { + ORB(label = "orb"), + SECURE_DOCUMENT(label = "secure document"), + DOCUMENT(label = "document"), + DEVICE(label = "device"), + SELFIE_CHECK(label = "selfie check"), + IDENTITY_CHECK(label = "identity check"), + ; + + internal fun toPreset(signal: String): Preset = when (this) { + ORB -> orbLegacy(signal = signal) + SECURE_DOCUMENT -> secureDocumentLegacy(signal = signal) + DOCUMENT -> documentLegacy(signal = signal) + DEVICE -> deviceLegacy(signal = signal) + SELFIE_CHECK -> selfieCheckLegacy(signal = signal) + IDENTITY_CHECK -> identityCheck( + attributes = listOf( + IdentityAttribute.MinimumAge(value = 21u), + IdentityAttribute.Nationality(value = "JPN"), + IdentityAttribute.DocumentType(value = DocumentType.PASSPORT), + ), + ) + } +} + +data class SampleUiState( + val appId: String = "app_d8bbd5341f16fb97a61e644b7e169c0e", + val rpId: String = "rp_7b4f23dd5fb2a826", + val action: String = "test-action", + val signal: String = "signal", + val environment: SampleEnvironment = SampleEnvironment.PRODUCTION, + val preset: SamplePreset = SamplePreset.DEVICE, + val connectorUrl: String? = null, + val isLoading: Boolean = false, + val logs: String = "", +) + +@Serializable +private data class SignaturePayload( + val sig: String, + val nonce: String, + @SerialName(value = "created_at") val createdAt: Long, + @SerialName(value = "expires_at") val expiresAt: Long, +) + +/** + * Shared verification flow driven by both the Compose and SwiftUI UIs: + * fetch an RP signature from the demo backend, create an IDKit request, + * expose the connector URL, poll for the proof, and verify it server-side. + */ +class SampleController { + private val signatureEndpoint = "https://idkit-js-example.vercel.app/api/rp-signature" + private val verifyEndpoint = "https://idkit-js-example.vercel.app/api/verify-proof" + private val returnToUrl = "idkitkmpsample://callback" + + private val scope = CoroutineScope(context = SupervisorJob() + Dispatchers.Main) + private val http = HttpClient() + private val json = Json { ignoreUnknownKeys = true } + + private val _state = MutableStateFlow(value = SampleUiState()) + val state = _state.asStateFlow() + + private var pendingRequest: IDKitRequest? = null + private var pollJob: Job? = null + + fun setAction(value: String) = _state.update { it.copy(action = value) } + fun setSignal(value: String) = _state.update { it.copy(signal = value) } + fun setEnvironment(value: SampleEnvironment) = _state.update { it.copy(environment = value) } + fun setPreset(value: SamplePreset) = _state.update { it.copy(preset = value) } + + /** + * Callback-based observation for SwiftUI (StateFlow generics erase in ObjC). + * Observation lasts until [dispose] cancels the controller scope. + */ + fun watchState(block: (SampleUiState) -> Unit) { + scope.launch { state.collect { block(it) } } + } + + fun generateRequest() { + val snapshot = _state.value + scope.launch { + _state.update { it.copy(isLoading = true) } + try { + log("Fetching RP signature from $signatureEndpoint") + val signature = fetchSignaturePayload(snapshot.action) + + val config = IDKitRequestConfig( + appId = snapshot.appId, + action = snapshot.action, + rpContext = RpContext( + rpId = snapshot.rpId, + nonce = signature.nonce, + createdAt = signature.createdAt.toULong(), + expiresAt = signature.expiresAt.toULong(), + signature = signature.sig, + ), + actionDescription = "KMP sample", + allowLegacyProofs = false, + requireUserPresence = false, + returnTo = returnToUrl, + environment = when (snapshot.environment) { + SampleEnvironment.PRODUCTION -> Environment.PRODUCTION + SampleEnvironment.STAGING -> Environment.STAGING + }, + ) + + val request = IDKit.request(config).preset(snapshot.preset.toPreset(snapshot.signal)) + + pendingRequest?.close() + pendingRequest = request + _state.update { it.copy(connectorUrl = request.connectorURI) } + log("Using preset: ${snapshot.preset.label}") + log("Generated request ID: ${request.requestId}") + log("Configured return_to callback: $returnToUrl") + startPolling(request, reason = "request generation") + } catch (error: Throwable) { + log("Error: ${error.message ?: error::class.simpleName}") + } finally { + _state.update { it.copy(isLoading = false) } + } + } + } + + fun handleDeepLink(url: String) { + log("Received deep link callback: $url") + val request = pendingRequest + if (request == null) { + log("No pending request found. Generate a connector URL first.") + return + } + if (pollJob?.isActive == true) { + log("Polling already running for request ${request.requestId}.") + return + } + startPolling(request, reason = "deep link callback") + } + + fun dispose() { + pendingRequest?.close() + http.close() + scope.cancel() + } + + private fun startPolling(request: IDKitRequest, reason: String) { + pollJob?.cancel() + log("Started polling for request ${request.requestId} (trigger: $reason).") + pollJob = scope.launch { + val finished = withTimeoutOrNull(timeout = 180_000.milliseconds) { + request.statusFlow(pollIntervalMs = 2_000u).collect { status -> + when (status) { + IDKitStatus.WaitingForConnection -> log("Waiting for World App to connect...") + IDKitStatus.AwaitingConfirmation -> log("Awaiting user confirmation...") + is IDKitStatus.Confirmed -> { + pendingRequest = null + request.close() + log("Proof confirmed. Calling verify endpoint: $verifyEndpoint") + try { + log("Verify response: ${verifyProof(resultJson = status.result.rawJson)}") + } catch (error: Throwable) { + log("Verify request failed: ${error.message ?: error::class.simpleName}") + } + } + + is IDKitStatus.Failed -> log("Proof completion failed: ${status.error.rawValue}") + is IDKitStatus.NetworkingError -> log("Networking error (${status.error.rawValue}), retrying...") + } + } + } + if (finished == null) { + log("Proof completion failed: timeout") + } + } + } + + private suspend fun fetchSignaturePayload(action: String): SignaturePayload { + val response = http.post(urlString = signatureEndpoint) { + contentType(ContentType.Application.Json) + setBody(buildJsonObject { put("action", action) }.toString()) + } + val body = response.bodyAsText() + check(value = response.status.isSuccess()) { "Backend request failed (${response.status.value}): $body" } + return json.decodeFromString(deserializer = SignaturePayload.serializer(), string = body) + } + + private suspend fun verifyProof(resultJson: String): String { + val payload = buildJsonObject { + put("rp_id", _state.value.rpId) + put( + key = "devPortalPayload", + element = json.decodeFromString(deserializer = JsonObject.serializer(), string = resultJson), + ) + } + val response = http.post(urlString = verifyEndpoint) { + contentType(ContentType.Application.Json) + setBody(payload.toString()) + } + val body = response.bodyAsText() + check(value = response.status.isSuccess()) { "Verify failed (${response.status.value}): $body" } + return body + } + + private fun log(message: String) { + _state.update { it.copy(logs = it.logs + "$message\n") } + } +} diff --git a/kotlin/Examples/IDKitSampleApp/app/build.gradle.kts b/kotlin/Examples/IDKitSampleApp/app/build.gradle.kts index 0060a993..1419c0df 100644 --- a/kotlin/Examples/IDKitSampleApp/app/build.gradle.kts +++ b/kotlin/Examples/IDKitSampleApp/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("com.android.application") - kotlin("android") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") } android { @@ -30,18 +31,10 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - } - buildFeatures { compose = true } - composeOptions { - kotlinCompilerExtensionVersion = "1.5.14" - } - packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" @@ -49,8 +42,12 @@ android { } } +kotlin { + jvmToolchain(17) +} + dependencies { - implementation(project(":bindings")) + implementation(project(":idkit")) implementation("androidx.core:core-ktx:1.15.0") implementation("androidx.activity:activity-compose:1.10.1") @@ -58,7 +55,7 @@ dependencies { implementation("androidx.compose.ui:ui-tooling-preview:1.7.8") implementation("androidx.compose.material3:material3:1.3.1") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") implementation("com.squareup.okhttp3:okhttp:4.12.0") debugImplementation("androidx.compose.ui:ui-tooling:1.7.8") diff --git a/kotlin/Examples/IDKitSampleApp/app/src/main/java/com/worldcoin/idkit/sample/MainActivity.kt b/kotlin/Examples/IDKitSampleApp/app/src/main/java/com/worldcoin/idkit/sample/MainActivity.kt index 5c0f6352..2359c96f 100644 --- a/kotlin/Examples/IDKitSampleApp/app/src/main/java/com/worldcoin/idkit/sample/MainActivity.kt +++ b/kotlin/Examples/IDKitSampleApp/app/src/main/java/com/worldcoin/idkit/sample/MainActivity.kt @@ -30,9 +30,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp +import com.worldcoin.idkit.DocumentType +import com.worldcoin.idkit.Environment import com.worldcoin.idkit.IDKit import com.worldcoin.idkit.IDKitRequest import com.worldcoin.idkit.IDKitRequestConfig +import com.worldcoin.idkit.IdentityAttribute +import com.worldcoin.idkit.RpContext import com.worldcoin.idkit.documentLegacy import com.worldcoin.idkit.idkitResultToJson import com.worldcoin.idkit.deviceLegacy @@ -54,10 +58,6 @@ import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONArray import org.json.JSONObject -import uniffi.idkit_core.DocumentType -import uniffi.idkit_core.Environment -import uniffi.idkit_core.IdentityAttribute -import uniffi.idkit_core.RpContext class MainActivity : ComponentActivity() { private val model = SampleModel() @@ -328,6 +328,8 @@ private class SampleModel { fun clear() { scope.cancel() + pendingRequest?.close() + pendingRequest = null } fun setAppForeground(isForeground: Boolean) { @@ -374,6 +376,7 @@ private class SampleModel { completionJob?.cancel() connectorURI = request.connectorURI + pendingRequest?.close() pendingRequest = request deepLinkReceivedForPendingRequest = false @@ -456,12 +459,18 @@ private class SampleModel { log("Verify response: $verifyResult") } catch (error: Throwable) { log("Verify request failed: ${error.message ?: error::class.simpleName}") + } finally { + request.close() } return@launch } is com.worldcoin.idkit.IDKitStatus.Failed -> { log("Proof completion failed: ${status.error.rawValue}") + if (pendingRequest === request) { + pendingRequest = null + } + request.close() return@launch } diff --git a/kotlin/Examples/IDKitSampleApp/build.gradle.kts b/kotlin/Examples/IDKitSampleApp/build.gradle.kts index 0cfeec76..2cdbc869 100644 --- a/kotlin/Examples/IDKitSampleApp/build.gradle.kts +++ b/kotlin/Examples/IDKitSampleApp/build.gradle.kts @@ -1,4 +1,8 @@ plugins { - id("com.android.application") version "8.7.3" apply false - kotlin("android") version "1.9.24" apply false + id("org.jetbrains.kotlin.multiplatform") version "2.3.21" apply false + id("org.jetbrains.kotlin.android") version "2.3.21" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "2.3.21" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false + id("com.android.application") version "8.11.2" apply false + id("com.android.library") version "8.11.2" apply false } diff --git a/kotlin/Examples/IDKitSampleApp/gradle.properties b/kotlin/Examples/IDKitSampleApp/gradle.properties index d2418ac5..61ed6adc 100644 --- a/kotlin/Examples/IDKitSampleApp/gradle.properties +++ b/kotlin/Examples/IDKitSampleApp/gradle.properties @@ -1,4 +1,6 @@ -org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +# Version used when building :idkit from source (kotlin/gradle.properties owns the release version). +version=5.0.0 + +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 android.useAndroidX=true kotlin.code.style=official -version=4.0.0 diff --git a/kotlin/Examples/IDKitSampleApp/settings.gradle.kts b/kotlin/Examples/IDKitSampleApp/settings.gradle.kts index a0a4ca48..6e416418 100644 --- a/kotlin/Examples/IDKitSampleApp/settings.gradle.kts +++ b/kotlin/Examples/IDKitSampleApp/settings.gradle.kts @@ -17,5 +17,5 @@ dependencyResolutionManagement { rootProject.name = "IDKitSampleApp" include(":app") -include(":bindings") -project(":bindings").projectDir = file("../../bindings") +include(":idkit") +project(":idkit").projectDir = file("../../idkit") diff --git a/kotlin/README.md b/kotlin/README.md index 7b081775..211962c8 100644 --- a/kotlin/README.md +++ b/kotlin/README.md @@ -1,261 +1,112 @@ # IDKit Kotlin SDK -Kotlin SDK for World ID verification, backed by the Rust core via UniFFI. - -## Installation - -The Kotlin SDK is published to Maven Central as `com.worldcoin:idkit` — once a version is released there, add `mavenCentral()` to your repositories and depend on it with no authentication. Release builds are also published to GitHub Packages; dev builds (`X.Y.Z-dev.`) are published there only. - -GitHub Packages requires authentication for Maven downloads, even for public packages. -Create a token with `read:packages` and expose it through environment variables. - -```kotlin -dependencyResolutionManagement { - repositories { - mavenCentral() - maven { - url = uri("https://maven.pkg.github.com/worldcoin/idkit") - credentials { - username = System.getenv("GITHUB_ACTOR") - password = System.getenv("GITHUB_TOKEN") - } - } - } -} -``` - -For local integration testing, build the Kotlin artifacts, publish them to `mavenLocal()`, and add `mavenLocal()` to the consuming app repositories: - -```bash -bash scripts/build-kotlin.sh -./kotlin/Examples/IDKitSampleApp/gradlew -p kotlin :bindings:publishToMavenLocal -``` - -Then add `mavenLocal()` to the consuming app repositories: +World ID SDK for Kotlin Multiplatform — one Kotlin API for **Android and iOS**, backed by the same Rust core as every other IDKit SDK. Plain Android apps consume it as a regular AAR; KMP projects use it from `commonMain`. ```kotlin -dependencyResolutionManagement { - repositories { - mavenLocal() - google() - mavenCentral() - } -} -``` - -Then add the dependency: - -```kotlin -implementation("com.worldcoin:idkit:") -``` - -## Local setup - -From repo root: - -```bash -bash scripts/build-kotlin.sh -``` - -This builds Rust artifacts, regenerates UniFFI Kotlin bindings, and copies native libraries used by the Kotlin module. - -## Canonical Kotlin API - -- Entry points: - - `IDKit.request(config: IDKitRequestConfig)` - - `IDKit.createSession(config: IDKitSessionConfig)` - - `IDKit.proveSession(sessionId: String, config: IDKitSessionConfig)` -- Request object: - - `connectorURI: String` - - `requestId: String` - - `pollStatusOnce(): IDKitStatus` - - `pollUntilCompletion(options: IDKitPollOptions): IDKitCompletionResult` -- Hashing: - - `IDKit.hashSignal(signal: String)` - - `IDKit.hashSignal(signal: ByteArray)` - -## Quickstart - -```kotlin -import com.worldcoin.idkit.CredentialRequest -import com.worldcoin.idkit.IDKit -import com.worldcoin.idkit.IDKitPollOptions -import com.worldcoin.idkit.IDKitRequestConfig -import com.worldcoin.idkit.IDKitCompletionResult -import com.worldcoin.idkit.IdentityAttribute -import com.worldcoin.idkit.selfieCheckLegacy -import com.worldcoin.idkit.identityCheck -import com.worldcoin.idkit.orbLegacy -import com.worldcoin.idkit.deviceLegacy -import uniffi.idkit_core.DocumentType -import uniffi.idkit_core.Environment -import uniffi.idkit_core.RpContext - -val rpContext = RpContext( - rpId = "rp_1234567890abcdef", - nonce = backendNonce, - createdAt = backendCreatedAt, - expiresAt = backendExpiresAt, - signature = backendSig, -) - val config = IDKitRequestConfig( - appId = "app_staging_1234567890abcdef", - action = "login", - rpContext = rpContext, - actionDescription = "Log in", - bridgeUrl = null, - allowLegacyProofs = false, - requireUserPresence = false, - overrideConnectBaseUrl = null, - returnTo = null, - environment = Environment.STAGING, + appId = "app_...", + action = "my-action", + rpContext = RpContext(rpId = "rp_...", nonce = nonce, createdAt = createdAt, expiresAt = expiresAt, signature = sig), + returnTo = "myapp://callback", ) -val request = IDKit - .request(config) - .preset(orbLegacy(signal = "user-123")) +val request = IDKit.request(config).preset(orbLegacy(signal = "my-signal")) +openWorldApp(request.connectorURI) -println("Connector URL: ${request.connectorURI}") - -when (val completion = request.pollUntilCompletion(IDKitPollOptions())) { - is IDKitCompletionResult.Success -> println("Verified: ${completion.result.protocolVersion}") - is IDKitCompletionResult.Failure -> println("Failed: ${completion.error.rawValue}") +when (val completion = request.pollUntilCompletion()) { + is IDKitCompletionResult.Success -> verifyOnBackend(completion.result.rawJson) + is IDKitCompletionResult.Failure -> handle(completion.error) } +request.close() ``` -For orb-or-device legacy verification, use: +## Installation -```kotlin -val request = IDKit - .request(config) - .preset(deviceLegacy(signal = "user-123")) -``` +The SDK is published to Maven Central as `com.worldcoin:idkit` — add `mavenCentral()` to your repositories and depend on it with no authentication. Release builds are also published to GitHub Packages; dev builds (`X.Y.Z-dev.`) are published there only (GitHub Packages requires a token with `read:packages` even for public packages). -For selfie-check verification, use: +Plain Android app or a KMP project's `commonMain` — same coordinates either way: ```kotlin -val request = IDKit - .request(config) - .preset(selfieCheckLegacy(signal = "user-123")) +dependencies { + implementation("com.worldcoin:idkit:") +} ``` -For document-based identity attestation, use: +Pure-iOS (Swift-only) apps should prefer the [Swift SDK](../swift), which has first-class Swift types. -```kotlin -val request = IDKit - .request(config) - .preset( - identityCheck( - attributes = listOf( - IdentityAttribute.MinimumAge(21u), - IdentityAttribute.Nationality("JPN"), - IdentityAttribute.DocumentType(DocumentType.PASSPORT), - ), - ), - ) -``` +### Migrating from 4.x -## Credential request options parity +5.0.0 replaces the UniFFI/JNA Android-only implementation with the Kotlin Multiplatform one. Coordinates (`com.worldcoin:idkit`) and package (`com.worldcoin.idkit`) are unchanged, but there are breaking API changes: -```kotlin -import com.worldcoin.idkit.CredentialRequest -import com.worldcoin.idkit.CredentialRequestOptions -import uniffi.idkit_core.CredentialType +- `IDKitBuilder.preset(...)` / `.constraints(...)` are now `suspend` (they open the bridge connection; 4.x did this blocking). +- Call `IDKitRequest.close()` when done with a request to release the native handle (safe to call twice). +- Types that previously leaked from `uniffi.idkit_core.*` (`RpContext`, `Environment`, `DocumentType`, `IdentityAttribute`, `ConstraintNode`, …) now live in `com.worldcoin.idkit` — update imports. -val orb = CredentialRequest( - CredentialType.ORB, - options = CredentialRequestOptions( - signal = "user-123", - genesisIssuedAtMin = 1_700_000_000u, - expiresAtMin = 1_800_000_000u, - ), -) -``` +## Architecture -## Session flow example +The SDK calls the Rust core **directly** through a small hand-written C ABI — it does not use UniFFI-generated bindings: -```kotlin -val sessionRequest = IDKit - .createSession(sessionConfig) - .constraints(anyOf(CredentialRequest(CredentialType.ORB))) - -val completion = sessionRequest.pollUntilCompletion() ``` - -## Android sample app - -A runnable Android sample exists at: - -- `kotlin/Examples/IDKitSampleApp` - -See `kotlin/Examples/IDKitSampleApp/README.md` for run steps. - -## Migration notes (`IdKit` -> `IDKit`) - -This release removes the legacy `IdKit` entrypoint and uses canonical `IDKit` naming. - -- `IdKit.request(...)` -> `IDKit.request(...)` -- old raw `IdKitBuilder` wrapper usage -> canonical `IDKitBuilder` -- old raw status/result wrappers -> `IDKitStatus` and `IDKitCompletionResult` - -## Local verification loop - -```bash -bash scripts/build-kotlin.sh + commonMain (kotlin/idkit) + public API + poll loop + status/error mapping + kotlinx-serialization DTOs for the JSON boundary + │ + internal expect object NativeBridge (10 fns) + ┌────────────┴────────────┐ + androidMain iosMain + JNA direct mapping Kotlin/Native cinterop + libidkit_kmp.so libidkit_kmp.a (static) + └────────────┬────────────┘ + rust/kmp-ffi (extern "C", JSON in/out) + │ + rust/core (idkit-core) ``` -If Gradle is available locally: +Why this shape: -```bash -gradle -p kotlin bindings:test -``` +- **Why not generate KMP bindings from UniFFI?** The Rust core uses UniFFI 0.31; no Kotlin Multiplatform binding generator supports it (Gobley, the maintained one, targets UniFFI 0.29.x, and the compiled-metadata formats are incompatible). Downgrading the workspace's UniFFI would regenerate the shipping Swift SDK bindings and couple future core upgrades to a third-party release cadence. +- **The C ABI** (`rust/kmp-ffi`, header at `rust/kmp-ffi/include/idkit_kmp.h`) passes JSON both ways and reuses the serde codecs the core already has. Every function returns an `{"ok": ...}` / `{"err": {code, message}}` envelope; panics are caught and converted to envelopes (never unwind across FFI); requests are opaque handles so double-free is a no-op; network-bound calls have a bounded 30s deadline and run off the main thread. It is independent of UniFFI versioning by construction. +- **Distinct native library name** (`libidkit_kmp` vs the UniFFI toolchain's `libidkit`) keeps host test artifacts and the Swift SDK's build products from colliding. -## Publishing +## Building -On production releases the Kotlin release workflow publishes to GitHub Packages and uploads a signed artifact to Maven Central (the first release awaits manual confirmation in the Central Portal before going live — see below). The GitHub Packages path uses GitHub's package credentials and can also be run locally: +Native artifacts are never committed; build them first from the repo root: ```bash -./kotlin/Examples/IDKitSampleApp/gradlew -p kotlin :bindings:publish +bash scripts/build-kotlin.sh # host lib + Android ABIs (Docker/cargo-ndk) + iOS static libs +SKIP_ANDROID=1 bash scripts/build-kotlin.sh # macOS host + iOS only (no Docker/NDK needed) ``` -Without `-Pidkit.publish.mavenCentral=true`, this does not configure Maven Central upload or signing tasks. +Outputs: -For local integration testing, publish to the local Maven repository with `:bindings:publishToMavenLocal` as described under [Installation](#installation). +- `target/release/libidkit_kmp.{dylib,so}` — host library for JVM unit tests +- `kotlin/idkit/src/androidMain/jniLibs//libidkit_kmp.so` — Android (gitignored) +- `target//release/libidkit_kmp.a` — iOS, referenced by the cinterop config -To publish to Maven Central from a local machine that already has credentials, keep the secrets in `~/.gradle/gradle.properties`: +Android cross-builds use the `kmp-android-release` cargo profile (`panic = "unwind"`) — **not** `android-release` — because the FFI layer's `catch_unwind` must be able to convert panics into error envelopes instead of aborting the host app. -```properties -mavenCentralUsername= -mavenCentralPassword= -signing.keyId= -signing.password= -signing.secretKeyRingFile=/path/to/secring.gpg -``` - -Then explicitly enable the Central publishing path for that Gradle invocation: +Then: ```bash -./kotlin/Examples/IDKitSampleApp/gradlew -p kotlin \ - -Pidkit.publish.mavenCentral=true \ - :bindings:publishToMavenCentral +cd kotlin +./gradlew :idkit:assemble # all targets enabled on this host +./gradlew :idkit:testReleaseUnitTest # commonTest on the host JVM (JNA → host lib) +./gradlew :idkit:iosSimulatorArm64Test # commonTest on the iOS simulator (cinterop, statically linked) +./gradlew :idkit:publishToMavenLocal # a guard task verifies the native artifacts for enabled targets ``` -To upload and release from the Central Portal deployment in one command, run: +Requires JDK 17+, the Android SDK (`local.properties` or `ANDROID_HOME`), and Xcode on macOS for the iOS targets. On Linux the iOS targets are disabled automatically; **publishing to a remote repository is macOS-only** (the build fails it elsewhere, because the upload would otherwise be missing the iOS variants). -```bash -./kotlin/Examples/IDKitSampleApp/gradlew -p kotlin \ - -Pidkit.publish.mavenCentral=true \ - :bindings:publishAndReleaseToMavenCentral -``` +## API notes -On production releases the workflow runs the upload-only `:bindings:publishToMavenCentral` step automatically (not `publishAndReleaseToMavenCentral`), using the Sonatype and GPG signing credentials stored as `production` environment secrets. The first release uploads to the Central Portal for manual confirmation before going live; a follow-up change switches it to fully automatic. +- `IDKitBuilder.preset(...)` / `.constraints(...)` are `suspend` and open the bridge connection over the network. +- Call `IDKitRequest.close()` when done with a request to release the native handle (safe to call twice; the samples do it after the terminal status). +- `IDKitResult.rawJson` is the untouched result JSON from the core — POST it verbatim to backend verification endpoints so unmodeled fields survive. +- `IDKit.hashSignal(String)` follows the JS `hashSignal` semantics; use the `ByteArray` overload for binary signals (including any with interior NUL bytes). +- Session and invite-code APIs are not exposed yet ("TODO: Re-enable when World ID 4.0 is live"). +- Kotlin and AGP versions are pinned in `kotlin/build.gradle.kts`; upgrade them in lockstep (Kotlin/Native ↔ Xcode compatibility matters here). -## Troubleshooting +## Example apps -- `connection_failed`: - - Check bridge URL/network and backend-generated RP context values. -- `timeout`: - - Increase `IDKitPollOptions(timeoutMs = ...)` or verify user completed flow in World App. -- `cancelled`: - - The polling coroutine was cancelled by the host app. +- [`Examples/IDKitSampleApp`](Examples/IDKitSampleApp) — plain Android app (Jetpack Compose) consuming the SDK the way an Android-only integrator would. +- [`Examples/IDKitKmpSampleApp`](Examples/IDKitKmpSampleApp) — KMP app: shared verification flow (Ktor + this SDK) driven by two native UIs, Jetpack Compose on Android and SwiftUI on iOS. See its README for run instructions (the iOS Xcode project is generated with XcodeGen, not checked in). diff --git a/kotlin/bindings/build.gradle.kts b/kotlin/bindings/build.gradle.kts deleted file mode 100644 index c9e4f21b..00000000 --- a/kotlin/bindings/build.gradle.kts +++ /dev/null @@ -1,166 +0,0 @@ -import com.vanniktech.maven.publish.AndroidSingleVariantLibrary -import org.gradle.api.publish.maven.MavenPublication -import org.gradle.api.publish.maven.tasks.PublishToMavenLocal -import org.gradle.api.publish.maven.tasks.PublishToMavenRepository -import org.gradle.jvm.tasks.Jar - -plugins { - id("com.android.library") - kotlin("android") - id("com.vanniktech.maven.publish.base") version "0.34.0" -} - -val libraryGroup = "com.worldcoin" -val libraryArtifactId = "idkit" - -// Allow callers to exercise the Maven publication with an explicit artifact version. -val libraryVersion = System.getenv("PKG_VERSION")?.takeIf { it.isNotBlank() } - ?: project.version.toString().takeIf { it.isNotBlank() && it != "unspecified" } - ?: throw GradleException("Could not find version in kotlin/gradle.properties") - -val enableMavenCentralPublishing = providers.gradleProperty("idkit.publish.mavenCentral") - .map(String::toBoolean) - .orElse(false) - -val emptyJavadocJar by tasks.registering(Jar::class) { - archiveClassifier.set("javadoc") -} - -val requiredNativeAbis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64") -val verifyKotlinNativeLibraries by tasks.registering { - group = "verification" - description = "Verifies that Kotlin publishing includes native IDKit libraries for every Android ABI." - - doLast { - val missingLibraries = requiredNativeAbis.map { abi -> - abi to layout.projectDirectory.file("src/main/jniLibs/$abi/libidkit.so").asFile - }.filter { (_, library) -> - !library.isFile || library.length() == 0L - } - - if (missingLibraries.isNotEmpty()) { - val missing = missingLibraries.joinToString(separator = "\n") { (abi, library) -> - "- $abi: ${library.relativeTo(projectDir)}" - } - throw GradleException( - "Missing native libraries required for publishing:\n$missing\n" + - "Run `bash scripts/build-kotlin.sh` from the repository root before publishing.", - ) - } - } -} - -group = libraryGroup -version = libraryVersion - -android { - namespace = "com.worldcoin.idkit" - compileSdk = 35 - - buildFeatures { - buildConfig = true - } - - defaultConfig { - minSdk = 23 - buildConfigField("String", "IDKIT_PACKAGE_VERSION", "\"$libraryVersion\"") - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = "17" - } - - testOptions { - unitTests.all { test -> - val rustLibDir = project.projectDir.resolve("../../target/release").canonicalPath - test.jvmArgs("-Djna.library.path=$rustLibDir") - } - } -} - -dependencies { - implementation("net.java.dev.jna:jna:5.14.0@aar") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1") - implementation(kotlin("stdlib")) - - testImplementation(kotlin("test")) - // The @aar variant doesn't bundle libjnidispatch — use the plain JVM jar for unit tests - testImplementation("net.java.dev.jna:jna:5.14.0") -} - -mavenPublishing { - configure( - AndroidSingleVariantLibrary( - variant = "release", - sourcesJar = true, - publishJavadocJar = false, - ), - ) - - coordinates(libraryGroup, libraryArtifactId, libraryVersion) - - pom { - name.set("IDKit Kotlin") - description.set("Kotlin bindings for IDKit backed by the Rust core") - url.set("https://github.com/worldcoin/idkit") - licenses { - license { - name.set("MIT License") - url.set("https://opensource.org/licenses/MIT") - } - } - developers { - developer { - id.set("worldcoin") - name.set("Worldcoin") - } - } - scm { - connection.set("scm:git:https://github.com/worldcoin/idkit.git") - developerConnection.set("scm:git:ssh://git@github.com/worldcoin/idkit.git") - url.set("https://github.com/worldcoin/idkit") - } - } - - if (enableMavenCentralPublishing.get()) { - publishToMavenCentral() - signAllPublications() - } -} - -publishing { - repositories { - maven { - name = "GitHubPackages" - url = uri("https://maven.pkg.github.com/worldcoin/idkit") - credentials { - username = providers.environmentVariable("GITHUB_ACTOR") - .orElse(providers.environmentVariable("GITHUB_USER")) - .orNull - password = providers.environmentVariable("GITHUB_TOKEN").orNull - } - } - } -} - -tasks.withType().configureEach { - dependsOn(verifyKotlinNativeLibraries) -} - -tasks.withType().configureEach { - dependsOn(verifyKotlinNativeLibraries) -} - -afterEvaluate { - publishing { - publications.withType().configureEach { - artifact(emptyJavadocJar) - } - } -} diff --git a/kotlin/bindings/src/main/kotlin/com/worldcoin/idkit/IdKit.kt b/kotlin/bindings/src/main/kotlin/com/worldcoin/idkit/IdKit.kt deleted file mode 100644 index b422357a..00000000 --- a/kotlin/bindings/src/main/kotlin/com/worldcoin/idkit/IdKit.kt +++ /dev/null @@ -1,438 +0,0 @@ -package com.worldcoin.idkit - -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.delay -import kotlinx.coroutines.ensureActive -// TODO: Re-enable when World ID 4.0 is live -// import kotlinx.serialization.json.JsonPrimitive -// import kotlinx.serialization.json.buildJsonObject -import kotlin.coroutines.coroutineContext -import uniffi.idkit_core.AppError -// TODO: Re-enable when World ID 4.0 is live -// import uniffi.idkit_core.ConstraintNode -// import uniffi.idkit_core.CredentialRequest -// import uniffi.idkit_core.CredentialType -import uniffi.idkit_core.IdKitBuilder -import uniffi.idkit_core.IdKitRequestConfig as NativeIDKitRequestConfig -import uniffi.idkit_core.IdKitRequestWrapper -import uniffi.idkit_core.IdKitResult -import uniffi.idkit_core.IdKitSessionConfig as NativeIDKitSessionConfig -import uniffi.idkit_core.Preset -import uniffi.idkit_core.Signal -import uniffi.idkit_core.StatusWrapper -// TODO: Re-enable when World ID 4.0 is live -// import uniffi.idkit_core.createSession as nativeCreateSession -// import uniffi.idkit_core.credentialToString -import uniffi.idkit_core.hashSignalFfi -import uniffi.idkit_core.idkitResultFromJson as nativeIdkitResultFromJson -import uniffi.idkit_core.idkitResultToJson as nativeIdkitResultToJson -// TODO: Re-enable when World ID 4.0 is live -// import uniffi.idkit_core.proveSession as nativeProveSession -import uniffi.idkit_core.request as nativeRequest - -typealias IDKitResult = IdKitResult -typealias RpContext = uniffi.idkit_core.RpContext -typealias Environment = uniffi.idkit_core.Environment -typealias DocumentType = uniffi.idkit_core.DocumentType -typealias IdentityAttribute = uniffi.idkit_core.IdentityAttribute -typealias ConnectUrlMode = uniffi.idkit_core.ConnectUrlMode - -private const val SDK_PACKAGE_NAME = "idkit_kotlin" - -/** Typed projection of the bridge request payload, exposed for building test fixtures. */ -typealias BridgeRequestPayload = uniffi.idkit_core.BridgeRequestPayloadWrapper -/** Protocol-level proof request inside a [BridgeRequestPayload]. */ -typealias ProofRequest = uniffi.idkit_core.ProofRequestWrapper -/** A per-credential request line item inside a [ProofRequest]. */ -typealias CredentialRequestItem = uniffi.idkit_core.CredentialRequestWrapper - -data class IDKitRequestConfig( - val appId: String, - val action: String, - val rpContext: RpContext, - val actionDescription: String? = null, - val bridgeUrl: String? = null, - val allowLegacyProofs: Boolean = false, - val requireUserPresence: Boolean = false, - val overrideConnectBaseUrl: String? = null, - val returnTo: String? = null, - val environment: Environment? = null, - val connectUrlMode: ConnectUrlMode? = null, -) { - internal fun toNative(): NativeIDKitRequestConfig = - NativeIDKitRequestConfig( - appId = appId, - packageName = SDK_PACKAGE_NAME, - packageVersion = IDKit.version, - action = action, - rpContext = rpContext, - actionDescription = actionDescription, - bridgeUrl = bridgeUrl, - allowLegacyProofs = allowLegacyProofs, - requireUserPresence = requireUserPresence, - overrideConnectBaseUrl = overrideConnectBaseUrl, - returnTo = returnTo, - environment = environment, - connectUrlMode = connectUrlMode, - ) -} - -data class IDKitSessionConfig( - val appId: String, - val rpContext: RpContext, - val actionDescription: String? = null, - val bridgeUrl: String? = null, - val requireUserPresence: Boolean = false, - val overrideConnectBaseUrl: String? = null, - val returnTo: String? = null, - val environment: Environment? = null, -) { - internal fun toNative(): NativeIDKitSessionConfig = - NativeIDKitSessionConfig( - appId = appId, - packageName = SDK_PACKAGE_NAME, - packageVersion = IDKit.version, - rpContext = rpContext, - actionDescription = actionDescription, - bridgeUrl = bridgeUrl, - requireUserPresence = requireUserPresence, - overrideConnectBaseUrl = overrideConnectBaseUrl, - returnTo = returnTo, - environment = environment, - ) -} - -class IDKitClientError(message: String) : IllegalArgumentException(message) - -enum class IDKitErrorCode(val rawValue: String) { - USER_REJECTED("user_rejected"), - VERIFICATION_REJECTED("verification_rejected"), - CREDENTIAL_UNAVAILABLE("credential_unavailable"), - WORLD_ID_4_NOT_AVAILABLE("world_id_4_not_available"), - WORLD_ID_3_NOT_AVAILABLE("world_id_3_not_available"), - MALFORMED_REQUEST("malformed_request"), - INVALID_NETWORK("invalid_network"), - INCLUSION_PROOF_PENDING("inclusion_proof_pending"), - INCLUSION_PROOF_FAILED("inclusion_proof_failed"), - UNEXPECTED_RESPONSE("unexpected_response"), - CONNECTION_FAILED("connection_failed"), - MAX_VERIFICATIONS_REACHED("max_verifications_reached"), - FAILED_BY_HOST_APP("failed_by_host_app"), - USER_PRESENCE_FAILED("user_presence_failed"), - INVALID_RP_SIGNATURE("invalid_rp_signature"), - NULLIFIER_REPLAYED("nullifier_replayed"), - DUPLICATE_NONCE("duplicate_nonce"), - UNKNOWN_RP("unknown_rp"), - INACTIVE_RP("inactive_rp"), - TIMESTAMP_TOO_OLD("timestamp_too_old"), - TIMESTAMP_TOO_FAR_IN_FUTURE("timestamp_too_far_in_future"), - INVALID_TIMESTAMP("invalid_timestamp"), - RP_SIGNATURE_EXPIRED("rp_signature_expired"), - IDENTITY_ATTRIBUTES_NOT_MATCHED("identity_attributes_not_matched"), - GENERIC_ERROR("generic_error"), - TIMEOUT("timeout"), - CANCELLED("cancelled"); - - internal companion object { - fun from(error: AppError): IDKitErrorCode = when (error) { - AppError.USER_REJECTED -> USER_REJECTED - AppError.VERIFICATION_REJECTED -> VERIFICATION_REJECTED - AppError.CREDENTIAL_UNAVAILABLE -> CREDENTIAL_UNAVAILABLE - AppError.WORLD_ID4_NOT_AVAILABLE -> WORLD_ID_4_NOT_AVAILABLE - AppError.WORLD_ID3_NOT_AVAILABLE -> WORLD_ID_3_NOT_AVAILABLE - AppError.MALFORMED_REQUEST -> MALFORMED_REQUEST - AppError.INVALID_NETWORK -> INVALID_NETWORK - AppError.INCLUSION_PROOF_PENDING -> INCLUSION_PROOF_PENDING - AppError.INCLUSION_PROOF_FAILED -> INCLUSION_PROOF_FAILED - AppError.UNEXPECTED_RESPONSE -> UNEXPECTED_RESPONSE - AppError.CONNECTION_FAILED -> CONNECTION_FAILED - AppError.MAX_VERIFICATIONS_REACHED -> MAX_VERIFICATIONS_REACHED - AppError.FAILED_BY_HOST_APP -> FAILED_BY_HOST_APP - AppError.USER_PRESENCE_FAILED -> USER_PRESENCE_FAILED - AppError.INVALID_RP_SIGNATURE -> INVALID_RP_SIGNATURE - AppError.NULLIFIER_REPLAYED -> NULLIFIER_REPLAYED - AppError.DUPLICATE_NONCE -> DUPLICATE_NONCE - AppError.UNKNOWN_RP -> UNKNOWN_RP - AppError.INACTIVE_RP -> INACTIVE_RP - AppError.TIMESTAMP_TOO_OLD -> TIMESTAMP_TOO_OLD - AppError.TIMESTAMP_TOO_FAR_IN_FUTURE -> TIMESTAMP_TOO_FAR_IN_FUTURE - AppError.INVALID_TIMESTAMP -> INVALID_TIMESTAMP - AppError.RP_SIGNATURE_EXPIRED -> RP_SIGNATURE_EXPIRED - AppError.IDENTITY_ATTRIBUTES_NOT_MATCHED -> IDENTITY_ATTRIBUTES_NOT_MATCHED - AppError.GENERIC_ERROR -> GENERIC_ERROR - } - } -} - -sealed interface IDKitStatus { - data object WaitingForConnection : IDKitStatus - data object AwaitingConfirmation : IDKitStatus - data class Confirmed(val result: IDKitResult) : IDKitStatus - data class Failed(val error: IDKitErrorCode) : IDKitStatus - data class NetworkingError(val error: IDKitErrorCode) : IDKitStatus -} - -sealed interface IDKitCompletionResult { - data class Success(val result: IDKitResult) : IDKitCompletionResult - data class Failure(val error: IDKitErrorCode) : IDKitCompletionResult -} - -data class IDKitPollOptions( - val pollIntervalMs: ULong = 1_000u, - val timeoutMs: ULong = 900_000u, -) - -// TODO: Re-enable when World ID 4.0 is live -// data class CredentialRequestOptions( -// val signal: String? = null, -// val genesisIssuedAtMin: ULong? = null, -// val expiresAtMin: ULong? = null, -// ) - -class IDKitBuilder internal constructor( - private val inner: IdKitBuilder, -) { - fun constraints(constraints: uniffi.idkit_core.ConstraintNode): IDKitRequest = - IDKitRequest(inner.constraints(constraints)) - - fun preset(preset: Preset): IDKitRequest = - IDKitRequest(inner.preset(preset)) -} - -class IDKitRequest internal constructor( - private val connectorUriValue: String, - private val requestIdValue: String, - private val pollStatusProvider: suspend () -> IDKitStatus, -) { - internal constructor(inner: IdKitRequestWrapper) : this( - connectorUriValue = inner.connectUrl(), - requestIdValue = inner.requestId(), - pollStatusProvider = { mapStatus(inner.pollStatusOnce()) }, - ) - - val connectorURI: String - get() = connectorUriValue - - val requestId: String - get() = requestIdValue - - suspend fun pollStatusOnce(): IDKitStatus = pollStatusProvider() - - suspend fun pollUntilCompletion( - options: IDKitPollOptions = IDKitPollOptions(), - ): IDKitCompletionResult { - val pollIntervalMs = options.pollIntervalMs.coerceAtLeast(1u) - val startedAt = System.currentTimeMillis() - - try { - while (true) { - coroutineContext.ensureActive() - - if (System.currentTimeMillis() - startedAt >= options.timeoutMs.toLong()) { - return IDKitCompletionResult.Failure(IDKitErrorCode.TIMEOUT) - } - - when (val status = pollStatusOnce()) { - is IDKitStatus.Confirmed -> return IDKitCompletionResult.Success(status.result) - is IDKitStatus.Failed -> return IDKitCompletionResult.Failure(status.error) - is IDKitStatus.NetworkingError -> delay(pollIntervalMs.toLong()) - IDKitStatus.AwaitingConfirmation, - IDKitStatus.WaitingForConnection -> delay(pollIntervalMs.toLong()) - } - } - } catch (_: CancellationException) { - return IDKitCompletionResult.Failure(IDKitErrorCode.CANCELLED) - } - } - - internal companion object { - internal fun forTesting( - connectorURI: String, - requestId: String, - pollStatusProvider: suspend () -> IDKitStatus, - ): IDKitRequest = IDKitRequest(connectorURI, requestId, pollStatusProvider) - - internal fun mapStatus(status: StatusWrapper): IDKitStatus = when (status) { - StatusWrapper.WaitingForConnection -> IDKitStatus.WaitingForConnection - StatusWrapper.AwaitingConfirmation -> IDKitStatus.AwaitingConfirmation - is StatusWrapper.Confirmed -> IDKitStatus.Confirmed(status.result) - is StatusWrapper.Failed -> IDKitStatus.Failed(IDKitErrorCode.from(status.error)) - is StatusWrapper.NetworkingError -> IDKitStatus.NetworkingError(IDKitErrorCode.from(status.error)) - } - } -} - -object IDKit { - val version: String = BuildConfig.IDKIT_PACKAGE_VERSION - - fun request(config: IDKitRequestConfig): IDKitBuilder { - require(config.appId.isNotBlank()) { "app_id is required" } - require(config.action.isNotBlank()) { "action is required" } - return IDKitBuilder(nativeRequest(config.toNative())) - } - - /** - * Builds the bridge request payload from a preset without opening a network - * connection. Intended for building test fixtures. - */ - fun createBridgePayloadFromPresets( - config: IDKitRequestConfig, - preset: Preset, - ): BridgeRequestPayload = - nativeRequest(config.toNative()).bridgeRequestPayloadFromPreset(preset) - - /** - * Builds the bridge request payload from custom constraints without opening a - * network connection. Intended for building test fixtures. - */ - fun createBridgePayloadFromConstraints( - config: IDKitRequestConfig, - constraints: uniffi.idkit_core.ConstraintNode, - ): BridgeRequestPayload = - nativeRequest(config.toNative()).bridgeRequestPayload(constraints) - - // TODO: Re-enable when World ID 4.0 is live - // fun createSession(config: IDKitSessionConfig): IDKitBuilder { - // require(config.appId.isNotBlank()) { "app_id is required" } - // return IDKitBuilder(nativeCreateSession(config.toNative())) - // } - - // fun proveSession(sessionId: String, config: IDKitSessionConfig): IDKitBuilder { - // require(sessionId.isNotBlank()) { "session_id is required" } - // require(config.appId.isNotBlank()) { "app_id is required" } - // return IDKitBuilder(nativeProveSession(sessionId, config.toNative())) - // } - - fun hashSignal(signal: String): String = hashSignalFfi(Signal.fromString(signal)) - - fun hashSignal(signal: ByteArray): String = hashSignalFfi(Signal.fromBytes(signal)) -} - -// TODO: Re-enable when World ID 4.0 is live -// private fun credentialRequestFromOptions( -// type: CredentialType, -// options: CredentialRequestOptions, -// ): CredentialRequest { -// val payload = buildJsonObject { -// put("type", JsonPrimitive(credentialToString(type))) -// options.signal?.let { put("signal", JsonPrimitive(it)) } -// options.genesisIssuedAtMin?.let { put("genesis_issued_at_min", JsonPrimitive(it.toLong())) } -// options.expiresAtMin?.let { put("expires_at_min", JsonPrimitive(it.toLong())) } -// } -// return CredentialRequest.fromJson(payload.toString()) -// } - -// fun CredentialRequest(type: CredentialType, signal: String? = null): CredentialRequest = -// CredentialRequest.withStringSignal(type, signal) - -// fun CredentialRequest(type: CredentialType, abiEncodedSignal: ByteArray): CredentialRequest = -// CredentialRequest(type, Signal.fromBytes(abiEncodedSignal)) - -// fun CredentialRequest(type: CredentialType, options: CredentialRequestOptions): CredentialRequest { -// if (options.genesisIssuedAtMin == null && options.expiresAtMin == null) { -// return CredentialRequest.withStringSignal(type, options.signal) -// } -// -// if (options.expiresAtMin == null) { -// return CredentialRequest.withGenesisMin(type, options.signal?.let { Signal.fromString(it) }, options.genesisIssuedAtMin!!) -// } -// -// if (options.genesisIssuedAtMin == null) { -// return CredentialRequest.withExpiresAtMin(type, options.signal?.let { Signal.fromString(it) }, options.expiresAtMin) -// } -// -// return credentialRequestFromOptions(type, options) -// } - -// fun anyOf(vararg items: CredentialRequest): ConstraintNode = -// ConstraintNode.any(items.map { ConstraintNode.item(it) }) - -// fun anyOf(items: List): ConstraintNode = -// ConstraintNode.any(items.map { ConstraintNode.item(it) }) - -// fun anyOfNodes(vararg nodes: ConstraintNode): ConstraintNode = -// ConstraintNode.any(nodes.toList()) - -// fun anyOfNodes(nodes: List): ConstraintNode = -// ConstraintNode.any(nodes) - -// fun allOf(vararg items: CredentialRequest): ConstraintNode = -// ConstraintNode.all(items.map { ConstraintNode.item(it) }) - -// fun allOf(items: List): ConstraintNode = -// ConstraintNode.all(items.map { ConstraintNode.item(it) }) - -// fun allOfNodes(vararg nodes: ConstraintNode): ConstraintNode = -// ConstraintNode.all(nodes.toList()) - -// fun allOfNodes(nodes: List): ConstraintNode = -// ConstraintNode.all(nodes) - -// fun enumerateOf(vararg items: CredentialRequest): ConstraintNode = -// enumerateOfNodes(items.map { ConstraintNode.item(it) }) - -// fun enumerateOf(items: List): ConstraintNode = -// enumerateOfNodes(items.map { ConstraintNode.item(it) }) - -// fun enumerateOfNodes(vararg nodes: ConstraintNode): ConstraintNode = -// enumerateOfNodes(nodes.toList()) - -// fun enumerateOfNodes(nodes: List): ConstraintNode { -// val nodesJson = nodes.joinToString(separator = ",") { it.toJson() } -// return ConstraintNode.fromJson("""{"enumerate":[${nodesJson}]}""") -// } - -/** - * Returns the orb legacy preset. - * - * This preset only returns World ID 3.0 proofs. Use it for compatibility with older IDKit versions. - */ -fun orbLegacy(signal: String? = null): Preset = Preset.OrbLegacy(signal = signal) - -/** - * Returns the secure document legacy preset. - * - * This preset only returns World ID 3.0 proofs. Use it for compatibility with older IDKit versions. - */ -fun secureDocumentLegacy(signal: String? = null): Preset = - Preset.SecureDocumentLegacy(signal = signal) - -/** - * Returns the document legacy preset. - * - * This preset only returns World ID 3.0 proofs. Use it for compatibility with older IDKit versions. - */ -fun documentLegacy(signal: String? = null): Preset = Preset.DocumentLegacy(signal = signal) - -/** - * Returns the device legacy preset. - * - * This preset only returns World ID 3.0 proofs. Use it for compatibility with older IDKit versions. - */ -fun deviceLegacy(signal: String? = null): Preset = Preset.DeviceLegacy(signal = signal) - -/** - * Returns the selfie check legacy preset. - * - * This preset only returns World ID 3.0 proofs. Use it for compatibility with older IDKit versions. - * Preview: Selfie Check is currently in preview. Contact us if you need it enabled. - */ -fun selfieCheckLegacy(signal: String? = null): Preset = Preset.SelfieCheckLegacy(signal = signal) - -/** - * Returns the identity check preset. - */ -fun identityCheck(attributes: List, legacySignal: String? = null): Preset = - Preset.IdentityCheck(attributes = attributes, legacySignal = legacySignal) - -fun idkitResultToJson(result: IDKitResult): String = nativeIdkitResultToJson(result) - -fun idkitResultFromJson(json: String): IDKitResult = nativeIdkitResultFromJson(json) - -fun hashSignal(signal: Signal): String = hashSignalFfi(signal) - -val ProofRequest.credentialIdentifiers: List - get() = proofRequests.map { it.identifier } - -val BridgeRequestPayload.credentialIdentifiers: List - get() = proofRequest?.credentialIdentifiers.orEmpty() diff --git a/kotlin/bindings/src/main/kotlin/com/worldcoin/idkit/KotlinCompat.kt b/kotlin/bindings/src/main/kotlin/com/worldcoin/idkit/KotlinCompat.kt deleted file mode 100644 index 1c5ae402..00000000 --- a/kotlin/bindings/src/main/kotlin/com/worldcoin/idkit/KotlinCompat.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.worldcoin.idkit - -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds -import uniffi.idkit_core.Signal - -val Signal.data: ByteArray - get() = this.asBytes() - -val Signal.string: String? - get() = this.asString() - -// ───────────────────────────────────────────────────────────────────────────── -// Canonical Status Extensions -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Flow-based status helper for IDKitRequest. - * - * @param pollInterval How long to wait between polls. - */ -fun IDKitRequest.statusFlow(pollInterval: Duration = 3.seconds): Flow = flow { - var last: IDKitStatus? = null - - while (true) { - val current = pollStatusOnce() - // Networking errors are silently retried, consistent with pollUntilCompletion - if (current != last && current !is IDKitStatus.NetworkingError) { - last = current - emit(current) - } - - when (current) { - is IDKitStatus.Confirmed, - is IDKitStatus.Failed -> return@flow - is IDKitStatus.NetworkingError, - IDKitStatus.AwaitingConfirmation, - IDKitStatus.WaitingForConnection -> { - delay(pollInterval) - } - } - } -} - -/** - * Convenience accessor for the IDKitResult when status is Confirmed. - */ -val IDKitStatus.Confirmed.idkitResult: IDKitResult - get() = this.result diff --git a/kotlin/bindings/src/test/kotlin/com/worldcoin/idkit/IDKitTests.kt b/kotlin/bindings/src/test/kotlin/com/worldcoin/idkit/IDKitTests.kt deleted file mode 100644 index 3a1fab8a..00000000 --- a/kotlin/bindings/src/test/kotlin/com/worldcoin/idkit/IDKitTests.kt +++ /dev/null @@ -1,465 +0,0 @@ -package com.worldcoin.idkit - -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.runBlocking -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals -import kotlin.test.assertNull -import kotlin.test.assertTrue -import uniffi.idkit_core.AppError -import uniffi.idkit_core.ConnectUrlMode -// TODO: Re-enable when World ID 4.0 is live -// import uniffi.idkit_core.CredentialType -import uniffi.idkit_core.DocumentType -import uniffi.idkit_core.Environment -import uniffi.idkit_core.IdentityAttribute -import uniffi.idkit_core.Preset -import uniffi.idkit_core.ResponseItem -import uniffi.idkit_core.RpContext -import uniffi.idkit_core.StatusWrapper -import uniffi.idkit_core.VerificationLevel -import uniffi.idkit_core.ConstraintKindWrapper -import uniffi.idkit_core.ConstraintNode -import uniffi.idkit_core.CredentialRequest -import uniffi.idkit_core.CredentialType - -class IDKitTests { - private fun sampleResult( - sessionId: String? = null, - userPresenceCompleted: Boolean = false, - ): IDKitResult = - IDKitResult( - protocolVersion = "4.0", - nonce = "0x1234", - action = if (sessionId == null) "login" else null, - actionDescription = "Sample action", - sessionId = sessionId, - responses = emptyList(), - userPresenceCompleted = userPresenceCompleted, - environment = "production", - identityAttested = null, - integrityBundle = null, - ) - - private fun sampleRpContext(): RpContext { - val signature = "0x" + "00".repeat(64) + "1b" - return RpContext( - rpId = "rp_1234567890abcdef", - nonce = "0x0000000000000000000000000000000000000000000000000000000000000001", - createdAt = 1_700_000_000u, - expiresAt = 1_700_003_600u, - signature = signature, - ) - } - - @Test - fun `IDKit entrypoints expose canonical builders`() { - val requestConfig = IDKitRequestConfig( - appId = "app_staging_1234567890abcdef", - action = "login", - rpContext = sampleRpContext(), - actionDescription = null, - bridgeUrl = null, - allowLegacyProofs = false, - requireUserPresence = false, - overrideConnectBaseUrl = null, - returnTo = null, - environment = Environment.STAGING, - connectUrlMode = ConnectUrlMode.DEFAULT, - ) - - // TODO: Re-enable when World ID 4.0 is live - // val sessionConfig = IDKitSessionConfig( - // appId = "app_staging_1234567890abcdef", - // rpContext = sampleRpContext(), - // actionDescription = null, - // bridgeUrl = null, - // requireUserPresence = false, - // overrideConnectBaseUrl = null, - // returnTo = null, - // environment = Environment.STAGING, - // ) - - IDKit.request(requestConfig) - // TODO: Re-enable when World ID 4.0 is live - // IDKit.createSession(sessionConfig) - // IDKit.proveSession("0x01", sessionConfig) - } - - @Test - fun `bridge request payload exposes identity check contract fields`() { - val config = IDKitRequestConfig( - appId = "app_staging_1234567890abcdef", - action = "test-action", - rpContext = sampleRpContext(), - actionDescription = "Identity check", - bridgeUrl = null, - allowLegacyProofs = false, - requireUserPresence = true, - overrideConnectBaseUrl = null, - returnTo = "idkitsample://callback", - environment = Environment.STAGING, - connectUrlMode = null, - ) - - val preset = identityCheck( - attributes = listOf( - IdentityAttribute.MinimumAge(21u), - IdentityAttribute.Nationality("JPN"), - ), - ) - - val payload = IDKit.createBridgePayloadFromPresets(config, preset) - - assertEquals("app_staging_1234567890abcdef", payload.appId) - assertEquals("idkit_kotlin", payload.packageName) - assertEquals(IDKit.version, payload.packageVersion) - assertEquals("test-action", payload.action) - assertEquals("Identity check", payload.actionDescription) - assertEquals(VerificationLevel.DOCUMENT, payload.verificationLevel) - assertEquals(true, payload.requireUserPresence) - assertEquals(true, payload.allowLegacyProofs) - assertEquals("idkitsample://callback", payload.returnToUrl) - assertEquals(Environment.STAGING, payload.environment) - assertNull(payload.timestamp) - - val attributes = payload.identityAttributes!! - assertEquals( - listOf( - IdentityAttribute.MinimumAge(21u), - IdentityAttribute.Nationality("JPN"), - ), - attributes, - ) - - val proofRequest = payload.proofRequest!! - assertEquals(1u, proofRequest.version) - assertEquals("uniqueness", proofRequest.proofType) - assertEquals("rp_1234567890abcdef", proofRequest.rpId) - assertEquals(1_700_000_000u, proofRequest.createdAt) - assertEquals(1_700_003_600u, proofRequest.expiresAt) - assertTrue(proofRequest.id.isNotEmpty()) - - val constraints = proofRequest.constraints!! - assertEquals(ConstraintKindWrapper.ANY, constraints.kind()) - assertEquals(2, constraints.children().size) - assertEquals(ConstraintKindWrapper.TYPE, constraints.children()[0].kind()) - assertEquals("passport", constraints.children()[0].identifier()) - assertEquals(ConstraintKindWrapper.TYPE, constraints.children()[1].kind()) - assertEquals("mnc", constraints.children()[1].identifier()) - - assertEquals(listOf("passport", "mnc"), proofRequest.credentialIdentifiers) - } - - @Test - fun `bridge request payload from constraints exposes passport or mnc`() { - val config = IDKitRequestConfig( - appId = "app_staging_1234567890abcdef", - action = "test-action", - rpContext = sampleRpContext(), - actionDescription = "Identity check", - bridgeUrl = null, - allowLegacyProofs = false, - requireUserPresence = false, - overrideConnectBaseUrl = null, - returnTo = null, - environment = Environment.STAGING, - connectUrlMode = null, - ) - - val constraints = ConstraintNode.any( - nodes = listOf( - ConstraintNode.item( - request = CredentialRequest.withStringSignal( - credentialType = CredentialType.PASSPORT, - signal = null, - ), - ), - ConstraintNode.item( - request = CredentialRequest.withStringSignal( - credentialType = CredentialType.MNC, - signal = null, - ), - ), - ), - ) - - val payload = IDKit.createBridgePayloadFromConstraints(config, constraints) - - assertNull(payload.identityAttributes) - - val proofRequest = payload.proofRequest!! - val payloadConstraints = proofRequest.constraints!! - assertEquals(ConstraintKindWrapper.ANY, payloadConstraints.kind()) - assertEquals(listOf("passport", "mnc"), proofRequest.credentialIdentifiers) - } - - @Test - fun `status mapping covers all canonical variants`() { - val result = sampleResult() - - assertEquals( - IDKitStatus.WaitingForConnection, - IDKitRequest.mapStatus(StatusWrapper.WaitingForConnection), - ) - assertEquals( - IDKitStatus.AwaitingConfirmation, - IDKitRequest.mapStatus(StatusWrapper.AwaitingConfirmation), - ) - assertEquals( - IDKitStatus.Confirmed(result), - IDKitRequest.mapStatus(StatusWrapper.Confirmed(result)), - ) - assertEquals( - IDKitStatus.Failed(IDKitErrorCode.INVALID_NETWORK), - IDKitRequest.mapStatus(StatusWrapper.Failed(AppError.INVALID_NETWORK)), - ) - assertEquals( - IDKitStatus.Failed(IDKitErrorCode.USER_PRESENCE_FAILED), - IDKitRequest.mapStatus(StatusWrapper.Failed(AppError.USER_PRESENCE_FAILED)), - ) - assertEquals( - IDKitStatus.Failed(IDKitErrorCode.INVALID_RP_SIGNATURE), - IDKitRequest.mapStatus(StatusWrapper.Failed(AppError.INVALID_RP_SIGNATURE)), - ) - assertEquals( - IDKitStatus.Failed(IDKitErrorCode.NULLIFIER_REPLAYED), - IDKitRequest.mapStatus(StatusWrapper.Failed(AppError.NULLIFIER_REPLAYED)), - ) - assertEquals( - IDKitStatus.Failed(IDKitErrorCode.DUPLICATE_NONCE), - IDKitRequest.mapStatus(StatusWrapper.Failed(AppError.DUPLICATE_NONCE)), - ) - assertEquals( - IDKitStatus.Failed(IDKitErrorCode.UNKNOWN_RP), - IDKitRequest.mapStatus(StatusWrapper.Failed(AppError.UNKNOWN_RP)), - ) - assertEquals( - IDKitStatus.Failed(IDKitErrorCode.INACTIVE_RP), - IDKitRequest.mapStatus(StatusWrapper.Failed(AppError.INACTIVE_RP)), - ) - assertEquals( - IDKitStatus.Failed(IDKitErrorCode.TIMESTAMP_TOO_OLD), - IDKitRequest.mapStatus(StatusWrapper.Failed(AppError.TIMESTAMP_TOO_OLD)), - ) - assertEquals( - IDKitStatus.Failed(IDKitErrorCode.TIMESTAMP_TOO_FAR_IN_FUTURE), - IDKitRequest.mapStatus(StatusWrapper.Failed(AppError.TIMESTAMP_TOO_FAR_IN_FUTURE)), - ) - assertEquals( - IDKitStatus.Failed(IDKitErrorCode.INVALID_TIMESTAMP), - IDKitRequest.mapStatus(StatusWrapper.Failed(AppError.INVALID_TIMESTAMP)), - ) - assertEquals( - IDKitStatus.Failed(IDKitErrorCode.RP_SIGNATURE_EXPIRED), - IDKitRequest.mapStatus(StatusWrapper.Failed(AppError.RP_SIGNATURE_EXPIRED)), - ) - assertEquals( - IDKitStatus.NetworkingError(IDKitErrorCode.CONNECTION_FAILED), - IDKitRequest.mapStatus(StatusWrapper.NetworkingError(AppError.CONNECTION_FAILED)), - ) - } - - @Test - fun `pollUntilCompletion success path`() = runBlocking { - val statuses = ArrayDeque( - listOf( - IDKitStatus.WaitingForConnection, - IDKitStatus.AwaitingConfirmation, - IDKitStatus.Confirmed(sampleResult()), - ), - ) - - val request = IDKitRequest.forTesting( - connectorURI = "https://world.org/verify?t=wld", - requestId = "7a6ff287-c95f-4330-b3de-9447f77ca3f9", - ) { - statuses.removeFirstOrNull() ?: IDKitStatus.WaitingForConnection - } - - val completion = request.pollUntilCompletion(IDKitPollOptions(pollIntervalMs = 1u, timeoutMs = 1_000u)) - assertEquals(IDKitCompletionResult.Success(sampleResult()), completion) - } - - @Test - fun `pollUntilCompletion timeout path`() = runBlocking { - val request = IDKitRequest.forTesting( - connectorURI = "https://world.org/verify?t=wld", - requestId = "7a6ff287-c95f-4330-b3de-9447f77ca3f9", - ) { - IDKitStatus.WaitingForConnection - } - - val completion = request.pollUntilCompletion(IDKitPollOptions(pollIntervalMs = 5u, timeoutMs = 20u)) - assertEquals(IDKitCompletionResult.Failure(IDKitErrorCode.TIMEOUT), completion) - } - - @Test - fun `pollUntilCompletion cancellation path`() = runBlocking { - val request = IDKitRequest.forTesting( - connectorURI = "https://world.org/verify?t=wld", - requestId = "7a6ff287-c95f-4330-b3de-9447f77ca3f9", - ) { - throw CancellationException("test cancellation") - } - - val completion = request.pollUntilCompletion(IDKitPollOptions(pollIntervalMs = 200u, timeoutMs = 10_000u)) - assertEquals(IDKitCompletionResult.Failure(IDKitErrorCode.CANCELLED), completion) - } - - @Test - fun `pollUntilCompletion recovers from networking errors`() = runBlocking { - val statuses = ArrayDeque( - listOf( - IDKitStatus.WaitingForConnection, - IDKitStatus.NetworkingError(IDKitErrorCode.CONNECTION_FAILED), - IDKitStatus.NetworkingError(IDKitErrorCode.CONNECTION_FAILED), - IDKitStatus.AwaitingConfirmation, - IDKitStatus.Confirmed(sampleResult()), - ), - ) - - val request = IDKitRequest.forTesting( - connectorURI = "https://world.org/verify?t=wld", - requestId = "7a6ff287-c95f-4330-b3de-9447f77ca3f9", - ) { - statuses.removeFirstOrNull() ?: IDKitStatus.WaitingForConnection - } - - val completion = request.pollUntilCompletion(IDKitPollOptions(pollIntervalMs = 1u, timeoutMs = 1_000u)) - assertEquals(IDKitCompletionResult.Success(sampleResult()), completion) - } - - @Test - fun `pollUntilCompletion app failure path`() = runBlocking { - val request = IDKitRequest.forTesting( - connectorURI = "https://world.org/verify?t=wld", - requestId = "7a6ff287-c95f-4330-b3de-9447f77ca3f9", - ) { - IDKitStatus.Failed(IDKitErrorCode.USER_REJECTED) - } - - val completion = request.pollUntilCompletion(IDKitPollOptions(pollIntervalMs = 1u, timeoutMs = 1_000u)) - assertEquals(IDKitCompletionResult.Failure(IDKitErrorCode.USER_REJECTED), completion) - } - - @Test - fun `hashSignal string and bytes overloads are deterministic`() { - val raw = "test-signal" - val hashFromString = IDKit.hashSignal(raw) - val hashFromBytes = IDKit.hashSignal(raw.toByteArray()) - - assertEquals(hashFromString, hashFromBytes) - assertTrue(hashFromString.startsWith("0x")) - assertTrue(hashFromString.isNotEmpty()) - } - - // TODO: Re-enable when World ID 4.0 is live - // @Test - // fun `CredentialRequest signal-only options`() { - // val request = CredentialRequest( - // CredentialType.ORB, - // options = CredentialRequestOptions(signal = "user-123"), - // ) - // - // assertEquals(CredentialType.ORB, request.credentialType()) - // assertEquals("user-123", request.getSignalBytes()!!.toString(Charsets.UTF_8)) - // assertEquals(null, request.genesisIssuedAtMin()) - // assertEquals(null, request.expiresAtMin()) - // } - - // @Test - // fun `CredentialRequest genesis-only options`() { - // val request = CredentialRequest( - // CredentialType.ORB, - // options = CredentialRequestOptions(genesisIssuedAtMin = 1_700_000_000u), - // ) - // - // assertEquals(1_700_000_000u, request.genesisIssuedAtMin()) - // assertEquals(null, request.expiresAtMin()) - // } - - // @Test - // fun `CredentialRequest expiry-only options`() { - // val request = CredentialRequest( - // CredentialType.ORB, - // options = CredentialRequestOptions(expiresAtMin = 1_800_000_000u), - // ) - // - // assertEquals(null, request.genesisIssuedAtMin()) - // assertEquals(1_800_000_000u, request.expiresAtMin()) - // } - - // @Test - // fun `CredentialRequest combined options`() { - // val request = CredentialRequest( - // CredentialType.ORB, - // options = CredentialRequestOptions( - // signal = "user-123", - // genesisIssuedAtMin = 1_700_000_000u, - // expiresAtMin = 1_800_000_000u, - // ), - // ) - // - // assertEquals(CredentialType.ORB, request.credentialType()) - // assertEquals("user-123", request.getSignalBytes()!!.toString(Charsets.UTF_8)) - // assertEquals(1_700_000_000u, request.genesisIssuedAtMin()) - // assertEquals(1_800_000_000u, request.expiresAtMin()) - // } - - @Test - fun `legacy preset helpers remain available`() { - val orb = orbLegacy(signal = "x") - val secureDoc = secureDocumentLegacy(signal = "y") - val doc = documentLegacy(signal = "z") - val device = deviceLegacy(signal = "d") - val face = selfieCheckLegacy(signal = "f") - - assertTrue(orb is Preset.OrbLegacy) - assertTrue(secureDoc is Preset.SecureDocumentLegacy) - assertTrue(doc is Preset.DocumentLegacy) - assertTrue(device is Preset.DeviceLegacy) - assertTrue(face is Preset.SelfieCheckLegacy) - assertEquals("x", (orb).signal) - assertEquals("y", (secureDoc).signal) - assertEquals("z", (doc).signal) - assertEquals("d", (device).signal) - assertEquals("f", (face).signal) - } - - @Test - fun `identityCheck helper exposes canonical preset`() { - val attributes = listOf( - IdentityAttribute.MinimumAge(21u), - IdentityAttribute.Nationality("JPN"), - IdentityAttribute.DocumentType(DocumentType.PASSPORT), - ) - - val preset = identityCheck(attributes = attributes) - - assertTrue(preset is Preset.IdentityCheck) - assertEquals(attributes, preset.attributes) - assertNull(preset.legacySignal) - } - - @Test - fun `identityCheck helper preserves legacySignal`() { - val attributes = listOf(IdentityAttribute.MinimumAge(18u)) - - val preset = identityCheck(attributes = attributes, legacySignal = "my-signal") - - assertTrue(preset is Preset.IdentityCheck) - assertEquals("my-signal", preset.legacySignal) - } - - @Test - fun `idkit result json helpers roundtrip`() { - val input = sampleResult() - val json = idkitResultToJson(input) - val output = idkitResultFromJson(json) - - assertEquals(input, output) - assertNotEquals("", json) - } -} diff --git a/kotlin/build.gradle.kts b/kotlin/build.gradle.kts index f769d3a7..94129360 100644 --- a/kotlin/build.gradle.kts +++ b/kotlin/build.gradle.kts @@ -1,4 +1,5 @@ plugins { - id("com.android.library") version "8.7.3" apply false - kotlin("android") version "1.9.24" apply false + id("org.jetbrains.kotlin.multiplatform") version "2.3.21" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "2.3.21" apply false + id("com.android.library") version "8.11.2" apply false } diff --git a/kotlin/gradle.properties b/kotlin/gradle.properties index e627dfe9..7f99d9d7 100644 --- a/kotlin/gradle.properties +++ b/kotlin/gradle.properties @@ -1 +1,11 @@ -version=4.0.5 +version=5.0.0 + +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +# Apple targets can only build on macOS hosts; elsewhere they are disabled so +# Android-only work (CI on Linux, plain Android consumers) still builds. +kotlin.native.ignoreDisabledTargets=true +# iosMain is shared across iosArm64/iosSimulatorArm64/iosX64 and uses the +# idkit_kmp cinterop; commonization makes the cinterop API visible there. +kotlin.mpp.enableCInteropCommonization=true diff --git a/kotlin/gradle/wrapper/gradle-wrapper.jar b/kotlin/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..980502d167d3610f88fa03b2f717935189d9fbcf GIT binary patch literal 43739 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 z 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/kotlin/gradlew.bat b/kotlin/gradlew.bat new file mode 100644 index 00000000..9b42019c --- /dev/null +++ b/kotlin/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/kotlin/idkit/build.gradle.kts b/kotlin/idkit/build.gradle.kts new file mode 100644 index 00000000..3d6118c9 --- /dev/null +++ b/kotlin/idkit/build.gradle.kts @@ -0,0 +1,236 @@ +import org.gradle.api.publish.maven.tasks.PublishToMavenLocal +import org.gradle.api.publish.maven.tasks.PublishToMavenRepository + +plugins { + id("org.jetbrains.kotlin.multiplatform") + id("org.jetbrains.kotlin.plugin.serialization") + id("com.android.library") + id("com.vanniktech.maven.publish.base") version "0.34.0" +} + +val libraryGroup = "com.worldcoin" +val libraryArtifactId = "idkit" + +// Allow callers to exercise the Maven publication with an explicit artifact version. +val libraryVersion = System.getenv("PKG_VERSION")?.takeIf { it.isNotBlank() } + ?: project.version.toString().takeIf { it.isNotBlank() && it != "unspecified" } + ?: throw GradleException("Could not find version in kotlin/gradle.properties") + +val enableMavenCentralPublishing = providers.gradleProperty("idkit.publish.mavenCentral") + .map(String::toBoolean) + .orElse(false) + +group = libraryGroup +version = libraryVersion + +// Derived from this module's location (kotlin/idkit) so it stays correct when +// the module is included from another build (the example apps remap projectDir). +val repoRoot: File = projectDir.parentFile.parentFile + +// commonMain has no BuildConfig; generate the package version constant instead. +val generateVersionFile by tasks.registering { + val outDir = layout.buildDirectory.dir("generated/idkitVersion/kotlin") + inputs.property("version", libraryVersion) + outputs.dir(outDir) + doLast { + val file = outDir.get() + .file("com/worldcoin/idkit/IDKitVersion.kt").asFile + file.parentFile.mkdirs() + file.writeText( + """ + // Generated by kotlin/idkit/build.gradle.kts — do not edit. + package com.worldcoin.idkit + + internal const val IDKIT_PACKAGE_VERSION: String = "$libraryVersion" + """.trimIndent() + "\n", + ) + } +} + +// Native artifacts produced by scripts/build-kotlin.sh, consumed at build time. +val requiredNativeAbis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64") +val iosRustTriples = listOf("aarch64-apple-ios", "aarch64-apple-ios-sim", "x86_64-apple-ios") + +// Apple targets can only build on macOS hosts (kotlin.native.ignoreDisabledTargets +// disables them elsewhere), so the iOS static libs are only required there. +val hostIsMac = System.getProperty("os.name").startsWith("Mac") + +val verifyKmpNativeLibraries by tasks.registering { + group = "verification" + description = "Verifies that publishing includes the Rust native libraries for every enabled target." + + doLast { + val missing = buildList { + requiredNativeAbis.forEach { abi -> + val lib = layout.projectDirectory + .file("src/androidMain/jniLibs/$abi/libidkit_kmp.so").asFile + if (!lib.isFile || lib.length() == 0L) add("- android/$abi: $lib") + } + if (hostIsMac) { + iosRustTriples.forEach { triple -> + val lib = repoRoot.resolve("target/$triple/release/libidkit_kmp.a") + if (!lib.isFile || lib.length() == 0L) add("- ios/$triple: $lib") + } + } + } + if (missing.isNotEmpty()) { + throw GradleException( + "Missing native libraries required for publishing:\n" + + missing.joinToString("\n") + "\n" + + "Run `bash scripts/build-kotlin.sh` from the repository root before publishing.", + ) + } + } +} + +// Publishing to a remote repository from a non-macOS host would upload root +// Gradle module metadata without the iOS variants — Android consumers would +// resolve fine while every KMP/iOS consumer breaks. Fail loudly instead. +val requireAppleHostForRemotePublish by tasks.registering { + group = "verification" + description = "Fails remote publishing on hosts that cannot build the iOS targets." + + doLast { + if (!hostIsMac) { + throw GradleException( + "Remote publishing must run on macOS so the iOS variants are included; " + + "publishing from this host would ship incomplete module metadata.", + ) + } + } +} + +kotlin { + jvmToolchain(17) + explicitApi() + + androidTarget { + publishLibraryVariants("release") + } + + val iosTargets = listOf( + iosArm64() to "aarch64-apple-ios", + iosSimulatorArm64() to "aarch64-apple-ios-sim", + iosX64() to "x86_64-apple-ios", + ) + iosTargets.forEach { (target, rustTriple) -> + target.compilations.getByName("main").cinterops.create("idkit_kmp") { + defFile(project.file("src/nativeInterop/cinterop/idkit_kmp.def")) + includeDirs(repoRoot.resolve("rust/kmp-ffi/include")) + extraOpts("-libraryPath", repoRoot.resolve("target/$rustTriple/release").absolutePath) + } + } + + sourceSets { + commonMain { + kotlin.srcDir(generateVersionFile) + dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + } + } + commonTest { + dependencies { + implementation(kotlin("test")) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") + } + } + androidMain { + dependencies { + implementation("net.java.dev.jna:jna:5.14.0@aar") + } + } + getByName("androidUnitTest") { + dependencies { + // The @aar variant has no host libjnidispatch; unit tests run on + // the host JVM and need the plain jar. + implementation("net.java.dev.jna:jna:5.14.0") + } + } + } +} + +android { + namespace = "com.worldcoin.idkit" + compileSdk = 35 + + defaultConfig { + minSdk = 23 + } + + // KMP does not remap jniLibs to androidMain automatically; without this the + // AAR would silently ship no native libraries and fail only at runtime. + sourceSets["main"].jniLibs.srcDir("src/androidMain/jniLibs") + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + testOptions { + unitTests.all { test -> + // Host JVM tests load the host dylib built by scripts/build-kotlin.sh. + test.jvmArgs("-Djna.library.path=${repoRoot.resolve("target/release").canonicalPath}") + } + } +} + +mavenPublishing { + configure( + com.vanniktech.maven.publish.KotlinMultiplatform( + javadocJar = com.vanniktech.maven.publish.JavadocJar.Empty(), + sourcesJar = true, + androidVariantsToPublish = listOf("release"), + ), + ) + coordinates(libraryGroup, libraryArtifactId, libraryVersion) + + pom { + name.set("IDKit Kotlin") + description.set("World ID SDK for Kotlin Multiplatform (Android + iOS), backed by the Rust core") + url.set("https://github.com/worldcoin/idkit") + licenses { + license { + name.set("MIT License") + url.set("https://opensource.org/licenses/MIT") + } + } + developers { + developer { + id.set("worldcoin") + name.set("World Contributors") + } + } + scm { + url.set("https://github.com/worldcoin/idkit") + connection.set("scm:git:git://github.com/worldcoin/idkit.git") + developerConnection.set("scm:git:ssh://git@github.com/worldcoin/idkit.git") + } + } + + if (enableMavenCentralPublishing.get()) { + publishToMavenCentral() + signAllPublications() + } +} + +publishing { + repositories { + maven { + name = "githubPackages" + url = uri("https://maven.pkg.github.com/worldcoin/idkit") + credentials { + username = System.getenv("GITHUB_ACTOR") ?: System.getenv("GITHUB_USER") + password = System.getenv("GITHUB_TOKEN") + } + } + } +} + +tasks.withType(PublishToMavenRepository::class.java).configureEach { + dependsOn(verifyKmpNativeLibraries) + dependsOn(requireAppleHostForRemotePublish) +} +tasks.withType(PublishToMavenLocal::class.java).configureEach { + dependsOn(verifyKmpNativeLibraries) +} diff --git a/kotlin/idkit/src/androidMain/kotlin/com/worldcoin/idkit/internal/IoDispatcher.android.kt b/kotlin/idkit/src/androidMain/kotlin/com/worldcoin/idkit/internal/IoDispatcher.android.kt new file mode 100644 index 00000000..6526d231 --- /dev/null +++ b/kotlin/idkit/src/androidMain/kotlin/com/worldcoin/idkit/internal/IoDispatcher.android.kt @@ -0,0 +1,6 @@ +package com.worldcoin.idkit.internal + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +internal actual val ioDispatcher: CoroutineDispatcher = Dispatchers.IO diff --git a/kotlin/idkit/src/androidMain/kotlin/com/worldcoin/idkit/internal/NativeBridge.android.kt b/kotlin/idkit/src/androidMain/kotlin/com/worldcoin/idkit/internal/NativeBridge.android.kt new file mode 100644 index 00000000..f40f91fa --- /dev/null +++ b/kotlin/idkit/src/androidMain/kotlin/com/worldcoin/idkit/internal/NativeBridge.android.kt @@ -0,0 +1,92 @@ +package com.worldcoin.idkit.internal + +import com.sun.jna.Native +import com.sun.jna.Pointer + +/** + * JNA direct mapping of the `idkit_kmp` C ABI (`libidkit_kmp.so` from the AAR's + * jniLibs, or the host library via `-Djna.library.path` in unit tests). + */ +internal actual object NativeBridge { + + private object C { + init { + // Deterministic const char* marshalling regardless of host defaults. + System.setProperty("jna.encoding", "UTF-8") + try { + Native.register(C::class.java, "idkit_kmp") + } catch (error: UnsatisfiedLinkError) { + throw UnsatisfiedLinkError( + "libidkit_kmp could not be loaded. Run `bash scripts/build-kotlin.sh` from the " + + "repository root to build the native artifacts (host unit tests also need " + + "-Djna.library.path=/target/release). Cause: ${error.message}", + ) + } + } + + // Returns are Pointer (not String) so the Rust allocation can be freed. + @JvmStatic external fun idkit_kmp_version(): Pointer? + @JvmStatic external fun idkit_kmp_hash_signal_string(signal: String): Pointer? + @JvmStatic external fun idkit_kmp_hash_signal_bytes(bytes: ByteArray?, len: Long): Pointer? + @JvmStatic external fun idkit_kmp_bridge_payload_from_preset( + configJson: String, + presetJson: String, + ): Pointer? + + @JvmStatic external fun idkit_kmp_bridge_payload_from_constraints( + configJson: String, + constraintsJson: String, + ): Pointer? + + @JvmStatic external fun idkit_kmp_request_create_with_preset( + configJson: String, + presetJson: String, + ): Pointer? + + @JvmStatic external fun idkit_kmp_request_create_with_constraints( + configJson: String, + constraintsJson: String, + ): Pointer? + + @JvmStatic external fun idkit_kmp_request_poll_once(handle: Long): Pointer? + @JvmStatic external fun idkit_kmp_request_free(handle: Long) + @JvmStatic external fun idkit_kmp_string_free(ptr: Pointer?) + } + + /** Copies the envelope out of native memory and frees the Rust allocation. */ + private fun consume(ptr: Pointer?): String { + checkNotNull(ptr) { "idkit_kmp returned NULL (allocator failure)" } + try { + return ptr.getString(0, "UTF-8") + } finally { + C.idkit_kmp_string_free(ptr) + } + } + + actual fun version(): String = consume(C.idkit_kmp_version()) + + actual fun hashSignalString(signal: String): String = + consume(C.idkit_kmp_hash_signal_string(signal)) + + actual fun hashSignalBytes(bytes: ByteArray): String = + consume(C.idkit_kmp_hash_signal_bytes(bytes, bytes.size.toLong())) + + actual fun bridgePayloadFromPreset(configJson: String, presetJson: String): String = + consume(C.idkit_kmp_bridge_payload_from_preset(configJson, presetJson)) + + actual fun bridgePayloadFromConstraints(configJson: String, constraintsJson: String): String = + consume(C.idkit_kmp_bridge_payload_from_constraints(configJson, constraintsJson)) + + actual fun requestCreateWithPreset(configJson: String, presetJson: String): String = + consume(C.idkit_kmp_request_create_with_preset(configJson, presetJson)) + + actual fun requestCreateWithConstraints(configJson: String, constraintsJson: String): String = + consume(C.idkit_kmp_request_create_with_constraints(configJson, constraintsJson)) + + actual fun requestPollOnce(handle: Long): String = + consume(C.idkit_kmp_request_poll_once(handle)) + + actual fun requestFree(handle: Long) { + C.idkit_kmp_request_free(handle) + } +} diff --git a/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Config.kt b/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Config.kt new file mode 100644 index 00000000..ece49ad7 --- /dev/null +++ b/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Config.kt @@ -0,0 +1,102 @@ +package com.worldcoin.idkit + +import com.worldcoin.idkit.internal.IdKitJson +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +internal const val SDK_PACKAGE_NAME: String = "idkit_kotlin" + +/** Bridge environment. */ +@Serializable +public enum class Environment { + @SerialName("production") + PRODUCTION, + + @SerialName("staging") + STAGING, +} + +/** Controls the format of [IDKitRequest.connectorURI]. */ +@Serializable +public enum class ConnectUrlMode { + /** The standard World App connect URL. */ + @SerialName("default") + DEFAULT, + + /** Wraps the connect URL inside an Apple App Clip invocation URL. */ + @SerialName("app_clip") + APP_CLIP, +} + +/** + * Relying Party context for protocol-level proof requests. + * + * Timestamps are Unix seconds. Validation (the `rp_` prefix, clock skew on + * [createdAt], expiry ordering) happens in the Rust core when the context is + * first used; violations surface as [IDKitException] with code `malformed_request`. + */ +@Serializable +public data class RpContext( + @SerialName("rp_id") val rpId: String, + val nonce: String, + @SerialName("created_at") val createdAt: ULong, + @SerialName("expires_at") val expiresAt: ULong, + val signature: String, +) + +/** Configuration for [IDKit.request]. Mirrors the Kotlin and Swift SDKs. */ +public data class IDKitRequestConfig( + val appId: String, + val action: String, + val rpContext: RpContext, + val actionDescription: String? = null, + val bridgeUrl: String? = null, + val allowLegacyProofs: Boolean = false, + val requireUserPresence: Boolean = false, + val overrideConnectBaseUrl: String? = null, + val returnTo: String? = null, + val environment: Environment? = null, + val connectUrlMode: ConnectUrlMode? = null, +) + +/** + * Wire DTO consumed by `RequestConfigDto` in rust/kmp-ffi/src/config.rs. + * The Rust side rejects unknown fields, so names here must match exactly. + */ +@Serializable +internal data class RequestConfigDto( + @SerialName("app_id") val appId: String, + @SerialName("package_name") val packageName: String, + @SerialName("package_version") val packageVersion: String, + val action: String, + @SerialName("rp_context") val rpContext: RpContext, + @SerialName("action_description") val actionDescription: String? = null, + @SerialName("bridge_url") val bridgeUrl: String? = null, + @SerialName("allow_legacy_proofs") val allowLegacyProofs: Boolean, + @SerialName("require_user_presence") val requireUserPresence: Boolean? = null, + @SerialName("override_connect_base_url") val overrideConnectBaseUrl: String? = null, + @SerialName("return_to") val returnTo: String? = null, + val environment: Environment? = null, + @SerialName("connect_url_mode") val connectUrlMode: ConnectUrlMode? = null, +) + +internal fun IDKitRequestConfig.toConfigJson(): String { + val dto = RequestConfigDto( + appId = appId, + // Package identity is fixed by the SDK for request attribution + // (PR #293); it is deliberately not user-configurable. + packageName = SDK_PACKAGE_NAME, + packageVersion = IDKit.version, + action = action, + rpContext = rpContext, + actionDescription = actionDescription, + bridgeUrl = bridgeUrl, + allowLegacyProofs = allowLegacyProofs, + requireUserPresence = requireUserPresence, + overrideConnectBaseUrl = overrideConnectBaseUrl, + returnTo = returnTo, + environment = environment, + connectUrlMode = connectUrlMode, + ) + return IdKitJson.encodeToString(RequestConfigDto.serializer(), dto) +} diff --git a/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Constraints.kt b/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Constraints.kt new file mode 100644 index 00000000..48bc0cc2 --- /dev/null +++ b/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Constraints.kt @@ -0,0 +1,134 @@ +package com.worldcoin.idkit + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject + +/** Credential type for [CredentialRequest]. */ +@Serializable +public enum class CredentialType { + @SerialName("proof_of_human") + PROOF_OF_HUMAN, + + @SerialName("selfie") + SELFIE, + + @SerialName("passport") + PASSPORT, + + @SerialName("mnc") + MNC, +} + +/** + * A single credential request inside a constraint tree. + * + * [signal] follows IDKit hashSignal semantics: a `0x`-prefixed even-length hex + * string is hashed as raw bytes, any other string as UTF-8 text. + */ +@Serializable +public data class CredentialRequest( + val type: CredentialType, + val signal: String? = null, + @SerialName("genesis_issued_at_min") val genesisIssuedAtMin: ULong? = null, + @SerialName("expires_at_min") val expiresAtMin: ULong? = null, +) + +/** + * World ID 4.0 constraint tree. Wire form matches the Rust core's untagged + * serde representation: an item is a flat [CredentialRequest] object, and + * combinators are `{"any": [...]}`, `{"all": [...]}`, `{"enumerate": [...]}`. + * + * Structural validation (depth/size limits, combinator arity) happens in the + * Rust core; violations surface as [IDKitException]. + */ +@Serializable(with = ConstraintNodeSerializer::class) +public sealed class ConstraintNode { + public data class Item(val request: CredentialRequest) : ConstraintNode() + public data class AnyOf(val nodes: List) : ConstraintNode() + public data class AllOf(val nodes: List) : ConstraintNode() + public data class EnumerateOf(val nodes: List) : ConstraintNode() +} + +internal object ConstraintNodeSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("com.worldcoin.idkit.ConstraintNode") + + private val listSerializer = ListSerializer(ConstraintNodeSerializer) + + override fun serialize(encoder: Encoder, value: ConstraintNode) { + val jsonEncoder = encoder as? JsonEncoder + ?: throw IllegalStateException("ConstraintNode supports JSON serialization only") + val json = jsonEncoder.json + val element = when (value) { + is ConstraintNode.Item -> json.encodeToJsonElement(CredentialRequest.serializer(), value.request) + is ConstraintNode.AnyOf -> buildJsonObject { + put("any", json.encodeToJsonElement(listSerializer, value.nodes)) + } + is ConstraintNode.AllOf -> buildJsonObject { + put("all", json.encodeToJsonElement(listSerializer, value.nodes)) + } + is ConstraintNode.EnumerateOf -> buildJsonObject { + put("enumerate", json.encodeToJsonElement(listSerializer, value.nodes)) + } + } + jsonEncoder.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): ConstraintNode { + val jsonDecoder = decoder as? JsonDecoder + ?: throw IllegalStateException("ConstraintNode supports JSON serialization only") + val json = jsonDecoder.json + val obj = jsonDecoder.decodeJsonElement().jsonObject + return when { + "any" in obj -> ConstraintNode.AnyOf(json.decodeFromJsonElement(listSerializer, obj.getValue("any"))) + "all" in obj -> ConstraintNode.AllOf(json.decodeFromJsonElement(listSerializer, obj.getValue("all"))) + "enumerate" in obj -> + ConstraintNode.EnumerateOf(json.decodeFromJsonElement(listSerializer, obj.getValue("enumerate"))) + else -> ConstraintNode.Item(json.decodeFromJsonElement(CredentialRequest.serializer(), obj)) + } + } +} + +/** Builds an `any` constraint over credential requests: at least one must be satisfiable. */ +public fun anyOf(vararg requests: CredentialRequest): ConstraintNode = + ConstraintNode.AnyOf(requests.map { ConstraintNode.Item(it) }) + +/** Builds an `any` constraint over credential requests: at least one must be satisfiable. */ +public fun anyOf(requests: List): ConstraintNode = + ConstraintNode.AnyOf(requests.map { ConstraintNode.Item(it) }) + +/** Builds an `any` constraint over constraint nodes. */ +public fun anyOfNodes(nodes: List): ConstraintNode = ConstraintNode.AnyOf(nodes) + +/** Builds an `all` constraint over credential requests: every one must be satisfiable. */ +public fun allOf(vararg requests: CredentialRequest): ConstraintNode = + ConstraintNode.AllOf(requests.map { ConstraintNode.Item(it) }) + +/** Builds an `all` constraint over credential requests: every one must be satisfiable. */ +public fun allOf(requests: List): ConstraintNode = + ConstraintNode.AllOf(requests.map { ConstraintNode.Item(it) }) + +/** Builds an `all` constraint over constraint nodes. */ +public fun allOfNodes(nodes: List): ConstraintNode = ConstraintNode.AllOf(nodes) + +/** Builds an `enumerate` constraint: World App reports each satisfiable branch. */ +public fun enumerateOf(vararg requests: CredentialRequest): ConstraintNode = + ConstraintNode.EnumerateOf(requests.map { ConstraintNode.Item(it) }) + +/** Builds an `enumerate` constraint: World App reports each satisfiable branch. */ +public fun enumerateOf(requests: List): ConstraintNode = + ConstraintNode.EnumerateOf(requests.map { ConstraintNode.Item(it) }) + +/** Builds an `enumerate` constraint over constraint nodes. */ +public fun enumerateOfNodes(nodes: List): ConstraintNode = + ConstraintNode.EnumerateOf(nodes) diff --git a/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Errors.kt b/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Errors.kt new file mode 100644 index 00000000..bdc96f67 --- /dev/null +++ b/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Errors.kt @@ -0,0 +1,15 @@ +package com.worldcoin.idkit + +/** Thrown for invalid client-side input (blank app id, malformed URLs, ...). */ +public class IDKitClientError(message: String) : IllegalArgumentException(message) + +/** + * Thrown when the native layer reports an error while creating a request or + * building a payload. [code] is the wire-level error code (e.g. + * `malformed_request`, `connection_failed`); the message carries actionable + * detail from the Rust core. + */ +public class IDKitException( + public val code: String, + message: String, +) : RuntimeException(message) diff --git a/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/IDKit.kt b/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/IDKit.kt new file mode 100644 index 00000000..70de42a1 --- /dev/null +++ b/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/IDKit.kt @@ -0,0 +1,77 @@ +package com.worldcoin.idkit + +import com.worldcoin.idkit.internal.IdKitJson +import com.worldcoin.idkit.internal.NativeBridge +import com.worldcoin.idkit.internal.unwrapEnvelope +import kotlinx.serialization.json.jsonPrimitive + +/** + * IDKit for Kotlin Multiplatform — the World ID SDK for Android and iOS. + * + * The public surface mirrors the platform-specific Kotlin and Swift SDKs; the + * implementation talks directly to the shared Rust core through a small C ABI + * (`rust/kmp-ffi`). + */ +public object IDKit { + /** SDK package version, reported to the bridge for request attribution. */ + public val version: String = IDKIT_PACKAGE_VERSION + + /** + * Starts building a uniqueness verification request. + * + * The returned builder opens the bridge connection when [IDKitBuilder.preset] + * or [IDKitBuilder.constraints] is called. + */ + public fun request(config: IDKitRequestConfig): IDKitBuilder { + if (config.appId.isBlank()) throw IDKitClientError("app_id is required") + if (config.action.isBlank()) throw IDKitClientError("action is required") + return IDKitBuilder(config.toConfigJson()) + } + + /** + * Builds the bridge request payload from a preset without opening a network + * connection. Intended for building test fixtures. + */ + public fun createBridgePayloadFromPresets( + config: IDKitRequestConfig, + preset: Preset, + ): BridgeRequestPayload { + val ok = unwrapEnvelope( + NativeBridge.bridgePayloadFromPreset( + config.toConfigJson(), + IdKitJson.encodeToString(Preset.serializer(), preset), + ), + ) + return IdKitJson.decodeFromJsonElement(BridgeRequestPayload.serializer(), ok) + } + + /** + * Builds the bridge request payload from custom constraints without opening + * a network connection. Intended for building test fixtures. + */ + public fun createBridgePayloadFromConstraints( + config: IDKitRequestConfig, + constraints: ConstraintNode, + ): BridgeRequestPayload { + val ok = unwrapEnvelope( + NativeBridge.bridgePayloadFromConstraints( + config.toConfigJson(), + IdKitJson.encodeToString(ConstraintNode.serializer(), constraints), + ), + ) + return IdKitJson.decodeFromJsonElement(BridgeRequestPayload.serializer(), ok) + } + + /** + * Hashes a signal to a `0x`-prefixed field element. A valid non-empty + * even-length `0x`-hex string is hashed as raw bytes, any other string as + * UTF-8 text (same semantics as the JS `hashSignal`). For signals with + * interior NUL bytes, use the [ByteArray] overload. + */ + public fun hashSignal(signal: String): String = + unwrapEnvelope(NativeBridge.hashSignalString(signal)).jsonPrimitive.content + + /** Hashes raw signal bytes to a `0x`-prefixed field element. */ + public fun hashSignal(signal: ByteArray): String = + unwrapEnvelope(NativeBridge.hashSignalBytes(signal)).jsonPrimitive.content +} diff --git a/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Payload.kt b/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Payload.kt new file mode 100644 index 00000000..259c33c9 --- /dev/null +++ b/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Payload.kt @@ -0,0 +1,81 @@ +package com.worldcoin.idkit + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** World ID 3.0 verification level. */ +@Serializable +public enum class VerificationLevel { + @SerialName("orb") + ORB, + + @SerialName("face") + FACE, + + @SerialName("device") + DEVICE, + + @SerialName("document") + DOCUMENT, + + @SerialName("secure_document") + SECURE_DOCUMENT, +} + +/** A per-credential request line item inside a [ProofRequest]. */ +@Serializable +public data class CredentialRequestItem( + val identifier: String, + @SerialName("issuer_schema_id") val issuerSchemaId: ULong, + val signal: String? = null, + @SerialName("genesis_issued_at_min") val genesisIssuedAtMin: ULong? = null, + @SerialName("expires_at_min") val expiresAtMin: ULong? = null, +) + +/** Protocol-level proof request inside a [BridgeRequestPayload]. */ +@Serializable +public data class ProofRequest( + val version: Int, + @SerialName("proof_type") val proofType: String, + @SerialName("rp_id") val rpId: String, + val id: String, + @SerialName("created_at") val createdAt: ULong, + @SerialName("expires_at") val expiresAt: ULong, + val action: String? = null, + val nonce: String? = null, + @SerialName("session_id") val sessionId: String? = null, + @SerialName("oprf_key_id") val oprfKeyId: String? = null, + val signature: String? = null, + @SerialName("proof_requests") val proofRequests: List = emptyList(), +) + +/** + * Typed projection of the plaintext bridge request payload, exposed for + * building test fixtures via [IDKit.createBridgePayloadFromPresets] and + * [IDKit.createBridgePayloadFromConstraints]. + */ +@Serializable +public data class BridgeRequestPayload( + @SerialName("app_id") val appId: String, + @SerialName("package_name") val packageName: String, + @SerialName("package_version") val packageVersion: String, + val signal: String, + @SerialName("verification_level") val verificationLevel: VerificationLevel, + @SerialName("allow_legacy_proofs") val allowLegacyProofs: Boolean, + @SerialName("require_user_presence") val requireUserPresence: Boolean, + val environment: Environment, + val action: String? = null, + @SerialName("action_description") val actionDescription: String? = null, + val timestamp: String? = null, + @SerialName("proof_request") val proofRequest: ProofRequest? = null, + @SerialName("identity_attributes") val identityAttributes: List? = null, + @SerialName("return_to_url") val returnToUrl: String? = null, +) + +/** Credential identifiers requested by this proof request, in request order. */ +public val ProofRequest.credentialIdentifiers: List + get() = proofRequests.map { it.identifier } + +/** Credential identifiers requested by this payload, in request order. */ +public val BridgeRequestPayload.credentialIdentifiers: List + get() = proofRequest?.credentialIdentifiers.orEmpty() diff --git a/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Presets.kt b/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Presets.kt new file mode 100644 index 00000000..8eb0952c --- /dev/null +++ b/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Presets.kt @@ -0,0 +1,186 @@ +package com.worldcoin.idkit + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** Identity document type used in [IdentityAttribute.DocumentType]. */ +public enum class DocumentType(public val rawValue: String) { + PASSPORT("passport"), + EID("eid"), + MNC("mnc"), +} + +/** + * Identity attribute filters for [identityCheck] presets. + * Wire form matches the Rust core: `{"type": "minimum_age", "value": 21}`. + */ +@Serializable(with = IdentityAttributeSerializer::class) +public sealed class IdentityAttribute { + public data class DocumentType(val value: com.worldcoin.idkit.DocumentType) : IdentityAttribute() + public data class DocumentNumber(val value: String) : IdentityAttribute() + public data class IssuingCountry(val value: String) : IdentityAttribute() + public data class FullName(val value: String) : IdentityAttribute() + public data class MinimumAge(val value: UByte) : IdentityAttribute() + public data class Nationality(val value: String) : IdentityAttribute() +} + +internal object IdentityAttributeSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("com.worldcoin.idkit.IdentityAttribute") + + override fun serialize(encoder: Encoder, value: IdentityAttribute) { + val jsonEncoder = encoder as? JsonEncoder + ?: throw IllegalStateException("IdentityAttribute supports JSON serialization only") + val (type, jsonValue) = when (value) { + is IdentityAttribute.DocumentType -> "document_type" to JsonPrimitive(value.value.rawValue) + is IdentityAttribute.DocumentNumber -> "document_number" to JsonPrimitive(value.value) + is IdentityAttribute.IssuingCountry -> "issuing_country" to JsonPrimitive(value.value) + is IdentityAttribute.FullName -> "full_name" to JsonPrimitive(value.value) + is IdentityAttribute.MinimumAge -> "minimum_age" to JsonPrimitive(value.value.toInt()) + is IdentityAttribute.Nationality -> "nationality" to JsonPrimitive(value.value) + } + jsonEncoder.encodeJsonElement( + buildJsonObject { + put("type", JsonPrimitive(type)) + put("value", jsonValue) + }, + ) + } + + override fun deserialize(decoder: Decoder): IdentityAttribute { + val jsonDecoder = decoder as? JsonDecoder + ?: throw IllegalStateException("IdentityAttribute supports JSON serialization only") + val obj = jsonDecoder.decodeJsonElement().jsonObject + val type = obj["type"]?.jsonPrimitive?.contentOrNull + ?: throw IllegalArgumentException("identity attribute is missing \"type\"") + val value = obj["value"] + ?: throw IllegalArgumentException("identity attribute is missing \"value\"") + return when (type) { + "document_type" -> { + val raw = value.jsonPrimitive.content + val documentType = DocumentType.entries.firstOrNull { it.rawValue == raw } + ?: throw IllegalArgumentException("unknown document type: $raw") + IdentityAttribute.DocumentType(documentType) + } + "document_number" -> IdentityAttribute.DocumentNumber(value.jsonPrimitive.content) + "issuing_country" -> IdentityAttribute.IssuingCountry(value.jsonPrimitive.content) + "full_name" -> IdentityAttribute.FullName(value.jsonPrimitive.content) + "minimum_age" -> IdentityAttribute.MinimumAge(value.jsonPrimitive.int.toUByte()) + "nationality" -> IdentityAttribute.Nationality(value.jsonPrimitive.content) + else -> throw IllegalArgumentException("unknown identity attribute type: $type") + } + } +} + +/** + * Credential presets for World ID verification. + * + * Serial names match the Rust core's `Preset` serde form + * (`#[serde(tag = "type")]` with PascalCase variants). + */ +@Serializable +public sealed class Preset { + /** Orb-only verification. World ID 3.0 proofs only. */ + @Serializable + @SerialName("OrbLegacy") + public data class OrbLegacy(val signal: String? = null) : Preset() + + /** Secure document verification. World ID 3.0 proofs only. */ + @Serializable + @SerialName("SecureDocumentLegacy") + public data class SecureDocumentLegacy(val signal: String? = null) : Preset() + + /** Document verification. World ID 3.0 proofs only. */ + @Serializable + @SerialName("DocumentLegacy") + public data class DocumentLegacy(val signal: String? = null) : Preset() + + /** Selfie check verification (preview). World ID 3.0 proofs only. */ + @Serializable + @SerialName("SelfieCheckLegacy") + public data class SelfieCheckLegacy(val signal: String? = null) : Preset() + + /** Device verification. World ID 3.0 proofs only. */ + @Serializable + @SerialName("DeviceLegacy") + public data class DeviceLegacy(val signal: String? = null) : Preset() + + /** Proof of human (World ID 4.0 with legacy fallback). */ + @Serializable + @SerialName("ProofOfHuman") + public data class ProofOfHuman(val signal: String? = null) : Preset() + + /** Passport credential (World ID 4.0 with legacy fallback). */ + @Serializable + @SerialName("Passport") + public data class Passport(val signal: String? = null) : Preset() + + /** My Number Card credential (World ID 4.0 with legacy fallback). */ + @Serializable + @SerialName("Mnc") + public data class Mnc(val signal: String? = null) : Preset() + + /** Document-based identity attestation (World ID 4.0). */ + @Serializable + @SerialName("IdentityCheck") + public data class IdentityCheck( + val attributes: List, + @SerialName("legacy_signal") val legacySignal: String? = null, + ) : Preset() +} + +/** + * Returns the orb legacy preset. + * + * This preset only returns World ID 3.0 proofs. Use it for compatibility with older IDKit versions. + */ +public fun orbLegacy(signal: String? = null): Preset = Preset.OrbLegacy(signal = signal) + +/** + * Returns the secure document legacy preset. + * + * This preset only returns World ID 3.0 proofs. Use it for compatibility with older IDKit versions. + */ +public fun secureDocumentLegacy(signal: String? = null): Preset = + Preset.SecureDocumentLegacy(signal = signal) + +/** + * Returns the document legacy preset. + * + * This preset only returns World ID 3.0 proofs. Use it for compatibility with older IDKit versions. + */ +public fun documentLegacy(signal: String? = null): Preset = Preset.DocumentLegacy(signal = signal) + +/** + * Returns the device legacy preset. + * + * This preset only returns World ID 3.0 proofs. Use it for compatibility with older IDKit versions. + */ +public fun deviceLegacy(signal: String? = null): Preset = Preset.DeviceLegacy(signal = signal) + +/** + * Returns the selfie check legacy preset. + * + * This preset only returns World ID 3.0 proofs. Use it for compatibility with older IDKit versions. + * Preview: Selfie Check is currently in preview. Contact us if you need it enabled. + */ +public fun selfieCheckLegacy(signal: String? = null): Preset = Preset.SelfieCheckLegacy(signal = signal) + +/** + * Returns the identity check preset. + */ +public fun identityCheck(attributes: List, legacySignal: String? = null): Preset = + Preset.IdentityCheck(attributes = attributes, legacySignal = legacySignal) diff --git a/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Request.kt b/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Request.kt new file mode 100644 index 00000000..79680059 --- /dev/null +++ b/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Request.kt @@ -0,0 +1,194 @@ +package com.worldcoin.idkit + +import com.worldcoin.idkit.internal.IdKitJson +import com.worldcoin.idkit.internal.NativeBridge +import com.worldcoin.idkit.internal.unwrapEnvelope +import kotlinx.coroutines.CancellationException +import com.worldcoin.idkit.internal.ioDispatcher +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlin.coroutines.coroutineContext +import kotlin.time.TimeSource + +@Serializable +internal data class StatusDto( + val state: String, + val result: JsonObject? = null, + @SerialName("error_code") val errorCode: String? = null, +) + +internal fun mapStatusDto(dto: StatusDto): IDKitStatus = when (dto.state) { + "waiting_for_connection" -> IDKitStatus.WaitingForConnection + "awaiting_confirmation" -> IDKitStatus.AwaitingConfirmation + "confirmed" -> { + val result = dto.result + ?: throw IDKitException("unexpected_response", "confirmed status is missing its result") + IDKitStatus.Confirmed( + IDKitResult.fromJson(IdKitJson.encodeToString(JsonObject.serializer(), result)), + ) + } + "failed" -> IDKitStatus.Failed(IDKitErrorCode.from(dto.errorCode ?: "generic_error")) + "networking_error" -> + IDKitStatus.NetworkingError(IDKitErrorCode.from(dto.errorCode ?: "connection_failed")) + else -> throw IDKitException("unexpected_response", "unknown status state: ${dto.state}") +} + +/** + * Builder returned by [IDKit.request]. Terminal calls ([preset] / [constraints]) + * open the bridge connection over the network and therefore suspend; the + * blocking FFI call runs on [ioDispatcher]. + */ +public class IDKitBuilder internal constructor( + private val configJson: String, +) { + /** Creates a bridge request from a [Preset]. */ + public suspend fun preset(preset: Preset): IDKitRequest = createRequest( + payloadJson = IdKitJson.encodeToString(Preset.serializer(), preset), + create = NativeBridge::requestCreateWithPreset, + ) + + /** Creates a bridge request from a [ConstraintNode] tree. */ + public suspend fun constraints(constraints: ConstraintNode): IDKitRequest = createRequest( + payloadJson = IdKitJson.encodeToString(ConstraintNode.serializer(), constraints), + create = NativeBridge::requestCreateWithConstraints, + ) + + private suspend fun createRequest( + payloadJson: String, + create: (configJson: String, payloadJson: String) -> String, + ): IDKitRequest = withContext(ioDispatcher) { + val ok = unwrapEnvelope(create(configJson, payloadJson)).jsonObject + IDKitRequest( + connectorUri = ok["connect_url"]?.jsonPrimitive?.content + ?: throw IDKitException("unexpected_response", "create response is missing connect_url"), + requestId = ok["request_id"]?.jsonPrimitive?.content + ?: throw IDKitException("unexpected_response", "create response is missing request_id"), + handle = ok["handle"]?.jsonPrimitive?.long + ?: throw IDKitException("unexpected_response", "create response is missing handle"), + ) + } +} + +/** An in-flight verification request. */ +public class IDKitRequest internal constructor( + private val connectorUriValue: String, + private val requestIdValue: String, + private val handle: Long?, + private val pollStatusProvider: suspend () -> IDKitStatus, +) { + internal constructor(connectorUri: String, requestId: String, handle: Long) : this( + connectorUriValue = connectorUri, + requestIdValue = requestId, + handle = handle, + pollStatusProvider = { + withContext(ioDispatcher) { + val ok = unwrapEnvelope(NativeBridge.requestPollOnce(handle)) + mapStatusDto(IdKitJson.decodeFromJsonElement(StatusDto.serializer(), ok)) + } + }, + ) + + /** Deep link that opens World App (or the QR-code payload). */ + public val connectorURI: String + get() = connectorUriValue + + /** Bridge request identifier. */ + public val requestId: String + get() = requestIdValue + + private var closed: Boolean = false + + /** Polls the bridge once for the current status. */ + public suspend fun pollStatusOnce(): IDKitStatus = pollStatusProvider() + + /** + * Polls until the request reaches a terminal state. + * + * Networking errors are retried silently; the wall-clock deadline yields + * [IDKitErrorCode.TIMEOUT] and coroutine cancellation yields + * [IDKitErrorCode.CANCELLED] — identical semantics to the Kotlin and Swift SDKs. + */ + public suspend fun pollUntilCompletion( + options: IDKitPollOptions = IDKitPollOptions(), + ): IDKitCompletionResult { + val pollIntervalMs = options.pollIntervalMs.coerceAtLeast(1u) + val startedAt = TimeSource.Monotonic.markNow() + + try { + while (true) { + coroutineContext.ensureActive() + + if (startedAt.elapsedNow().inWholeMilliseconds.toULong() >= options.timeoutMs) { + return IDKitCompletionResult.Failure(IDKitErrorCode.TIMEOUT) + } + + when (val status = pollStatusOnce()) { + is IDKitStatus.Confirmed -> return IDKitCompletionResult.Success(status.result) + is IDKitStatus.Failed -> return IDKitCompletionResult.Failure(status.error) + is IDKitStatus.NetworkingError -> delay(pollIntervalMs.toLong()) + IDKitStatus.AwaitingConfirmation, + IDKitStatus.WaitingForConnection, + -> delay(pollIntervalMs.toLong()) + } + } + } catch (_: CancellationException) { + return IDKitCompletionResult.Failure(IDKitErrorCode.CANCELLED) + } + } + + /** + * Releases the native request handle. Call when done with the request; + * polling after close reports an `invalid_handle` [IDKitException]. + * Safe to call more than once. + */ + public fun close() { + if (!closed) { + closed = true + handle?.let { NativeBridge.requestFree(it) } + } + } + + internal companion object { + internal fun forTesting( + connectorURI: String, + requestId: String, + pollStatusProvider: suspend () -> IDKitStatus, + ): IDKitRequest = IDKitRequest(connectorURI, requestId, null, pollStatusProvider) + } +} + +/** + * Flow-based status helper for [IDKitRequest]. Emits on every distinct status + * (networking errors are silently retried, consistent with + * [IDKitRequest.pollUntilCompletion]) and completes on a terminal status. + */ +public fun IDKitRequest.statusFlow(pollIntervalMs: ULong = 3_000u): Flow = flow { + var last: IDKitStatus? = null + + while (true) { + val current = pollStatusOnce() + if (current != last && current !is IDKitStatus.NetworkingError) { + last = current + emit(current) + } + + when (current) { + is IDKitStatus.Confirmed, + is IDKitStatus.Failed, + -> return@flow + is IDKitStatus.NetworkingError, + IDKitStatus.AwaitingConfirmation, + IDKitStatus.WaitingForConnection, + -> delay(pollIntervalMs.toLong()) + } + } +} diff --git a/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Result.kt b/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Result.kt new file mode 100644 index 00000000..4c10d205 --- /dev/null +++ b/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Result.kt @@ -0,0 +1,151 @@ +package com.worldcoin.idkit + +import com.worldcoin.idkit.internal.IdKitJson +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonContentPolymorphicSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonObject + +/** World App integrity bundle for proving request-time app integrity. */ +@Serializable +public data class IntegrityBundle( + val version: Int, + /** Signature format used by the device (e.g. `apple_app_attest`, `android_keystore`). */ + @SerialName("signature_format") val signatureFormat: String, + /** Unix timestamp of this request, in seconds. */ + val timestamp: ULong, + /** Hex-encoded device signature. */ + val signature: String, + /** Attestation Gateway JWT proving integrity of the signing key. */ + val jwt: String, +) + +/** + * A single credential response item. + * + * Discrimination follows the Rust core's untagged serde: `session_nullifier` + * present → [Session]; `issuer_schema_id` present → [V4]; otherwise [V3]. + */ +@Serializable(with = ResponseItemSerializer::class) +public sealed class ResponseItem { + /** World ID v4 uniqueness proof. */ + @Serializable + public data class V4( + val identifier: String, + @SerialName("signal_hash") val signalHash: String? = null, + @SerialName("issuer_schema_id") val issuerSchemaId: ULong, + /** Compressed Groth16 proof (4 elements) followed by the Merkle root, all hex strings. */ + val proof: List, + /** RP-scoped nullifier (hex string). */ + val nullifier: String, + @SerialName("expires_at_min") val expiresAtMin: ULong, + ) : ResponseItem() + + /** World ID v4 session proof. */ + @Serializable + public data class Session( + val identifier: String, + @SerialName("signal_hash") val signalHash: String? = null, + @SerialName("issuer_schema_id") val issuerSchemaId: ULong, + val proof: List, + /** 1st element is the session nullifier, 2nd is the generated action. */ + @SerialName("session_nullifier") val sessionNullifier: List, + @SerialName("expires_at_min") val expiresAtMin: ULong, + ) : ResponseItem() + + /** World ID v3 legacy proof. */ + @Serializable + public data class V3( + val identifier: String, + @SerialName("signal_hash") val signalHash: String, + /** ABI-encoded proof (hex string). */ + val proof: String, + @SerialName("merkle_root") val merkleRoot: String, + val nullifier: String, + ) : ResponseItem() +} + +internal object ResponseItemSerializer : JsonContentPolymorphicSerializer(ResponseItem::class) { + override fun selectDeserializer(element: JsonElement): DeserializationStrategy { + val obj = element.jsonObject + return when { + "session_nullifier" in obj -> ResponseItem.Session.serializer() + "issuer_schema_id" in obj -> ResponseItem.V4.serializer() + else -> ResponseItem.V3.serializer() + } + } +} + +@Serializable +internal data class IDKitResultDto( + @SerialName("protocol_version") val protocolVersion: String, + val nonce: String, + val action: String? = null, + @SerialName("action_description") val actionDescription: String? = null, + @SerialName("session_id") val sessionId: String? = null, + val responses: List = emptyList(), + @SerialName("user_presence_completed") val userPresenceCompleted: Boolean, + val environment: String, + @SerialName("identity_attested") val identityAttested: Boolean? = null, + @SerialName("integrity_bundle") val integrityBundle: IntegrityBundle? = null, +) + +/** + * The result of a confirmed verification. + * + * [rawJson] is the untouched result JSON from the Rust core; send it verbatim + * to backend verification endpoints so no field is lost in translation. + */ +public class IDKitResult internal constructor( + private val dto: IDKitResultDto, + public val rawJson: String, +) { + /** Protocol version ("4.0" or "3.0"). */ + public val protocolVersion: String get() = dto.protocolVersion + + /** Nonce used in the request. */ + public val nonce: String get() = dto.nonce + + /** Action identifier (uniqueness proofs only). */ + public val action: String? get() = dto.action + + /** Action description, when provided in the request. */ + public val actionDescription: String? get() = dto.actionDescription + + /** Opaque session identifier (`session_`, session proofs only). */ + public val sessionId: String? get() = dto.sessionId + + /** Credential responses. */ + public val responses: List get() = dto.responses + + /** Whether World App completed the requested user-presence check. */ + public val userPresenceCompleted: Boolean get() = dto.userPresenceCompleted + + /** Environment used for the request ("production" or "staging"). */ + public val environment: String get() = dto.environment + + /** Whether identity attributes were attested (IdentityCheck requests only). */ + public val identityAttested: Boolean? get() = dto.identityAttested + + /** Optional World App integrity bundle. */ + public val integrityBundle: IntegrityBundle? get() = dto.integrityBundle + + override fun equals(other: Any?): Boolean = other is IDKitResult && other.dto == dto + override fun hashCode(): Int = dto.hashCode() + override fun toString(): String = "IDKitResult(protocolVersion=$protocolVersion, " + + "responses=${responses.size}, environment=$environment)" + + public companion object { + /** Parses a result from its JSON wire form. */ + public fun fromJson(json: String): IDKitResult = + IDKitResult(IdKitJson.decodeFromString(IDKitResultDto.serializer(), json), json) + } +} + +/** Serializes a result to JSON — returns the raw wire form unchanged. */ +public fun idkitResultToJson(result: IDKitResult): String = result.rawJson + +/** Parses a result from its JSON wire form. */ +public fun idkitResultFromJson(json: String): IDKitResult = IDKitResult.fromJson(json) diff --git a/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Status.kt b/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Status.kt new file mode 100644 index 00000000..d7f42c81 --- /dev/null +++ b/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Status.kt @@ -0,0 +1,77 @@ +package com.worldcoin.idkit + +/** Error codes surfaced by verification statuses. Mirrors the Kotlin and Swift SDKs. */ +public enum class IDKitErrorCode(public val rawValue: String) { + USER_REJECTED("user_rejected"), + VERIFICATION_REJECTED("verification_rejected"), + CREDENTIAL_UNAVAILABLE("credential_unavailable"), + WORLD_ID_4_NOT_AVAILABLE("world_id_4_not_available"), + WORLD_ID_3_NOT_AVAILABLE("world_id_3_not_available"), + MALFORMED_REQUEST("malformed_request"), + INVALID_NETWORK("invalid_network"), + INCLUSION_PROOF_PENDING("inclusion_proof_pending"), + INCLUSION_PROOF_FAILED("inclusion_proof_failed"), + UNEXPECTED_RESPONSE("unexpected_response"), + CONNECTION_FAILED("connection_failed"), + MAX_VERIFICATIONS_REACHED("max_verifications_reached"), + FAILED_BY_HOST_APP("failed_by_host_app"), + USER_PRESENCE_FAILED("user_presence_failed"), + INVALID_RP_SIGNATURE("invalid_rp_signature"), + NULLIFIER_REPLAYED("nullifier_replayed"), + DUPLICATE_NONCE("duplicate_nonce"), + UNKNOWN_RP("unknown_rp"), + INACTIVE_RP("inactive_rp"), + TIMESTAMP_TOO_OLD("timestamp_too_old"), + TIMESTAMP_TOO_FAR_IN_FUTURE("timestamp_too_far_in_future"), + INVALID_TIMESTAMP("invalid_timestamp"), + RP_SIGNATURE_EXPIRED("rp_signature_expired"), + IDENTITY_ATTRIBUTES_NOT_MATCHED("identity_attributes_not_matched"), + GENERIC_ERROR("generic_error"), + + /** Client-side: [IDKitRequest.pollUntilCompletion] hit its deadline. */ + TIMEOUT("timeout"), + + /** Client-side: the polling coroutine was cancelled. */ + CANCELLED("cancelled"), + ; + + internal companion object { + /** Maps a wire error code; unknown codes degrade to [GENERIC_ERROR]. */ + fun from(rawValue: String): IDKitErrorCode = + entries.firstOrNull { it.rawValue == rawValue } ?: GENERIC_ERROR + } +} + +/** Status of a verification request, as reported by a single poll. */ +public sealed interface IDKitStatus { + /** Waiting for World App to retrieve the request. */ + public data object WaitingForConnection : IDKitStatus + + /** World App has retrieved the request; waiting for user confirmation. */ + public data object AwaitingConfirmation : IDKitStatus + + /** The user confirmed and provided proof(s). */ + public data class Confirmed(val result: IDKitResult) : IDKitStatus + + /** The request failed terminally. */ + public data class Failed(val error: IDKitErrorCode) : IDKitStatus + + /** A transport-level failure; safe to retry. */ + public data class NetworkingError(val error: IDKitErrorCode) : IDKitStatus +} + +/** Terminal outcome of [IDKitRequest.pollUntilCompletion]. */ +public sealed interface IDKitCompletionResult { + public data class Success(val result: IDKitResult) : IDKitCompletionResult + public data class Failure(val error: IDKitErrorCode) : IDKitCompletionResult +} + +/** Options for [IDKitRequest.pollUntilCompletion]. */ +public data class IDKitPollOptions( + val pollIntervalMs: ULong = 1_000u, + val timeoutMs: ULong = 900_000u, +) + +/** Convenience accessor for the [IDKitResult] when status is [IDKitStatus.Confirmed]. */ +public val IDKitStatus.Confirmed.idkitResult: IDKitResult + get() = this.result diff --git a/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/internal/Envelope.kt b/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/internal/Envelope.kt new file mode 100644 index 00000000..9138b5c9 --- /dev/null +++ b/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/internal/Envelope.kt @@ -0,0 +1,51 @@ +package com.worldcoin.idkit.internal + +import com.worldcoin.idkit.IDKitException +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonObject + +@Serializable +internal data class EnvelopeError( + val code: String, + val message: String, +) + +@Serializable +private data class EnvelopeDto( + val ok: JsonElement? = null, + @SerialName("err") val error: EnvelopeError? = null, +) + +/** + * Unwraps a `{"ok": ...}` / `{"err": {...}}` envelope from the C ABI. + * + * @return the `ok` value + * @throws IDKitException carrying the wire error code and message + */ +internal fun unwrapEnvelope(envelopeJson: String): JsonElement { + val envelope = try { + // Decode via JsonElement first so `ok` values of any JSON type survive. + val root = IdKitJson.parseToJsonElement(envelopeJson).jsonObject + EnvelopeDto( + ok = root["ok"], + error = root["err"]?.let { IdKitJson.decodeFromJsonElement(EnvelopeError.serializer(), it) }, + ) + } catch (cause: SerializationException) { + throw IDKitException( + code = "invalid_envelope", + message = "malformed FFI envelope: ${cause.message}", + ) + } catch (cause: IllegalArgumentException) { + throw IDKitException( + code = "invalid_envelope", + message = "malformed FFI envelope: ${cause.message}", + ) + } + + envelope.ok?.let { return it } + envelope.error?.let { throw IDKitException(code = it.code, message = it.message) } + throw IDKitException(code = "invalid_envelope", message = "FFI envelope has neither ok nor err") +} diff --git a/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/internal/IoDispatcher.kt b/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/internal/IoDispatcher.kt new file mode 100644 index 00000000..5562b339 --- /dev/null +++ b/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/internal/IoDispatcher.kt @@ -0,0 +1,10 @@ +package com.worldcoin.idkit.internal + +import kotlinx.coroutines.CoroutineDispatcher + +/** + * Dispatcher for the blocking FFI calls (network-bound bridge create/poll). + * `Dispatchers.IO` exists on both JVM and Native but is not exposed in the + * common API, hence this seam. + */ +internal expect val ioDispatcher: CoroutineDispatcher diff --git a/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/internal/Json.kt b/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/internal/Json.kt new file mode 100644 index 00000000..edbcf49c --- /dev/null +++ b/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/internal/Json.kt @@ -0,0 +1,19 @@ +package com.worldcoin.idkit.internal + +import kotlinx.serialization.json.Json + +/** + * Shared JSON configuration for the FFI boundary. + * + * - `ignoreUnknownKeys`: the Rust side may add fields; decoding must not break. + * - `explicitNulls = false`: absent optionals are omitted, matching the Rust + * DTOs' `#[serde(default)]` fields (the config DTO rejects unknown keys, so + * field names must match exactly — but nulls may simply be dropped). + * - `classDiscriminator = "type"`: matches core `Preset`'s `#[serde(tag = "type")]`. + */ +internal val IdKitJson: Json = Json { + ignoreUnknownKeys = true + explicitNulls = false + encodeDefaults = false + classDiscriminator = "type" +} diff --git a/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/internal/NativeBridge.kt b/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/internal/NativeBridge.kt new file mode 100644 index 00000000..8abc2463 --- /dev/null +++ b/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/internal/NativeBridge.kt @@ -0,0 +1,21 @@ +package com.worldcoin.idkit.internal + +/** + * Thin platform seam over the `idkit_kmp` C ABI (see `rust/kmp-ffi/include/idkit_kmp.h`). + * + * Every function returns the raw JSON envelope string (`{"ok": ...}` or + * `{"err": {"code", "message"}}`); decoding happens once in [Envelope]. + * Functions backed by network I/O (`requestCreate*`, `requestPollOnce`) block + * the calling thread — callers must dispatch them on [kotlinx.coroutines.Dispatchers.IO]. + */ +internal expect object NativeBridge { + fun version(): String + fun hashSignalString(signal: String): String + fun hashSignalBytes(bytes: ByteArray): String + fun bridgePayloadFromPreset(configJson: String, presetJson: String): String + fun bridgePayloadFromConstraints(configJson: String, constraintsJson: String): String + fun requestCreateWithPreset(configJson: String, presetJson: String): String + fun requestCreateWithConstraints(configJson: String, constraintsJson: String): String + fun requestPollOnce(handle: Long): String + fun requestFree(handle: Long) +} diff --git a/kotlin/idkit/src/commonTest/kotlin/com/worldcoin/idkit/NativeContractTests.kt b/kotlin/idkit/src/commonTest/kotlin/com/worldcoin/idkit/NativeContractTests.kt new file mode 100644 index 00000000..629d094a --- /dev/null +++ b/kotlin/idkit/src/commonTest/kotlin/com/worldcoin/idkit/NativeContractTests.kt @@ -0,0 +1,111 @@ +package com.worldcoin.idkit + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Contract tests that exercise the real Rust core through the C ABI. They + * require the native artifacts from `scripts/build-kotlin.sh`: + * host `libidkit_kmp.dylib/.so` for JVM unit tests (via `jna.library.path`), + * and the statically linked `libidkit_kmp.a` for iOS simulator tests. + */ +class NativeContractTests { + + @Test + fun bridgePayloadFromIdentityCheckPresetExposesContractFields() { + val payload = IDKit.createBridgePayloadFromPresets( + config = sampleRequestConfig(), + preset = identityCheck( + attributes = listOf( + IdentityAttribute.MinimumAge(21u), + IdentityAttribute.Nationality("JPN"), + ), + ), + ) + + assertEquals("app_staging_1234567890abcdef", payload.appId) + assertEquals("idkit_kotlin", payload.packageName) + assertEquals(IDKit.version, payload.packageVersion) + assertEquals("test-action", payload.action) + assertEquals("Identity check", payload.actionDescription) + assertEquals(VerificationLevel.DOCUMENT, payload.verificationLevel) + assertEquals(true, payload.requireUserPresence) + // IdentityCheck overrides allowLegacyProofs to true. + assertEquals(true, payload.allowLegacyProofs) + assertEquals("idkitsample://callback", payload.returnToUrl) + assertEquals(Environment.STAGING, payload.environment) + assertNull(payload.timestamp) + + assertEquals( + listOf( + IdentityAttribute.MinimumAge(21u), + IdentityAttribute.Nationality("JPN"), + ), + payload.identityAttributes, + ) + + val proofRequest = assertNotNull(payload.proofRequest) + assertEquals(1, proofRequest.version) + assertEquals("uniqueness", proofRequest.proofType) + assertEquals("rp_1234567890abcdef", proofRequest.rpId) + assertEquals(1_700_000_000uL, proofRequest.createdAt) + assertEquals(1_700_003_600uL, proofRequest.expiresAt) + assertTrue(proofRequest.id.isNotEmpty()) + + assertEquals(listOf("passport", "mnc"), payload.credentialIdentifiers) + } + + @Test + fun bridgePayloadFromConstraintsExposesPassportOrMnc() { + val payload = IDKit.createBridgePayloadFromConstraints( + config = sampleRequestConfig(), + constraints = anyOf( + CredentialRequest(CredentialType.PASSPORT), + CredentialRequest(CredentialType.MNC), + ), + ) + + // Constraint requests keep DEVICE for v3 parser compatibility; real + // selection lives in the proof request. + assertEquals(VerificationLevel.DEVICE, payload.verificationLevel) + assertEquals(false, payload.allowLegacyProofs) + assertEquals(listOf("passport", "mnc"), payload.credentialIdentifiers) + } + + @Test + fun invalidAppIdSurfacesAsIDKitException() { + val exception = assertFailsWith { + IDKit.createBridgePayloadFromPresets( + config = sampleRequestConfig().copy(appId = "bogus"), + preset = orbLegacy(), + ) + } + assertEquals("malformed_request", exception.code) + } + + @Test + fun blankInputsSurfaceAsClientErrors() { + assertFailsWith { + IDKit.request(sampleRequestConfig().copy(appId = " ")) + } + assertFailsWith { + IDKit.request(sampleRequestConfig().copy(action = "")) + } + } + + @Test + fun hashSignalStringAndBytesOverloadsAreDeterministic() { + val raw = "test-signal" + val hashFromString = IDKit.hashSignal(raw) + val hashFromBytes = IDKit.hashSignal(raw.encodeToByteArray()) + + assertEquals(hashFromString, hashFromBytes) + assertTrue(hashFromString.startsWith("0x")) + assertEquals(66, hashFromString.length) + assertEquals(hashFromString, IDKit.hashSignal(raw)) + } +} diff --git a/kotlin/idkit/src/commonTest/kotlin/com/worldcoin/idkit/PollLoopTests.kt b/kotlin/idkit/src/commonTest/kotlin/com/worldcoin/idkit/PollLoopTests.kt new file mode 100644 index 00000000..9ed80c9d --- /dev/null +++ b/kotlin/idkit/src/commonTest/kotlin/com/worldcoin/idkit/PollLoopTests.kt @@ -0,0 +1,99 @@ +package com.worldcoin.idkit + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class PollLoopTests { + + private fun requestReturning(pollStatusProvider: suspend () -> IDKitStatus): IDKitRequest = + IDKitRequest.forTesting( + connectorURI = "https://world.org/verify?t=wld", + requestId = "7a6ff287-c95f-4330-b3de-9447f77ca3f9", + pollStatusProvider = pollStatusProvider, + ) + + @Test + fun pollUntilCompletionSuccessPath() = runTest { + val statuses = ArrayDeque( + listOf( + IDKitStatus.WaitingForConnection, + IDKitStatus.AwaitingConfirmation, + IDKitStatus.Confirmed(sampleResult()), + ), + ) + val request = requestReturning { statuses.removeFirstOrNull() ?: IDKitStatus.WaitingForConnection } + + val completion = request.pollUntilCompletion(IDKitPollOptions(pollIntervalMs = 1u, timeoutMs = 60_000u)) + assertEquals(IDKitCompletionResult.Success(sampleResult()), completion) + } + + @Test + fun pollUntilCompletionTimeoutPath() = runTest { + val request = requestReturning { IDKitStatus.WaitingForConnection } + + // timeoutMs = 0 makes the deadline check deterministic under virtual time. + val completion = request.pollUntilCompletion(IDKitPollOptions(pollIntervalMs = 5u, timeoutMs = 0u)) + assertEquals(IDKitCompletionResult.Failure(IDKitErrorCode.TIMEOUT), completion) + } + + @Test + fun pollUntilCompletionCancellationPath() = runTest { + val request = requestReturning { throw CancellationException("test cancellation") } + + val completion = request.pollUntilCompletion(IDKitPollOptions(pollIntervalMs = 200u, timeoutMs = 10_000u)) + assertEquals(IDKitCompletionResult.Failure(IDKitErrorCode.CANCELLED), completion) + } + + @Test + fun pollUntilCompletionRecoversFromNetworkingErrors() = runTest { + val statuses = ArrayDeque( + listOf( + IDKitStatus.WaitingForConnection, + IDKitStatus.NetworkingError(IDKitErrorCode.CONNECTION_FAILED), + IDKitStatus.NetworkingError(IDKitErrorCode.CONNECTION_FAILED), + IDKitStatus.AwaitingConfirmation, + IDKitStatus.Confirmed(sampleResult()), + ), + ) + val request = requestReturning { statuses.removeFirstOrNull() ?: IDKitStatus.WaitingForConnection } + + val completion = request.pollUntilCompletion(IDKitPollOptions(pollIntervalMs = 1u, timeoutMs = 60_000u)) + assertEquals(IDKitCompletionResult.Success(sampleResult()), completion) + } + + @Test + fun pollUntilCompletionAppFailurePath() = runTest { + val request = requestReturning { IDKitStatus.Failed(IDKitErrorCode.USER_REJECTED) } + + val completion = request.pollUntilCompletion(IDKitPollOptions(pollIntervalMs = 1u, timeoutMs = 60_000u)) + assertEquals(IDKitCompletionResult.Failure(IDKitErrorCode.USER_REJECTED), completion) + } + + @Test + fun statusFlowEmitsDistinctStatesAndCompletes() = runTest { + val statuses = ArrayDeque( + listOf( + IDKitStatus.WaitingForConnection, + IDKitStatus.WaitingForConnection, + IDKitStatus.NetworkingError(IDKitErrorCode.CONNECTION_FAILED), + IDKitStatus.AwaitingConfirmation, + IDKitStatus.Confirmed(sampleResult()), + ), + ) + val request = requestReturning { statuses.removeFirstOrNull() ?: IDKitStatus.WaitingForConnection } + + val emitted = mutableListOf() + request.statusFlow(pollIntervalMs = 1u).collect { emitted.add(it) } + + assertEquals( + listOf( + IDKitStatus.WaitingForConnection, + IDKitStatus.AwaitingConfirmation, + IDKitStatus.Confirmed(sampleResult()), + ), + emitted, + ) + } +} diff --git a/kotlin/idkit/src/commonTest/kotlin/com/worldcoin/idkit/ResultJsonTests.kt b/kotlin/idkit/src/commonTest/kotlin/com/worldcoin/idkit/ResultJsonTests.kt new file mode 100644 index 00000000..91d92783 --- /dev/null +++ b/kotlin/idkit/src/commonTest/kotlin/com/worldcoin/idkit/ResultJsonTests.kt @@ -0,0 +1,98 @@ +package com.worldcoin.idkit + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ResultJsonTests { + + @Test + fun parsesV3Result() { + val result = idkitResultFromJson(SAMPLE_V3_RESULT_JSON) + + assertEquals("3.0", result.protocolVersion) + assertEquals("0x01", result.nonce) + assertEquals("test-action", result.action) + assertNull(result.sessionId) + assertEquals(true, result.userPresenceCompleted) + assertEquals("staging", result.environment) + + val item = assertIs(result.responses.single()) + assertEquals("proof_of_human", item.identifier) + assertEquals("0xabcd", item.proof) + assertEquals("0x1234", item.merkleRoot) + assertEquals("0x5678", item.nullifier) + } + + @Test + fun parsesV4AndSessionItems() { + val json = """ + { + "protocol_version": "4.0", + "nonce": "0x02", + "session_id": "session_00ff", + "responses": [ + { + "identifier": "passport", + "issuer_schema_id": 9303, + "proof": ["0x01", "0x02", "0x03", "0x04", "0x05"], + "nullifier": "0xaa", + "expires_at_min": 1700003600 + }, + { + "identifier": "proof_of_human", + "issuer_schema_id": 1, + "proof": ["0x01"], + "session_nullifier": ["0xbb", "0xcc"], + "expires_at_min": 1700003600 + } + ], + "user_presence_completed": false, + "environment": "production", + "identity_attested": true, + "integrity_bundle": { + "version": 1, + "signature_format": "apple_app_attest", + "timestamp": 1700000100, + "signature": "0xdd", + "jwt": "ey.." + } + } + """.trimIndent() + + val result = idkitResultFromJson(json) + + assertEquals("session_00ff", result.sessionId) + assertEquals(true, result.identityAttested) + assertEquals("apple_app_attest", result.integrityBundle?.signatureFormat) + + val v4 = assertIs(result.responses[0]) + assertEquals(9303uL, v4.issuerSchemaId) + assertEquals(5, v4.proof.size) + + val session = assertIs(result.responses[1]) + assertEquals(listOf("0xbb", "0xcc"), session.sessionNullifier) + } + + @Test + fun rawJsonIsPreservedVerbatim() { + // The raw wire form (including any fields this SDK does not model) + // must be forwarded unchanged to backend verification endpoints. + val jsonWithUnknownField = SAMPLE_V3_RESULT_JSON.replace( + "\"protocol_version\": \"3.0\",", + "\"protocol_version\": \"3.0\",\n \"future_field\": {\"a\": 1},", + ) + val result = idkitResultFromJson(jsonWithUnknownField) + + assertEquals(jsonWithUnknownField, result.rawJson) + assertEquals(jsonWithUnknownField, idkitResultToJson(result)) + assertTrue(idkitResultToJson(result).contains("future_field")) + } + + @Test + fun equalityIsStructural() { + assertEquals(idkitResultFromJson(SAMPLE_V3_RESULT_JSON), sampleResult()) + } +} diff --git a/kotlin/idkit/src/commonTest/kotlin/com/worldcoin/idkit/SerializationTests.kt b/kotlin/idkit/src/commonTest/kotlin/com/worldcoin/idkit/SerializationTests.kt new file mode 100644 index 00000000..6e3c622b --- /dev/null +++ b/kotlin/idkit/src/commonTest/kotlin/com/worldcoin/idkit/SerializationTests.kt @@ -0,0 +1,167 @@ +package com.worldcoin.idkit + +import com.worldcoin.idkit.internal.IdKitJson +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Golden-JSON tests: the encoded forms must match the Rust core's serde + * schemas exactly (Preset tag="type" PascalCase, IdentityAttribute + * {"type","value"}, ConstraintNode untagged, config DTO snake_case). + */ +class SerializationTests { + + private fun assertJsonEquals(expected: String, actual: String) { + assertEquals(IdKitJson.parseToJsonElement(expected), IdKitJson.parseToJsonElement(actual)) + } + + @Test + fun presetsEncodeToCoreSchema() { + assertJsonEquals( + """{"type":"OrbLegacy","signal":"sig"}""", + IdKitJson.encodeToString(Preset.serializer(), orbLegacy("sig")), + ) + assertJsonEquals( + """{"type":"OrbLegacy"}""", + IdKitJson.encodeToString(Preset.serializer(), orbLegacy()), + ) + assertJsonEquals( + """{"type":"SecureDocumentLegacy"}""", + IdKitJson.encodeToString(Preset.serializer(), secureDocumentLegacy()), + ) + assertJsonEquals( + """{"type":"DocumentLegacy"}""", + IdKitJson.encodeToString(Preset.serializer(), documentLegacy()), + ) + assertJsonEquals( + """{"type":"DeviceLegacy"}""", + IdKitJson.encodeToString(Preset.serializer(), deviceLegacy()), + ) + assertJsonEquals( + """{"type":"SelfieCheckLegacy"}""", + IdKitJson.encodeToString(Preset.serializer(), selfieCheckLegacy()), + ) + assertJsonEquals( + """ + { + "type": "IdentityCheck", + "attributes": [ + {"type": "minimum_age", "value": 21}, + {"type": "nationality", "value": "JPN"}, + {"type": "document_type", "value": "passport"} + ] + } + """.trimIndent(), + IdKitJson.encodeToString( + Preset.serializer(), + identityCheck( + attributes = listOf( + IdentityAttribute.MinimumAge(21u), + IdentityAttribute.Nationality("JPN"), + IdentityAttribute.DocumentType(DocumentType.PASSPORT), + ), + ), + ), + ) + } + + @Test + fun identityAttributesRoundTrip() { + val attributes = listOf( + IdentityAttribute.DocumentType(DocumentType.MNC), + IdentityAttribute.DocumentNumber("A1234567"), + IdentityAttribute.IssuingCountry("JPN"), + IdentityAttribute.FullName("Jane Doe"), + IdentityAttribute.MinimumAge(18u), + IdentityAttribute.Nationality("USA"), + ) + attributes.forEach { attribute -> + val encoded = IdKitJson.encodeToString(IdentityAttributeSerializer, attribute) + val decoded = IdKitJson.decodeFromString(IdentityAttributeSerializer, encoded) + assertEquals(attribute, decoded) + } + } + + @Test + fun constraintNodesEncodeToCoreSchema() { + assertJsonEquals( + """{"any":[{"type":"passport"},{"type":"mnc","signal":"sig-2"}]}""", + IdKitJson.encodeToString( + ConstraintNode.serializer(), + anyOf( + CredentialRequest(CredentialType.PASSPORT), + CredentialRequest(CredentialType.MNC, signal = "sig-2"), + ), + ), + ) + assertJsonEquals( + """{"all":[{"type":"proof_of_human"}]}""", + IdKitJson.encodeToString( + ConstraintNode.serializer(), + allOf(CredentialRequest(CredentialType.PROOF_OF_HUMAN)), + ), + ) + assertJsonEquals( + """{"enumerate":[{"type":"selfie","genesis_issued_at_min":1700000000}]}""", + IdKitJson.encodeToString( + ConstraintNode.serializer(), + enumerateOf( + CredentialRequest(CredentialType.SELFIE, genesisIssuedAtMin = 1_700_000_000u), + ), + ), + ) + // Nested combinators + assertJsonEquals( + """{"any":[{"all":[{"type":"passport"}]},{"type":"mnc"}]}""", + IdKitJson.encodeToString( + ConstraintNode.serializer(), + anyOfNodes( + listOf( + allOf(CredentialRequest(CredentialType.PASSPORT)), + ConstraintNode.Item(CredentialRequest(CredentialType.MNC)), + ), + ), + ), + ) + } + + @Test + fun constraintNodesRoundTrip() { + val node = anyOfNodes( + listOf( + allOf( + CredentialRequest(CredentialType.PASSPORT, signal = "s"), + CredentialRequest(CredentialType.MNC, expiresAtMin = 42u), + ), + ConstraintNode.Item(CredentialRequest(CredentialType.PROOF_OF_HUMAN)), + ), + ) + val encoded = IdKitJson.encodeToString(ConstraintNode.serializer(), node) + assertEquals(node, IdKitJson.decodeFromString(ConstraintNode.serializer(), encoded)) + } + + @Test + fun configDtoCarriesPackageIdentity() { + val json = IdKitJson.parseToJsonElement(sampleRequestConfig().toConfigJson()).jsonObject + + assertEquals("app_staging_1234567890abcdef", json.getValue("app_id").jsonPrimitive.content) + assertEquals(SDK_PACKAGE_NAME, json.getValue("package_name").jsonPrimitive.content) + assertEquals(IDKit.version, json.getValue("package_version").jsonPrimitive.content) + assertEquals("test-action", json.getValue("action").jsonPrimitive.content) + assertEquals("staging", json.getValue("environment").jsonPrimitive.content) + assertEquals("idkitsample://callback", json.getValue("return_to").jsonPrimitive.content) + assertEquals( + "rp_1234567890abcdef", + json.getValue("rp_context").jsonObject.getValue("rp_id").jsonPrimitive.content, + ) + assertEquals( + "1700000000", + json.getValue("rp_context").jsonObject.getValue("created_at").jsonPrimitive.content, + ) + // Optional nulls are omitted, matching the strict Rust-side DTO. + assertEquals(false, "bridge_url" in json) + assertEquals(false, "connect_url_mode" in json) + } +} diff --git a/kotlin/idkit/src/commonTest/kotlin/com/worldcoin/idkit/StatusMappingTests.kt b/kotlin/idkit/src/commonTest/kotlin/com/worldcoin/idkit/StatusMappingTests.kt new file mode 100644 index 00000000..4bcd22ba --- /dev/null +++ b/kotlin/idkit/src/commonTest/kotlin/com/worldcoin/idkit/StatusMappingTests.kt @@ -0,0 +1,80 @@ +package com.worldcoin.idkit + +import com.worldcoin.idkit.internal.IdKitJson +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class StatusMappingTests { + + private fun statusOf(json: String): IDKitStatus = + mapStatusDto(IdKitJson.decodeFromString(StatusDto.serializer(), json)) + + @Test + fun mapsPendingStates() { + assertEquals(IDKitStatus.WaitingForConnection, statusOf("""{"state":"waiting_for_connection"}""")) + assertEquals(IDKitStatus.AwaitingConfirmation, statusOf("""{"state":"awaiting_confirmation"}""")) + } + + @Test + fun mapsConfirmedWithResult() { + val resultObject = IdKitJson.parseToJsonElement(SAMPLE_V3_RESULT_JSON).jsonObject + val statusJson = IdKitJson.encodeToString( + JsonObject.serializer(), + JsonObject( + mapOf( + "state" to IdKitJson.parseToJsonElement("\"confirmed\""), + "result" to resultObject, + ), + ), + ) + + val status = statusOf(statusJson) + assertEquals(IDKitStatus.Confirmed(sampleResult()), status) + } + + @Test + fun confirmedWithoutResultIsAnError() { + assertFailsWith { statusOf("""{"state":"confirmed"}""") } + } + + @Test + fun mapsTerminalAndRetryableFailures() { + assertEquals( + IDKitStatus.Failed(IDKitErrorCode.USER_REJECTED), + statusOf("""{"state":"failed","error_code":"user_rejected"}"""), + ) + assertEquals( + IDKitStatus.NetworkingError(IDKitErrorCode.CONNECTION_FAILED), + statusOf("""{"state":"networking_error","error_code":"connection_failed"}"""), + ) + } + + @Test + fun mapsEveryWireErrorCode() { + // All 25 wire codes from AppError (error.rs); TIMEOUT and CANCELLED are client-side. + val wireCodes = IDKitErrorCode.entries - listOf(IDKitErrorCode.TIMEOUT, IDKitErrorCode.CANCELLED) + assertEquals(25, wireCodes.size) + wireCodes.forEach { code -> + assertEquals( + IDKitStatus.Failed(code), + statusOf("""{"state":"failed","error_code":"${code.rawValue}"}"""), + ) + } + } + + @Test + fun unknownErrorCodeDegradesToGeneric() { + assertEquals( + IDKitStatus.Failed(IDKitErrorCode.GENERIC_ERROR), + statusOf("""{"state":"failed","error_code":"brand_new_error"}"""), + ) + } + + @Test + fun unknownStateIsAnError() { + assertFailsWith { statusOf("""{"state":"totally_new_state"}""") } + } +} diff --git a/kotlin/idkit/src/commonTest/kotlin/com/worldcoin/idkit/TestFixtures.kt b/kotlin/idkit/src/commonTest/kotlin/com/worldcoin/idkit/TestFixtures.kt new file mode 100644 index 00000000..dc332872 --- /dev/null +++ b/kotlin/idkit/src/commonTest/kotlin/com/worldcoin/idkit/TestFixtures.kt @@ -0,0 +1,44 @@ +package com.worldcoin.idkit + +internal fun sampleRpContext(): RpContext = RpContext( + rpId = "rp_1234567890abcdef", + nonce = "0x0000000000000000000000000000000000000000000000000000000000000001", + createdAt = 1_700_000_000u, + expiresAt = 1_700_003_600u, + signature = "0x" + "00".repeat(64) + "1b", +) + +internal fun sampleRequestConfig(): IDKitRequestConfig = IDKitRequestConfig( + appId = "app_staging_1234567890abcdef", + action = "test-action", + rpContext = sampleRpContext(), + actionDescription = "Identity check", + bridgeUrl = null, + allowLegacyProofs = false, + requireUserPresence = true, + overrideConnectBaseUrl = null, + returnTo = "idkitsample://callback", + environment = Environment.STAGING, + connectUrlMode = null, +) + +internal val SAMPLE_V3_RESULT_JSON: String = """ + { + "protocol_version": "3.0", + "nonce": "0x01", + "action": "test-action", + "responses": [ + { + "identifier": "proof_of_human", + "signal_hash": "0x00c5", + "proof": "0xabcd", + "merkle_root": "0x1234", + "nullifier": "0x5678" + } + ], + "user_presence_completed": true, + "environment": "staging" + } +""".trimIndent() + +internal fun sampleResult(): IDKitResult = idkitResultFromJson(SAMPLE_V3_RESULT_JSON) diff --git a/kotlin/idkit/src/iosMain/kotlin/com/worldcoin/idkit/internal/IoDispatcher.ios.kt b/kotlin/idkit/src/iosMain/kotlin/com/worldcoin/idkit/internal/IoDispatcher.ios.kt new file mode 100644 index 00000000..29494db0 --- /dev/null +++ b/kotlin/idkit/src/iosMain/kotlin/com/worldcoin/idkit/internal/IoDispatcher.ios.kt @@ -0,0 +1,7 @@ +package com.worldcoin.idkit.internal + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO + +internal actual val ioDispatcher: CoroutineDispatcher = Dispatchers.IO diff --git a/kotlin/idkit/src/iosMain/kotlin/com/worldcoin/idkit/internal/NativeBridge.ios.kt b/kotlin/idkit/src/iosMain/kotlin/com/worldcoin/idkit/internal/NativeBridge.ios.kt new file mode 100644 index 00000000..5b8514b7 --- /dev/null +++ b/kotlin/idkit/src/iosMain/kotlin/com/worldcoin/idkit/internal/NativeBridge.ios.kt @@ -0,0 +1,75 @@ +package com.worldcoin.idkit.internal + +import com.worldcoin.idkit.cinterop.idkit_kmp_bridge_payload_from_constraints +import com.worldcoin.idkit.cinterop.idkit_kmp_bridge_payload_from_preset +import com.worldcoin.idkit.cinterop.idkit_kmp_hash_signal_bytes +import com.worldcoin.idkit.cinterop.idkit_kmp_hash_signal_string +import com.worldcoin.idkit.cinterop.idkit_kmp_request_create_with_constraints +import com.worldcoin.idkit.cinterop.idkit_kmp_request_create_with_preset +import com.worldcoin.idkit.cinterop.idkit_kmp_request_free +import com.worldcoin.idkit.cinterop.idkit_kmp_request_poll_once +import com.worldcoin.idkit.cinterop.idkit_kmp_string_free +import com.worldcoin.idkit.cinterop.idkit_kmp_version +import kotlinx.cinterop.ByteVar +import kotlinx.cinterop.CPointer +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.toKString +import kotlinx.cinterop.usePinned + +/** + * Kotlin/Native cinterop binding of the `idkit_kmp` C ABI (`libidkit_kmp.a`, + * statically linked into the framework via the cinterop klib). + */ +@OptIn(ExperimentalForeignApi::class) +internal actual object NativeBridge { + + /** Copies the envelope out of native memory and frees the Rust allocation. */ + private fun consume(ptr: CPointer?): String { + checkNotNull(ptr) { "idkit_kmp returned NULL (allocator failure)" } + try { + return ptr.toKString() + } finally { + idkit_kmp_string_free(ptr) + } + } + + actual fun version(): String = consume(idkit_kmp_version()) + + actual fun hashSignalString(signal: String): String = + consume(idkit_kmp_hash_signal_string(signal)) + + actual fun hashSignalBytes(bytes: ByteArray): String = + if (bytes.isEmpty()) { + consume(idkit_kmp_hash_signal_bytes(null, 0u)) + } else { + bytes.usePinned { pinned -> + consume( + idkit_kmp_hash_signal_bytes( + pinned.addressOf(0).reinterpret(), + bytes.size.toULong(), + ), + ) + } + } + + actual fun bridgePayloadFromPreset(configJson: String, presetJson: String): String = + consume(idkit_kmp_bridge_payload_from_preset(configJson, presetJson)) + + actual fun bridgePayloadFromConstraints(configJson: String, constraintsJson: String): String = + consume(idkit_kmp_bridge_payload_from_constraints(configJson, constraintsJson)) + + actual fun requestCreateWithPreset(configJson: String, presetJson: String): String = + consume(idkit_kmp_request_create_with_preset(configJson, presetJson)) + + actual fun requestCreateWithConstraints(configJson: String, constraintsJson: String): String = + consume(idkit_kmp_request_create_with_constraints(configJson, constraintsJson)) + + actual fun requestPollOnce(handle: Long): String = + consume(idkit_kmp_request_poll_once(handle.toULong())) + + actual fun requestFree(handle: Long) { + idkit_kmp_request_free(handle.toULong()) + } +} diff --git a/kotlin/idkit/src/nativeInterop/cinterop/idkit_kmp.def b/kotlin/idkit/src/nativeInterop/cinterop/idkit_kmp.def new file mode 100644 index 00000000..dea9ac75 --- /dev/null +++ b/kotlin/idkit/src/nativeInterop/cinterop/idkit_kmp.def @@ -0,0 +1,7 @@ +package = com.worldcoin.idkit.cinterop +headers = idkit_kmp.h +headerFilter = idkit_kmp.h +staticLibraries = libidkit_kmp.a +# Header include dir and per-target library search path are injected from +# kotlin/idkit/build.gradle.kts (includeDirs / -libraryPath) so this file stays +# free of machine-specific relative paths. diff --git a/kotlin/settings.gradle.kts b/kotlin/settings.gradle.kts index f18eea95..dde3a789 100644 --- a/kotlin/settings.gradle.kts +++ b/kotlin/settings.gradle.kts @@ -15,4 +15,4 @@ dependencyResolutionManagement { } rootProject.name = "idkit-kotlin" -include(":bindings") +include(":idkit") diff --git a/rust/core/src/bridge.rs b/rust/core/src/bridge.rs index 7a01b7c6..b6f78978 100644 --- a/rust/core/src/bridge.rs +++ b/rust/core/src/bridge.rs @@ -733,7 +733,7 @@ impl BridgeConnection { /// /// Returns an error if the request cannot be created or the bridge call fails #[allow(dead_code, clippy::too_many_lines)] - pub(crate) async fn create(params: BridgeConnectionParams) -> Result { + pub async fn create(params: BridgeConnectionParams) -> Result { // Generate encryption key and IV #[cfg(feature = "native-crypto")] let (key_bytes, nonce_bytes) = crate::crypto::generate_key()?; @@ -2184,8 +2184,12 @@ impl From for StatusWrapper { } } -#[cfg(feature = "ffi")] -fn to_app_error(error: &Error) -> AppError { +/// Maps an internal [`Error`] to the wire-level [`AppError`] surfaced to SDK callers. +/// +/// Used by binding layers (`UniFFI` wrappers and the KMP C ABI) so every SDK +/// reports identical error codes for the same underlying failure. +#[must_use] +pub fn to_app_error(error: &Error) -> AppError { match error { Error::InvalidConfiguration(_) => AppError::MalformedRequest, Error::BridgeError(_) => AppError::ConnectionFailed, @@ -2206,8 +2210,11 @@ fn to_app_error(error: &Error) -> AppError { /// Networking errors are network/transport-level failures where the bridge /// never returned a meaningful response. These are safe to retry because /// the request itself is valid — only the delivery failed. -#[cfg(feature = "ffi")] -fn is_networking_error(error: &Error) -> bool { +/// +/// Binding layers use this to distinguish retryable `NetworkingError` statuses +/// from terminal `Failed` statuses. +#[must_use] +pub fn is_networking_error(error: &Error) -> bool { match error { Error::Timeout | Error::ConnectionFailed | Error::BridgeError(_) => true, #[cfg(any(feature = "bridge", feature = "bridge-wasm"))] diff --git a/rust/core/src/preset.rs b/rust/core/src/preset.rs index 42a9f41a..7c4c1909 100644 --- a/rust/core/src/preset.rs +++ b/rust/core/src/preset.rs @@ -4,9 +4,7 @@ //! automatically handling both World ID 4.0 and 3.0 protocol formats. use crate::types::IdentityAttribute; -#[cfg(any(test, feature = "ffi", feature = "wasm-bindings"))] use crate::types::{CredentialRequest, CredentialType, VerificationLevel}; -#[cfg(any(test, feature = "ffi", feature = "wasm-bindings"))] use crate::{ConstraintNode, Signal}; use serde::{Deserialize, Serialize}; @@ -121,12 +119,20 @@ pub enum Preset { }, } -#[cfg(any(test, feature = "ffi", feature = "wasm-bindings"))] -pub(crate) struct BridgeParams { +/// Bridge session parameters derived from a [`Preset`]. +/// +/// Consumed by binding layers (`UniFFI` wrappers and the KMP C ABI) to build +/// `BridgeConnectionParams` from a preset. +pub struct BridgeParams { + /// World ID 4.0 constraint tree (None for legacy-only presets) pub constraints: Option, + /// World ID 3.0 legacy verification level pub legacy_verification_level: Option, + /// Legacy signal string (if configured) pub legacy_signal: Option, + /// Identity attribute filters (`IdentityCheck` presets only) pub identity_attributes: Option>, + /// Override for `allow_legacy_proofs` (`None` = let caller decide) pub allow_legacy_proofs_override: Option, } @@ -202,9 +208,8 @@ impl Preset { /// - `Option` - override for `allow_legacy_proofs` (`None` = let caller decide) // TODO: This should be removed it was introduced to keep legacy preset compatible with proof_request // TODO: but we decided to keep legacy presets only 3.0, will tackle separately - #[cfg(any(test, feature = "ffi", feature = "wasm-bindings"))] #[must_use] - pub(crate) fn into_bridge_params(self) -> BridgeParams { + pub fn into_bridge_params(self) -> BridgeParams { match self { Self::OrbLegacy { signal } => BridgeParams { constraints: None, diff --git a/rust/kmp-ffi/Cargo.toml b/rust/kmp-ffi/Cargo.toml new file mode 100644 index 00000000..fc287505 --- /dev/null +++ b/rust/kmp-ffi/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "idkit-kmp-ffi" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +description = "C ABI facade over idkit-core for the IDKit Kotlin Multiplatform SDK" +publish = false + +[lib] +name = "idkit_kmp" +# `lib` keeps the crate linkable from its own integration tests; cdylib/staticlib +# are the artifacts consumed by the Kotlin Multiplatform SDK (JNA / cinterop). +crate-type = ["lib", "cdylib", "staticlib"] + +[dependencies] +# Deliberately without the `ffi` (UniFFI) feature: the KMP ABI is hand-written, +# and omitting UniFFI keeps uniffi_* symbols out of the binary so an app can +# link both this library and the UniFFI-based SDKs without symbol collisions. +idkit-core = { path = "../core", default-features = false, features = ["native-crypto", "bridge"] } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } diff --git a/rust/kmp-ffi/include/idkit_kmp.h b/rust/kmp-ffi/include/idkit_kmp.h new file mode 100644 index 00000000..5eefc634 --- /dev/null +++ b/rust/kmp-ffi/include/idkit_kmp.h @@ -0,0 +1,77 @@ +#ifndef IDKIT_KMP_H +#define IDKIT_KMP_H + +/* + * C ABI for the IDKit Kotlin Multiplatform SDK. + * + * Hand-written facade over the Rust core (rust/kmp-ffi). Consumed by: + * - Kotlin/Native (iOS) via cinterop + * - Kotlin/JVM (Android) via JNA direct mapping + * + * Contract: + * - Every function returns a heap-allocated, NUL-terminated UTF-8 JSON + * envelope: {"ok": } or {"err": {"code": "...", "message": "..."}}. + * The caller MUST free every returned pointer with idkit_kmp_string_free. + * - All char* inputs are NUL-terminated UTF-8. Null/invalid inputs yield an + * {"err": {"code": "invalid_argument", ...}} envelope, never a crash. + * - Panics never unwind across this boundary; they become + * {"err": {"code": "internal_panic", ...}} envelopes. + * - Functions marked BLOCKING perform network I/O with a bounded (30s) + * deadline and must be called off the main thread. + */ + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* ok: crate version string, e.g. "4.0.0". */ +char *idkit_kmp_version(void); + +/* ok: "0x"-prefixed field-element hex. Strings use IDKit hashSignal semantics: + * a valid non-empty even-length 0x-hex string is hashed as raw bytes, any + * other string as UTF-8 text. For signals with interior NUL bytes use the + * bytes variant. */ +char *idkit_kmp_hash_signal_string(const char *signal); + +/* bytes may be NULL iff len == 0. */ +char *idkit_kmp_hash_signal_bytes(const uint8_t *bytes, uint64_t len); + +/* Build the plaintext bridge request payload without network I/O (test + * fixtures / debugging). + * config_json: RequestConfigDto (see rust/kmp-ffi/src/config.rs) + * preset_json: core Preset serde form, e.g. {"type":"OrbLegacy","signal":"..."} + * constraints_json: core ConstraintNode serde form, e.g. {"any":[{"type":"passport"}]} + * ok: the payload JSON object. */ +char *idkit_kmp_bridge_payload_from_preset(const char *config_json, + const char *preset_json); +char *idkit_kmp_bridge_payload_from_constraints(const char *config_json, + const char *constraints_json); + +/* BLOCKING (POST /request to the bridge). + * ok: {"handle": , "connect_url": "...", "request_id": "..."} */ +char *idkit_kmp_request_create_with_preset(const char *config_json, + const char *preset_json); +char *idkit_kmp_request_create_with_constraints(const char *config_json, + const char *constraints_json); + +/* BLOCKING (GET /response from the bridge). ok is one of: + * {"state":"waiting_for_connection"} | {"state":"awaiting_confirmation"} + * {"state":"confirmed","result":{...IDKitResult...}} + * {"state":"failed","error_code":"user_rejected"} (terminal) + * {"state":"networking_error","error_code":"connection_failed"} (retryable) + * err only for invalid_handle / internal failures. */ +char *idkit_kmp_request_poll_once(uint64_t handle); + +/* Releases the request. Idempotent; unknown handles are ignored. */ +void idkit_kmp_request_free(uint64_t handle); + +/* Frees a string returned by any idkit_kmp_* function. NULL is a no-op. */ +void idkit_kmp_string_free(char *ptr); + +#ifdef __cplusplus +} +#endif + +#endif /* IDKIT_KMP_H */ diff --git a/rust/kmp-ffi/src/config.rs b/rust/kmp-ffi/src/config.rs new file mode 100644 index 00000000..5ed694c0 --- /dev/null +++ b/rust/kmp-ffi/src/config.rs @@ -0,0 +1,170 @@ +//! Config DTOs mirrored by the Kotlin commonMain layer. +//! +//! These are ports of the request arm of core's `IDKitConfig::to_params` / +//! `to_params_from_preset` (see `rust/core/src/bridge.rs`), decoded from JSON +//! produced by `IDKitRequestConfig.toConfigJson()` in Kotlin. + +use serde::Deserialize; + +use idkit::bridge::{BridgeConnectionParams, Environment, RequestKind}; +use idkit::types::{AppId, BridgeUrl, IdentityAttribute, RpContext, VerificationLevel}; +use idkit::{ConstraintNode, Preset}; + +use crate::envelope::FfiError; + +#[derive(Debug, Deserialize)] +pub(crate) struct RpContextDto { + rp_id: String, + nonce: String, + /// Unix seconds + created_at: u64, + /// Unix seconds + expires_at: u64, + signature: String, +} + +impl RpContextDto { + /// Goes through the validating constructor (rp_ prefix, clock skew, + /// expiry ordering) rather than deserializing the core type directly. + fn into_rp_context(self) -> Result { + RpContext::new( + &self.rp_id, + self.nonce, + self.created_at, + self.expires_at, + self.signature, + ) + .map_err(FfiError::Core) + } +} + +/// Mirror of core's `ConnectUrlMode`, which is gated behind the `ffi` feature. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ConnectUrlMode { + #[default] + Default, + AppClip, +} + +impl ConnectUrlMode { + /// Applies the mode to a raw connect URL. App Clip mode wraps it in an + /// Apple App Clip invocation URL, byte-for-byte like the `UniFFI` wrapper. + pub(crate) fn apply(self, url: String) -> String { + match self { + Self::Default => url, + Self::AppClip => { + let encoded = idkit::crypto::base64_url_encode(url.as_bytes()); + format!( + "https://appclip.apple.com/id?p=org.worldcoin.insight.Clip&experience={encoded}" + ) + } + } + } +} + +/// JSON schema for the request configuration produced by Kotlin commonMain. +/// +/// `deny_unknown_fields` keeps the Kotlin and Rust sides honest: a field-name +/// drift fails loudly at the boundary instead of being silently dropped. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct RequestConfigDto { + app_id: String, + package_name: String, + package_version: String, + action: String, + rp_context: RpContextDto, + #[serde(default)] + action_description: Option, + #[serde(default)] + bridge_url: Option, + allow_legacy_proofs: bool, + #[serde(default)] + require_user_presence: Option, + #[serde(default)] + override_connect_base_url: Option, + #[serde(default)] + return_to: Option, + #[serde(default)] + environment: Option, + #[serde(default)] + connect_url_mode: Option, +} + +impl RequestConfigDto { + pub(crate) fn connect_url_mode(&self) -> ConnectUrlMode { + self.connect_url_mode.unwrap_or_default() + } + + /// Port of core's `IDKitConfig::to_params` (Request arm). + pub(crate) fn into_params_with_constraints( + self, + constraints: ConstraintNode, + ) -> Result { + constraints.validate().map_err(FfiError::Core)?; + // Device keeps the payload parseable by pre-4.0 World App versions; + // v4 requests carry real credential selection in `proof_request`. + self.into_params( + Some(constraints), + VerificationLevel::Device, + String::new(), + None, + None, + ) + } + + /// Port of core's `IDKitConfig::to_params_from_preset` (Request arm). + pub(crate) fn into_params_with_preset( + self, + preset: Preset, + ) -> Result { + let bridge_params = preset.into_bridge_params(); + let legacy_verification_level = bridge_params + .legacy_verification_level + .unwrap_or(VerificationLevel::Device); + self.into_params( + bridge_params.constraints, + legacy_verification_level, + bridge_params.legacy_signal.unwrap_or_default(), + bridge_params.identity_attributes, + bridge_params.allow_legacy_proofs_override, + ) + } + + fn into_params( + self, + constraints: Option, + legacy_verification_level: VerificationLevel, + legacy_signal: String, + identity_attributes: Option>, + allow_legacy_proofs_override: Option, + ) -> Result { + let app_id = AppId::new(self.app_id).map_err(FfiError::Core)?; + let bridge_url = self + .bridge_url + .map(|url| BridgeUrl::new(url, &app_id)) + .transpose() + .map_err(FfiError::Core)?; + Ok(BridgeConnectionParams { + app_id, + package_name: self.package_name, + package_version: self.package_version, + kind: RequestKind::Uniqueness { + action: self.action, + }, + constraints, + rp_context: self.rp_context.into_rp_context()?, + action_description: self.action_description, + legacy_verification_level, + legacy_signal, + bridge_url, + allow_legacy_proofs: allow_legacy_proofs_override.unwrap_or(self.allow_legacy_proofs), + require_user_presence: self.require_user_presence.unwrap_or(false), + override_connect_base_url: self.override_connect_base_url, + return_to: self.return_to, + environment: self.environment, + identity_attributes, + }) + } +} diff --git a/rust/kmp-ffi/src/envelope.rs b/rust/kmp-ffi/src/envelope.rs new file mode 100644 index 00000000..cfb786bf --- /dev/null +++ b/rust/kmp-ffi/src/envelope.rs @@ -0,0 +1,93 @@ +//! JSON envelope helpers shared by every exported function. +//! +//! Every FFI function returns `{"ok": }` or `{"err": {"code", "message"}}` +//! so the Kotlin side has a single, uniform decode path and no failure is ever +//! silently swallowed at the boundary. + +use std::any::Any; +use std::ffi::{c_char, CString}; + +/// Errors surfaced through the `{"err": ...}` envelope. +pub(crate) enum FfiError { + /// Caller passed a null pointer or non-UTF-8 string. + InvalidArgument(String), + /// Caller passed JSON that failed to parse or validate. + Json(String), + /// Caller referenced a handle that does not exist (or was already freed). + InvalidHandle(u64), + /// The embedded async runtime could not be constructed. + Runtime(String), + /// An error propagated from idkit-core. + Core(idkit::Error), +} + +impl FfiError { + pub(crate) fn code(&self) -> String { + match self { + Self::InvalidArgument(_) => "invalid_argument".to_owned(), + Self::Json(_) => "invalid_json".to_owned(), + Self::InvalidHandle(_) => "invalid_handle".to_owned(), + Self::Runtime(_) => "internal_error".to_owned(), + // Same code mapping as the UniFFI wrappers so all SDKs report + // identical codes for the same underlying failure. + Self::Core(error) => app_error_code(idkit::bridge::to_app_error(error)), + } + } + + pub(crate) fn message(&self) -> String { + match self { + Self::InvalidArgument(msg) | Self::Json(msg) | Self::Runtime(msg) => msg.clone(), + Self::InvalidHandle(handle) => { + format!("unknown request handle {handle} (already freed?)") + } + Self::Core(error) => error.to_string(), + } + } +} + +impl From for FfiError { + fn from(error: idkit::Error) -> Self { + Self::Core(error) + } +} + +/// Wire code string for an [`idkit::error::AppError`] (its serde `snake_case` name). +pub(crate) fn app_error_code(error: idkit::error::AppError) -> String { + serde_json::to_value(error) + .ok() + .and_then(|value| value.as_str().map(str::to_owned)) + .unwrap_or_else(|| "generic_error".to_owned()) +} + +pub(crate) fn ok_cstring(value: &serde_json::Value) -> *mut c_char { + into_cstring(&serde_json::json!({ "ok": value })) +} + +pub(crate) fn err_cstring(code: &str, message: &str) -> *mut c_char { + into_cstring(&serde_json::json!({ "err": { "code": code, "message": message } })) +} + +fn into_cstring(value: &serde_json::Value) -> *mut c_char { + let json = serde_json::to_string(value).unwrap_or_else(|_| { + r#"{"err":{"code":"internal_error","message":"envelope serialization failed"}}"#.to_owned() + }); + // serde_json escapes control characters (including NUL) so CString::new can + // only fail on the fallback above — keep a hard static fallback regardless. + CString::new(json) + .unwrap_or_else(|_| { + CString::new( + r#"{"err":{"code":"internal_error","message":"embedded NUL in envelope"}}"#, + ) + .expect("static fallback envelope contains no NUL") + }) + .into_raw() +} + +/// Best-effort extraction of a panic payload's message. +pub(crate) fn panic_message(payload: &(dyn Any + Send)) -> String { + payload + .downcast_ref::<&str>() + .map(|s| (*s).to_owned()) + .or_else(|| payload.downcast_ref::().cloned()) + .unwrap_or_else(|| "panic with non-string payload".to_owned()) +} diff --git a/rust/kmp-ffi/src/lib.rs b/rust/kmp-ffi/src/lib.rs new file mode 100644 index 00000000..b7ba59fa --- /dev/null +++ b/rust/kmp-ffi/src/lib.rs @@ -0,0 +1,315 @@ +//! C ABI facade over `idkit-core` for the `IDKit` Kotlin Multiplatform SDK. +//! +//! Contract (mirrored by `include/idkit_kmp.h` and Kotlin's `NativeBridge`): +//! - Every function returns a heap-allocated, NUL-terminated UTF-8 JSON envelope: +//! `{"ok": }` on success or `{"err": {"code", "message"}}` on failure. +//! - Callers free every returned string with [`idkit_kmp_string_free`]. +//! - Requests are opaque `u64` handles; freeing is idempotent. +//! - Panics never unwind across the boundary — they become `internal_panic` +//! envelopes. This requires a `panic=unwind` build profile; Android builds use +//! the `kmp-android-release` workspace profile for exactly this reason. +//! - Network-bound calls (`request_create_*`, `request_poll_once`) block the +//! calling thread with a bounded deadline; Kotlin dispatches them off the +//! main thread. + +#![deny(clippy::all, clippy::pedantic, clippy::nursery)] +#![allow(clippy::module_name_repetitions)] +// The exported functions return owned C strings the caller must free; a +// #[must_use] on every extern fn adds nothing for FFI callers. +#![allow(clippy::must_use_candidate)] +// Keep helper visibility explicit at pub(crate) even inside private modules. +#![allow(clippy::redundant_pub_crate)] + +mod config; +mod envelope; +mod registry; + +use std::ffi::{c_char, CStr, CString}; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::time::Duration; + +use envelope::{err_cstring, ok_cstring, panic_message, FfiError}; + +/// Bounded deadline for a single bridge network call. The Kotlin poll loop +/// treats the resulting timeout as a retryable networking error, so a hung +/// connection can never wedge a caller indefinitely. +const NETWORK_CALL_TIMEOUT: Duration = Duration::from_secs(30); + +/// Runs `f` inside `catch_unwind` and always returns an envelope C string: +/// no panic crosses the FFI boundary and no error is swallowed. +fn ffi_boundary(f: impl FnOnce() -> Result) -> *mut c_char { + match catch_unwind(AssertUnwindSafe(f)) { + Ok(Ok(value)) => ok_cstring(&value), + Ok(Err(error)) => err_cstring(&error.code(), &error.message()), + Err(payload) => err_cstring("internal_panic", &panic_message(payload.as_ref())), + } +} + +/// Reads a required UTF-8 C string argument. +/// +/// # Safety +/// +/// `ptr` must be null or a valid NUL-terminated string that outlives the call. +unsafe fn read_str<'a>(ptr: *const c_char, name: &str) -> Result<&'a str, FfiError> { + if ptr.is_null() { + return Err(FfiError::InvalidArgument(format!( + "{name} must not be null" + ))); + } + CStr::from_ptr(ptr) + .to_str() + .map_err(|_| FfiError::InvalidArgument(format!("{name} must be valid UTF-8"))) +} + +fn parse_json(json: &str, name: &str) -> Result { + serde_json::from_str(json).map_err(|error| FfiError::Json(format!("{name}: {error}"))) +} + +/// Awaits a bridge future on the shared runtime with a bounded deadline. +fn block_on_bridge( + future: impl std::future::Future>, +) -> Result { + registry::runtime()? + .block_on(async { tokio::time::timeout(NETWORK_CALL_TIMEOUT, future).await }) + .map_err(|_elapsed| FfiError::Core(idkit::Error::Timeout))? + .map_err(FfiError::Core) +} + +fn create_request( + params: idkit::bridge::BridgeConnectionParams, + mode: config::ConnectUrlMode, +) -> Result { + let conn = block_on_bridge(idkit::BridgeConnection::create(params))?; + let connect_url = mode.apply(conn.connect_url()); + let request_id = conn.request_id().to_owned(); + let handle = registry::insert_request(conn); + Ok(serde_json::json!({ + "handle": handle, + "connect_url": connect_url, + "request_id": request_id, + })) +} + +/// ok: the crate version string. +#[no_mangle] +pub extern "C" fn idkit_kmp_version() -> *mut c_char { + ffi_boundary(|| { + Ok(serde_json::Value::String( + env!("CARGO_PKG_VERSION").to_owned(), + )) + }) +} + +/// ok: `0x`-prefixed field-element hex of the hashed signal. +/// +/// # Safety +/// +/// `signal` must be null or a valid NUL-terminated UTF-8 string. +#[no_mangle] +pub unsafe extern "C" fn idkit_kmp_hash_signal_string(signal: *const c_char) -> *mut c_char { + ffi_boundary(|| { + let signal = read_str(signal, "signal")?; + Ok(serde_json::Value::String(idkit::crypto::hash_signal( + &idkit::types::Signal::from_string(signal), + ))) + }) +} + +/// ok: `0x`-prefixed field-element hex of the hashed signal bytes. +/// +/// # Safety +/// +/// `bytes` must point to `len` readable bytes; it may be null iff `len == 0`. +#[no_mangle] +pub unsafe extern "C" fn idkit_kmp_hash_signal_bytes(bytes: *const u8, len: u64) -> *mut c_char { + ffi_boundary(|| { + let data = if len == 0 { + Vec::new() + } else if bytes.is_null() { + return Err(FfiError::InvalidArgument( + "bytes must not be null when len > 0".to_owned(), + )); + } else { + let len = usize::try_from(len) + .map_err(|_| FfiError::InvalidArgument("len does not fit in usize".to_owned()))?; + std::slice::from_raw_parts(bytes, len).to_vec() + }; + Ok(serde_json::Value::String(idkit::crypto::hash_signal( + &idkit::types::Signal::from_bytes(data), + ))) + }) +} + +/// Builds the plaintext bridge request payload for a preset without any +/// network I/O (test fixtures / debugging). ok: the payload JSON object. +/// +/// # Safety +/// +/// Both arguments must be null or valid NUL-terminated UTF-8 strings. +#[no_mangle] +pub unsafe extern "C" fn idkit_kmp_bridge_payload_from_preset( + config_json: *const c_char, + preset_json: *const c_char, +) -> *mut c_char { + ffi_boundary(|| { + let config: config::RequestConfigDto = + parse_json(read_str(config_json, "config_json")?, "config_json")?; + let preset: idkit::Preset = + parse_json(read_str(preset_json, "preset_json")?, "preset_json")?; + let params = config.into_params_with_preset(preset)?; + idkit::bridge::build_request_payload_json(¶ms, false).map_err(FfiError::Core) + }) +} + +/// Builds the plaintext bridge request payload for a constraint tree without +/// any network I/O. ok: the payload JSON object. +/// +/// # Safety +/// +/// Both arguments must be null or valid NUL-terminated UTF-8 strings. +#[no_mangle] +pub unsafe extern "C" fn idkit_kmp_bridge_payload_from_constraints( + config_json: *const c_char, + constraints_json: *const c_char, +) -> *mut c_char { + ffi_boundary(|| { + let config: config::RequestConfigDto = + parse_json(read_str(config_json, "config_json")?, "config_json")?; + let constraints: idkit::ConstraintNode = parse_json( + read_str(constraints_json, "constraints_json")?, + "constraints_json", + )?; + let params = config.into_params_with_constraints(constraints)?; + idkit::bridge::build_request_payload_json(¶ms, false).map_err(FfiError::Core) + }) +} + +/// Creates a bridge request from a preset. BLOCKING network call — dispatch +/// off the main thread. ok: `{"handle": u64, "connect_url": "...", "request_id": "..."}`. +/// +/// # Safety +/// +/// Both arguments must be null or valid NUL-terminated UTF-8 strings. +#[no_mangle] +pub unsafe extern "C" fn idkit_kmp_request_create_with_preset( + config_json: *const c_char, + preset_json: *const c_char, +) -> *mut c_char { + ffi_boundary(|| { + let config: config::RequestConfigDto = + parse_json(read_str(config_json, "config_json")?, "config_json")?; + let preset: idkit::Preset = + parse_json(read_str(preset_json, "preset_json")?, "preset_json")?; + let mode = config.connect_url_mode(); + let params = config.into_params_with_preset(preset)?; + create_request(params, mode) + }) +} + +/// Creates a bridge request from a constraint tree. BLOCKING network call — +/// dispatch off the main thread. ok: same shape as the preset variant. +/// +/// # Safety +/// +/// Both arguments must be null or valid NUL-terminated UTF-8 strings. +#[no_mangle] +pub unsafe extern "C" fn idkit_kmp_request_create_with_constraints( + config_json: *const c_char, + constraints_json: *const c_char, +) -> *mut c_char { + ffi_boundary(|| { + let config: config::RequestConfigDto = + parse_json(read_str(config_json, "config_json")?, "config_json")?; + let constraints: idkit::ConstraintNode = parse_json( + read_str(constraints_json, "constraints_json")?, + "constraints_json", + )?; + let mode = config.connect_url_mode(); + let params = config.into_params_with_constraints(constraints)?; + create_request(params, mode) + }) +} + +/// Polls the request once. BLOCKING network call — dispatch off the main thread. +/// +/// ok is one of: +/// - `{"state": "waiting_for_connection"}` | `{"state": "awaiting_confirmation"}` +/// - `{"state": "confirmed", "result": { ...IDKitResult... }}` +/// - `{"state": "failed", "error_code": "user_rejected"}` (terminal) +/// - `{"state": "networking_error", "error_code": "connection_failed"}` (retryable) +/// +/// err only for `invalid_handle` / internal failures. +#[no_mangle] +pub extern "C" fn idkit_kmp_request_poll_once(handle: u64) -> *mut c_char { + ffi_boundary(|| { + // Arc clone — the registry lock is not held during network I/O. + let entry = registry::request_entry(handle)?; + let value = match block_on_bridge(entry.conn.poll_for_status()) { + Ok(idkit::Status::WaitingForConnection) => { + serde_json::json!({ "state": "waiting_for_connection" }) + } + Ok(idkit::Status::AwaitingConfirmation) => { + serde_json::json!({ "state": "awaiting_confirmation" }) + } + Ok(idkit::Status::Confirmed(result)) => serde_json::json!({ + "state": "confirmed", + "result": serde_json::to_value(&result) + .map_err(|error| FfiError::Runtime(format!("result serialization: {error}")))?, + }), + Ok(idkit::Status::Failed(app_error)) => serde_json::json!({ + "state": "failed", + "error_code": envelope::app_error_code(app_error), + }), + Err(FfiError::Core(error)) => { + // Mirrors the UniFFI StatusWrapper semantics: transport-level + // failures are retryable, everything else is terminal. + let state = if idkit::bridge::is_networking_error(&error) { + "networking_error" + } else { + "failed" + }; + serde_json::json!({ + "state": state, + "error_code": envelope::app_error_code(idkit::bridge::to_app_error(&error)), + }) + } + Err(other) => return Err(other), + }; + Ok(value) + }) +} + +/// Releases the request handle. Idempotent; unknown handles are ignored. +#[no_mangle] +pub extern "C" fn idkit_kmp_request_free(handle: u64) { + registry::remove_request(handle); +} + +/// Frees a string previously returned by any `idkit_kmp_*` function. +/// Passing null is a no-op. +/// +/// # Safety +/// +/// `ptr` must be null or a pointer previously returned by this library that +/// has not already been freed. +#[no_mangle] +pub unsafe extern "C" fn idkit_kmp_string_free(ptr: *mut c_char) { + if !ptr.is_null() { + drop(CString::from_raw(ptr)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ffi_boundary_converts_panics_to_envelopes() { + let ptr = ffi_boundary(|| panic!("boom")); + let json = unsafe { CStr::from_ptr(ptr) }.to_str().unwrap().to_owned(); + unsafe { idkit_kmp_string_free(ptr) }; + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(value["err"]["code"], "internal_panic"); + assert_eq!(value["err"]["message"], "boom"); + } +} diff --git a/rust/kmp-ffi/src/registry.rs b/rust/kmp-ffi/src/registry.rs new file mode 100644 index 00000000..fc69ff46 --- /dev/null +++ b/rust/kmp-ffi/src/registry.rs @@ -0,0 +1,62 @@ +//! Handle registry and shared async runtime. +//! +//! Requests cross the FFI as opaque `u64` handles rather than raw pointers so +//! that stale or double frees are safe no-ops instead of undefined behavior. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, LazyLock, Mutex, MutexGuard, PoisonError}; + +use crate::envelope::FfiError; + +pub(crate) struct RequestEntry { + pub(crate) conn: idkit::BridgeConnection, +} + +static NEXT_HANDLE: AtomicU64 = AtomicU64::new(1); + +static REGISTRY: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +// One bounded runtime shared by all requests. (The UniFFI wrappers build a +// runtime per request; sharing a single single-threaded runtime here avoids +// unbounded thread growth when many requests are alive at once.) A build +// failure is reported as an error envelope, never a panic. +static RUNTIME: LazyLock> = LazyLock::new(|| { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .thread_name("idkit-kmp") + .enable_all() + .build() + .map_err(|error| format!("failed to build tokio runtime: {error}")) +}); + +pub(crate) fn runtime() -> Result<&'static tokio::runtime::Runtime, FfiError> { + RUNTIME + .as_ref() + .map_err(|error| FfiError::Runtime(error.clone())) +} + +fn registry() -> MutexGuard<'static, HashMap>> { + // A panic while holding the lock is already converted to an envelope by + // ffi_boundary; recover the map instead of poisoning every later call. + REGISTRY.lock().unwrap_or_else(PoisonError::into_inner) +} + +pub(crate) fn insert_request(conn: idkit::BridgeConnection) -> u64 { + let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed); + registry().insert(handle, Arc::new(RequestEntry { conn })); + handle +} + +/// Clones the entry out so the registry lock is never held across network I/O. +pub(crate) fn request_entry(handle: u64) -> Result, FfiError> { + registry() + .get(&handle) + .cloned() + .ok_or(FfiError::InvalidHandle(handle)) +} + +pub(crate) fn remove_request(handle: u64) { + registry().remove(&handle); +} diff --git a/rust/kmp-ffi/tests/ffi_contract.rs b/rust/kmp-ffi/tests/ffi_contract.rs new file mode 100644 index 00000000..6183257a --- /dev/null +++ b/rust/kmp-ffi/tests/ffi_contract.rs @@ -0,0 +1,247 @@ +//! Contract tests for the C ABI envelope, argument validation, payload +//! construction, and handle lifecycle. These exercise the exact entry points +//! the Kotlin Multiplatform SDK calls. + +use std::ffi::{c_char, CStr, CString}; + +use idkit_kmp::{ + idkit_kmp_bridge_payload_from_constraints, idkit_kmp_bridge_payload_from_preset, + idkit_kmp_hash_signal_bytes, idkit_kmp_hash_signal_string, idkit_kmp_request_free, + idkit_kmp_request_poll_once, idkit_kmp_string_free, idkit_kmp_version, +}; + +/// Consumes an envelope pointer: copies the JSON out and frees the C string. +fn consume(ptr: *mut c_char) -> serde_json::Value { + assert!(!ptr.is_null(), "FFI function returned NULL"); + let json = unsafe { CStr::from_ptr(ptr) } + .to_str() + .expect("envelope must be UTF-8") + .to_owned(); + unsafe { idkit_kmp_string_free(ptr) }; + serde_json::from_str(&json).expect("envelope must be valid JSON") +} + +fn expect_ok(ptr: *mut c_char) -> serde_json::Value { + let envelope = consume(ptr); + assert!( + envelope.get("ok").is_some(), + "expected ok envelope, got: {envelope}" + ); + envelope["ok"].clone() +} + +fn expect_err(ptr: *mut c_char) -> (String, String) { + let envelope = consume(ptr); + let err = envelope + .get("err") + .unwrap_or_else(|| panic!("expected err envelope, got: {envelope}")); + ( + err["code"].as_str().expect("err.code").to_owned(), + err["message"].as_str().expect("err.message").to_owned(), + ) +} + +fn cstring(s: &str) -> CString { + CString::new(s).unwrap() +} + +/// Matches the fixed RpContext used by the existing Kotlin/Swift SDK tests +/// (created_at in the past is accepted; only future timestamps are rejected; +/// the signature must be a well-formed 65-byte hex ECDSA signature). +fn sample_config_json() -> String { + let signature = format!("0x{}1b", "00".repeat(64)); + serde_json::json!({ + "app_id": "app_staging_1234567890abcdef", + "package_name": "idkit_kmp", + "package_version": "0.1.0", + "action": "test-action", + "rp_context": { + "rp_id": "rp_1234567890abcdef", + "nonce": "0x0000000000000000000000000000000000000000000000000000000000000001", + "created_at": 1_700_000_000u64, + "expires_at": 1_700_003_600u64, + "signature": signature + }, + "action_description": "Identity check", + "allow_legacy_proofs": false, + "require_user_presence": true, + "return_to": "idkitsample://callback", + "environment": "staging" + }) + .to_string() +} + +#[test] +fn version_returns_ok_envelope() { + let version = expect_ok(idkit_kmp_version()); + assert_eq!(version.as_str().unwrap(), env!("CARGO_PKG_VERSION")); +} + +#[test] +fn hash_signal_string_and_bytes_agree() { + let signal = cstring("test-signal"); + let from_string = expect_ok(unsafe { idkit_kmp_hash_signal_string(signal.as_ptr()) }); + let bytes = b"test-signal"; + let from_bytes = + expect_ok(unsafe { idkit_kmp_hash_signal_bytes(bytes.as_ptr(), bytes.len() as u64) }); + + let hash = from_string.as_str().unwrap(); + assert_eq!(hash, from_bytes.as_str().unwrap()); + assert!(hash.starts_with("0x"), "hash must be 0x-prefixed: {hash}"); + assert_eq!(hash.len(), 66, "hash must be a 32-byte hex value"); + + // Deterministic across calls. + let again = expect_ok(unsafe { idkit_kmp_hash_signal_string(signal.as_ptr()) }); + assert_eq!(hash, again.as_str().unwrap()); +} + +#[test] +fn hash_signal_rejects_null_and_invalid_utf8() { + let (code, _) = expect_err(unsafe { idkit_kmp_hash_signal_string(std::ptr::null()) }); + assert_eq!(code, "invalid_argument"); + + let invalid = [0xFFu8, 0xFE, 0x00]; + let (code, message) = + expect_err(unsafe { idkit_kmp_hash_signal_string(invalid.as_ptr().cast::()) }); + assert_eq!(code, "invalid_argument"); + assert!(message.contains("UTF-8"), "message: {message}"); + + let (code, _) = expect_err(unsafe { idkit_kmp_hash_signal_bytes(std::ptr::null(), 4) }); + assert_eq!(code, "invalid_argument"); +} + +#[test] +fn bridge_payload_from_identity_check_preset_matches_contract() { + let config = cstring(&sample_config_json()); + let preset = cstring( + &serde_json::json!({ + "type": "IdentityCheck", + "attributes": [ + { "type": "minimum_age", "value": 21 }, + { "type": "nationality", "value": "JPN" } + ], + "legacy_signal": null + }) + .to_string(), + ); + + let payload = expect_ok(unsafe { + idkit_kmp_bridge_payload_from_preset(config.as_ptr(), preset.as_ptr()) + }); + + assert_eq!(payload["app_id"], "app_staging_1234567890abcdef"); + assert_eq!(payload["package_name"], "idkit_kmp"); + assert_eq!(payload["package_version"], "0.1.0"); + assert_eq!(payload["action"], "test-action"); + assert_eq!(payload["action_description"], "Identity check"); + assert_eq!(payload["verification_level"], "document"); + assert_eq!(payload["require_user_presence"], true); + // IdentityCheck overrides allow_legacy_proofs to true (see Preset::into_bridge_params). + assert_eq!(payload["allow_legacy_proofs"], true); + assert_eq!(payload["return_to_url"], "idkitsample://callback"); + assert_eq!(payload["environment"], "staging"); + assert!( + payload.get("timestamp").is_none(), + "bridge path has no timestamp" + ); + + assert_eq!( + payload["identity_attributes"], + serde_json::json!([ + { "type": "minimum_age", "value": 21 }, + { "type": "nationality", "value": "JPN" } + ]) + ); + + let proof_request = &payload["proof_request"]; + assert_eq!(proof_request["proof_type"], "uniqueness"); + assert_eq!(proof_request["rp_id"], "rp_1234567890abcdef"); + assert_eq!(proof_request["created_at"], 1_700_000_000u64); + assert_eq!(proof_request["expires_at"], 1_700_003_600u64); +} + +#[test] +fn bridge_payload_from_constraints_matches_contract() { + let config = cstring(&sample_config_json()); + let constraints = cstring( + &serde_json::json!({ "any": [ { "type": "passport" }, { "type": "mnc" } ] }).to_string(), + ); + + let payload = expect_ok(unsafe { + idkit_kmp_bridge_payload_from_constraints(config.as_ptr(), constraints.as_ptr()) + }); + + // Constraint requests keep Device for v3 parser compatibility; real + // selection lives in proof_request. + assert_eq!(payload["verification_level"], "device"); + assert_eq!(payload["allow_legacy_proofs"], false); + assert!(payload["proof_request"].is_object(), "payload: {payload}"); +} + +#[test] +fn invalid_config_and_preset_yield_error_envelopes() { + let preset = cstring(r#"{"type":"OrbLegacy"}"#); + + let (code, message) = expect_err(unsafe { + idkit_kmp_bridge_payload_from_preset(cstring("not json").as_ptr(), preset.as_ptr()) + }); + assert_eq!(code, "invalid_json"); + assert!(message.contains("config_json"), "message: {message}"); + + let bad_app_id = sample_config_json().replace("app_staging_1234567890abcdef", "bogus"); + let (code, _) = expect_err(unsafe { + idkit_kmp_bridge_payload_from_preset(cstring(&bad_app_id).as_ptr(), preset.as_ptr()) + }); + assert_eq!(code, "malformed_request"); + + // Unknown config fields fail loudly instead of being silently dropped. + let drifted = sample_config_json().replace("\"action\"", "\"acton\""); + let (code, _) = expect_err(unsafe { + idkit_kmp_bridge_payload_from_preset(cstring(&drifted).as_ptr(), preset.as_ptr()) + }); + assert_eq!(code, "invalid_json"); + + let (code, message) = expect_err(unsafe { + idkit_kmp_bridge_payload_from_preset( + cstring(&sample_config_json()).as_ptr(), + cstring(r#"{"type":"NoSuchPreset"}"#).as_ptr(), + ) + }); + assert_eq!(code, "invalid_json"); + assert!(message.contains("preset_json"), "message: {message}"); +} + +#[test] +fn future_created_at_is_rejected() { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let config = sample_config_json() + .replace("1700000000", &(now + 3_600).to_string()) + .replace("1700003600", &(now + 7_200).to_string()); + let preset = cstring(r#"{"type":"OrbLegacy"}"#); + let (code, message) = expect_err(unsafe { + idkit_kmp_bridge_payload_from_preset(cstring(&config).as_ptr(), preset.as_ptr()) + }); + assert_eq!(code, "malformed_request"); + assert!(message.contains("created_at"), "message: {message}"); +} + +#[test] +fn handle_lifecycle_is_safe() { + // Handle 0 is never allocated (allocation starts at 1). + let (code, message) = expect_err(idkit_kmp_request_poll_once(0)); + assert_eq!(code, "invalid_handle"); + assert!(message.contains('0'), "message: {message}"); + + // Freeing unknown handles (and double-freeing) is a no-op. + idkit_kmp_request_free(0); + idkit_kmp_request_free(u64::MAX); + idkit_kmp_request_free(u64::MAX); +} + +#[test] +fn string_free_accepts_null() { + unsafe { idkit_kmp_string_free(std::ptr::null_mut()) }; +} diff --git a/scripts/build-kotlin.sh b/scripts/build-kotlin.sh index ade12e5e..04c23249 100755 --- a/scripts/build-kotlin.sh +++ b/scripts/build-kotlin.sh @@ -1,57 +1,40 @@ #!/bin/bash +# Builds the native artifacts for the Kotlin Multiplatform SDK (kotlin/). +# +# Outputs: +# - Host library target/release/libidkit_kmp.{dylib,so} (JVM unit tests via JNA) +# - Android jniLibs kotlin/idkit/src/androidMain/jniLibs//libidkit_kmp.so +# - iOS static libs target//release/libidkit_kmp.a (Kotlin/Native cinterop) +# +# Env toggles: SKIP_ANDROID=1 skips Android cross builds, SKIP_IOS=1 skips iOS builds. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -KOTLIN_DIR="$PROJECT_ROOT/kotlin" -OUT_DIR="$KOTLIN_DIR/bindings/src/main/kotlin" -JNI_DIR="$KOTLIN_DIR/bindings/src/main/jniLibs" - -echo "📦 Building Kotlin bindings from UniFFI" - -mkdir -p "$OUT_DIR" - -SYSTEM=$(uname -s) -LIB_EXT="so" -case "$SYSTEM" in - Darwin) LIB_EXT="dylib" ;; - MINGW*|MSYS*|CYGWIN*) LIB_EXT="dll" ;; -esac - -HOST_LIB="$PROJECT_ROOT/target/release/libidkit.$LIB_EXT" - -echo "🎯 Installing Android Rust targets" -rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android >/dev/null - -echo "🔧 Building Rust library (host) for binding generation" -CARGO_PROFILE_RELEASE_STRIP=none cargo build --package idkit-core --release --locked --features uniffi-bindings - -echo "🧬 Generating Kotlin bindings" -CARGO_PROFILE_RELEASE_STRIP=none cargo run -p uniffi-bindgen generate \ - --library "$HOST_LIB" \ - --language kotlin \ - --no-format \ - --out-dir "$OUT_DIR" - -if [ -n "${CI:-}" ]; then - echo "🧹 Cleaning host build artifacts to free disk space for Android builds" - # Preserve the host library — JVM unit tests need it via jna.library.path - cp "$HOST_LIB" "/tmp/libidkit.$LIB_EXT" - cargo clean --package idkit-core --release || true - rm -rf ~/.cargo/registry/cache || true - mkdir -p "$(dirname "$HOST_LIB")" - mv "/tmp/libidkit.$LIB_EXT" "$HOST_LIB" -fi +JNI_DIR="$PROJECT_ROOT/kotlin/idkit/src/androidMain/jniLibs" + +echo "📦 Building IDKit Kotlin native artifacts (rust/kmp-ffi)" + +cd "$PROJECT_ROOT" -echo "🤖 Building Android ABIs" -mkdir -p "$JNI_DIR" +echo "🔧 Building host library (for JVM unit tests)" +cargo build --package idkit-kmp-ffi --release --locked + +# ───────────────────────────────────────────────────────────────────────────── +# Android +# ───────────────────────────────────────────────────────────────────────────── declare -a TARGETS=( "aarch64-linux-android:arm64-v8a" "armv7-linux-androideabi:armeabi-v7a" "x86_64-linux-android:x86_64" "i686-linux-android:x86" ) +# 16KB page-size alignment, mirrors Cross.toml ANDROID_RUSTFLAGS="-C link-arg=-Wl,-z,max-page-size=16384 -C link-arg=-Wl,-z,common-page-size=4096" +# NOTE: Android builds use the kmp-android-release profile (panic=unwind) so the +# FFI layer's catch_unwind can convert panics into error envelopes instead of +# aborting the host app. Do NOT switch to android-release (panic=abort). +ANDROID_PROFILE="kmp-android-release" DOCKER_READY=0 check_docker_ready() { @@ -96,6 +79,11 @@ fi if [[ "${SKIP_ANDROID:-0}" == "1" ]]; then echo "⚠️ SKIP_ANDROID=1 set; skipping Android cross builds." else + echo "🎯 Installing Android Rust targets" + rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android >/dev/null + + echo "🤖 Building Android ABIs" + mkdir -p "$JNI_DIR" if [[ "$DOCKER_READY" == "1" ]]; then if ! command -v cross >/dev/null 2>&1; then echo "⏳ Installing cross (for Android targets)" @@ -105,9 +93,9 @@ else for entry in "${TARGETS[@]}"; do IFS=":" read -r TARGET ABI <<< "$entry" echo " • $TARGET -> $ABI" - RUSTFLAGS="$ANDROID_RUSTFLAGS" CROSS_NO_WARNINGS=1 cross build --package idkit-core --target "$TARGET" --profile android-release --locked --features uniffi-bindings + RUSTFLAGS="$ANDROID_RUSTFLAGS" CROSS_NO_WARNINGS=1 cross build --package idkit-kmp-ffi --target "$TARGET" --profile "$ANDROID_PROFILE" --locked mkdir -p "$JNI_DIR/$ABI" - cp "$PROJECT_ROOT/target/$TARGET/android-release/libidkit.so" "$JNI_DIR/$ABI/libidkit.so" + cp "$PROJECT_ROOT/target/$TARGET/$ANDROID_PROFILE/libidkit_kmp.so" "$JNI_DIR/$ABI/libidkit_kmp.so" # Clean up Docker resources to save disk space during multi-target builds (CI only) if [ -n "${CI:-}" ] && command -v docker >/dev/null 2>&1; then echo " ↳ Cleaning Docker resources after $TARGET build..." @@ -122,11 +110,41 @@ else -t x86 \ -t x86_64 \ -o "$JNI_DIR" \ - --manifest-path "$PROJECT_ROOT/rust/core/Cargo.toml" \ - build --profile android-release --features uniffi-bindings + --manifest-path "$PROJECT_ROOT/rust/kmp-ffi/Cargo.toml" \ + build --profile "$ANDROID_PROFILE" + # cargo-ndk copies every cdylib it built, including idkit-core's own + # libidkit.so (a build dependency). Only libidkit_kmp.so may ship in the + # AAR — libidkit.so belongs to the UniFFI toolchain and would collide. + find "$JNI_DIR" -name "libidkit.so" -delete else echo "⚠️ Docker and cargo-ndk are unavailable; skipping Android cross builds. Set SKIP_ANDROID=1 to silence." fi fi -echo "✅ Kotlin bindings ready in $OUT_DIR with jniLibs under $JNI_DIR" +# ───────────────────────────────────────────────────────────────────────────── +# iOS (static libs consumed by Kotlin/Native cinterop; Darwin hosts only) +# ───────────────────────────────────────────────────────────────────────────── +if [[ "${SKIP_IOS:-0}" == "1" ]]; then + echo "⚠️ SKIP_IOS=1 set; skipping iOS builds." +elif [[ "$(uname -s)" != "Darwin" ]]; then + echo "⚠️ Not on macOS; skipping iOS builds." +else + echo "🎯 Installing iOS Rust targets" + rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios >/dev/null + + echo "🍎 Building iOS static libraries" + export IPHONEOS_DEPLOYMENT_TARGET="13.0" + for TARGET in aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios; do + echo " • $TARGET" + cargo build --package idkit-kmp-ffi --target "$TARGET" --release --locked + done +fi + +echo "✅ Kotlin native artifacts ready:" +echo " host: $PROJECT_ROOT/target/release/libidkit_kmp.*" +if [[ "${SKIP_ANDROID:-0}" != "1" ]]; then + echo " android: $JNI_DIR//libidkit_kmp.so" +fi +if [[ "${SKIP_IOS:-0}" != "1" && "$(uname -s)" == "Darwin" ]]; then + echo " ios: $PROJECT_ROOT/target//release/libidkit_kmp.a" +fi diff --git a/scripts/package-kotlin.sh b/scripts/package-kotlin.sh index 3efc4769..0ef65546 100755 --- a/scripts/package-kotlin.sh +++ b/scripts/package-kotlin.sh @@ -6,9 +6,9 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" KOTLIN_DIR="$PROJECT_ROOT/kotlin" DIST_DIR="$KOTLIN_DIR/dist" -echo "📦 Packaging Kotlin bindings" +echo "📦 Packaging Kotlin SDK module" -# Build bindings and native libs (host + Android if Docker available) +# Build native libs (host + Android if Docker/cargo-ndk available + iOS on macOS) SKIP_ANDROID=${SKIP_ANDROID:-0} "$SCRIPT_DIR/build-kotlin.sh" VERSION=$(grep '^version=' "$KOTLIN_DIR/gradle.properties" | cut -d= -f2- | tr -d '[:space:]') @@ -20,10 +20,10 @@ fi rm -rf "$DIST_DIR" mkdir -p "$DIST_DIR" -echo "🗜️ Zipping bindings (version $VERSION)" +echo "🗜️ Zipping SDK module (version $VERSION)" ( cd "$KOTLIN_DIR" - zip -r "dist/idkit-kotlin-${VERSION}.zip" bindings > /dev/null + zip -r "dist/idkit-kotlin-${VERSION}.zip" idkit -x "idkit/build/*" -x "idkit/.gradle/*" > /dev/null ) echo "✅ Kotlin package ready: $DIST_DIR/idkit-kotlin-${VERSION}.zip" From 7a37decb3bd3598f4611e143e9ebe5097d7f03d9 Mon Sep 17 00:00:00 2001 From: Jaiden Siu <82122144+jaidensiu@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:31:27 -0700 Subject: [PATCH 2/2] chore: harden kmp sdk for release --- .github/workflows/ci.yml | 40 ++++++++++ .github/workflows/publish-kotlin.yml | 13 +++- .../kmpsample/shared/SampleController.kt | 47 +++++++---- .../gradle/wrapper/gradle-wrapper.properties | 2 +- kotlin/README.md | 10 ++- kotlin/idkit/build.gradle.kts | 25 +++++- .../kotlin/com/worldcoin/idkit/Request.kt | 77 ++++++++++++++----- .../com/worldcoin/idkit/PollLoopTests.kt | 22 ++++++ 8 files changed, 193 insertions(+), 43 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2b013bb..00c62493 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -407,3 +407,43 @@ jobs: - name: Run iOS simulator tests working-directory: kotlin/ run: ./gradlew :idkit:iosSimulatorArm64Test + + # Rehearse the full multi-target publication so release-day breakage in + # variant metadata or publication wiring is caught on PRs (the release + # itself is the first time the real publish tasks otherwise run). The + # rehearsal flag skips the Android .so check — mac runners cannot + # cross-build them — and remote publishing rejects that flag, so it + # cannot leak into a release. + - name: Rehearse full KMP publication (Maven Local) + run: | + set -euo pipefail + + ./kotlin/gradlew -p kotlin :idkit:publishToMavenLocal \ + -Pidkit.rehearsal.allowMissingAndroidNativeLibs=true + + VERSION="$(grep '^version=' kotlin/gradle.properties | cut -d= -f2- | tr -d '[:space:]')" + REPO="$HOME/.m2/repository/com/worldcoin" + + for module in idkit-iosarm64 idkit-iossimulatorarm64 idkit-iosx64; do + for ext in klib pom module; do + artifact="$REPO/$module/$VERSION/$module-$VERSION.$ext" + if [ ! -s "$artifact" ]; then + echo "::error::Missing Maven publication artifact: $artifact" + exit 1 + fi + done + done + + # The root module metadata is what consumers resolve; every target + # variant must be present in it. + ROOT_MODULE="$REPO/idkit/$VERSION/idkit-$VERSION.module" + for variant in \ + releaseApiElements-published \ + iosArm64ApiElements-published \ + iosSimulatorArm64ApiElements-published \ + iosX64ApiElements-published; do + if ! grep -q "\"$variant\"" "$ROOT_MODULE"; then + echo "::error::Root Gradle module metadata is missing variant: $variant" + exit 1 + fi + done diff --git a/.github/workflows/publish-kotlin.yml b/.github/workflows/publish-kotlin.yml index b59922bc..4521d6ec 100644 --- a/.github/workflows/publish-kotlin.yml +++ b/.github/workflows/publish-kotlin.yml @@ -322,9 +322,20 @@ jobs: echo "::error::Maven Central deployment for $PKG_VERSION did not validate within ~30 minutes; skipping GitHub Packages. Check the Central Portal." exit 1 + # GitHub Packages uploads are not atomic across the KMP modules, so publish + # the target modules first and the root module LAST. Consumers resolve the + # root module metadata; root-last means a partial failure leaves the version + # unresolvable (safe to retry) instead of resolvable-but-missing-variants. + # Two Gradle invocations because task order within one is not guaranteed. - name: Publish to GitHub Packages working-directory: kotlin/ - run: ./gradlew :idkit:publish + run: | + ./gradlew \ + :idkit:publishAndroidReleasePublicationToGithubPackagesRepository \ + :idkit:publishIosArm64PublicationToGithubPackagesRepository \ + :idkit:publishIosSimulatorArm64PublicationToGithubPackagesRepository \ + :idkit:publishIosX64PublicationToGithubPackagesRepository + ./gradlew :idkit:publishKotlinMultiplatformPublicationToGithubPackagesRepository env: PKG_VERSION: ${{ needs.prepare.outputs.version }} GITHUB_ACTOR: ${{ github.actor }} diff --git a/kotlin/Examples/IDKitKmpSampleApp/shared/src/commonMain/kotlin/com/worldcoin/idkit/kmpsample/shared/SampleController.kt b/kotlin/Examples/IDKitKmpSampleApp/shared/src/commonMain/kotlin/com/worldcoin/idkit/kmpsample/shared/SampleController.kt index 63a76804..98780050 100644 --- a/kotlin/Examples/IDKitKmpSampleApp/shared/src/commonMain/kotlin/com/worldcoin/idkit/kmpsample/shared/SampleController.kt +++ b/kotlin/Examples/IDKitKmpSampleApp/shared/src/commonMain/kotlin/com/worldcoin/idkit/kmpsample/shared/SampleController.kt @@ -23,11 +23,13 @@ import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.contentType import io.ktor.http.isSuccess +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -154,6 +156,9 @@ class SampleController { val request = IDKit.request(config).preset(snapshot.preset.toPreset(snapshot.signal)) + // Stop polling the old request before releasing its native + // handle, or the in-flight poll would hit a freed handle. + pollJob?.cancelAndJoin() pendingRequest?.close() pendingRequest = request _state.update { it.copy(connectorUrl = request.connectorURI) } @@ -194,24 +199,36 @@ class SampleController { log("Started polling for request ${request.requestId} (trigger: $reason).") pollJob = scope.launch { val finished = withTimeoutOrNull(timeout = 180_000.milliseconds) { - request.statusFlow(pollIntervalMs = 2_000u).collect { status -> - when (status) { - IDKitStatus.WaitingForConnection -> log("Waiting for World App to connect...") - IDKitStatus.AwaitingConfirmation -> log("Awaiting user confirmation...") - is IDKitStatus.Confirmed -> { - pendingRequest = null - request.close() - log("Proof confirmed. Calling verify endpoint: $verifyEndpoint") - try { - log("Verify response: ${verifyProof(resultJson = status.result.rawJson)}") - } catch (error: Throwable) { - log("Verify request failed: ${error.message ?: error::class.simpleName}") + try { + request.statusFlow(pollIntervalMs = 2_000u).collect { status -> + when (status) { + IDKitStatus.WaitingForConnection -> log("Waiting for World App to connect...") + IDKitStatus.AwaitingConfirmation -> log("Awaiting user confirmation...") + // Terminal statuses release the native handle automatically. + is IDKitStatus.Confirmed -> { + pendingRequest = null + log("Proof confirmed. Calling verify endpoint: $verifyEndpoint") + try { + log("Verify response: ${verifyProof(resultJson = status.result.rawJson)}") + } catch (error: Throwable) { + log("Verify request failed: ${error.message ?: error::class.simpleName}") + } + } + + is IDKitStatus.Failed -> { + pendingRequest = null + log("Proof completion failed: ${status.error.rawValue}") } - } - is IDKitStatus.Failed -> log("Proof completion failed: ${status.error.rawValue}") - is IDKitStatus.NetworkingError -> log("Networking error (${status.error.rawValue}), retrying...") + // statusFlow retries transport errors internally and never + // emits this; the branch exists for exhaustiveness only. + is IDKitStatus.NetworkingError -> {} + } } + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log("Polling failed: ${error.message ?: error::class.simpleName}") } } if (finished == null) { diff --git a/kotlin/Examples/IDKitSampleApp/gradle/wrapper/gradle-wrapper.properties b/kotlin/Examples/IDKitSampleApp/gradle/wrapper/gradle-wrapper.properties index 09523c0e..d4081da4 100644 --- a/kotlin/Examples/IDKitSampleApp/gradle/wrapper/gradle-wrapper.properties +++ b/kotlin/Examples/IDKitSampleApp/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/kotlin/README.md b/kotlin/README.md index 211962c8..452f2cf3 100644 --- a/kotlin/README.md +++ b/kotlin/README.md @@ -17,7 +17,6 @@ when (val completion = request.pollUntilCompletion()) { is IDKitCompletionResult.Success -> verifyOnBackend(completion.result.rawJson) is IDKitCompletionResult.Failure -> handle(completion.error) } -request.close() ``` ## Installation @@ -39,8 +38,13 @@ Pure-iOS (Swift-only) apps should prefer the [Swift SDK](../swift), which has fi 5.0.0 replaces the UniFFI/JNA Android-only implementation with the Kotlin Multiplatform one. Coordinates (`com.worldcoin:idkit`) and package (`com.worldcoin.idkit`) are unchanged, but there are breaking API changes: - `IDKitBuilder.preset(...)` / `.constraints(...)` are now `suspend` (they open the bridge connection; 4.x did this blocking). -- Call `IDKitRequest.close()` when done with a request to release the native handle (safe to call twice). +- `IDKitRequest` holds a native handle. It is released automatically once polling reaches a terminal status (`Confirmed`/`Failed`); if you abandon a request before that (including after a poll timeout or cancellation), call `IDKitRequest.close()` — it implements `AutoCloseable`, is safe to call twice, and can be used with `use { }`. - Types that previously leaked from `uniffi.idkit_core.*` (`RpContext`, `Environment`, `DocumentType`, `IdentityAttribute`, `ConstraintNode`, …) now live in `com.worldcoin.idkit` — update imports. +- `statusFlow(pollInterval: Duration)` is now `statusFlow(pollIntervalMs: ULong)`; zero-argument calls are unaffected. +- Constraint trees are plain data classes now: build them with `anyOf`/`allOf`/`enumerateOf` and `CredentialRequest(type, signal, ...)` instead of the removed uniffi `ConstraintNode`/`CredentialRequest` factories. The `Signal` type is gone — signals are `String` (or `ByteArray` for `hashSignal`). +- `IDKitResult` is no longer a data class (no `copy()`/destructuring); parse fixtures with `idkitResultFromJson`. `IntegrityBundle.signatureFormat` is a plain string now. +- Errors from the native layer throw `com.worldcoin.idkit.IDKitException` (a `RuntimeException` carrying the wire `code`) instead of `uniffi.idkit_core.IdkitException`. +- Non-Gradle (plain Maven) consumers: the root artifact is metadata-only under KMP; depend on `com.worldcoin:idkit-android` directly instead. ## Architecture @@ -100,7 +104,7 @@ Requires JDK 17+, the Android SDK (`local.properties` or `ANDROID_HOME`), and Xc ## API notes - `IDKitBuilder.preset(...)` / `.constraints(...)` are `suspend` and open the bridge connection over the network. -- Call `IDKitRequest.close()` when done with a request to release the native handle (safe to call twice; the samples do it after the terminal status). +- The native request handle is released automatically at a terminal status; call `IDKitRequest.close()` (`AutoCloseable`) only when abandoning a request early — e.g. after a poll timeout, on cancellation, or when replacing a pending request. - `IDKitResult.rawJson` is the untouched result JSON from the core — POST it verbatim to backend verification endpoints so unmodeled fields survive. - `IDKit.hashSignal(String)` follows the JS `hashSignal` semantics; use the `ByteArray` overload for binary signals (including any with interior NUL bytes). - Session and invite-code APIs are not exposed yet ("TODO: Re-enable when World ID 4.0 is live"). diff --git a/kotlin/idkit/build.gradle.kts b/kotlin/idkit/build.gradle.kts index 3d6118c9..344072a8 100644 --- a/kotlin/idkit/build.gradle.kts +++ b/kotlin/idkit/build.gradle.kts @@ -55,16 +55,27 @@ val iosRustTriples = listOf("aarch64-apple-ios", "aarch64-apple-ios-sim", "x86_6 // disables them elsewhere), so the iOS static libs are only required there. val hostIsMac = System.getProperty("os.name").startsWith("Mac") +// CI rehearses the full multi-target publication to Maven Local on macOS hosts +// that cannot cross-build the Android .so files. The flag only relaxes the +// Android native-library check for LOCAL publishing; remote publishing rejects +// it outright (see requireAppleHostForRemotePublish). +val rehearsalAllowMissingAndroidNativeLibs = providers + .gradleProperty("idkit.rehearsal.allowMissingAndroidNativeLibs") + .map(String::toBoolean) + .orElse(false) + val verifyKmpNativeLibraries by tasks.registering { group = "verification" description = "Verifies that publishing includes the Rust native libraries for every enabled target." doLast { val missing = buildList { - requiredNativeAbis.forEach { abi -> - val lib = layout.projectDirectory - .file("src/androidMain/jniLibs/$abi/libidkit_kmp.so").asFile - if (!lib.isFile || lib.length() == 0L) add("- android/$abi: $lib") + if (!rehearsalAllowMissingAndroidNativeLibs.get()) { + requiredNativeAbis.forEach { abi -> + val lib = layout.projectDirectory + .file("src/androidMain/jniLibs/$abi/libidkit_kmp.so").asFile + if (!lib.isFile || lib.length() == 0L) add("- android/$abi: $lib") + } } if (hostIsMac) { iosRustTriples.forEach { triple -> @@ -97,6 +108,12 @@ val requireAppleHostForRemotePublish by tasks.registering { "publishing from this host would ship incomplete module metadata.", ) } + if (rehearsalAllowMissingAndroidNativeLibs.get()) { + throw GradleException( + "idkit.rehearsal.allowMissingAndroidNativeLibs is a CI rehearsal flag for " + + "publishToMavenLocal only; remote publishing must include the Android native libraries.", + ) + } } } diff --git a/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Request.kt b/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Request.kt index 79680059..0ceed6ed 100644 --- a/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Request.kt +++ b/kotlin/idkit/src/commonMain/kotlin/com/worldcoin/idkit/Request.kt @@ -16,6 +16,9 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.long +import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.AtomicReference +import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.coroutines.coroutineContext import kotlin.time.TimeSource @@ -67,24 +70,40 @@ public class IDKitBuilder internal constructor( create: (configJson: String, payloadJson: String) -> String, ): IDKitRequest = withContext(ioDispatcher) { val ok = unwrapEnvelope(create(configJson, payloadJson)).jsonObject - IDKitRequest( - connectorUri = ok["connect_url"]?.jsonPrimitive?.content - ?: throw IDKitException("unexpected_response", "create response is missing connect_url"), - requestId = ok["request_id"]?.jsonPrimitive?.content - ?: throw IDKitException("unexpected_response", "create response is missing request_id"), - handle = ok["handle"]?.jsonPrimitive?.long - ?: throw IDKitException("unexpected_response", "create response is missing handle"), - ) + val handle = ok["handle"]?.jsonPrimitive?.long + ?: throw IDKitException("unexpected_response", "create response is missing handle") + try { + IDKitRequest( + connectorUri = ok["connect_url"]?.jsonPrimitive?.content + ?: throw IDKitException("unexpected_response", "create response is missing connect_url"), + requestId = ok["request_id"]?.jsonPrimitive?.content + ?: throw IDKitException("unexpected_response", "create response is missing request_id"), + handle = handle, + ) + } catch (error: Throwable) { + // The Rust side has already registered this request; free the handle + // so a malformed create response cannot leak the connection. + NativeBridge.requestFree(handle) + throw error + } } } -/** An in-flight verification request. */ +/** + * An in-flight verification request. + * + * Holds a native handle that is released automatically once polling observes a + * terminal status ([IDKitStatus.Confirmed] or [IDKitStatus.Failed]). If you + * abandon a request before that — including after [pollUntilCompletion] times + * out or is cancelled — call [close] (or rely on the [AutoCloseable] contract). + */ +@OptIn(ExperimentalAtomicApi::class) public class IDKitRequest internal constructor( private val connectorUriValue: String, private val requestIdValue: String, private val handle: Long?, private val pollStatusProvider: suspend () -> IDKitStatus, -) { +) : AutoCloseable { internal constructor(connectorUri: String, requestId: String, handle: Long) : this( connectorUriValue = connectorUri, requestIdValue = requestId, @@ -105,10 +124,25 @@ public class IDKitRequest internal constructor( public val requestId: String get() = requestIdValue - private var closed: Boolean = false + private val closed = AtomicBoolean(false) + private val terminalStatus = AtomicReference(null) - /** Polls the bridge once for the current status. */ - public suspend fun pollStatusOnce(): IDKitStatus = pollStatusProvider() + /** + * Polls the bridge once for the current status. + * + * The first terminal status ([IDKitStatus.Confirmed] / [IDKitStatus.Failed]) + * releases the native handle and is cached; subsequent calls return it + * without touching the bridge. + */ + public suspend fun pollStatusOnce(): IDKitStatus { + terminalStatus.load()?.let { return it } + val status = pollStatusProvider() + if (status is IDKitStatus.Confirmed || status is IDKitStatus.Failed) { + terminalStatus.store(status) + close() + } + return status + } /** * Polls until the request reaches a terminal state. @@ -116,6 +150,10 @@ public class IDKitRequest internal constructor( * Networking errors are retried silently; the wall-clock deadline yields * [IDKitErrorCode.TIMEOUT] and coroutine cancellation yields * [IDKitErrorCode.CANCELLED] — identical semantics to the Kotlin and Swift SDKs. + * + * A terminal status releases the native handle automatically. On TIMEOUT or + * CANCELLED the request stays open so polling can resume later; [close] it + * when abandoning the request instead. */ public suspend fun pollUntilCompletion( options: IDKitPollOptions = IDKitPollOptions(), @@ -146,13 +184,14 @@ public class IDKitRequest internal constructor( } /** - * Releases the native request handle. Call when done with the request; - * polling after close reports an `invalid_handle` [IDKitException]. - * Safe to call more than once. + * Releases the native request handle. Called automatically when polling + * observes a terminal status; call it yourself when abandoning a request + * early. Safe to call more than once, from any thread. Polling a request + * closed before a terminal status reports an `invalid_handle` + * [IDKitException]. */ - public fun close() { - if (!closed) { - closed = true + public override fun close() { + if (closed.compareAndSet(expectedValue = false, newValue = true)) { handle?.let { NativeBridge.requestFree(it) } } } diff --git a/kotlin/idkit/src/commonTest/kotlin/com/worldcoin/idkit/PollLoopTests.kt b/kotlin/idkit/src/commonTest/kotlin/com/worldcoin/idkit/PollLoopTests.kt index 9ed80c9d..62e3ecbd 100644 --- a/kotlin/idkit/src/commonTest/kotlin/com/worldcoin/idkit/PollLoopTests.kt +++ b/kotlin/idkit/src/commonTest/kotlin/com/worldcoin/idkit/PollLoopTests.kt @@ -71,6 +71,28 @@ class PollLoopTests { assertEquals(IDKitCompletionResult.Failure(IDKitErrorCode.USER_REJECTED), completion) } + @Test + fun pollStatusOnceCachesTerminalStatusAndStopsPolling() = runTest { + var polls = 0 + val request = requestReturning { + polls += 1 + IDKitStatus.Failed(IDKitErrorCode.USER_REJECTED) + } + + assertEquals(IDKitStatus.Failed(IDKitErrorCode.USER_REJECTED), request.pollStatusOnce()) + // The terminal status is cached and the handle released; the bridge + // must not be polled again. + assertEquals(IDKitStatus.Failed(IDKitErrorCode.USER_REJECTED), request.pollStatusOnce()) + assertEquals(1, polls) + } + + @Test + fun closeIsIdempotent() = runTest { + val request = requestReturning { IDKitStatus.WaitingForConnection } + request.close() + request.close() + } + @Test fun statusFlowEmitsDistinctStatesAndCompletes() = runTest { val statuses = ArrayDeque(