diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index 211e851b..15353ad5 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -21,6 +21,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + submodules: recursive - name: Set up pinned JDK uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 @@ -31,6 +33,23 @@ jobs: - name: Set up and validate Gradle uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Set up Rust 1.95.0 + uses: dtolnay/rust-toolchain@1.95.0 + with: + targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android,i686-linux-android + + - name: Set up Android NDK + run: sdkmanager "ndk;28.2.13676358" + + - name: Install cargo-ndk + run: cargo install cargo-ndk --version 4.1.2 --locked + + - name: Build NDR FFI from pinned source + run: ./app/src/main/ndr-ffi/build-android.sh + + - name: Verify generated NDR binding is current + run: git diff --exit-code -- app/src/main/java/uniffi/ndr_ffi/ndr_ffi.kt + - name: Verify native library inputs run: tools/arti-build/verify-checksums.sh @@ -64,6 +83,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + submodules: recursive - name: Set up pinned JDK uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 @@ -74,6 +95,20 @@ jobs: - name: Set up and validate Gradle uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Set up Rust 1.95.0 + uses: dtolnay/rust-toolchain@1.95.0 + with: + targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android,i686-linux-android + + - name: Set up Android NDK + run: sdkmanager "ndk;28.2.13676358" + + - name: Install cargo-ndk + run: cargo install cargo-ndk --version 4.1.2 --locked + + - name: Build NDR FFI from pinned source + run: ./app/src/main/ndr-ffi/build-android.sh + - name: Build debug APK run: ./gradlew assembleDebug @@ -95,6 +130,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + submodules: recursive - name: Set up pinned JDK uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 @@ -107,6 +144,23 @@ jobs: with: cache-disabled: true + - name: Set up Rust 1.95.0 + uses: dtolnay/rust-toolchain@1.95.0 + with: + targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android,i686-linux-android + + - name: Set up Android NDK + run: sdkmanager "ndk;28.2.13676358" + + - name: Install cargo-ndk + run: cargo install cargo-ndk --version 4.1.2 --locked + + - name: Build NDR FFI from pinned source + run: ./app/src/main/ndr-ffi/build-android.sh + + - name: Verify generated NDR binding is current + run: git diff --exit-code -- app/src/main/java/uniffi/ndr_ffi/ndr_ffi.kt + - name: Build canonical unsigned release run: tools/reproducible-builds/build-in-container.sh "$RUNNER_TEMP/release-${{ matrix.replica }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ccf20b33..6617e3cd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,6 +38,7 @@ jobs: uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ env.RELEASE_TAG }} + submodules: recursive - name: Verify workflow provenance ref run: | @@ -52,6 +53,23 @@ jobs: with: cache-disabled: true + - name: Set up Rust 1.95.0 + uses: dtolnay/rust-toolchain@1.95.0 + with: + targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android,i686-linux-android + + - name: Set up Android NDK + run: sdkmanager "ndk;28.2.13676358" + + - name: Install cargo-ndk + run: cargo install cargo-ndk --version 4.1.2 --locked + + - name: Build NDR FFI from pinned source + run: ./app/src/main/ndr-ffi/build-android.sh + + - name: Verify generated NDR binding is current + run: git diff --exit-code -- app/src/main/java/uniffi/ndr_ffi/ndr_ffi.kt + - name: Build canonical unsigned APKs and AAB run: tools/reproducible-builds/build-in-container.sh "$RUNNER_TEMP/release-${{ matrix.replica }}" diff --git a/.gitignore b/.gitignore index 71a4f9f7..9256f6c2 100644 --- a/.gitignore +++ b/.gitignore @@ -72,5 +72,8 @@ tools/arti-build/.arti-source/ tools/arti-build/target/ .reproducible-build/ +# Generated from the pinned nostr-double-ratchet source submodule. +app/src/main/jniLibs/*/libndr_ffi.so + # JVM heap dumps (a Gradle daemon OOM drops these in the repo root) *.hprof diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..743725be --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "vendor/nostr-double-ratchet"] + path = vendor/nostr-double-ratchet + url = https://github.com/irislib/nostr-double-ratchet.git + shallow = true diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 32fe53db..e0cfbc36 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -37,6 +37,9 @@ android { "GITHUB_RELEASE_CERT_SHA256", "\"$normalizedGithubReleaseCertSha256\"" ) + // Keep NDR dark until the Apple and Android implementations are + // reviewed and ready to be enabled together. + buildConfigField("boolean", "NDR_ROLLOUT_ENABLED", "false") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -161,6 +164,7 @@ dependencies { // WebSocket implementation(libs.okhttp) + implementation("net.java.dev.jna:jna:5.13.0@aar") // WorkManager for background APK downloads implementation(libs.androidx.work.runtime.ktx) @@ -186,6 +190,8 @@ dependencies { // Testing testImplementation(libs.bundles.testing) androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.espresso.core) androidTestImplementation(libs.bundles.compose.testing) debugImplementation(libs.androidx.compose.ui.tooling) } diff --git a/app/gradle.lockfile b/app/gradle.lockfile index bd31d677..bfff04d9 100644 --- a/app/gradle.lockfile +++ b/app/gradle.lockfile @@ -74,10 +74,10 @@ androidx.compose.ui:ui-util-android:1.11.4=debugAndroidTestCompileClasspath,debu androidx.compose.ui:ui-util:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.ui:ui:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose:compose-bom:2026.06.01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.concurrent:concurrent-futures-ktx:1.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.concurrent:concurrent-futures-ktx:1.2.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -androidx.concurrent:concurrent-futures:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.concurrent:concurrent-futures:1.2.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.concurrent:concurrent-futures-ktx:1.1.0=debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.concurrent:concurrent-futures-ktx:1.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.concurrent:concurrent-futures:1.1.0=debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.concurrent:concurrent-futures:1.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath androidx.core:core-backported-fixes:1.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.core:core-ktx:1.19.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.core:core-viewtree:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -144,25 +144,16 @@ androidx.sqlite:sqlite-framework:2.4.0=debugAndroidTestLintChecksClasspath,debug androidx.sqlite:sqlite:2.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.startup:startup-runtime:1.1.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath androidx.startup:startup-runtime:1.2.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.test.espresso:espresso-core:3.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.test.espresso:espresso-core:3.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -androidx.test.espresso:espresso-idling-resource:3.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.test.espresso:espresso-idling-resource:3.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -androidx.test.ext:junit:1.1.5=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.test.ext:junit:1.3.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -androidx.test.services:storage:1.4.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.test.services:storage:1.6.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -androidx.test:annotation:1.0.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.test:core:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.test:core:1.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -androidx.test:monitor:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.test:monitor:1.8.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -androidx.test:runner:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.test:runner:1.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.test.espresso:espresso-core:3.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.test.espresso:espresso-idling-resource:3.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.test.ext:junit:1.3.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.test.services:storage:1.6.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.test:core:1.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.test:monitor:1.8.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.test:runner:1.7.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath androidx.tracing:tracing-android:1.3.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.tracing:tracing-ktx:1.3.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.tracing:tracing:1.0.0=debugAndroidTestCompileClasspath -androidx.tracing:tracing:1.1.0=debugUnitTestCompileClasspath +androidx.tracing:tracing:1.1.0=debugAndroidTestCompileClasspath,debugUnitTestCompileClasspath androidx.tracing:tracing:1.3.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.transition:transition:1.6.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.vectordrawable:vectordrawable-animated:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -248,7 +239,7 @@ com.google.dagger:dagger:2.59=debugAndroidTestLintChecksClasspath,debugLintCheck com.google.errorprone:error_prone_annotation:2.38.0=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath com.google.errorprone:error_prone_annotations:2.23.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher com.google.errorprone:error_prone_annotations:2.36.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action -com.google.errorprone:error_prone_annotations:2.48.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.48.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.firebase:firebase-annotations:16.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.firebase:firebase-components:16.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.firebase:firebase-encoders-json:17.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -287,7 +278,6 @@ com.squareup.okhttp3:okhttp-android:5.4.0=debugAndroidTestCompileClasspath,debug com.squareup.okhttp3:okhttp:5.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.squareup.okio:okio-jvm:3.17.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.squareup.okio:okio:3.17.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.squareup:javawriter:2.1.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath com.sun.istack:istack-commons-runtime:3.0.8=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle com.sun.xml.fastinfoset:FastInfoset:1.2.16=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle commons-codec:commons-codec:1.17.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle @@ -344,6 +334,7 @@ junit:junit:4.13.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksCl net.bytebuddy:byte-buddy-agent:1.17.7=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath net.bytebuddy:byte-buddy:1.17.7=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath net.java.dev.jna:jna-platform:5.6.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +net.java.dev.jna:jna:5.13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath net.java.dev.jna:jna:5.6.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action net.sf.kxml:kxml2:2.3.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action no.nordicsemi.android:ble:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath @@ -365,8 +356,7 @@ org.conscrypt:conscrypt-openjdk-uber:2.5.2=debugUnitTestLintChecksClasspath,debu org.glassfish.jaxb:jaxb-runtime:2.3.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle org.glassfish.jaxb:txw2:2.3.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle org.hamcrest:hamcrest-core:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -org.hamcrest:hamcrest-integration:1.3=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -org.hamcrest:hamcrest-library:1.3=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.hamcrest:hamcrest-library:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath org.jetbrains.kotlin:compose-group-mapping:2.4.10=composeMappingProducerClasspath org.jetbrains.kotlin:kotlin-build-tools-api:2.4.0=kotlinAbiValidationCompatClasspath org.jetbrains.kotlin:kotlin-build-tools-api:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 4363120a..af97b545 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -17,6 +17,13 @@ -keep class com.bitchat.android.nostr.** { *; } -keep class com.bitchat.android.identity.** { *; } +# UniFFI's JNA backend resolves exported functions and Structure fields by +# their generated JVM names at runtime. Preserve both sides of that reflective +# boundary in minified release builds. +-keep class uniffi.ndr_ffi.** { *; } +-keep class com.sun.jna.** { *; } +-dontwarn com.sun.jna.** + # Room loads generated database implementations by name and invokes their no-argument # constructors reflectively. R8 full-mode can otherwise optimize away WorkDatabase_Impl's # constructor, causing AndroidX Startup to crash before Application.onCreate. diff --git a/app/src/androidTest/kotlin/com/bitchat/android/nostr/PairwiseFfiInstrumentedTest.kt b/app/src/androidTest/kotlin/com/bitchat/android/nostr/PairwiseFfiInstrumentedTest.kt new file mode 100644 index 00000000..0d6df486 --- /dev/null +++ b/app/src/androidTest/kotlin/com/bitchat/android/nostr/PairwiseFfiInstrumentedTest.kt @@ -0,0 +1,191 @@ +package com.bitchat.android.nostr + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import java.io.File +import java.util.UUID +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import uniffi.ndr_ffi.FfiKeyPair +import uniffi.ndr_ffi.PairwiseAction +import uniffi.ndr_ffi.PairwiseInvite +import uniffi.ndr_ffi.PairwiseManager +import uniffi.ndr_ffi.generateKeypair + +@RunWith(AndroidJUnit4::class) +class PairwiseFfiInstrumentedTest { + @Test + fun directFfiHandshakeSendRestartDeduplicateExpiryAndRetirement() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val testRoot = File( + context.filesDir, + "ndr-pairwise-ffi-${UUID.randomUUID()}" + ) + assertTrue(testRoot.mkdirs()) + + val aliceKeys = generateKeypair() + val bobKeys = generateKeypair() + val alicePath = File(testRoot, "alice") + val bobPath = File(testRoot, "bob") + var alice: PairwiseManager? = null + var bob: PairwiseManager? = null + + try { + alice = manager(aliceKeys, alicePath) + bob = manager(bobKeys, bobPath) + + val inviteJson = alice.currentInviteEventJson() + PairwiseInvite.fromEventJson(inviteJson).use { invite -> + assertEquals(aliceKeys.publicKeyHex, invite.getPeerPubkeyHex()) + } + val accepted = bob.acceptInviteFromEventJson( + inviteJson, + aliceKeys.publicKeyHex + ) + assertEquals(aliceKeys.publicKeyHex, accepted.peerPubkeyHex) + assertTrue(accepted.createdNewSession) + + val handshakeActions = bob.pendingActions() + assertEquals(handshakeActions, bob.pendingActions()) + val response = handshakeActions.single { it.kind == "out_of_band" } + val bootstrap = handshakeActions.single { it.kind == "publish" } + assertNotNull(response.sessionId) + assertEquals(response.sessionId, bootstrap.sessionId) + assertEquals(aliceKeys.publicKeyHex, response.peerPubkeyHex) + assertPairwiseWireEvent(response, expectedKind = 1059) + assertPairwiseWireEvent(bootstrap, expectedKind = 1060) + + bob.close() + bob = manager(bobKeys, bobPath) + assertEquals(handshakeActions, bob.pendingActions()) + + alice.processOutOfBandResponse( + requireNotNull(response.eventJson), + bobKeys.publicKeyHex + ) + val halfReady = requireNotNull(alice.sessionInfo(bobKeys.publicKeyHex)) + assertFalse(halfReady.sendReady) + alice.processEvent(requireNotNull(bootstrap.eventJson)) + assertTrue(requireNotNull(alice.sessionInfo(bobKeys.publicKeyHex)).sendReady) + + bob.ackActions(handshakeActions.map(PairwiseAction::actionId)) + bob.close() + bob = manager(bobKeys, bobPath) + assertTrue( + bob.pendingActions().none { pending -> + pending.actionId in handshakeActions.map(PairwiseAction::actionId) + } + ) + + val expiresAtSeconds = + (System.currentTimeMillis() / 1_000L).toULong() + 3_600UL + val text = "bitchat1:expiring-direct-ffi" + val sent = alice.sendText( + bobKeys.publicKeyHex, + text, + expiresAtSeconds + ) + val publish = alice.pendingActions().single { action -> + action.kind == "publish" && action.outerEventId == sent.outerEventId + } + assertPairwiseWireEvent(publish, expectedKind = 1060) + val publishJson = requireNotNull(publish.eventJson) + + alice.close() + alice = manager(aliceKeys, alicePath) + val replayedPublish = alice.pendingActions().single { action -> + action.actionId == publish.actionId + } + assertEquals(publishJson, replayedPublish.eventJson) + + bob.processEvent(publishJson) + bob.processEvent(publishJson) + val deliveries = bob.pendingActions().filter { it.kind == "delivery" } + assertEquals(1, deliveries.size) + val delivery = deliveries.single() + assertEquals(aliceKeys.publicKeyHex, delivery.peerPubkeyHex) + assertEquals(sent.innerEventId, delivery.innerEventId) + assertTrue(requireNotNull(delivery.innerEventId).matches(HEX_32)) + assertEquals(expiresAtSeconds, delivery.expiresAtSeconds) + + val innerJson = requireNotNull(delivery.innerEventJson) + val inner = JSONObject(innerJson) + assertEquals(14, inner.getInt("kind")) + assertEquals(aliceKeys.publicKeyHex, inner.getString("pubkey")) + assertEquals(text, inner.getString("content")) + assertTrue( + containsTag( + inner.getJSONArray("tags"), + "expiration", + expiresAtSeconds.toString() + ) + ) + + bob.close() + bob = manager(bobKeys, bobPath) + val replayedDelivery = bob.pendingActions().single { action -> + action.actionId == delivery.actionId + } + assertEquals(innerJson, replayedDelivery.innerEventJson) + bob.ackActions(listOf(delivery.actionId)) + bob.close() + bob = manager(bobKeys, bobPath) + assertTrue(bob.pendingActions().none { it.actionId == delivery.actionId }) + + alice.ackActions(listOf(publish.actionId)) + alice.close() + alice = manager(aliceKeys, alicePath) + assertTrue(alice.pendingActions().none { it.actionId == publish.actionId }) + + assertTrue(alice.retirePeer(bobKeys.publicKeyHex)) + assertFalse(alice.retirePeer(bobKeys.publicKeyHex)) + alice.close() + alice = manager(aliceKeys, alicePath) + assertFalse(alice.knownPeerPubkeys().contains(bobKeys.publicKeyHex)) + } finally { + alice?.close() + bob?.close() + testRoot.deleteRecursively() + } + } + + private fun manager(keys: FfiKeyPair, path: File): PairwiseManager = + PairwiseManager.newWithStoragePath( + keys.publicKeyHex, + keys.privateKeyHex, + path.absolutePath + ) + + private fun assertPairwiseWireEvent(action: PairwiseAction, expectedKind: Int) { + val event = JSONObject(requireNotNull(action.eventJson)) + assertEquals(expectedKind, event.getInt("kind")) + assertNotEquals(37368, event.getInt("kind")) + if (expectedKind == 1060) { + assertFalse(containsTag(event.getJSONArray("tags"), "p")) + } + } + + private fun containsTag( + tags: JSONArray, + name: String, + expectedValue: String? = null + ): Boolean = + (0 until tags.length()).any { index -> + val tag = tags.getJSONArray(index) + tag.length() > 0 && + tag.getString(0) == name && + (expectedValue == null || + (tag.length() > 1 && tag.getString(1) == expectedValue)) + } + + companion object { + private val HEX_32 = Regex("^[0-9a-f]{64}$") + } +} diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index 7b5926ef..9c151f8a 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -13,6 +13,16 @@ class BitchatApplication : Application() { override fun onCreate() { super.onCreate() + if (!com.bitchat.android.nostr.NdrPanicStartupRecovery + .recoverBeforeNetwork(this) + ) { + android.util.Log.e( + "BitchatApplication", + "Network startup blocked until panic wipe retry succeeds" + ) + return + } + // Start the single process-wide power policy before transport components are constructed. com.bitchat.android.mesh.PowerManager.getInstance(this).start() @@ -31,6 +41,20 @@ class BitchatApplication : Application() { // Initialize favorites persistence early so MessageRouter/NostrTransport can use it on startup try { com.bitchat.android.favorites.FavoritesPersistenceService.initialize(this) + com.bitchat.android.favorites.FavoritesPersistenceService.shared + .setNdrPeerRetirementGuard { oldPeerPubkeyHex -> + val identity = + com.bitchat.android.nostr.NostrIdentityBridge + .getCurrentNostrIdentity(this) + ?: return@setNdrPeerRetirementGuard false + val ndr = com.bitchat.android.nostr.NdrNostrService.getInstance(this) + if (com.bitchat.android.model.NdrFeatureGate.isEnabled()) { + ndr.configureIfNeeded(identity) + ndr.retirePeer(oldPeerPubkeyHex) + } else { + ndr.retirePeerForMaintenance(identity, oldPeerPubkeyHex) + } + } } catch (_: Exception) { } // Restore private conversations before background transports can deliver new messages. diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 6f80052a..144e9d8a 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -82,6 +82,18 @@ class MainActivity : OrientationAwareActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (!com.bitchat.android.nostr.NdrPanicStartupRecovery + .isNetworkStartupAllowed() + ) { + finishAndRemoveTask() + return + } + if (com.bitchat.android.service.AppShutdownCoordinator + .isShutdownCommitted() + ) { + finishAndRemoveTask() + return + } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { this.setRecentsScreenshotEnabled(false) } @@ -112,8 +124,6 @@ class MainActivity : OrientationAwareActivity() { return } - com.bitchat.android.service.AppShutdownCoordinator.cancelPendingShutdown() - // Enable edge-to-edge display for modern Android look enableEdgeToEdge() @@ -720,6 +730,13 @@ class MainActivity : OrientationAwareActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) + + if (com.bitchat.android.service.AppShutdownCoordinator + .isShutdownCommitted() + ) { + finishAndRemoveTask() + return + } // Check if this is a quit request from the notification if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) { @@ -728,8 +745,6 @@ class MainActivity : OrientationAwareActivity() { return } - com.bitchat.android.service.AppShutdownCoordinator.cancelPendingShutdown() - // Handle notification intents when app is already running if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { handleNotificationIntent(intent) diff --git a/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt b/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt index ae453bed..480127a5 100644 --- a/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt +++ b/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt @@ -15,6 +15,8 @@ import java.util.* data class FavoriteRelationship( val peerNoisePublicKey: ByteArray, // Noise static public key (32 bytes) val peerNostrPublicKey: String?, // npub bech32 string + val peerNdrSessionPubkeyHex: String? = null, + val ndrRequired: Boolean = false, val peerNickname: String, val isFavorite: Boolean, // We favorited them val theyFavoritedUs: Boolean, // They favorited us @@ -31,6 +33,8 @@ data class FavoriteRelationship( if (!peerNoisePublicKey.contentEquals(other.peerNoisePublicKey)) return false if (peerNostrPublicKey != other.peerNostrPublicKey) return false + if (peerNdrSessionPubkeyHex != other.peerNdrSessionPubkeyHex) return false + if (ndrRequired != other.ndrRequired) return false if (peerNickname != other.peerNickname) return false if (isFavorite != other.isFavorite) return false if (theyFavoritedUs != other.theyFavoritedUs) return false @@ -41,6 +45,8 @@ data class FavoriteRelationship( override fun hashCode(): Int { var result = peerNoisePublicKey.contentHashCode() result = 31 * result + (peerNostrPublicKey?.hashCode() ?: 0) + result = 31 * result + (peerNdrSessionPubkeyHex?.hashCode() ?: 0) + result = 31 * result + ndrRequired.hashCode() result = 31 * result + peerNickname.hashCode() result = 31 * result + isFavorite.hashCode() result = 31 * result + theyFavoritedUs.hashCode() @@ -76,12 +82,21 @@ interface FavoritesChangeListener { * Manages favorites with Noise↔Nostr mapping * Singleton pattern matching iOS implementation. */ -class FavoritesPersistenceService private constructor(private val context: Context) { +class FavoritesPersistenceService private constructor( + private val stateManager: SecureIdentityStateManager +) { + internal constructor( + stateManager: SecureIdentityStateManager, + testOnly: Boolean + ) : this(stateManager) { + require(testOnly) { "Injected favorites storage is test-only" } + } companion object { private const val TAG = "FavoritesPersistenceService" - private const val FAVORITES_KEY = "favorite_relationships" // noiseHex -> relationship - private const val PEERID_INDEX_KEY = "favorite_peerid_index" // peerID(16-hex) -> npub + internal const val FAVORITES_KEY = "favorite_relationships" + internal const val PEERID_INDEX_KEY = "favorite_peerid_index" + internal const val NDR_REBIND_JOURNAL_KEY = "favorite_ndr_rebind_v1" @Volatile private var INSTANCE: FavoritesPersistenceService? = null @@ -93,42 +108,55 @@ class FavoritesPersistenceService private constructor(private val context: Conte if (INSTANCE == null) { synchronized(this) { if (INSTANCE == null) { - INSTANCE = FavoritesPersistenceService(context.applicationContext) + INSTANCE = FavoritesPersistenceService( + SecureIdentityStateManager(context.applicationContext) + ) } } } } } - private val stateManager = SecureIdentityStateManager(context) private val gson = Gson() private val favorites = mutableMapOf() // noiseHex -> relationship private val peerIdIndex = mutableMapOf() // peerID (lowercase 16-hex) -> npub private val listeners = mutableListOf() + private var ndrPeerRetirementGuard: ((oldPeerPubkeyHex: String) -> Boolean)? = null + private val ndrRebindsInProgress = mutableSetOf() + private var pendingNdrRebind: FavoriteNdrRebindJournal? = null + private var ndrRebindJournalCorrupt = false + private var favoritesStorageUnreadable = false init { loadFavorites() + loadNdrRebindJournal() loadPeerIdIndex() } /** Get favorite status for Noise public key */ + @Synchronized fun getFavoriteStatus(noisePublicKey: ByteArray): FavoriteRelationship? { val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey) - return favorites[keyHex] + return favoriteForReadLocked(keyHex) } /** Get favorite status for a mesh peer ID or full Noise public key hex. */ + @Synchronized fun getFavoriteStatus(peerID: String): FavoriteRelationship? { val pid = peerID.trim().lowercase() if (ContactIdentityResolver.isNoiseKeyHex(pid)) { - return favorites[pid] + return favoriteForReadLocked(pid) } ContactIdentityResolver.fingerprintFromContactConversationId(pid)?.let { fingerprint -> - return favorites.values.firstOrNull { relationship -> + return favorites.entries.firstNotNullOfOrNull { (keyHex, _) -> + val relationship = favoriteForReadLocked(keyHex) + ?: return@firstNotNullOfOrNull null ContactIdentityResolver.fingerprintHex(relationship.peerNoisePublicKey) .equals(fingerprint, ignoreCase = true) + .takeIf { it } + ?.let { relationship } } } @@ -136,8 +164,12 @@ class FavoritesPersistenceService private constructor(private val context: Conte peerIdIndex[pid]?.let { indexedNpub -> findNoiseKey(indexedNpub)?.let { return getFavoriteStatus(it) } } - return favorites.values.firstOrNull { relationship -> - ContactIdentityResolver.peerIdForNoiseKey(relationship.peerNoisePublicKey) == pid + return favorites.entries.firstNotNullOfOrNull { (keyHex, _) -> + val relationship = favoriteForReadLocked(keyHex) + ?: return@firstNotNullOfOrNull null + relationship.takeIf { + ContactIdentityResolver.peerIdForNoiseKey(it.peerNoisePublicKey) == pid + } } } @@ -145,49 +177,90 @@ class FavoritesPersistenceService private constructor(private val context: Conte } /** Update Nostr public key for a peer (indexed by Noise key) */ - fun updateNostrPublicKey(noisePublicKey: ByteArray, nostrPubkey: String) { + fun updateNostrPublicKey(noisePublicKey: ByteArray, nostrPubkey: String): Boolean { val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey) - val normalizedNpub = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey) - ?.let { ContactIdentityResolver.npubFromHex(it) } - ?: nostrPubkey - val existing = favorites[keyHex] - - if (existing != null) { - val updated = existing.copy( - peerNostrPublicKey = normalizedNpub, - lastUpdated = Date() + val normalizedHex = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey) ?: return false + val normalizedNpub = ContactIdentityResolver.npubFromHex(normalizedHex) ?: return false + recoverPendingNdrRebind() + var journal: FavoriteNdrRebindJournal? = null + val committedWithoutRetirement = synchronized(this) { + if (ndrRebindJournalCorrupt || + favoritesStorageUnreadable || + pendingNdrRebind != null || + ndrRebindsInProgress.isNotEmpty() || + isIdentityBoundToAnotherFavorite(keyHex, normalizedHex) + ) return false + val existing = favorites[keyHex] + val oldPeer = existing?.let(::effectiveNdrPeerPubkeyHex) + val isRebind = oldPeer != null && + !oldPeer.equals(normalizedHex, ignoreCase = true) + val wasNdrRequired = existing?.ndrRequired == true || + existing?.peerNdrSessionPubkeyHex != null + val requiresRetirement = wasNdrRequired && isRebind + val updated = relationshipWithNostrIdentity( + existing = existing, + noisePublicKey = noisePublicKey, + normalizedNpub = normalizedNpub, + clearExplicitNdrPeer = requiresRetirement, + requireNdr = wasNdrRequired ) - favorites[keyHex] = updated - } else { - val relationship = FavoriteRelationship( - peerNoisePublicKey = noisePublicKey, - peerNostrPublicKey = normalizedNpub, - peerNickname = "Unknown", - isFavorite = false, - theyFavoritedUs = false, - favoritedAt = Date(), - lastUpdated = Date() - ) - favorites[keyHex] = relationship + val mustRetire = requiresRetirement && + !isIdentityReferencedByAnotherFavorite(keyHex, requireNotNull(oldPeer)) + if (!mustRetire) { + commitFavoriteUpdateLocked(keyHex, updated) + } else { + journal = FavoriteNdrRebindJournal( + noiseKeyHex = keyHex, + oldPeerPubkeyHex = requireNotNull(oldPeer), + expectedNostrPubkeyHex = existing.peerNostrPublicKey + ?.let(ContactIdentityResolver::nostrPubkeyHex), + expectedNdrSessionPubkeyHex = existing.peerNdrSessionPubkeyHex, + expectedNdrRequired = true, + targetNostrPubkeyHex = normalizedHex, + targetNdrSessionPubkeyHex = null, + targetNdrRequired = true, + retireOldPeer = true + ) + beginNdrRebindLocked(requireNotNull(journal)) + false + } } - saveFavorites() + val pendingJournal = journal + val committed = if (pendingJournal == null) { + committedWithoutRetirement + } else { + completePendingNdrRebind(pendingJournal) + } + if (!committed) return false + notifyChanged(keyHex) Log.d(TAG, "Updated Nostr pubkey association for ${keyHex.take(16)}...") + return true } /** Update Nostr pubkey for a specific mesh peerID. */ + @Synchronized fun updateNostrPublicKeyForPeerID(peerID: String, nostrPubkey: String) { + if (ndrRebindJournalCorrupt || + favoritesStorageUnreadable || + pendingNdrRebind != null + ) return val pid = peerID.trim().lowercase() val normalizedNpub = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey) ?.let { ContactIdentityResolver.npubFromHex(it) } ?: nostrPubkey if (ContactIdentityResolver.isMeshPeerId(pid)) { - peerIdIndex[pid] = normalizedNpub - savePeerIdIndex() - notifyChanged(pid) - Log.d(TAG, "Indexed npub for peerID ${pid.take(8)}…") + val snapshot = peerIdIndex.toMutableMap().apply { + this[pid] = normalizedNpub + } + if (commitPeerIdIndexSnapshotLocked(snapshot)) { + peerIdIndex.clear() + peerIdIndex.putAll(snapshot) + notifyChanged(pid) + Log.d(TAG, "Indexed npub for peerID ${pid.take(8)}…") + } } else { Log.w(TAG, "updateNostrPublicKeyForPeerID called with non-16hex peerID: $peerID") } @@ -195,12 +268,14 @@ class FavoritesPersistenceService private constructor(private val context: Conte /** Resolve Nostr pubkey via current peerID mapping or stored Noise identity. */ + @Synchronized fun findNostrPubkeyForPeerID(peerID: String): String? { val pid = peerID.trim().lowercase() return peerIdIndex[pid] ?: getFavoriteStatus(pid)?.peerNostrPublicKey } /** Resolve mesh peerID for a given Nostr pubkey (npub or hex). */ + @Synchronized fun findPeerIDForNostrPubkey(nostrPubkey: String): String? { val targetHex = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey) ?: return null @@ -208,8 +283,14 @@ class FavoritesPersistenceService private constructor(private val context: Conte ContactIdentityResolver.nostrPubkeyHex(stored) == targetHex }?.let { return it.key } - favorites.values.firstOrNull { relationship -> - relationship.peerNostrPublicKey?.let { ContactIdentityResolver.nostrPubkeyHex(it) } == targetHex + favorites.entries.firstNotNullOfOrNull { (keyHex, _) -> + val relationship = favoriteForReadLocked(keyHex) + ?: return@firstNotNullOfOrNull null + relationship.takeIf { + it.peerNostrPublicKey + ?.let(ContactIdentityResolver::nostrPubkeyHex) == targetHex || + it.peerNdrSessionPubkeyHex == targetHex + } }?.let { relationship -> return ContactIdentityResolver.peerIdForNoiseKey(relationship.peerNoisePublicKey) } @@ -218,8 +299,13 @@ class FavoritesPersistenceService private constructor(private val context: Conte } /** Update favorite status */ + @Synchronized fun updateFavoriteStatus(noisePublicKey: ByteArray, nickname: String, isFavorite: Boolean) { val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey) + if (ndrRebindJournalCorrupt || + favoritesStorageUnreadable || + pendingNdrRebind?.noiseKeyHex == keyHex + ) return val existing = favorites[keyHex] @@ -242,55 +328,417 @@ class FavoritesPersistenceService private constructor(private val context: Conte ) } - favorites[keyHex] = updated - saveFavorites() + if (!commitFavoriteUpdateLocked(keyHex, updated)) return notifyChanged(keyHex) Log.d(TAG, "Updated favorite status for $nickname: $isFavorite") } /** Update peer favorited-us flag */ + @Synchronized fun updatePeerFavoritedUs(noisePublicKey: ByteArray, theyFavoritedUs: Boolean) { val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey) + if (ndrRebindJournalCorrupt || + favoritesStorageUnreadable || + pendingNdrRebind?.noiseKeyHex == keyHex + ) return val existing = favorites[keyHex] val updated = existing.withPeerFavoritedUs(noisePublicKey, theyFavoritedUs) - favorites[keyHex] = updated - saveFavorites() + if (!commitFavoriteUpdateLocked(keyHex, updated)) return notifyChanged(keyHex) Log.d(TAG, "Updated peer favorited us for ${keyHex.take(16)}...: $theyFavoritedUs") } - fun getMutualFavorites(): List = favorites.values.filter { it.isMutual } - fun getOurFavorites(): List = favorites.values.filter { it.isFavorite } - fun getAllRelationships(): List = favorites.values.toList() + @Synchronized + fun getMutualFavorites(): List = + favoritesForReadLocked().filter { it.isMutual } + + @Synchronized + fun getOurFavorites(): List = + favoritesForReadLocked().filter { it.isFavorite } + + @Synchronized + fun getAllRelationships(): List = favoritesForReadLocked() + + /** + * Clear contact bindings only after the caller has durably wiped native NDR state. + */ + @Synchronized + fun clearAllFavoritesAfterNdrReset(): Boolean { + val committed = runCatching { + stateManager.commitSecureValuesSynchronously( + removals = setOf( + FAVORITES_KEY, + PEERID_INDEX_KEY, + NDR_REBIND_JOURNAL_KEY + ) + ) + }.getOrDefault(false) + if (!committed) return false - fun clearAllFavorites() { favorites.clear() - saveFavorites() peerIdIndex.clear() - savePeerIdIndex() + pendingNdrRebind = null + ndrRebindsInProgress.clear() + ndrRebindJournalCorrupt = false + favoritesStorageUnreadable = false Log.i(TAG, "Cleared all favorites") notifyAllCleared() + return true } /** Find Noise key by Nostr pubkey */ + @Synchronized fun findNoiseKey(forNostrPubkey: String): ByteArray? { val targetHex = ContactIdentityResolver.nostrPubkeyHex(forNostrPubkey) ?: return null - return favorites.values.firstOrNull { rel -> - rel.peerNostrPublicKey?.let { stored -> ContactIdentityResolver.nostrPubkeyHex(stored) } == targetHex - }?.peerNoisePublicKey + return favorites.entries.firstNotNullOfOrNull { (keyHex, _) -> + val relationship = favoriteForReadLocked(keyHex) + ?: return@firstNotNullOfOrNull null + relationship.peerNoisePublicKey.takeIf { + relationship.peerNostrPublicKey + ?.let(ContactIdentityResolver::nostrPubkeyHex) == targetHex || + relationship.peerNdrSessionPubkeyHex == targetHex + } + } } /** Find Nostr pubkey by Noise key */ + @Synchronized fun findNostrPubkey(forNoiseKey: ByteArray): String? { val keyHex = ContactIdentityResolver.noiseKeyHex(forNoiseKey) - return favorites[keyHex]?.peerNostrPublicKey + return favoriteForReadLocked(keyHex)?.peerNostrPublicKey + } + + /** Persist the owner pubkey used to look up this peer's ratchet session. */ + fun updateNdrSessionPubkeyHex(noisePublicKey: ByteArray, peerPubkeyHex: String): Boolean { + val normalized = ContactIdentityResolver.nostrPubkeyHex(peerPubkeyHex) ?: return false + val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey) + recoverPendingNdrRebind() + var journal: FavoriteNdrRebindJournal? = null + val committedWithoutRetirement = synchronized(this) { + if (ndrRebindJournalCorrupt || + favoritesStorageUnreadable || + pendingNdrRebind != null || + ndrRebindsInProgress.isNotEmpty() || + isIdentityBoundToAnotherFavorite(keyHex, normalized) + ) return false + val existing = favorites[keyHex] ?: return false + if (existing.peerNdrSessionPubkeyHex == normalized && existing.ndrRequired) { + return true + } + val oldPeer = effectiveNdrPeerPubkeyHex(existing) + val isRebind = oldPeer != null && + !oldPeer.equals(normalized, ignoreCase = true) + val wasNdrRequired = existing.ndrRequired || + existing.peerNdrSessionPubkeyHex != null + val requiresRetirement = wasNdrRequired && isRebind + val updated = existing.copy( + peerNdrSessionPubkeyHex = normalized, + ndrRequired = true, + lastUpdated = Date() + ) + val mustRetire = requiresRetirement && + !isIdentityReferencedByAnotherFavorite(keyHex, requireNotNull(oldPeer)) + val needsDurablePin = !existing.ndrRequired + if (!mustRetire && (!needsDurablePin || oldPeer == null)) { + val committed = commitFavoriteUpdateLocked(keyHex, updated) + if (!committed && needsDurablePin) { + favoritesStorageUnreadable = true + } + committed + } else { + journal = FavoriteNdrRebindJournal( + noiseKeyHex = keyHex, + oldPeerPubkeyHex = requireNotNull(oldPeer), + expectedNostrPubkeyHex = existing.peerNostrPublicKey + ?.let(ContactIdentityResolver::nostrPubkeyHex), + expectedNdrSessionPubkeyHex = existing.peerNdrSessionPubkeyHex, + expectedNdrRequired = existing.ndrRequired, + targetNostrPubkeyHex = existing.peerNostrPublicKey + ?.let(ContactIdentityResolver::nostrPubkeyHex), + targetNdrSessionPubkeyHex = normalized, + targetNdrRequired = true, + retireOldPeer = mustRetire + ) + beginNdrRebindLocked(requireNotNull(journal)) + false + } + } + + val pendingJournal = journal + val committed = if (pendingJournal == null) { + committedWithoutRetirement + } else { + completePendingNdrRebind(pendingJournal) + } + if (!committed) return false + + notifyChanged(keyHex) + return true + } + + /** Resolve the best ratchet-session lookup key for this Noise identity. */ + @Synchronized + fun findNdrSessionPubkeyHex(forNoiseKey: ByteArray): String? { + val keyHex = ContactIdentityResolver.noiseKeyHex(forNoiseKey) + pendingNdrRebind + ?.takeIf { it.noiseKeyHex == keyHex } + ?.targetEffectivePeerPubkeyHex() + ?.let { return it } + val relationship = favorites[keyHex] ?: return null + return relationship.peerNdrSessionPubkeyHex + ?: relationship.peerNostrPublicKey?.let(ContactIdentityResolver::nostrPubkeyHex) + } + + @Synchronized + fun isNdrRequired(forNoiseKey: ByteArray): Boolean { + val keyHex = ContactIdentityResolver.noiseKeyHex(forNoiseKey) + return favorites[keyHex]?.ndrRequired == true || + pendingNdrRebind?.noiseKeyHex == keyHex + } + + @Synchronized + fun isNdrRebindBlocked(forNoiseKey: ByteArray): Boolean { + val keyHex = ContactIdentityResolver.noiseKeyHex(forNoiseKey) + return ndrRebindJournalCorrupt || + favoritesStorageUnreadable || + pendingNdrRebind?.noiseKeyHex == keyHex + } + + @Synchronized + fun isNdrProtectionStateReadable(): Boolean = + !ndrRebindJournalCorrupt && !favoritesStorageUnreadable + + /** + * Stored-only binding used for authenticated NDR routes. + * + * A journal target is not authorized until the target favorite itself is durable. + */ + @Synchronized + fun getStoredFavoriteForNdrRoute( + noisePublicKey: ByteArray + ): FavoriteRelationship? { + if (ndrRebindJournalCorrupt || favoritesStorageUnreadable) return null + val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey) + val relationship = favorites[keyHex] ?: return null + val journal = pendingNdrRebind + return if (journal?.noiseKeyHex == keyHex) { + relationship.takeIf(journal::matchesTargetBinding) + } else { + relationship + } + } + + /** + * Accept relay-delivered NDR content only from a currently bound, unquarantined peer. + */ + @Synchronized + fun isCurrentNdrPeerAuthorized(peerPubkeyHex: String): Boolean { + val normalized = ContactIdentityResolver.nostrPubkeyHex(peerPubkeyHex) ?: return false + if (ndrRebindJournalCorrupt || favoritesStorageUnreadable) return false + return favorites.entries.any { (keyHex, _) -> + val noiseKey = favorites[keyHex]?.peerNoisePublicKey + ?: return@any false + getStoredFavoriteForNdrRoute(noiseKey) + ?.let(::effectiveNdrPeerPubkeyHex) + ?.equals(normalized, ignoreCase = true) == true + } + } + + /** + * Legacy kind-1059 is allowed only before a contact has a durable NDR pin. + */ + @Synchronized + fun isLegacyNostrInboundAllowed(peerPubkeyHex: String): Boolean { + val normalized = ContactIdentityResolver.nostrPubkeyHex(peerPubkeyHex) ?: return false + if (ndrRebindJournalCorrupt || favoritesStorageUnreadable) return false + val journal = pendingNdrRebind + if (journal != null && normalized in journal.quarantinedIdentityPubkeys()) { + return false + } + return favorites.entries.none { (keyHex, relationship) -> + keyHex != journal?.noiseKeyHex && + relationship.ndrRequired && + relationshipReferencesIdentity(relationship, normalized) + } } // MARK: - Persistence + private fun favoriteForReadLocked(keyHex: String): FavoriteRelationship? { + val relationship = favorites[keyHex] ?: return null + val journal = pendingNdrRebind + return if (journal?.noiseKeyHex == keyHex) { + journal.applyTarget(relationship) + } else { + relationship + } + } + + private fun favoritesForReadLocked(): List = + favorites.keys.mapNotNull(::favoriteForReadLocked) + + private fun commitFavoriteUpdateLocked( + keyHex: String, + relationship: FavoriteRelationship + ): Boolean { + val snapshot = favorites.toMutableMap() + snapshot[keyHex] = relationship + if (!commitFavoritesSnapshotLocked(snapshot)) return false + favorites.clear() + favorites.putAll(snapshot) + return true + } + + private fun commitFavoritesSnapshotLocked( + snapshot: Map + ): Boolean = runCatching { + stateManager.commitSecureValuesSynchronously( + values = mapOf(FAVORITES_KEY to favoritesJson(snapshot)) + ) + }.getOrDefault(false) + + private fun commitPeerIdIndexSnapshotLocked( + snapshot: Map + ): Boolean = runCatching { + stateManager.commitSecureValuesSynchronously( + values = mapOf(PEERID_INDEX_KEY to gson.toJson(snapshot)) + ) + }.getOrDefault(false) + + private fun favoritesJson( + snapshot: Map + ): String { + val data = snapshot.mapValues { (_, relationship) -> + FavoriteRelationshipData.fromFavoriteRelationship(relationship) + } + return gson.toJson(data) + } + + private fun beginNdrRebindLocked(journal: FavoriteNdrRebindJournal): Boolean { + if (pendingNdrRebind != null || + ndrRebindJournalCorrupt || + favoritesStorageUnreadable + ) return false + if (!journal.isValid()) return false + val journalJson = gson.toJson(journal) + val committed = runCatching { + stateManager.commitSecureValuesSynchronously( + values = mapOf(NDR_REBIND_JOURNAL_KEY to journalJson) + ) + }.getOrDefault(false) + if (!committed) { + favoritesStorageUnreadable = true + return false + } + pendingNdrRebind = journal + ndrRebindsInProgress.add(journal.noiseKeyHex) + return true + } + + private fun completePendingNdrRebind( + expectedJournal: FavoriteNdrRebindJournal + ): Boolean { + val ownsCurrentBinding = synchronized(this) { + val journal = pendingNdrRebind + if (journal != expectedJournal || + ndrRebindJournalCorrupt || + favoritesStorageUnreadable + ) { + false + } else { + val current = favorites[journal.noiseKeyHex] + val currentMatchesExpected = current != null && + journal.matchesExpectedBinding(current) + val currentMatchesTarget = current != null && + journal.matchesTargetBinding(current) + val targetCollides = journal.targetIdentityPubkeys().any { target -> + isIdentityBoundToAnotherFavorite(journal.noiseKeyHex, target) + } + if (current == null || + (!currentMatchesExpected && !currentMatchesTarget) || + targetCollides + ) { + ndrRebindJournalCorrupt = true + false + } else { + true + } + } + } + if (!ownsCurrentBinding) return false + + if (expectedJournal.retireOldPeer && + !retireBeforeRebind(expectedJournal.oldPeerPubkeyHex) + ) { + Log.e(TAG, "NDR rebind remains quarantined because old peer retirement failed") + return false + } + + return synchronized(this) { + if (pendingNdrRebind != expectedJournal || + ndrRebindJournalCorrupt || + favoritesStorageUnreadable + ) { + false + } else { + val current = favorites[expectedJournal.noiseKeyHex] + val currentMatchesExpected = current != null && + expectedJournal.matchesExpectedBinding(current) + val currentMatchesTarget = current != null && + expectedJournal.matchesTargetBinding(current) + val targetCollides = expectedJournal.targetIdentityPubkeys().any { target -> + isIdentityBoundToAnotherFavorite( + expectedJournal.noiseKeyHex, + target + ) + } + if (current == null || + (!currentMatchesExpected && !currentMatchesTarget) || + targetCollides + ) { + ndrRebindJournalCorrupt = true + false + } else { + val targetCommitted = currentMatchesTarget || + commitFavoriteUpdateLocked( + expectedJournal.noiseKeyHex, + expectedJournal.applyTarget(current) + ) + val targetVerified = targetCommitted && + favorites[expectedJournal.noiseKeyHex] + ?.let(expectedJournal::matchesTargetBinding) == true + if (!targetVerified) { + false + } else { + val cleared = runCatching { + stateManager.commitSecureValuesSynchronously( + removals = setOf(NDR_REBIND_JOURNAL_KEY) + ) + }.getOrDefault(false) + if (cleared) { + pendingNdrRebind = null + ndrRebindsInProgress.remove(expectedJournal.noiseKeyHex) + } + cleared + } + } + } + } + } + + private fun recoverPendingNdrRebind(): Boolean { + val journal = synchronized(this) { + if (ndrRebindJournalCorrupt || favoritesStorageUnreadable) return false + pendingNdrRebind + } ?: return true + val recovered = completePendingNdrRebind(journal) + if (recovered) notifyChanged(journal.noiseKeyHex) + return recovered + } + private fun loadFavorites() { try { val favoritesJson = stateManager.getSecureValue(FAVORITES_KEY) @@ -299,27 +747,55 @@ class FavoritesPersistenceService private constructor(private val context: Conte val data: Map = gson.fromJson(favoritesJson, type) favorites.clear() + var needsNdrPinMigration = false data.forEach { (key, relationshipData) -> - favorites[key] = relationshipData.toFavoriteRelationship() + val relationship = relationshipData.toFavoriteRelationship() + favorites[key] = relationship + val storedNdrPeer = relationshipData.peerNdrSessionPubkeyHex + if (storedNdrPeer != null && + relationship.peerNdrSessionPubkeyHex == null + ) { + favoritesStorageUnreadable = true + } else if (storedNdrPeer != null && + (!relationshipData.ndrRequired || + storedNdrPeer != relationship.peerNdrSessionPubkeyHex) + ) { + needsNdrPinMigration = true + } + } + if (needsNdrPinMigration && + !commitFavoritesSnapshotLocked(favorites) + ) { + favoritesStorageUnreadable = true + Log.e(TAG, "Failed to durably migrate existing NDR downgrade pins") } Log.d(TAG, "Loaded ${favorites.size} favorite relationships") } } catch (e: Exception) { + favoritesStorageUnreadable = true Log.e(TAG, "Failed to load favorites: ${e.message}") } } - private fun saveFavorites() { - try { - val data = favorites.mapValues { (_, relationship) -> - FavoriteRelationshipData.fromFavoriteRelationship(relationship) - } - val favoritesJson = gson.toJson(data) - stateManager.storeSecureValue(FAVORITES_KEY, favoritesJson) - Log.d(TAG, "Saved ${favorites.size} favorite relationships") - } catch (e: Exception) { - Log.e(TAG, "Failed to save favorites: ${e.message}") + private fun loadNdrRebindJournal() { + val journalJson = runCatching { + stateManager.getSecureValue(NDR_REBIND_JOURNAL_KEY) + }.getOrElse { + ndrRebindJournalCorrupt = true + Log.e(TAG, "Failed to read NDR rebind journal") + return + } ?: return + + val journal = runCatching { + gson.fromJson(journalJson, FavoriteNdrRebindJournal::class.java) + }.getOrNull() + if (journal == null || !journal.isValid()) { + ndrRebindJournalCorrupt = true + Log.e(TAG, "Invalid NDR rebind journal; keeping transport fail-closed") + return } + pendingNdrRebind = journal + ndrRebindsInProgress.add(journal.noiseKeyHex) } private fun loadPeerIdIndex() { @@ -342,16 +818,6 @@ class FavoritesPersistenceService private constructor(private val context: Conte } } - private fun savePeerIdIndex() { - try { - val json = gson.toJson(peerIdIndex) - stateManager.storeSecureValue(PEERID_INDEX_KEY, json) - Log.d(TAG, "Saved ${peerIdIndex.size} peerID→npub mappings") - } catch (e: Exception) { - Log.e(TAG, "Failed to save peerID index: ${e.message}") - } - } - // MARK: - Listeners fun addListener(listener: FavoritesChangeListener) { synchronized(listeners) { if (!listeners.contains(listener)) listeners.add(listener) } @@ -359,6 +825,72 @@ class FavoritesPersistenceService private constructor(private val context: Conte fun removeListener(listener: FavoritesChangeListener) { synchronized(listeners) { listeners.remove(listener) } } + + fun setNdrPeerRetirementGuard( + guard: ((oldPeerPubkeyHex: String) -> Boolean)? + ) { + synchronized(this) { + ndrPeerRetirementGuard = guard + } + if (guard != null) recoverPendingNdrRebind() + } + + private fun effectiveNdrPeerPubkeyHex( + relationship: FavoriteRelationship + ): String? = relationship.peerNdrSessionPubkeyHex + ?: relationship.peerNostrPublicKey?.let(ContactIdentityResolver::nostrPubkeyHex) + + private fun relationshipWithNostrIdentity( + existing: FavoriteRelationship?, + noisePublicKey: ByteArray, + normalizedNpub: String, + clearExplicitNdrPeer: Boolean, + requireNdr: Boolean + ): FavoriteRelationship = existing?.copy( + peerNostrPublicKey = normalizedNpub, + peerNdrSessionPubkeyHex = + if (clearExplicitNdrPeer) null else existing.peerNdrSessionPubkeyHex, + ndrRequired = requireNdr, + lastUpdated = Date() + ) ?: FavoriteRelationship( + peerNoisePublicKey = noisePublicKey, + peerNostrPublicKey = normalizedNpub, + ndrRequired = requireNdr, + peerNickname = "Unknown", + isFavorite = false, + theyFavoritedUs = false, + favoritedAt = Date(), + lastUpdated = Date() + ) + + private fun isIdentityBoundToAnotherFavorite( + noiseKeyHex: String, + peerPubkeyHex: String + ): Boolean = favorites.any { (otherNoiseKeyHex, relationship) -> + otherNoiseKeyHex != noiseKeyHex && + relationshipReferencesIdentity(relationship, peerPubkeyHex) + } + + private fun isIdentityReferencedByAnotherFavorite( + noiseKeyHex: String, + peerPubkeyHex: String + ): Boolean = isIdentityBoundToAnotherFavorite(noiseKeyHex, peerPubkeyHex) + + private fun relationshipReferencesIdentity( + relationship: FavoriteRelationship, + peerPubkeyHex: String + ): Boolean = + relationship.peerNdrSessionPubkeyHex + ?.equals(peerPubkeyHex, ignoreCase = true) == true || + relationship.peerNostrPublicKey + ?.let(ContactIdentityResolver::nostrPubkeyHex) + ?.equals(peerPubkeyHex, ignoreCase = true) == true + + private fun retireBeforeRebind(oldPeerPubkeyHex: String): Boolean { + val guard = synchronized(this) { ndrPeerRetirementGuard } ?: return false + return runCatching { guard(oldPeerPubkeyHex) }.getOrDefault(false) + } + private fun notifyChanged(noiseKeyHex: String) { runCatching { AppStateStore.canonicalizePrivateChats() } val snapshot = synchronized(listeners) { listeners.toList() } @@ -374,6 +906,8 @@ class FavoritesPersistenceService private constructor(private val context: Conte private data class FavoriteRelationshipData( val peerNoisePublicKeyHex: String, val peerNostrPublicKey: String?, + val peerNdrSessionPubkeyHex: String? = null, + val ndrRequired: Boolean = false, val peerNickname: String, val isFavorite: Boolean, val theyFavoritedUs: Boolean, @@ -385,6 +919,8 @@ private data class FavoriteRelationshipData( return FavoriteRelationshipData( peerNoisePublicKeyHex = ContactIdentityResolver.noiseKeyHex(relationship.peerNoisePublicKey), peerNostrPublicKey = relationship.peerNostrPublicKey, + peerNdrSessionPubkeyHex = relationship.peerNdrSessionPubkeyHex, + ndrRequired = relationship.ndrRequired, peerNickname = relationship.peerNickname, isFavorite = relationship.isFavorite, theyFavoritedUs = relationship.theyFavoritedUs, @@ -396,9 +932,13 @@ private data class FavoriteRelationshipData( fun toFavoriteRelationship(): FavoriteRelationship { val noiseKeyBytes = ContactIdentityResolver.bytesFromHex(peerNoisePublicKeyHex) ?: ByteArray(0) + val normalizedNdrSessionPubkeyHex = peerNdrSessionPubkeyHex + ?.let(ContactIdentityResolver::nostrPubkeyHex) return FavoriteRelationship( peerNoisePublicKey = noiseKeyBytes, peerNostrPublicKey = peerNostrPublicKey, + peerNdrSessionPubkeyHex = normalizedNdrSessionPubkeyHex, + ndrRequired = ndrRequired || normalizedNdrSessionPubkeyHex != null, peerNickname = peerNickname, isFavorite = isFavorite, theyFavoritedUs = theyFavoritedUs, @@ -407,3 +947,83 @@ private data class FavoriteRelationshipData( ) } } + +private data class FavoriteNdrRebindJournal( + val version: Int = 1, + val noiseKeyHex: String, + val oldPeerPubkeyHex: String, + val expectedNostrPubkeyHex: String?, + val expectedNdrSessionPubkeyHex: String?, + val expectedNdrRequired: Boolean, + val targetNostrPubkeyHex: String?, + val targetNdrSessionPubkeyHex: String?, + val targetNdrRequired: Boolean, + val retireOldPeer: Boolean +) { + fun isValid(): Boolean { + val isPubkey: (String) -> Boolean = { + it.length == 64 && it.all { character -> character in "0123456789abcdef" } + } + return version == 1 && + isPubkey(noiseKeyHex) && + isPubkey(oldPeerPubkeyHex) && + listOfNotNull( + expectedNostrPubkeyHex, + expectedNdrSessionPubkeyHex, + targetNostrPubkeyHex, + targetNdrSessionPubkeyHex + ).all(isPubkey) && + expectedEffectivePeerPubkeyHex() == oldPeerPubkeyHex && + targetEffectivePeerPubkeyHex() != null && + targetNdrRequired && + (if (retireOldPeer) { + (expectedNdrRequired || expectedNdrSessionPubkeyHex != null) && + targetEffectivePeerPubkeyHex() != oldPeerPubkeyHex + } else { + !expectedNdrRequired && targetNdrSessionPubkeyHex != null + }) + } + + private fun expectedEffectivePeerPubkeyHex(): String? = + expectedNdrSessionPubkeyHex ?: expectedNostrPubkeyHex + + fun targetEffectivePeerPubkeyHex(): String? = + targetNdrSessionPubkeyHex ?: targetNostrPubkeyHex + + fun targetIdentityPubkeys(): Set = + listOfNotNull(targetNostrPubkeyHex, targetNdrSessionPubkeyHex).toSet() + + fun quarantinedIdentityPubkeys(): Set = + buildSet { + add(oldPeerPubkeyHex) + addAll( + listOfNotNull( + expectedNostrPubkeyHex, + expectedNdrSessionPubkeyHex, + targetNostrPubkeyHex, + targetNdrSessionPubkeyHex + ) + ) + } + + fun matchesExpectedBinding(relationship: FavoriteRelationship): Boolean = + relationship.peerNostrPublicKey + ?.let(ContactIdentityResolver::nostrPubkeyHex) == expectedNostrPubkeyHex && + relationship.peerNdrSessionPubkeyHex == expectedNdrSessionPubkeyHex && + relationship.ndrRequired == expectedNdrRequired + + fun matchesTargetBinding(relationship: FavoriteRelationship): Boolean = + relationship.peerNostrPublicKey + ?.let(ContactIdentityResolver::nostrPubkeyHex) == targetNostrPubkeyHex && + relationship.peerNdrSessionPubkeyHex == targetNdrSessionPubkeyHex && + relationship.ndrRequired == targetNdrRequired + + fun applyTarget(relationship: FavoriteRelationship): FavoriteRelationship = + relationship.copy( + peerNostrPublicKey = targetNostrPubkeyHex + ?.let(ContactIdentityResolver::npubFromHex), + peerNdrSessionPubkeyHex = targetNdrSessionPubkeyHex, + ndrRequired = targetNdrRequired, + lastUpdated = Date() + ) +} diff --git a/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt b/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt index da6898e8..3fb94b3d 100644 --- a/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt +++ b/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt @@ -10,6 +10,8 @@ import com.bitchat.android.model.BitchatMessageType import java.io.File import java.io.FileOutputStream import java.io.InputStream +import java.nio.file.Files +import java.nio.file.StandardCopyOption import java.text.SimpleDateFormat import java.util.* @@ -195,7 +197,8 @@ object FileUtils { */ fun saveIncomingFile( context: Context, - file: com.bitchat.android.model.BitchatFilePacket + file: com.bitchat.android.model.BitchatFilePacket, + stableId: String? = null ): String { val lowerMime = file.mimeType.lowercase() val isImage = lowerMime.startsWith("image/") @@ -219,6 +222,17 @@ object FileUtils { ?: (if (isImage) "img" else "file")) .replace(Regex("[^A-Za-z0-9._-]"), "_") val ext = extFromMime(lowerMime) + if (stableId != null) { + require(stableId.matches(Regex("^[0-9a-fA-F]{64}$"))) { + "Stable incoming file ID must be a 32-byte hex event ID" + } + val transmittedName = (file.fileName.takeIf { it.isNotBlank() } + ?: if (isImage) "image$ext" else "file$ext") + .replace(Regex("[^A-Za-z0-9._-]"), "_") + .take(80) + val stableName = "ndr_${stableId.lowercase()}_$transmittedName" + return saveIncomingFileAtomically(dir, stableName, file.content) + } var safeName = if (baseName.contains('.')) baseName else baseName + ext var idx = 1 while (java.io.File(dir, safeName).exists() && idx < 1000) { @@ -264,6 +278,37 @@ object FileUtils { } } + private fun saveIncomingFileAtomically( + directory: File, + fileName: String, + content: ByteArray + ): String { + val target = File(directory, fileName) + if (target.isFile && + target.length() == content.size.toLong() && + runCatching { target.readBytes().contentEquals(content) }.getOrDefault(false) + ) { + return target.absolutePath + } + + val temporary = File(directory, ".$fileName.${UUID.randomUUID()}.tmp") + try { + FileOutputStream(temporary).use { output -> + output.write(content) + output.fd.sync() + } + Files.move( + temporary.toPath(), + target.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ) + return target.absolutePath + } finally { + temporary.delete() + } + } + /** * Classify BitchatMessageType from MIME string used in file messages. */ diff --git a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt index 1efa87a2..1ee3e879 100644 --- a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt +++ b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt @@ -37,6 +37,11 @@ class SecureIdentityStateManager { private const val KEY_CACHED_FINGERPRINT_NICKNAMES = "cached_fingerprint_nicknames" private const val KEY_PRIVATE_MEDIA_CAPABILITY_PINS = "private_media_capability_pins_v1" private const val KEY_AUTHENTICATED_PEER_STATES = "authenticated_peer_states_v1" + private val NDR_PROTECTION_KEYS = setOf( + "favorite_relationships", + "favorite_peerid_index", + "favorite_ndr_rebind_v1" + ) // BLE, Wi-Fi Aware, and Noise services each hold their own manager // instance over the same encrypted preferences. Serialize pin updates @@ -466,18 +471,38 @@ class SecureIdentityStateManager { * Clear all identity data (for panic mode) */ @SuppressLint("UseKtx") - fun clearIdentityData() { - try { - synchronized(privateMediaPinsLock) { + fun clearIdentityData(): Boolean { + return try { + val cleared = synchronized(privateMediaPinsLock) { privateMediaPinsEpoch += 1 privateMediaPinsEpochAtCreation = privateMediaPinsEpoch - if (!prefs.edit().clear().commit()) { - Log.e(TAG, "Identity preference wipe could not be committed") + synchronized(lock) { + val protectedValues = NDR_PROTECTION_KEYS.mapNotNull { key -> + prefs.getString(key, null)?.let { value -> key to value } + }.toMap() + val editor = prefs.edit().clear() + protectedValues.forEach { (key, value) -> + editor.putString(key, value) + } + val committed = editor.commit() + val storedValues = prefs.all + val wipeVerified = + storedValues.keys == protectedValues.keys && + protectedValues.all { (key, value) -> + storedValues[key] == value + } + committed && wipeVerified } } - Log.w(TAG, "All identity data cleared") + if (cleared) { + Log.w(TAG, "All identity data cleared") + } else { + Log.e(TAG, "Identity preference wipe could not be committed safely") + } + cleared } catch (e: Exception) { Log.e(TAG, "Failed to clear identity data: ${e.message}") + false } } @@ -496,6 +521,29 @@ class SecureIdentityStateManager { fun storeSecureValue(key: String, value: String) { prefs.edit().putString(key, value).apply() } + + /** + * Durably store a value before acknowledging an external operation. + */ + fun storeSecureValueSynchronously(key: String, value: String): Boolean { + return commitSecureValuesSynchronously(mapOf(key to value)) + } + + /** + * Atomically commit and read back a set of secure string mutations. + */ + fun commitSecureValuesSynchronously( + values: Map = emptyMap(), + removals: Set = emptySet() + ): Boolean = synchronized(lock) { + if (values.keys.any { it in removals }) return@synchronized false + val editor = prefs.edit() + values.forEach { (key, value) -> editor.putString(key, value) } + removals.forEach(editor::remove) + if (!editor.commit()) return@synchronized false + values.all { (key, value) -> prefs.getString(key, null) == value } && + removals.none(prefs::contains) + } /** * Retrieve a string value from secure preferences diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt index 0e4f29af..61bae6ef 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -107,6 +107,18 @@ class BluetoothConnectionManager( fun getCurrentLinkID(deviceAddress: String): String? = connectionTracker.getCurrentLinkID(deviceAddress) + fun currentNdrTransportTarget(peerID: String): NdrTransportTarget? { + val deviceAddress = connectionTracker.addressPeerMap.entries + .firstOrNull { it.value == peerID } + ?.key + ?: return null + val linkID = connectionTracker.getCurrentLinkID(deviceAddress) ?: return null + return NdrTransportTarget( + endpointId = deviceAddress, + generationToken = linkID + ) + } + private fun isBleTransportEnabled(): Boolean { return try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value @@ -394,6 +406,28 @@ class BluetoothConnectionManager( serverManager.getCharacteristic() ) } + + fun sendPacketToNdrTargetConfirmed( + target: NdrTransportTarget, + routed: RoutedPacket, + preflight: () -> Boolean, + completion: (Boolean) -> Unit + ) { + val linkID = target.generationToken as? String + if (!isActive || !isBleTransportEnabled() || linkID == null) { + completion(false) + return + } + packetBroadcaster.sendPacketToLinkConfirmed( + routed = routed, + deviceAddress = target.endpointId, + linkID = linkID, + gattServer = serverManager.getGattServer(), + characteristic = serverManager.getCharacteristic(), + preflight = preflight, + completion = completion + ) + } // Expose role controls for debug UI diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 22f17867..a742602d 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -5,6 +5,7 @@ import android.util.Log import com.bitchat.android.crypto.EncryptionService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.AuthenticatedPeerState +import com.bitchat.android.model.NdrFeatureGate import com.bitchat.android.model.PeerCapabilities import com.bitchat.android.protocol.MessagePadding import com.bitchat.android.model.RoutedPacket @@ -22,6 +23,8 @@ import com.bitchat.android.service.TransportBridgeService import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import java.util.* +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean import kotlin.math.sign import kotlin.random.Random @@ -43,6 +46,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic companion object { private const val TAG = "BluetoothMeshService" + private const val NDR_TRANSPORT_ID = "BLE" + private const val BLE_AUTHENTICATION_TIMEOUT_MS = 20_000L private val MAX_TTL: UByte = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS private const val PEER_DISCONNECT_GRACE_MS = com.bitchat.android.util.AppConstants.Mesh.PEER_DISCONNECT_GRACE_MS } @@ -542,6 +547,34 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) { delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs) } + + override fun onNdrEventReceived( + peerID: String, + payload: ByteArray, + timestampMs: Long, + authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession + ) { + if (NdrFeatureGate.isEnabled() && + sessionProvesAuthenticatedCapability( + peerID, + PeerCapabilities.NOSTR_DOUBLE_RATCHET, + authenticatedSession + ) + ) { + val transportTarget = + connectionManager.currentNdrTransportTarget(peerID) ?: return + delegate?.didReceiveNdrEvent( + NdrMeshRoute( + transportId = NDR_TRANSPORT_ID, + peerID = peerID, + authenticatedSession = authenticatedSession, + transportTarget = transportTarget + ), + payload, + timestampMs + ) + } + } } // PacketProcessor delegates @@ -1206,10 +1239,114 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic sendNoisePayloadToPeer(payload, peerID, "verify response") } - private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String, label: String) { + fun currentNdrRoute(peerID: String, transportId: String? = null): NdrMeshRoute? { + if (!NdrFeatureGate.isEnabled() || + (transportId != null && transportId != NDR_TRANSPORT_ID) + ) return null + val authenticatedSession = authenticatedSessionProvingCapability( + peerID, + PeerCapabilities.NOSTR_DOUBLE_RATCHET + ) ?: return null + val transportTarget = + connectionManager.currentNdrTransportTarget(peerID) ?: return null + return NdrMeshRoute( + transportId = NDR_TRANSPORT_ID, + peerID = peerID, + authenticatedSession = authenticatedSession, + transportTarget = transportTarget + ) + } + + fun sendNdrEvent( + route: NdrMeshRoute, + eventPayload: String, + isStillAuthorized: () -> Boolean, + completion: (admitted: Boolean) -> Unit + ) { + if (!NdrFeatureGate.isEnabled() || + route.transportId != NDR_TRANSPORT_ID || + eventPayload.isBlank() + ) { + completion(false) + return + } + val completionDelivered = AtomicBoolean(false) + fun complete(admitted: Boolean) { + if (completionDelivered.compareAndSet(false, true)) { + runCatching { completion(admitted) } + } + } serviceScope.launch { + var handedToTransport = false try { - val encrypted = encryptionService.encrypt(payload.encode(), recipientPeerID) + val preflight = { + currentNdrRoute(route.peerID, route.transportId) == route && + isStillAuthorized() + } + if (!preflight()) return@launch + val encrypted = encryptionService.encryptForSession( + NoisePayload( + type = NoisePayloadType.NDR_EVENT, + data = eventPayload.toByteArray(Charsets.UTF_8) + ).encode(), + route.peerID, + route.authenticatedSession + ) + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(route.peerID), + timestamp = System.currentTimeMillis().toULong(), + payload = encrypted, + signature = null, + ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS + ) + val signedPacket = signPacketBeforeBroadcast(packet) + handedToTransport = true + connectionManager.sendPacketToNdrTargetConfirmed( + target = route.transportTarget, + routed = RoutedPacket(signedPacket), + preflight = preflight, + completion = ::complete + ) + } catch (e: Exception) { + Log.e(TAG, "Failed to send NDR event to ${route.peerID}: ${e.message}") + } finally { + if (!handedToTransport) complete(false) + } + } + } + + private fun sendNoisePayloadToPeer( + payload: NoisePayload, + recipientPeerID: String, + label: String, + expectedSession: com.bitchat.android.noise.AuthenticatedNoiseSession? = null, + preflight: () -> Boolean = { true }, + completion: ((admitted: Boolean) -> Unit)? = null + ) { + val completionDelivered = AtomicBoolean(false) + fun complete(admitted: Boolean) { + if (completionDelivered.compareAndSet(false, true)) { + runCatching { completion?.invoke(admitted) } + } + } + val job = serviceScope.launch { + var admitted = false + try { + if (!preflight()) { + return@launch + } + val encrypted = if (expectedSession == null) { + encryptionService.encrypt(payload.encode(), recipientPeerID) + } else { + encryptionService.encryptForSession( + payload.encode(), + recipientPeerID, + expectedSession + ) + } val packet = BitchatPacket( version = 1u, type = MessageType.NOISE_ENCRYPTED.value, @@ -1222,11 +1359,16 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic ) val signedPacket = signPacketBeforeBroadcast(packet) - broadcastRoutedPacket(RoutedPacket(signedPacket)) + admitted = broadcastRoutedPacket(RoutedPacket(signedPacket)) } catch (e: Exception) { Log.e(TAG, "Failed to send $label to $recipientPeerID: ${e.message}") + } finally { + complete(admitted) } } + job.invokeOnCompletion { + complete(false) + } } /** @@ -1442,6 +1584,33 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic return peerManager.getPeerInfo(peerID) } + fun peerSupportsAuthenticatedCapability( + peerID: String, + capability: PeerCapabilities + ): Boolean = authenticatedSessionProvingCapability(peerID, capability) != null + + private fun authenticatedSessionProvingCapability( + peerID: String, + capability: PeerCapabilities + ): com.bitchat.android.noise.AuthenticatedNoiseSession? { + val authenticatedSession = encryptionService.getAuthenticatedSession(peerID) ?: return null + return authenticatedSession.takeIf { + sessionProvesAuthenticatedCapability(peerID, capability, it) + } + } + + private fun sessionProvesAuthenticatedCapability( + peerID: String, + capability: PeerCapabilities, + authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession + ): Boolean { + val proven = authenticatedPeerState.status( + peerID, + authenticatedSession + ) as? AuthenticatedPeerStateStatus.Proven ?: return false + return proven.state.capabilities.contains(capability) + } + /** * Update peer information with verification data */ diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt index 9ff9856d..8b993ea2 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt @@ -226,6 +226,38 @@ class BluetoothPacketBroadcaster( notifyDevice(serverTarget, data, gattServer, characteristic) } + fun sendPacketToLinkConfirmed( + routed: RoutedPacket, + deviceAddress: String, + linkID: String, + gattServer: BluetoothGattServer?, + characteristic: BluetoothGattCharacteristic?, + preflight: () -> Boolean, + completion: (Boolean) -> Unit + ) { + fragmentingSender.sendConfirmed( + routed = routed, + description = "BLE link $deviceAddress", + preflight = preflight, + sendSingle = sendSingle@{ single -> + val data = single.packet.toBinaryData( + padding = BLEPacketPaddingPolicy.shouldPadForBLE(single.packet.type) + ) ?: return@sendSingle false + val currentLink = connectionTracker.getDeviceConnection(deviceAddress) + ?.takeIf { it.linkID == linkID } + ?: return@sendSingle false + if (currentLink.isClient) { + return@sendSingle writeToDeviceConn(currentLink, data) + } + val serverTarget = connectionTracker.getSubscribedDevices() + .firstOrNull { it.address == deviceAddress } + ?: return@sendSingle false + notifyDevice(serverTarget, data, gattServer, characteristic) + }, + completion = completion + ) + } + private fun sendSinglePacketToPeer( routed: RoutedPacket, targetPeerID: String, diff --git a/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt b/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt index 9c6bd15e..d4865141 100644 --- a/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt +++ b/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.security.MessageDigest import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean /** * Shared transport send wrapper that applies bitchat packet fragmentation and @@ -112,6 +113,77 @@ class FragmentingPacketSender( return true } + /** + * Completes successfully only after every fragment has been admitted by + * the exact transport target. [preflight] is re-run for every fragment so + * a replaced session, revoked favorite, or disconnected link stops the + * transfer without acknowledging its durable caller. + */ + fun sendConfirmed( + routed: RoutedPacket, + description: String, + preflight: () -> Boolean, + sendSingle: (RoutedPacket) -> Boolean, + completion: (Boolean) -> Unit + ) { + val completionDelivered = AtomicBoolean(false) + fun complete(admitted: Boolean) { + if (completionDelivered.compareAndSet(false, true)) { + completion(admitted) + } + } + + val transferId = transferIdFor(routed) + val packets = packetsForTransport(routed) + if (packets == null) { + complete(false) + return + } + val total = packets.size + val job = scope.launch(start = CoroutineStart.LAZY) { + var sent = 0 + try { + if (transferId != null) { + TransferProgressManager.start(transferId, total) + } + for (packet in packets) { + if (!isActive || !preflight()) return@launch + val fragment = routed.copy( + packet = packet, + transferId = transferId, + preparedPackets = null + ) + if (!sendSingle(fragment)) return@launch + sent += 1 + if (transferId != null) { + TransferProgressManager.progress(transferId, sent, total) + } + if (sent < total) { + delay(interFragmentDelayMs) + } + } + if (transferId != null) { + TransferProgressManager.complete(transferId, total) + } + complete(true) + } catch (e: Exception) { + Log.e(logTag, "Confirmed fragment send failed for $description: ${e.message}", e) + } finally { + complete(false) + } + } + if (transferId != null) { + transferJobs[transferId] = job + job.invokeOnCompletion { + transferJobs.remove(transferId, job) + complete(false) + } + } else { + job.invokeOnCompletion { complete(false) } + } + job.start() + } + fun cancelTransfer(transferId: String): Boolean { val job = transferJobs.remove(transferId) ?: return false job.cancel() diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index 28cd3d02..c0187389 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -6,6 +6,7 @@ import com.bitchat.android.crypto.EncryptionService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatFilePacket import com.bitchat.android.model.AuthenticatedPeerState +import com.bitchat.android.model.NdrFeatureGate import com.bitchat.android.model.PeerCapabilities import com.bitchat.android.model.IdentityAnnouncement import com.bitchat.android.model.NoisePayload @@ -25,6 +26,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean /** * Shared mesh coordinator that wires all mesh-layer components and provides common APIs @@ -437,6 +439,34 @@ class MeshCore( override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) { delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs) } + + override fun onNdrEventReceived( + peerID: String, + payload: ByteArray, + timestampMs: Long, + authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession + ) { + if (NdrFeatureGate.isEnabled() && + sessionProvesAuthenticatedCapability( + peerID, + PeerCapabilities.NOSTR_DOUBLE_RATCHET, + authenticatedSession + ) + ) { + val transportTarget = + transport.currentNdrTransportTarget(peerID) ?: return + delegate?.didReceiveNdrEvent( + NdrMeshRoute( + transportId = transport.id, + peerID = peerID, + authenticatedSession = authenticatedSession, + transportTarget = transportTarget + ), + payload, + timestampMs + ) + } + } } packetProcessor.delegate = object : PacketProcessorDelegate { @@ -798,10 +828,114 @@ class MeshCore( sendNoisePayloadToPeer(payload, peerID) } - private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String) { + fun currentNdrRoute(peerID: String, transportId: String? = null): NdrMeshRoute? { + if (!NdrFeatureGate.isEnabled() || + (transportId != null && transportId != transport.id) + ) return null + val authenticatedSession = authenticatedSessionProvingCapability( + peerID, + PeerCapabilities.NOSTR_DOUBLE_RATCHET + ) ?: return null + val transportTarget = transport.currentNdrTransportTarget(peerID) ?: return null + return NdrMeshRoute( + transportId = transport.id, + peerID = peerID, + authenticatedSession = authenticatedSession, + transportTarget = transportTarget + ) + } + + fun sendNdrEvent( + route: NdrMeshRoute, + eventPayload: String, + isStillAuthorized: () -> Boolean, + completion: (admitted: Boolean) -> Unit + ) { + if (!NdrFeatureGate.isEnabled() || + route.transportId != transport.id || + eventPayload.isBlank() + ) { + completion(false) + return + } + val completionDelivered = AtomicBoolean(false) + fun complete(admitted: Boolean) { + if (completionDelivered.compareAndSet(false, true)) { + runCatching { completion(admitted) } + } + } scope.launch { + var handedToTransport = false try { - val encrypted = encryptionService.encrypt(payload.encode(), recipientPeerID) + val preflight = { + currentNdrRoute(route.peerID, route.transportId) == route && + isStillAuthorized() + } + if (!preflight()) return@launch + val encrypted = encryptionService.encryptForSession( + NoisePayload( + type = NoisePayloadType.NDR_EVENT, + data = eventPayload.toByteArray(Charsets.UTF_8) + ).encode(), + route.peerID, + route.authenticatedSession + ) + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), + recipientID = MeshPacketUtils.hexStringToByteArray(route.peerID), + timestamp = System.currentTimeMillis().toULong(), + payload = encrypted, + signature = null, + ttl = maxTtl + ) + val signedPacket = signPacketBeforeBroadcast(packet) + handedToTransport = true + transport.sendPacketToNdrTargetConfirmed( + peerID = route.peerID, + target = route.transportTarget, + routed = RoutedPacket(signedPacket), + preflight = preflight, + completion = ::complete + ) + } catch (e: Exception) { + Log.e("MeshCore", "Failed to send NDR event to ${route.peerID}: ${e.message}") + } finally { + if (!handedToTransport) complete(false) + } + } + } + + private fun sendNoisePayloadToPeer( + payload: NoisePayload, + recipientPeerID: String, + expectedSession: com.bitchat.android.noise.AuthenticatedNoiseSession? = null, + preflight: () -> Boolean = { true }, + directAdmissionPeerID: String? = null, + completion: ((admitted: Boolean) -> Unit)? = null + ) { + val completionDelivered = AtomicBoolean(false) + fun complete(admitted: Boolean) { + if (completionDelivered.compareAndSet(false, true)) { + runCatching { completion?.invoke(admitted) } + } + } + val job = scope.launch { + var admitted = false + try { + if (!preflight()) { + return@launch + } + val encrypted = if (expectedSession == null) { + encryptionService.encrypt(payload.encode(), recipientPeerID) + } else { + encryptionService.encryptForSession( + payload.encode(), + recipientPeerID, + expectedSession + ) + } val packet = BitchatPacket( version = 1u, type = MessageType.NOISE_ENCRYPTED.value, @@ -812,11 +946,22 @@ class MeshCore( signature = null, ttl = maxTtl ) - dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet))) + val signedPacket = signPacketBeforeBroadcast(packet) + admitted = if (directAdmissionPeerID != null) { + transport.sendPacketToPeer(directAdmissionPeerID, signedPacket) + } else { + dispatchGlobal(RoutedPacket(signedPacket)) + true + } } catch (e: Exception) { Log.e("MeshCore", "Failed to send Noise payload to $recipientPeerID: ${e.message}") + } finally { + complete(admitted) } } + job.invokeOnCompletion { + complete(false) + } } fun sendBroadcastAnnounce() { @@ -979,6 +1124,33 @@ class MeshCore( fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID) + fun peerSupportsAuthenticatedCapability( + peerID: String, + capability: PeerCapabilities + ): Boolean = authenticatedSessionProvingCapability(peerID, capability) != null + + private fun authenticatedSessionProvingCapability( + peerID: String, + capability: PeerCapabilities + ): com.bitchat.android.noise.AuthenticatedNoiseSession? { + val authenticatedSession = encryptionService.getAuthenticatedSession(peerID) ?: return null + return authenticatedSession.takeIf { + sessionProvesAuthenticatedCapability(peerID, capability, it) + } + } + + private fun sessionProvesAuthenticatedCapability( + peerID: String, + capability: PeerCapabilities, + authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession + ): Boolean { + val proven = authenticatedPeerState.status( + peerID, + authenticatedSession + ) as? AuthenticatedPeerStateStatus.Proven ?: return false + return proven.state.capabilities.contains(capability) + } + fun updatePeerInfo( peerID: String, nickname: String, diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt b/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt index b54b1152..d5bea74b 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshDelegate.kt @@ -13,6 +13,7 @@ interface MeshDelegate { fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {} fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {} + fun didReceiveNdrEvent(route: NdrMeshRoute, payload: ByteArray, timestampMs: Long) {} /** Current Noise generation either proved peer state or exhausted its 5-second watchdog. */ fun didResolvePrivateMediaPolicy(peerID: String) {} fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt index 7b357149..24808864 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt @@ -1,6 +1,25 @@ package com.bitchat.android.mesh import com.bitchat.android.model.BitchatFilePacket +import com.bitchat.android.noise.AuthenticatedNoiseSession + +data class NdrTransportTarget( + val endpointId: String, + val generationToken: Any +) + +/** + * One exact authenticated Noise generation on one transport. + * + * NDR OOB responses must never be routed through a reusable peer alias: + * replacing the Noise session invalidates this token. + */ +data class NdrMeshRoute( + val transportId: String, + val peerID: String, + val authenticatedSession: AuthenticatedNoiseSession, + val transportTarget: NdrTransportTarget +) /** * Transport-agnostic mesh service API for UI and routing layers. @@ -19,6 +38,15 @@ interface MeshService { fun sendFavoriteNotification(peerID: String, isFavorite: Boolean) {} fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) + fun currentNdrRoute(peerID: String, transportId: String? = null): NdrMeshRoute? = null + fun sendNdrEvent( + route: NdrMeshRoute, + payload: String, + isStillAuthorized: () -> Boolean, + completion: (admitted: Boolean) -> Unit + ) { + completion(false) + } fun sendFileBroadcast(file: BitchatFilePacket) fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) @@ -41,6 +69,10 @@ interface MeshService { fun initiateNoiseHandshake(peerID: String) fun getPeerFingerprint(peerID: String): String? fun getPeerInfo(peerID: String): PeerInfo? + fun peerSupportsAuthenticatedCapability( + peerID: String, + capability: com.bitchat.android.model.PeerCapabilities + ): Boolean fun updatePeerInfo( peerID: String, nickname: String, diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt b/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt index 354fcd7e..c8df37a0 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt @@ -16,6 +16,18 @@ interface MeshTransport { fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean + fun currentNdrTransportTarget(peerID: String): NdrTransportTarget? = null + + fun sendPacketToNdrTargetConfirmed( + peerID: String, + target: NdrTransportTarget, + routed: RoutedPacket, + preflight: () -> Boolean, + completion: (Boolean) -> Unit + ) { + completion(false) + } + /** * Send through an exact transport generation rather than a reusable peer alias. * Transports that cannot prove the link identity must decline the operation. diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index 9434fc6e..57e89507 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -5,6 +5,7 @@ import com.bitchat.android.favorites.FavoriteControlMessage import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType import com.bitchat.android.model.AuthenticatedPeerState +import com.bitchat.android.model.NdrFeatureGate import com.bitchat.android.model.RoutedPacket import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType @@ -199,6 +200,16 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro com.bitchat.android.model.NoisePayloadType.VERIFY_RESPONSE -> { delegate?.onVerifyResponseReceived(peerID, noisePayload.data, packet.timestamp.toLong()) } + com.bitchat.android.model.NoisePayloadType.NDR_EVENT -> { + if (NdrFeatureGate.isEnabled()) { + delegate?.onNdrEventReceived( + peerID, + noisePayload.data, + packet.timestamp.toLong(), + decryption.authenticatedSession + ) + } + } } } catch (e: Exception) { @@ -738,4 +749,10 @@ interface MessageHandlerDelegate { fun onReadReceiptReceived(messageID: String, peerID: String) fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long) fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) + fun onNdrEventReceived( + peerID: String, + payload: ByteArray, + timestampMs: Long, + authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession + ) } diff --git a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt index 3efe758d..763d5b49 100644 --- a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt @@ -5,6 +5,7 @@ import android.util.Log import com.bitchat.android.favorites.FavoriteControlMessage import com.bitchat.android.model.BitchatFilePacket import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.NdrFeatureGate import com.bitchat.android.noise.NoiseSession import com.bitchat.android.wifiaware.WifiAwareController import kotlinx.coroutines.CoroutineScope @@ -32,6 +33,7 @@ class UnifiedMeshService( companion object { private const val TAG = "UnifiedMeshService" + private const val BLE_NDR_TRANSPORT_ID = "BLE" } private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) @@ -150,6 +152,39 @@ class UnifiedMeshService( } } + override fun currentNdrRoute(peerID: String, transportId: String?): NdrMeshRoute? { + if (!NdrFeatureGate.isEnabled()) return null + return when (transportId) { + null -> bluetooth.currentNdrRoute(peerID) + ?: wifiService()?.currentNdrRoute(peerID) + BLE_NDR_TRANSPORT_ID -> + bluetooth.currentNdrRoute(peerID, transportId) + else -> wifiService()?.currentNdrRoute(peerID, transportId) + } + } + + override fun sendNdrEvent( + route: NdrMeshRoute, + payload: String, + isStillAuthorized: () -> Boolean, + completion: (admitted: Boolean) -> Unit + ) { + if (!NdrFeatureGate.isEnabled()) { + completion(false) + return + } + if (route.transportId == BLE_NDR_TRANSPORT_ID) { + bluetooth.sendNdrEvent(route, payload, isStillAuthorized, completion) + return + } + val wifi = wifiService() + if (wifi == null) { + completion(false) + return + } + wifi.sendNdrEvent(route, payload, isStillAuthorized, completion) + } + override fun sendFileBroadcast(file: BitchatFilePacket) { when { isBleEnabled() -> bluetooth.sendFileBroadcast(file) @@ -301,6 +336,36 @@ class UnifiedMeshService( } } + override fun peerSupportsAuthenticatedCapability( + peerID: String, + capability: com.bitchat.android.model.PeerCapabilities + ): Boolean = + bleSupportsAuthenticatedCapability(peerID, capability) || + wifiSupportsAuthenticatedCapability(peerID, capability) + + private fun bleSupportsAuthenticatedCapability( + peerID: String, + capability: com.bitchat.android.model.PeerCapabilities + ): Boolean { + return try { + isBleReady(peerID) && bluetooth.peerSupportsAuthenticatedCapability(peerID, capability) + } catch (_: Exception) { + false + } + } + + private fun wifiSupportsAuthenticatedCapability( + peerID: String, + capability: com.bitchat.android.model.PeerCapabilities + ): Boolean { + return try { + isWifiReady(peerID) && + wifiService()?.peerSupportsAuthenticatedCapability(peerID, capability) == true + } catch (_: Exception) { + false + } + } + override fun updatePeerInfo( peerID: String, nickname: String, @@ -413,6 +478,10 @@ class UnifiedMeshService( delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs) } + override fun didReceiveNdrEvent(route: NdrMeshRoute, payload: ByteArray, timestampMs: Long) { + delegate?.didReceiveNdrEvent(route, payload, timestampMs) + } + override fun didResolvePrivateMediaPolicy(peerID: String) { delegate?.didResolvePrivateMediaPolicy(peerID) } diff --git a/app/src/main/java/com/bitchat/android/model/NdrFeatureGate.kt b/app/src/main/java/com/bitchat/android/model/NdrFeatureGate.kt new file mode 100644 index 00000000..3373dbee --- /dev/null +++ b/app/src/main/java/com/bitchat/android/model/NdrFeatureGate.kt @@ -0,0 +1,22 @@ +package com.bitchat.android.model + +import com.bitchat.android.BuildConfig + +/** + * Coordinated rollout gate for Nostr double-ratchet transport. + * + * Production builds stay fail-closed until the pairwise NDR implementations + * are reviewed and ready to be enabled together on Apple and Android. + */ +object NdrFeatureGate { + @Volatile + private var debugTestOverride = false + + fun isEnabled(): Boolean = + BuildConfig.NDR_ROLLOUT_ENABLED || (BuildConfig.DEBUG && debugTestOverride) + + internal fun setEnabledForTests(enabled: Boolean) { + check(BuildConfig.DEBUG) { "The NDR test override is unavailable in release builds" } + debugTestOverride = enabled + } +} diff --git a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt index aacdafd4..97d03d1f 100644 --- a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt +++ b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt @@ -26,7 +26,9 @@ enum class NoisePayloadType(val value: UByte) { VERIFY_RESPONSE(0x11u), // Verification response FILE_TRANSFER(0x20u), /** Authenticated capabilities + Ed25519 binding for the current Noise generation. */ - PEER_STATE(0x21u); + PEER_STATE(0x21u), + /** UTF-8 Nostr event/URL used only for authenticated double-ratchet bootstrap. */ + NDR_EVENT(0x22u); companion object { diff --git a/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt b/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt index d53f3d20..bb93aec4 100644 --- a/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt +++ b/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt @@ -32,8 +32,19 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable { /** Noise-encrypted private BitchatFilePacket using payload type 0x20. */ val PRIVATE_MEDIA = PeerCapabilities(1L shl 8) + /** Authenticated out-of-band bootstrap for the Nostr double ratchet. */ + val NOSTR_DOUBLE_RATCHET = PeerCapabilities(1L shl 11) + /** Capabilities implemented by this Android build. */ - val LOCAL_SUPPORTED = PRIVATE_MEDIA + val LOCAL_SUPPORTED: PeerCapabilities + get() { + val ndrCapability = if (NdrFeatureGate.isEnabled()) { + NOSTR_DOUBLE_RATCHET.rawValue + } else { + 0L + } + return PeerCapabilities(PRIVATE_MEDIA.rawValue or ndrCapability) + } /** * Decode the low 64 bits and ignore any future extension bytes, which diff --git a/app/src/main/java/com/bitchat/android/nostr/AccountResetCoordinator.kt b/app/src/main/java/com/bitchat/android/nostr/AccountResetCoordinator.kt new file mode 100644 index 00000000..2f9dadab --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/AccountResetCoordinator.kt @@ -0,0 +1,116 @@ +package com.bitchat.android.nostr + +import android.app.Application +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.services.MessageRouter +import com.bitchat.android.services.MessageRouterResetToken + +internal class AccountResetLease internal constructor( + internal val id: AccountResetLeaseId, + internal val transport: NostrTransport?, + internal val transportToken: NostrTransportResetToken?, + internal val router: MessageRouter?, + internal val routerToken: MessageRouterResetToken?, + internal val relay: NostrRelayManager, + internal val relayToken: RelayAccountResetToken +) { + internal var relayDiscarded = false + internal var completionAttempted = false +} + +/** + * Owns the cross-component account barrier used by panic and process exit. + * + * All production account resets enter here. This makes the individual reset + * tokens one composite lease and prevents an older panic from installing or + * starting a replacement mesh after a newer panic or quit has begun. + */ +internal object AccountResetCoordinator { + private val gate = AccountResetGate() + + fun begin( + application: Application, + terminal: Boolean = false + ): AccountResetLease? { + var result: AccountResetLease? = null + gate.begin(terminal = terminal) { id -> + val transport = NostrTransport.tryGetInstance() + val router = MessageRouter.tryGetInstance() + val transportToken = transport?.discardForAccountReset() + val routerToken = router?.discardForAccountReset() + + // Invalidate callbacks before removing relay subscriptions. The + // runtime call also cancels account-bound jobs when initialized. + NostrInboundAccountLifecycle.invalidate() + NostrBackgroundRuntime.invalidateAccount() + + val relay = NostrRelayManager.getInstance(application) + val relayToken = relay.beginAccountReset() + result = AccountResetLease( + id = id, + transport = transport, + transportToken = transportToken, + router = router, + routerToken = routerToken, + relay = relay, + relayToken = relayToken + ) + } ?: return null + return result + } + + /** + * Remove relay-owned state after the NDR runtime has quiesced. + */ + fun discardRelay(lease: AccountResetLease): Boolean { + var discarded = false + val owned = gate.runIfCurrent(lease.id) { + discarded = lease.relay.discardForAccountReset(lease.relayToken) + if (discarded) { + lease.relayDiscarded = true + } + } + return owned && discarded + } + + /** + * Install and start the replacement identity while this lease still owns + * the reset. + */ + fun complete( + lease: AccountResetLease, + installReplacement: () -> MeshService, + startReplacement: (MeshService) -> Unit + ): Boolean { + var reopened = false + val owned = gate.runIfCurrent(lease.id) { + if (!lease.relayDiscarded || lease.completionAttempted) { + return@runIfCurrent + } + lease.completionAttempted = true + val replacement = installReplacement() + lease.transport?.senderPeerID = replacement.myPeerID + lease.router?.installReplacementMeshForAccountReset(replacement) + + val relayReopened = + lease.relay.completeAccountReset(lease.relayToken) + val transportReopened = + relayReopened && + (lease.transportToken?.let { token -> + lease.transport?.completeAccountReset(token) == true + } ?: true) + val routerReopened = + transportReopened && + (lease.routerToken?.let { token -> + lease.router?.completeAccountReset(token) == true + } ?: true) + + if (routerReopened && transportReopened && relayReopened) { + NostrBackgroundRuntime.resetSubscriptions() + startReplacement(replacement) + reopened = true + } + } + return owned && reopened + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/AccountResetGate.kt b/app/src/main/java/com/bitchat/android/nostr/AccountResetGate.kt new file mode 100644 index 00000000..7726086e --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/AccountResetGate.kt @@ -0,0 +1,50 @@ +package com.bitchat.android.nostr + +@JvmInline +internal value class AccountResetLeaseId(val epoch: Long) + +/** + * Serializes the short ownership-sensitive portions of account replacement. + * + * Destructive clearing may happen outside this gate after [begin]. Reopening + * transports and installing the replacement identity must happen through + * [runIfCurrent], so an older reset cannot mutate state after a newer one starts. + */ +internal class AccountResetGate { + private val lock = Any() + private var epoch = 0L + private var terminal = false + + fun begin(): AccountResetLeaseId = + checkNotNull(begin(terminal = false)) + + fun begin( + terminal: Boolean, + mutation: (AccountResetLeaseId) -> Unit = {} + ): AccountResetLeaseId? { + check(!Thread.holdsLock(lock)) { + "An account reset cannot begin from its own owned mutation" + } + return synchronized(lock) { + if (this.terminal) { + return@synchronized null + } + epoch += 1 + this.terminal = terminal + AccountResetLeaseId(epoch).also(mutation) + } + } + + fun runIfCurrent( + lease: AccountResetLeaseId, + mutation: () -> Unit + ): Boolean = + synchronized(lock) { + if (lease.epoch != epoch) { + false + } else { + mutation() + true + } + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt index 88d41466..402899f4 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt @@ -3,7 +3,7 @@ package com.bitchat.android.nostr import android.app.Application import android.util.Log import com.bitchat.android.model.BitchatMessage -import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import java.util.Date @@ -16,7 +16,6 @@ import java.util.Date class GeohashMessageHandler( private val application: Application, private val repo: GeohashRepository, - private val scope: CoroutineScope, private val dataManager: com.bitchat.android.ui.DataManager, private val addChannelMessage: (String, BitchatMessage) -> Unit ) { @@ -27,6 +26,7 @@ class GeohashMessageHandler( private val seen = HashSet() private val max = 2000 + @Synchronized private fun dedupe(id: String): Boolean { if (seen.contains(id)) return true seen.add(id) @@ -38,13 +38,28 @@ class GeohashMessageHandler( return false } - fun onEvent(event: NostrEvent, subscribedGeohash: String) { - scope.launch { + @Synchronized + internal fun clearAccountState() { + processedIds.clear() + seen.clear() + } + + internal fun onEvent( + event: NostrEvent, + subscribedGeohash: String, + accountEpoch: NostrAccountEpoch + ) { + val accountContext = + NostrInboundAccountLifecycle.contextFor(accountEpoch) + ?: return + accountContext.receiveScope.launch { try { + if (!NostrInboundAccountLifecycle.isCurrent(accountEpoch)) { + return@launch + } if (event.kind != NostrKind.EPHEMERAL_EVENT && event.kind != NostrKind.GEOHASH_PRESENCE) return@launch val tagGeo = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }?.getOrNull(1) if (tagGeo == null || !tagGeo.equals(subscribedGeohash, true)) return@launch - if (dedupe(event.id)) return@launch // PoW validation (if enabled) - apply to chat messages primarily if (event.kind == NostrKind.EPHEMERAL_EVENT) { @@ -60,47 +75,72 @@ class GeohashMessageHandler( // Blocked users check (use injected DataManager which has loaded state) if (dataManager.isGeohashUserBlocked(pubkey)) return@launch - // Update participant count (last seen) on BOTH Presence (20001) and Chat (20000) events - if (event.kind == NostrKind.GEOHASH_PRESENCE || event.kind == NostrKind.EPHEMERAL_EVENT) { - repo.updateParticipant(subscribedGeohash, pubkey, Date(event.createdAt * 1000L)) - } - - event.tags.find { it.size >= 2 && it[0] == "n" }?.let { repo.cacheNickname(pubkey, it[1]) } - event.tags.find { it.size >= 2 && it[0] == "t" && it[1] == "teleport" }?.let { repo.markTeleported(pubkey) } - // Register a geohash DM alias for this participant so MessageRouter can route DMs via Nostr - try { - com.bitchat.android.nostr.GeohashAliasRegistry.put("nostr_${pubkey.take(16)}", pubkey) - } catch (_: Exception) { } - - // Stop here for presence events - they don't produce chat messages - if (event.kind == NostrKind.GEOHASH_PRESENCE) return@launch - - // Skip our own events for message emission - val my = NostrIdentityBridge.deriveIdentity(subscribedGeohash, application) - if (my.publicKeyHex.equals(pubkey, true)) return@launch - val isTeleportPresence = event.tags.any { it.size >= 2 && it[0] == "t" && it[1] == "teleport" } && event.content.trim().isEmpty() - if (isTeleportPresence) return@launch - - val senderName = repo.displayNameForNostrPubkeyUI(pubkey) - val hasNonce = try { NostrProofOfWork.hasNonce(event) } catch (_: Exception) { false } - val msg = BitchatMessage( - id = event.id, - sender = senderName, - content = event.content, - timestamp = Date(event.createdAt * 1000L), - isRelay = false, - originalSender = repo.displayNameForNostrPubkey(pubkey), - senderPeerID = "nostr:${pubkey.take(8)}", - senderNostrPubkey = pubkey, - mentions = null, - channel = "#$subscribedGeohash", - powDifficulty = try { - if (hasNonce) NostrProofOfWork.calculateDifficulty(event.id).takeIf { it > 0 } else null - } catch (_: Exception) { null } - ) - addChannelMessage("geo:$subscribedGeohash", msg) + NostrInboundAccountLifecycle.runIfCurrent(accountEpoch) { + if (dedupe(event.id)) return@runIfCurrent + repo.updateParticipant( + subscribedGeohash, + pubkey, + Date(event.createdAt * 1000L) + ) + event.tags.find { + it.size >= 2 && it[0] == "n" + }?.let { + repo.cacheNickname(pubkey, it[1]) + } + event.tags.find { + it.size >= 2 && it[0] == "t" && it[1] == "teleport" + }?.let { + repo.markTeleported(pubkey) + } + GeohashAliasRegistry.put( + "nostr_${pubkey.take(16)}", + pubkey + ) + val emitMessage = + event.kind != NostrKind.GEOHASH_PRESENCE && + !isTeleportPresence && + !NostrIdentityBridge.deriveIdentity( + subscribedGeohash, + application + ).publicKeyHex.equals(pubkey, true) + if (emitMessage) { + val hasNonce = try { + NostrProofOfWork.hasNonce(event) + } catch (_: Exception) { + false + } + val msg = BitchatMessage( + id = event.id, + sender = repo.displayNameForNostrPubkeyUI(pubkey), + content = event.content, + timestamp = Date(event.createdAt * 1000L), + isRelay = false, + originalSender = repo.displayNameForNostrPubkey(pubkey), + senderPeerID = "nostr:${pubkey.take(8)}", + senderNostrPubkey = pubkey, + mentions = null, + channel = "#$subscribedGeohash", + powDifficulty = try { + if (hasNonce) { + NostrProofOfWork.calculateDifficulty(event.id) + .takeIf { it > 0 } + } else { + null + } + } catch (_: Exception) { + null + } + ) + addChannelMessage( + "geo:$subscribedGeohash", + msg + ) + } + } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.e(TAG, "onEvent error: ${e.message}") } diff --git a/app/src/main/java/com/bitchat/android/nostr/NdrAccountEpochGuard.kt b/app/src/main/java/com/bitchat/android/nostr/NdrAccountEpochGuard.kt new file mode 100644 index 00000000..b6cffe15 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NdrAccountEpochGuard.kt @@ -0,0 +1,53 @@ +package com.bitchat.android.nostr + +internal data class NdrAccountEpoch( + val generation: Long, + val accountPubkeyHex: String +) + +/** + * Serializes account-bound receive mutations against account invalidation. + * + * Invalidation waits for a mutation already inside [runIfCurrent], then + * advances the generation before the wipe starts. Old-account jobs can + * therefore neither overlap nor repopulate the fresh post-wipe epoch. + */ +internal class NdrAccountEpochGuard { + private val lock = Any() + private var generation = 0L + private var accountPubkeyHex: String? = null + + fun begin(accountPubkeyHex: String): NdrAccountEpoch = synchronized(lock) { + val normalizedPubkeyHex = accountPubkeyHex.lowercase() + generation += 1 + this.accountPubkeyHex = normalizedPubkeyHex + NdrAccountEpoch(generation, normalizedPubkeyHex) + } + + fun invalidate() = synchronized(lock) { + generation += 1 + accountPubkeyHex = null + } + + fun isCurrent(epoch: NdrAccountEpoch): Boolean = synchronized(lock) { + isCurrentLocked(epoch) + } + + fun runIfCurrent(epoch: NdrAccountEpoch, mutation: () -> Unit): Boolean = + synchronized(lock) { + if (!isCurrentLocked(epoch)) { + false + } else { + mutation() + true + } + } + + private fun isCurrentLocked(epoch: NdrAccountEpoch): Boolean = + generation == epoch.generation && + accountPubkeyHex == epoch.accountPubkeyHex +} + +/** Account-wide names used by both legacy gift-wrap and NDR receive paths. */ +internal typealias NostrAccountEpoch = NdrAccountEpoch +internal typealias NostrAccountEpochGuard = NdrAccountEpochGuard diff --git a/app/src/main/java/com/bitchat/android/nostr/NdrApplicationMessageDecoder.kt b/app/src/main/java/com/bitchat/android/nostr/NdrApplicationMessageDecoder.kt new file mode 100644 index 00000000..5fb41a12 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NdrApplicationMessageDecoder.kt @@ -0,0 +1,89 @@ +package com.bitchat.android.nostr + +internal data class NdrApplicationMessage( + val content: String, + val timestampMs: Long, + val expiresAtSeconds: Long? +) { + fun isExpiredAt(nowSeconds: Long): Boolean = + expiresAtSeconds?.let { it <= nowSeconds } == true +} + +internal object NdrApplicationMessageDecoder { + private const val PROTOCOL_TAG = "ndr-protocol" + private const val PROTOCOL_VALUE = "pairwise-rumor" + private const val VERSION_TAG = "ndr-version" + private const val VERSION_VALUE = "1" + private const val MILLISECOND_TIMESTAMP_TAG = "ms" + private const val EXPIRATION_TAG = "expiration" + private val UNSIGNED_DECIMAL = Regex("^[0-9]+$") + + fun decode(message: NdrDecryptedMessage): NdrApplicationMessage? = + runCatching { decodeStrict(message) }.getOrNull() + + private fun decodeStrict(message: NdrDecryptedMessage): NdrApplicationMessage? { + val plaintext = message.content.trim() + if (!NdrInputPolicy.isWithinEncodedEventLimit(plaintext) || + !NdrInputPolicy.isPubkeyHex(message.senderPubkeyHex) || + !NdrInputPolicy.isEventIdHex(message.eventId) + ) return null + + val event = NostrEvent.fromJsonString(plaintext) ?: return null + if (event.kind != NostrKind.DIRECT_MESSAGE) return null + if (event.sig != null) return null + if (!NdrInputPolicy.isPubkeyHex(event.pubkey)) return null + if (!event.pubkey.equals(message.senderPubkeyHex, ignoreCase = true)) return null + if (event.createdAt <= 0 || event.id.isBlank()) return null + if (!event.id.equals(event.computeEventIdHex(), ignoreCase = true)) return null + if (!message.eventId.equals(event.id, ignoreCase = true)) return null + if (!NdrInputPolicy.hasBoundedTags(event)) return null + if (!event.hasExactlyOneTag(PROTOCOL_TAG, PROTOCOL_VALUE)) return null + if (!event.hasExactlyOneTag(VERSION_TAG, VERSION_VALUE)) return null + val timestampMs = event.requiredMillisecondTimestamp() ?: return null + val expiresAtSeconds = event.optionalExpirationSeconds() ?: run { + if (event.tags.any { it.firstOrNull() == EXPIRATION_TAG }) return null + null + } + val actionExpiresAtSeconds = message.expiresAtSeconds?.let { + if (it > Long.MAX_VALUE.toULong()) return null + it.toLong() + } + if (actionExpiresAtSeconds != expiresAtSeconds) return null + + return NdrApplicationMessage( + content = event.content, + timestampMs = timestampMs, + expiresAtSeconds = expiresAtSeconds + ) + } + + private fun NostrEvent.hasExactlyOneTag(name: String, value: String): Boolean { + val matches = tags.filter { it.firstOrNull() == name } + return matches.size == 1 && + matches.single().size == 2 && + matches.single()[1] == value + } + + private fun NostrEvent.optionalExpirationSeconds(): Long? { + val matches = tags.filter { it.firstOrNull() == EXPIRATION_TAG } + if (matches.isEmpty()) return null + if (matches.size != 1) return null + val tag = matches.single() + if (tag.size != 2 || !UNSIGNED_DECIMAL.matches(tag[1])) return null + return tag[1] + .toULongOrNull() + ?.takeIf { it <= Long.MAX_VALUE.toULong() } + ?.toLong() + } + + private fun NostrEvent.requiredMillisecondTimestamp(): Long? { + val matches = tags.filter { it.firstOrNull() == MILLISECOND_TIMESTAMP_TAG } + if (matches.size != 1) return null + val tag = matches.single() + if (tag.size != 2 || !UNSIGNED_DECIMAL.matches(tag[1])) return null + return tag[1] + .toULongOrNull() + ?.takeIf { it <= Long.MAX_VALUE.toULong() } + ?.toLong() + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NdrBootstrapDecider.kt b/app/src/main/java/com/bitchat/android/nostr/NdrBootstrapDecider.kt new file mode 100644 index 00000000..95ccde58 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NdrBootstrapDecider.kt @@ -0,0 +1,38 @@ +package com.bitchat.android.nostr + +enum class NdrBootstrapAction { + NONE, + START_NOISE_HANDSHAKE, + SEND_OOB_INVITE +} + +object NdrBootstrapDecider { + private const val INVITE_RETRY_MS = 15_000L + private const val HANDSHAKE_RETRY_MS = 5_000L + + fun decide( + hasActiveDoubleRatchet: Boolean, + hasEstablishedNoiseSession: Boolean, + nowMs: Long, + lastInviteAttemptMs: Long, + lastHandshakeAttemptMs: Long + ): NdrBootstrapAction { + if (hasActiveDoubleRatchet) { + return NdrBootstrapAction.NONE + } + + if (!hasEstablishedNoiseSession) { + return if (nowMs - lastHandshakeAttemptMs >= HANDSHAKE_RETRY_MS) { + NdrBootstrapAction.START_NOISE_HANDSHAKE + } else { + NdrBootstrapAction.NONE + } + } + + return if (nowMs - lastInviteAttemptMs >= INVITE_RETRY_MS) { + NdrBootstrapAction.SEND_OOB_INVITE + } else { + NdrBootstrapAction.NONE + } + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NdrBootstrapTriggerCoordinator.kt b/app/src/main/java/com/bitchat/android/nostr/NdrBootstrapTriggerCoordinator.kt new file mode 100644 index 00000000..11ab03e2 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NdrBootstrapTriggerCoordinator.kt @@ -0,0 +1,30 @@ +package com.bitchat.android.nostr + +/** + * Bridges lifecycle events that can make an NDR bootstrap newly eligible. + * + * Authenticated capability resolution happens after the peer-list callback that + * carries the announcement, while a favorite can become mutual without any peer + * update. Both events therefore need an explicit bootstrap trigger. + */ +internal class NdrBootstrapTriggerCoordinator( + private val connectedPeerIDs: () -> List, + private val noiseKeyHexForPeer: (String) -> String?, + private val requestBootstrap: (String) -> Unit +) { + fun onAuthenticatedPolicyResolved(peerID: String) { + requestBootstrap(peerID) + } + + fun onFavoriteChanged(noiseKeyHex: String) { + val changedKey = noiseKeyHex.trim() + if (changedKey.isEmpty()) return + + connectedPeerIDs() + .distinct() + .filter { peerID -> + noiseKeyHexForPeer(peerID)?.equals(changedKey, ignoreCase = true) == true + } + .forEach(requestBootstrap) + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NdrEstablishedSessionMarkerStore.kt b/app/src/main/java/com/bitchat/android/nostr/NdrEstablishedSessionMarkerStore.kt new file mode 100644 index 00000000..a0dadc36 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NdrEstablishedSessionMarkerStore.kt @@ -0,0 +1,127 @@ +package com.bitchat.android.nostr + +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.nio.channels.FileChannel +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.NoSuchFileException +import java.nio.file.StandardOpenOption +import java.nio.file.attribute.BasicFileAttributes + +interface NdrEstablishedSessionMarkerStore { + fun contains(accountPubkeyHex: String): Boolean + fun mark(accountPubkeyHex: String) + fun clearEstablishedSessions() + fun isPanicWipeRequired(): Boolean + fun markPanicWipeRequired() + fun clearPanicWipeRequired() +} + +/** + * A downgrade marker intentionally stored outside the ratchet database tree. + * + * If the database is later missing while this marker remains, the host must + * fail closed instead of silently creating a fresh no-session runtime. + */ +internal class FileNdrEstablishedSessionMarkerStore( + private val directory: File +) : NdrEstablishedSessionMarkerStore { + override fun contains(accountPubkeyHex: String): Boolean = + markerExists(markerFile(accountPubkeyHex)) + + override fun mark(accountPubkeyHex: String) { + check(NdrInputPolicy.isPubkeyHex(accountPubkeyHex)) + publishMarker(markerFile(accountPubkeyHex), "pairwise-v1\n") + } + + override fun clearEstablishedSessions() { + if (!directoryExists()) return + val entries = directory.listFiles() + ?: throw IOException("Failed to inspect NDR marker directory") + entries.filter { it.name.endsWith(".established") } + .forEach { marker -> + if (!markerExists(marker) || + !marker.delete() || + markerExists(marker) + ) { + throw IOException("Failed to clear NDR downgrade marker") + } + } + syncDirectory() + } + + override fun isPanicWipeRequired(): Boolean = + markerExists(panicWipeMarker()) + + override fun markPanicWipeRequired() { + publishMarker(panicWipeMarker(), "panic-wipe-required-v1\n") + } + + override fun clearPanicWipeRequired() { + val marker = panicWipeMarker() + if (markerExists(marker) && (!marker.delete() || markerExists(marker))) { + throw IOException("Failed to clear NDR panic marker") + } + if (directoryExists()) { + syncDirectory() + } + } + + private fun publishMarker(marker: File, content: String) { + if (!directoryExists() && !directory.mkdirs()) { + throw IOException("Failed to create NDR marker directory") + } + if (markerExists(marker)) return + val temporary = File(directory, ".${marker.name}.tmp") + FileOutputStream(temporary).use { output -> + output.write(content.toByteArray(Charsets.UTF_8)) + output.fd.sync() + } + if (!temporary.renameTo(marker) || !markerExists(marker)) { + temporary.delete() + throw IOException("Failed to publish NDR marker") + } + syncDirectory() + } + + private fun directoryExists(): Boolean = + readAttributesOrNull(directory)?.let { attributes -> + if (!attributes.isDirectory) { + throw IOException("NDR marker path is not a directory") + } + true + } ?: false + + private fun markerExists(marker: File): Boolean = + readAttributesOrNull(marker)?.let { attributes -> + if (!attributes.isRegularFile) { + throw IOException("NDR marker path is not a regular file") + } + true + } ?: false + + private fun readAttributesOrNull(file: File): BasicFileAttributes? = + try { + Files.readAttributes( + file.toPath(), + BasicFileAttributes::class.java, + LinkOption.NOFOLLOW_LINKS + ) + } catch (_: NoSuchFileException) { + null + } + + private fun syncDirectory() { + FileChannel.open(directory.toPath(), StandardOpenOption.READ).use { channel -> + channel.force(true) + } + } + + private fun markerFile(accountPubkeyHex: String): File = + File(directory, "${accountPubkeyHex.lowercase()}.established") + + private fun panicWipeMarker(): File = + File(directory, "panic-wipe-required") +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NdrInviteRetryCoordinator.kt b/app/src/main/java/com/bitchat/android/nostr/NdrInviteRetryCoordinator.kt new file mode 100644 index 00000000..4bb40376 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NdrInviteRetryCoordinator.kt @@ -0,0 +1,133 @@ +package com.bitchat.android.nostr + +import com.bitchat.android.mesh.NdrMeshRoute +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.resume +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine + +internal data class NdrInviteRetryToken( + val peerID: String, + val peerPubkeyHex: String, + val inviteEventId: String, + val route: NdrMeshRoute +) + +internal data class NdrInviteRetryRequest( + val token: NdrInviteRetryToken, + val eventJson: String +) + +/** + * Retries admission of one invite on one exact authenticated transport generation. + * + * A repeated trigger for the same token cannot reset its finite retry budget. Every delayed + * attempt is revalidated by the caller so a replaced Noise generation, changed invite, + * favorite revocation/rebind, or completed pairwise session makes the request stale. + */ +internal class NdrInviteRetryCoordinator( + private val scope: CoroutineScope, + private val retryDelaysMs: List = DEFAULT_RETRY_DELAYS_MS, + private val isStillValid: (NdrInviteRetryRequest) -> Boolean, + private val send: ( + request: NdrInviteRetryRequest, + completion: (admitted: Boolean) -> Unit + ) -> Unit, + private val onAdmitted: (NdrInviteRetryRequest) -> Unit +) { + private data class ActiveRetry( + val request: NdrInviteRetryRequest, + val job: Job + ) + + private val lock = Any() + private val activeRetries = mutableMapOf() + + fun start(request: NdrInviteRetryRequest) { + val peerID = request.token.peerID + val job = scope.launch(start = CoroutineStart.LAZY) { + runAttempts(request) + } + val shouldStart = synchronized(lock) { + val current = activeRetries[peerID] + if (current?.request?.token == request.token) { + false + } else { + current?.job?.cancel() + activeRetries[peerID] = ActiveRetry(request, job) + true + } + } + if (shouldStart) { + job.start() + } else { + job.cancel() + } + } + + fun cancel(peerID: String) { + synchronized(lock) { + activeRetries.remove(peerID) + }?.job?.cancel() + } + + fun retainPeers(peerIDs: Set) { + val retired = synchronized(lock) { + val stalePeerIDs = activeRetries.keys - peerIDs + stalePeerIDs.mapNotNull(activeRetries::remove) + } + retired.forEach { it.job.cancel() } + } + + fun cancelAll() { + val retired = synchronized(lock) { + activeRetries.values.toList().also { activeRetries.clear() } + } + retired.forEach { it.job.cancel() } + } + + private suspend fun runAttempts(request: NdrInviteRetryRequest) { + for (attemptIndex in 0..retryDelaysMs.size) { + if (attemptIndex > 0) { + delay(retryDelaysMs[attemptIndex - 1]) + } + if (!isCurrent(request) || !isStillValid(request)) return + + val admitted = awaitAdmission(request) + if (!isCurrent(request)) return + if (admitted) { + onAdmitted(request) + return + } + } + } + + private suspend fun awaitAdmission(request: NdrInviteRetryRequest): Boolean = + suspendCancellableCoroutine { continuation -> + val delivered = AtomicBoolean(false) + try { + send(request) { admitted -> + if (delivered.compareAndSet(false, true) && continuation.isActive) { + continuation.resume(admitted) + } + } + } catch (_: Exception) { + if (delivered.compareAndSet(false, true) && continuation.isActive) { + continuation.resume(false) + } + } + } + + private fun isCurrent(request: NdrInviteRetryRequest): Boolean = + synchronized(lock) { + activeRetries[request.token.peerID]?.request === request + } + + companion object { + internal val DEFAULT_RETRY_DELAYS_MS = listOf(250L, 500L, 1_000L, 2_000L) + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NdrNostrService.kt b/app/src/main/java/com/bitchat/android/nostr/NdrNostrService.kt new file mode 100644 index 00000000..2b4c30a6 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NdrNostrService.kt @@ -0,0 +1,1376 @@ +package com.bitchat.android.nostr + +import android.content.Context +import android.util.Log +import com.bitchat.android.model.NdrFeatureGate +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.math.min + +internal class BitchatNdrRelayAdapter( + private val relayManager: NostrRelayManager, + private val accountRelayUrls: List = NostrRelayManager.defaultRelays() +) : NdrRelayManager { + override fun subscribe( + filter: NostrFilter, + id: String, + handler: (NostrEvent) -> Boolean + ) { + check( + relayManager.subscribeAfterSuccessfulProcessing( + filter = filter, + id = id, + targetRelayUrls = accountRelayUrls, + handler = handler + ) + ) { + "NDR relay subscription was rejected during account reset" + } + } + + override fun unsubscribe(id: String) { + relayManager.unsubscribe(id) + } + + override fun sendEventConfirmed( + event: NostrEvent, + completion: (accepted: Boolean) -> Unit + ) { + relayManager.sendEventConfirmed( + event = event, + relayUrls = accountRelayUrls, + completion = completion + ) + } + + override fun cancelConfirmedEvent(eventId: String) { + relayManager.cancelConfirmedEvent(eventId) + } + + override fun setOnConnectionAvailable(handler: () -> Unit) { + relayManager.setNdrConnectionAvailableHandler(handler) + } +} + +class NdrNostrService( + private val relayManager: NdrRelayManager, + private val runtimeFactory: NdrPairwiseRuntimeFactory, + private val storageDirectoryProvider: () -> String, + private val storageResetter: () -> Unit = { + val storageDirectory = java.io.File(storageDirectoryProvider()) + if (storageDirectory.exists() && !storageDirectory.deleteRecursively()) { + throw java.io.IOException("Failed to delete ${storageDirectory.absolutePath}") + } + }, + private val establishedSessionMarkers: NdrEstablishedSessionMarkerStore = + FileNdrEstablishedSessionMarkerStore( + requireNotNull(java.io.File(storageDirectoryProvider()).parentFile) + .resolve("ndr-established-sessions") + ), + private val panicStorageQuarantine: NdrPanicStorageQuarantine = + FileNdrPanicStorageQuarantine(java.io.File(storageDirectoryProvider())), + private val pairwiseStateExists: (String) -> Boolean = Companion::pairwiseStateExists, + private val invitePeerResolver: (String) -> String? = Companion::resolvePairwiseInvitePubkeyHex, + private val retryScheduler: NdrRetryScheduler = Companion.DEFAULT_RETRY_SCHEDULER, + private val nowSecondsProvider: () -> ULong = { + (System.currentTimeMillis() / 1_000L).coerceAtLeast(0L).toULong() + } +) { + + companion object { + private const val TAG = "NdrNostrService" + private const val NDR_MESSAGE_KIND = 1060 + private const val PUBLISH_RETRY_INITIAL_DELAY_MS = 1_000L + private const val PUBLISH_RETRY_MAX_DELAY_MS = 30_000L + private const val OOB_RETRY_INITIAL_DELAY_MS = 250L + private const val OOB_RETRY_MAX_DELAY_MS = 4_000L + private const val OOB_RETRY_MAX_ATTEMPTS = 5 + private const val PAIRWISE_STATE_PREFIX = "ndr-pairwise-state-v1-" + private const val PAIRWISE_STATE_SUFFIX = ".json" + + private val RETRY_EXECUTOR = Executors.newSingleThreadScheduledExecutor { runnable -> + Thread(runnable, "ndr-publish-retry").apply { isDaemon = true } + } + private val DEFAULT_RETRY_SCHEDULER = NdrRetryScheduler { delayMs, task -> + val future = RETRY_EXECUTOR.schedule(task, delayMs, TimeUnit.MILLISECONDS) + NdrRetryCancellation { future.cancel(false) } + } + + @Volatile + private var INSTANCE: NdrNostrService? = null + + fun getInstance(context: Context): NdrNostrService { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: create(context.applicationContext).also { INSTANCE = it } + } + } + + private fun create(context: Context): NdrNostrService { + val storageDirectory = context.filesDir.resolve("ndr") + val establishedMarkerDirectory = + context.filesDir.resolve("ndr-established-sessions") + val relayManager = + BitchatNdrRelayAdapter(NostrRelayManager.getInstance(context)) + + val runtimeFactory = object : NdrPairwiseRuntimeFactory { + override fun newWithStoragePath( + ourPubkeyHex: String, + ourIdentityPrivkeyHex: String, + storagePath: String + ): NdrPairwiseRuntime { + return UniffiNdrPairwiseRuntime( + uniffi.ndr_ffi.PairwiseManager.newWithStoragePath( + ourPubkeyHex, + ourIdentityPrivkeyHex, + storagePath + ) + ) + } + } + + return NdrNostrService( + relayManager = relayManager, + runtimeFactory = runtimeFactory, + storageDirectoryProvider = { + storageDirectory.apply { mkdirs() }.absolutePath + }, + storageResetter = { + if (storageDirectory.exists() && !storageDirectory.deleteRecursively()) { + throw java.io.IOException("Failed to delete ${storageDirectory.absolutePath}") + } + }, + establishedSessionMarkers = + FileNdrEstablishedSessionMarkerStore(establishedMarkerDirectory) + ) + } + + private fun resolvePairwiseInvitePubkeyHex(payload: String): String? { + return try { + val invite = if (payload.startsWith("{")) { + uniffi.ndr_ffi.PairwiseInvite.fromEventJson(payload) + } else { + uniffi.ndr_ffi.PairwiseInvite.fromUrl(payload) + } + invite.use { + it.`getPeerPubkeyHex`() + .lowercase() + .takeIf(NdrInputPolicy::isPubkeyHex) + } + } catch (_: Throwable) { + null + } + } + + private fun pairwiseStateExists(storagePath: String): Boolean { + val directory = java.io.File(storagePath) + return directory.isDirectory && + directory.listFiles()?.any { file -> + file.isFile && + file.name.startsWith(PAIRWISE_STATE_PREFIX) && + file.name.endsWith(PAIRWISE_STATE_SUFFIX) + } == true + } + } + + @Volatile + var onDecryptedMessage: (( + message: NdrDecryptedMessage, + completion: (NdrDeliveryResult) -> Unit + ) -> Unit)? = null + @Synchronized set(value) { + field = value + if (value != null && NdrFeatureGate.isEnabled()) { + drainAndApplyPubSubEventsLocked() + } + } + + @Volatile + var onOutOfBandPayload: (( + payload: NdrOutOfBandPayload, + completion: (admitted: Boolean) -> Unit + ) -> Unit)? = null + @Synchronized set(value) { + field = value + if (value != null && NdrFeatureGate.isEnabled()) { + drainAndApplyPubSubEventsLocked() + } + } + + @Volatile + private var pairwiseRuntime: NdrPairwiseRuntime? = null + + @Volatile + private var configuredForPubkeyHex: String? = null + + @Volatile + private var configurationFailurePubkeyHex: String? = null + + @Volatile + private var panicResetBlocked = runCatching { + establishedSessionMarkers.isPanicWipeRequired() || + panicStorageQuarantine.isPending() + }.getOrDefault(true) + + private val activeSubIds = linkedSetOf() + private val inFlightRelayEventsByActionId = linkedMapOf() + private val publishRetryAttempts = linkedMapOf() + private val publishRetryTasks = linkedMapOf() + private val outOfBandRetryAttempts = linkedMapOf() + private val outOfBandRetryTasks = linkedMapOf() + private val dispatchedDeliveryActionIds = linkedSetOf() + private val dispatchedOutOfBandActionIds = linkedSetOf() + private val knownActivePeerPubkeys = linkedSetOf() + private var nextRuntimeEpoch = 0L + private var activeRuntimeEpoch: Long? = null + private var processExitShutdown = false + + init { + relayManager.setOnConnectionAvailable { + onRelayConnectionAvailable() + } + } + + @get:Synchronized + val isConfigured: Boolean + get() = NdrFeatureGate.isEnabled() && pairwiseRuntime != null + + @get:Synchronized + val isPanicWipeRequired: Boolean + get() = panicResetBlocked + + @Synchronized + fun currentInviteEventJson(): String? { + if (!NdrFeatureGate.isEnabled()) return null + return runCatching { pairwiseRuntime?.currentInviteEventJson() }.getOrNull() + } + + @Synchronized + fun configureIfNeeded( + identity: NostrIdentity, + accountGuard: () -> Boolean = { true } + ): Boolean { + if (!accountGuard()) return false + if (!NdrFeatureGate.isEnabled()) { + teardownLocked() + configurationFailurePubkeyHex = null + return false + } + return configureRuntimeIfNeededLocked(identity, drainPendingActions = true) + } + + private fun configureRuntimeIfNeededLocked( + identity: NostrIdentity, + drainPendingActions: Boolean + ): Boolean { + if (processExitShutdown) { + Log.w(TAG, "Refusing to reopen NDR during committed process exit") + return false + } + if (panicResetBlocked) { + Log.e(TAG, "Refusing to configure NDR after an incomplete panic wipe") + return false + } + val pubkeyHex = identity.publicKeyHex.lowercase() + if (configurationFailurePubkeyHex == pubkeyHex) { + Log.e(TAG, "Refusing to reopen failed NDR storage before reset or identity change") + return false + } + if (configurationFailurePubkeyHex != null) { + configurationFailurePubkeyHex = null + } + if (configuredForPubkeyHex == pubkeyHex && pairwiseRuntime != null) { + if (drainPendingActions) { + drainAndApplyPubSubEventsLocked() + } + return true + } + + teardownLocked() + configuredForPubkeyHex = pubkeyHex + + try { + val storagePath = java.io.File( + storageDirectoryProvider(), + "pairwise-v1/$pubkeyHex" + ).absolutePath + if (establishedSessionMarkers.contains(pubkeyHex) && + !pairwiseStateExists(storagePath) + ) { + throw java.io.IOException( + "Established pairwise state is missing for $pubkeyHex" + ) + } + val runtime = runtimeFactory.newWithStoragePath( + ourPubkeyHex = pubkeyHex, + ourIdentityPrivkeyHex = identity.privateKeyHex, + storagePath = storagePath + ) + pairwiseRuntime = runtime + activeRuntimeEpoch = ++nextRuntimeEpoch + if (!persistEstablishedMarkerIfNeededLocked(runtime)) { + return false + } + if (drainPendingActions) { + drainAndApplyPubSubEventsLocked() + } + return true + } catch (_: Throwable) { + Log.e(TAG, "Failed to configure NDR") + teardownLocked() + configurationFailurePubkeyHex = pubkeyHex + return false + } + } + + @Synchronized + fun hasActiveSession(peerPubkeyHex: String): Boolean { + if (!NdrFeatureGate.isEnabled()) return false + val runtime = pairwiseRuntime ?: return false + val peer = peerPubkeyHex.lowercase() + return try { + val sessionInfo = runtime.sessionInfo(peer) ?: return false + knownActivePeerPubkeys.add(peer) + persistEstablishedMarkerIfNeededLocked(runtime) && + sessionInfo.isActive + } catch (_: Throwable) { + false + } + } + + /** + * True for any durable native session record, including the half-ready state between the + * authenticated OOB response and bootstrap kind-1060 delivery. + */ + @Synchronized + fun hasPairwiseSession(peerPubkeyHex: String): Boolean { + if (!NdrFeatureGate.isEnabled()) return false + val runtime = pairwiseRuntime ?: return false + val peer = peerPubkeyHex.lowercase() + return try { + if (runtime.sessionInfo(peer) == null) { + false + } else { + knownActivePeerPubkeys.add(peer) + persistEstablishedMarkerIfNeededLocked(runtime) + } + } catch (_: Throwable) { + false + } + } + + @Synchronized + fun activeSessionStateJson(peerPubkeyHex: String): String? { + if (!NdrFeatureGate.isEnabled()) return null + val runtime = pairwiseRuntime ?: return null + return try { + runtime.sessionInfo(peerPubkeyHex.lowercase())?.let { info -> + """{"send_ready":${info.sendReady},"receive_ready":${info.receiveReady}}""" + } + } catch (_: Throwable) { + null + } + } + + @Synchronized + fun sendIfPossible( + text: String, + peerPubkeyHex: String, + expiresAtSeconds: ULong? = null, + accountGuard: () -> Boolean = { true } + ): NdrSendResult { + if (!accountGuard()) return NdrSendResult.FAILED + if (!NdrFeatureGate.isEnabled()) return NdrSendResult.NO_SESSION + val runtime = pairwiseRuntime ?: return if (configurationFailurePubkeyHex != null) { + NdrSendResult.FAILED + } else { + NdrSendResult.NO_SESSION + } + val peer = peerPubkeyHex.lowercase() + val sessionInfo = try { + runtime.sessionInfo(peer) + } catch (_: Throwable) { + return NdrSendResult.FAILED + } + if (sessionInfo == null) { + return NdrSendResult.NO_SESSION + } + knownActivePeerPubkeys.add(peer) + if (!sessionInfo.sendReady) { + return NdrSendResult.FAILED + } + try { + runtime.sendText(peer, text, expiresAtSeconds) + } catch (_: Throwable) { + Log.d(TAG, "NDR send failed") + runCatching { drainAndApplyPubSubEventsLocked() } + return NdrSendResult.FAILED + } + + // Once sendText returns, the native runtime has durably advanced the + // ratchet and owns the pending publish action. Never ask the caller to + // retry that plaintext, even if host-side marker/drain work now fails. + if (!persistEstablishedMarkerIfNeededLocked(runtime)) { + Log.e(TAG, "NDR message admitted before host marker persistence failed") + return NdrSendResult.SENT + } + runCatching { drainAndApplyPubSubEventsLocked() } + .onFailure { Log.w(TAG, "NDR message admitted but pending-action drain failed") } + return NdrSendResult.SENT + } + + @Synchronized + fun retirePeer(peerPubkeyHex: String): Boolean { + if (!NdrFeatureGate.isEnabled()) return false + return retirePeerLocked(peerPubkeyHex, drainPendingActions = true) + } + + /** + * Open existing native state only to durably retire a rebound peer while rollout is off. + */ + @Synchronized + fun retirePeerForMaintenance( + identity: NostrIdentity, + peerPubkeyHex: String + ): Boolean { + if (!configureRuntimeIfNeededLocked(identity, drainPendingActions = false)) { + return false + } + return retirePeerLocked(peerPubkeyHex, drainPendingActions = false) + } + + private fun retirePeerLocked( + peerPubkeyHex: String, + drainPendingActions: Boolean + ): Boolean { + val peer = peerPubkeyHex.lowercase() + if (!NdrInputPolicy.isPubkeyHex(peer)) return false + val runtime = pairwiseRuntime ?: return false + val existedBefore = try { + runtime.sessionInfo(peer) != null + } catch (_: Throwable) { + return false + } + if (!existedBefore) { + knownActivePeerPubkeys.remove(peer) + return true + } + return try { + val retired = runtime.retirePeer(peer) + val absentAfter = runtime.sessionInfo(peer) == null + if (retired || absentAfter) { + knownActivePeerPubkeys.remove(peer) + if (drainPendingActions) { + drainAndApplyPubSubEventsLocked() + } + true + } else { + false + } + } catch (_: Throwable) { + val absentAfter = runCatching { + runtime.sessionInfo(peer) == null + }.getOrDefault(false) + if (absentAfter) { + knownActivePeerPubkeys.remove(peer) + if (drainPendingActions) { + runCatching { drainAndApplyPubSubEventsLocked() } + } + true + } else { + Log.e(TAG, "Failed to retire rebound NDR peer") + false + } + } + } + + @Synchronized + fun processOutOfBandEventJson( + eventJson: String, + expectedPeerPubkeyHex: String? = null + ): NdrOutOfBandProcessResult { + if (!NdrFeatureGate.isEnabled()) { + return NdrOutOfBandProcessResult(emptyList()) + } + val runtime = pairwiseRuntime ?: return NdrOutOfBandProcessResult(emptyList()) + val trimmedPayload = eventJson.trim() + val expectedPeer = expectedPeerPubkeyHex + ?.lowercase() + ?.takeIf { it.matches(Regex("^[0-9a-f]{64}$")) } + ?: return NdrOutOfBandProcessResult(emptyList()) + if (!NdrInputPolicy.isWithinEncodedEventLimit(trimmedPayload)) { + return NdrOutOfBandProcessResult(emptyList()) + } + val parsedEvent = NostrEvent.fromJsonString(trimmedPayload) + if (parsedEvent != null && !NdrInputPolicy.hasBoundedTags(parsedEvent)) { + return NdrOutOfBandProcessResult(emptyList()) + } + val inboundInvite = runCatching { + parseOutOfBandInvite(trimmedPayload) + }.getOrNull() + var acceptResult: NdrAcceptInviteResult? = null + var processingSucceeded = false + + // Pairwise invites must be authored by the exact peer authenticated by + // the Noise session. Gift-wrap response authors remain ephemeral. + val claimedPeer = inboundInvite?.peerPubkeyHex + if (claimedPeer != null && claimedPeer != expectedPeer) { + Log.w(TAG, "Rejecting OOB event with an authenticated-peer mismatch") + return NdrOutOfBandProcessResult(emptyList()) + } + val canMutateSession = + inboundInvite?.transport == OutOfBandInviteTransport.EVENT_JSON || + inboundInvite?.transport == OutOfBandInviteTransport.URL || + parsedEvent?.kind == NostrKind.GIFT_WRAP + if (!canMutateSession) { + Log.w(TAG, "Rejecting non-handshake OOB payload") + return NdrOutOfBandProcessResult(emptyList()) + } + if (!persistEstablishedMarkerIfNeededLocked(runtime, force = true)) { + return NdrOutOfBandProcessResult(emptyList()) + } + + try { + when { + inboundInvite?.transport == OutOfBandInviteTransport.EVENT_JSON -> { + acceptResult = runtime.acceptInviteFromEventJson(trimmedPayload, expectedPeer) + processingSucceeded = true + } + inboundInvite?.transport == OutOfBandInviteTransport.URL -> { + acceptResult = runtime.acceptInviteFromUrl(trimmedPayload, expectedPeer) + processingSucceeded = true + } + parsedEvent?.kind == NostrKind.GIFT_WRAP -> { + runtime.processOutOfBandResponse(trimmedPayload, expectedPeer) + processingSucceeded = true + } + else -> Unit + } + } catch (_: NdrSessionNotReadyException) { + Log.d(TAG, "OOB session is not ready") + } catch (_: Throwable) { + Log.d(TAG, "Ignoring invalid OOB event") + } + + acceptResult?.let { + if (it.peerPubkeyHex.lowercase() != expectedPeer) { + Log.w(TAG, "Rejecting OOB result with an authenticated-peer mismatch") + return NdrOutOfBandProcessResult(emptyList()) + } + } + if (processingSucceeded && + !persistEstablishedMarkerIfNeededLocked(runtime) + ) { + return NdrOutOfBandProcessResult(emptyList()) + } + val collectOutOfBandPublishes = onOutOfBandPayload == null + val outOfBandPublishes = drainAndApplyPubSubEventsLocked( + collectOutOfBandPublishes = collectOutOfBandPublishes + ) + val sessionLookupPubkeyHex = if (processingSucceeded) { + acceptResult?.peerPubkeyHex?.lowercase() ?: expectedPeer + } else { + null + } + + return NdrOutOfBandProcessResult( + outboundPayloads = outOfBandPublishes, + sessionLookupPubkeyHex = sessionLookupPubkeyHex + ) + } + + @Synchronized + fun processInboundRelayEvent(event: NostrEvent): Boolean { + if (!NdrFeatureGate.isEnabled()) return false + val runtime = pairwiseRuntime ?: return false + val runtimeEpoch = activeRuntimeEpoch ?: return false + return processInboundRelayEventLocked(event, runtime, runtimeEpoch) + } + + @Synchronized + fun replayPendingOutOfBandPayloads() { + if (!NdrFeatureGate.isEnabled() || onOutOfBandPayload == null) return + drainAndApplyPubSubEventsLocked() + } + + @Synchronized + fun onOutOfBandTransportAvailable() { + if (!NdrFeatureGate.isEnabled() || pairwiseRuntime == null) return + outOfBandRetryTasks.values.forEach(NdrRetryCancellation::cancel) + outOfBandRetryTasks.clear() + outOfBandRetryAttempts.clear() + drainAndApplyPubSubEventsLocked() + } + + private fun processInboundRelayEventLocked( + event: NostrEvent, + runtime: NdrPairwiseRuntime, + runtimeEpoch: Long + ): Boolean { + if (!isCurrentRuntimeLocked(runtime, runtimeEpoch)) return false + if (event.kind != NDR_MESSAGE_KIND) return false + if (!NdrInputPolicy.hasBoundedTags(event)) return false + if (event.tags.any { it.firstOrNull() == "p" }) { + Log.w(TAG, "Rejecting recipient-tagged inbound NDR relay event") + return false + } + val eventJson = event.toJsonString() + if (!NdrInputPolicy.isWithinEncodedEventLimit(eventJson)) return false + + try { + runtime.processEvent(eventJson) + if (!persistEstablishedMarkerIfNeededLocked(runtime)) { + return false + } + } catch (_: Throwable) { + Log.d(TAG, "Ignoring invalid NDR relay event") + drainAndApplyPubSubEventsLocked() + return false + } + + drainAndApplyPubSubEventsLocked() + return true + } + + @Synchronized + private fun drainAndApplyPubSubEventsLocked( + collectOutOfBandPublishes: Boolean = false + ): List { + val runtime = pairwiseRuntime ?: return emptyList() + val runtimeEpoch = activeRuntimeEpoch ?: return emptyList() + val outOfBandPublishes = mutableListOf() + val acknowledgedActionIds = mutableListOf() + + val events = try { + runtime.pendingActions(nowSecondsProvider()) + } catch (_: Throwable) { + Log.e(TAG, "Failed to drain NDR events") + return emptyList() + } + val sessionsAwaitingOutOfBandAdmission = events + .asSequence() + .filter { it.kind == "out_of_band" } + .mapNotNull(NdrPubSubEvent::sessionId) + .toSet() + + events.forEach { event -> + if (applyPubSubEventLocked( + runtime = runtime, + runtimeEpoch = runtimeEpoch, + event = event, + sessionsAwaitingOutOfBandAdmission = sessionsAwaitingOutOfBandAdmission, + collectOutOfBandPublish = if (collectOutOfBandPublishes) { + { value -> outOfBandPublishes.add(value) } + } else { + null + } + )) { + acknowledgedActionIds += event.actionId + } + } + if (acknowledgedActionIds.isNotEmpty()) { + acknowledgeEventsLocked(runtime, runtimeEpoch, acknowledgedActionIds) + } + + return outOfBandPublishes + } + + @Synchronized + private fun applyPubSubEventLocked( + runtime: NdrPairwiseRuntime, + runtimeEpoch: Long, + event: NdrPubSubEvent, + sessionsAwaitingOutOfBandAdmission: Set, + collectOutOfBandPublish: ((NdrOutOfBandPayload) -> Unit)? + ): Boolean { + if (event.actionId.isBlank()) { + Log.w(TAG, "Ignoring NDR action without a stable id") + return false + } + return when (event.kind) { + "subscribe" -> { + val subid = event.subid?.takeIf(String::isNotBlank) ?: return false + val filterJson = event.filterJson ?: return false + if (hasRecipientFilter(filterJson)) { + Log.w(TAG, "Rejecting recipient-bearing NDR relay filter") + return false + } + val filter = try { + parseFilterJson(filterJson) + } catch (_: Throwable) { + Log.w(TAG, "Ignoring malformed NDR relay filter") + return false + } + if (!isPairwiseMessageSubscription(filter)) { + Log.w(TAG, "Rejecting non-pairwise NDR relay filter") + return false + } + if (!activeSubIds.add(subid)) { + return true + } + try { + relayManager.subscribe(filter, subid) { inbound -> + synchronized(this) { + processInboundRelayEventLocked( + inbound, + runtime, + runtimeEpoch + ) + } + } + true + } catch (_: Throwable) { + activeSubIds.remove(subid) + Log.w(TAG, "Failed to install NDR relay filter") + false + } + } + + "unsubscribe" -> { + val subid = event.subid ?: return true + if (activeSubIds.remove(subid)) { + try { + relayManager.unsubscribe(subid) + true + } catch (_: Throwable) { + activeSubIds.add(subid) + Log.w(TAG, "Failed to remove NDR relay filter") + false + } + } else { + true + } + } + + "publish" -> { + val sessionId = event.sessionId?.takeIf(String::isNotBlank) + ?: run { + Log.w(TAG, "Rejecting NDR publish without a session id") + return true + } + if (sessionId in sessionsAwaitingOutOfBandAdmission) { + return false + } + val eventJson = event.eventJson ?: return true + val nostrEvent = NostrEvent.fromJsonString(eventJson) ?: return true + if (nostrEvent.kind != NDR_MESSAGE_KIND) { + Log.w(TAG, "Rejecting non-message NDR relay publish") + return true + } + if (nostrEvent.tags.any { it.firstOrNull() == "p" }) { + Log.w(TAG, "Rejecting recipient-tagged NDR relay publish") + return true + } + if (event.actionId in publishRetryTasks || + event.actionId in inFlightRelayEventsByActionId + ) return false + inFlightRelayEventsByActionId[event.actionId] = nostrEvent.id + try { + relayManager.sendEventConfirmed(nostrEvent) { accepted -> + synchronized(this) { + if (!isCurrentRuntimeLocked(runtime, runtimeEpoch)) { + return@synchronized + } + inFlightRelayEventsByActionId.remove(event.actionId) + if (accepted) { + val acknowledged = acknowledgeEventsLocked( + runtime, + runtimeEpoch, + listOf(event.actionId) + ) + if (acknowledged) { + clearPublishRetryLocked(event.actionId) + } else { + schedulePublishRetryLocked( + runtime, + runtimeEpoch, + event.actionId + ) + } + } else { + schedulePublishRetryLocked( + runtime, + runtimeEpoch, + event.actionId + ) + } + } + } + } catch (_: Throwable) { + inFlightRelayEventsByActionId.remove(event.actionId) + schedulePublishRetryLocked(runtime, runtimeEpoch, event.actionId) + Log.w(TAG, "Failed to queue NDR relay publish") + } + false + } + + "out_of_band" -> { + if (event.sessionId.isNullOrBlank()) { + Log.w(TAG, "Rejecting NDR out-of-band action without a session id") + return true + } + val eventJson = event.eventJson ?: return true + val nostrEvent = NostrEvent.fromJsonString(eventJson) ?: return true + if (nostrEvent.kind != NostrKind.GIFT_WRAP) { + Log.w(TAG, "Rejecting invalid NDR out-of-band action") + return true + } + val peerPubkeyHex = event.peerPubkeyHex + ?.lowercase() + ?.takeIf(NdrInputPolicy::isPubkeyHex) + ?: run { + Log.w(TAG, "Rejecting NDR out-of-band action without an exact peer") + return true + } + val payload = NdrOutOfBandPayload( + actionId = event.actionId, + eventJson = eventJson, + peerPubkeyHex = peerPubkeyHex, + runtimeEpoch = runtimeEpoch, + runtime = runtime + ) + if (collectOutOfBandPublish != null) { + collectOutOfBandPublish(payload) + } else { + onOutOfBandPayload?.let { callback -> + dispatchOutOfBandPayloadLocked( + runtime, + runtimeEpoch, + payload, + callback + ) + } + } + false + } + + "delivery" -> { + if (!NdrFeatureGate.isEnabled()) return true + val content = event.content ?: return true + val senderPubkeyHex = event.senderPubkeyHex ?: return true + val innerEventId = event.eventId ?: return true + if (!NdrInputPolicy.isWithinEncodedEventLimit(content) || + !NdrInputPolicy.isPubkeyHex(senderPubkeyHex) || + !NdrInputPolicy.isEventIdHex(innerEventId) + ) return true + val message = NdrDecryptedMessage( + actionId = event.actionId, + content = content, + senderPubkeyHex = senderPubkeyHex.lowercase(), + eventId = innerEventId.lowercase(), + expiresAtSeconds = event.expiresAtSeconds + ) + val callback = onDecryptedMessage + if (callback != null) { + dispatchDecryptedMessageLocked( + runtime, + runtimeEpoch, + message, + callback + ) + } + false + } + + else -> true + } + } + + @Synchronized + private fun dispatchDecryptedMessageLocked( + runtime: NdrPairwiseRuntime, + runtimeEpoch: Long, + message: NdrDecryptedMessage, + callback: ( + message: NdrDecryptedMessage, + completion: (NdrDeliveryResult) -> Unit + ) -> Unit + ) { + if (!dispatchedDeliveryActionIds.add(message.actionId)) return + try { + callback(message) { result -> + synchronized(this) { + if (!isCurrentRuntimeLocked(runtime, runtimeEpoch)) { + return@synchronized + } + dispatchedDeliveryActionIds.remove(message.actionId) + if (result.shouldAcknowledge) { + acknowledgeEventsLocked( + runtime, + runtimeEpoch, + listOf(message.actionId) + ) + } + } + } + } catch (_: Throwable) { + dispatchedDeliveryActionIds.remove(message.actionId) + Log.w(TAG, "NDR delivery callback failed") + } + } + + @Synchronized + fun acknowledgeOutOfBandPayload(payload: NdrOutOfBandPayload): Boolean { + val runtime = payload.runtime ?: return false + val runtimeEpoch = payload.runtimeEpoch ?: return false + if (!NdrFeatureGate.isEnabled()) return false + val acknowledged = + acknowledgeEventsLocked(runtime, runtimeEpoch, listOf(payload.actionId)) + if (acknowledged) { + clearOutOfBandRetryLocked(payload.actionId) + drainAndApplyPubSubEventsLocked() + } + return acknowledged + } + + @Synchronized + private fun dispatchOutOfBandPayloadLocked( + runtime: NdrPairwiseRuntime, + runtimeEpoch: Long, + payload: NdrOutOfBandPayload, + callback: ( + payload: NdrOutOfBandPayload, + completion: (admitted: Boolean) -> Unit + ) -> Unit + ) { + if (!dispatchedOutOfBandActionIds.add(payload.actionId)) return + try { + callback(payload) { admitted -> + synchronized(this) { + if (!isCurrentRuntimeLocked(runtime, runtimeEpoch)) { + return@synchronized + } + dispatchedOutOfBandActionIds.remove(payload.actionId) + if (admitted) { + val acknowledged = acknowledgeEventsLocked( + runtime, + runtimeEpoch, + listOf(payload.actionId) + ) + if (acknowledged) { + clearOutOfBandRetryLocked(payload.actionId) + // A publish from this exact handshake session may + // now cross the relay boundary. + drainAndApplyPubSubEventsLocked() + } else { + scheduleOutOfBandRetryLocked( + runtime, + runtimeEpoch, + payload.actionId + ) + } + } else { + scheduleOutOfBandRetryLocked( + runtime, + runtimeEpoch, + payload.actionId + ) + } + } + } + } catch (_: Throwable) { + dispatchedOutOfBandActionIds.remove(payload.actionId) + scheduleOutOfBandRetryLocked(runtime, runtimeEpoch, payload.actionId) + Log.w(TAG, "NDR out-of-band delivery callback failed") + } + } + + private fun acknowledgeEventsLocked( + runtime: NdrPairwiseRuntime, + runtimeEpoch: Long, + actionIds: List + ): Boolean { + if (!isCurrentRuntimeLocked(runtime, runtimeEpoch)) return false + return try { + runtime.ackActions(actionIds.distinct()) + true + } catch (_: Throwable) { + Log.w(TAG, "Failed to acknowledge NDR actions") + false + } + } + + private fun isCurrentRuntimeLocked( + runtime: NdrPairwiseRuntime, + runtimeEpoch: Long + ): Boolean { + return pairwiseRuntime === runtime && activeRuntimeEpoch == runtimeEpoch + } + + private fun persistEstablishedMarkerIfNeededLocked( + runtime: NdrPairwiseRuntime, + force: Boolean = false + ): Boolean { + val accountPubkeyHex = configuredForPubkeyHex ?: return false + return try { + val hasPairwiseSessionRecord = force || + runtime.knownPeerPubkeys().any { peerPubkeyHex -> + runtime.sessionInfo(peerPubkeyHex) != null + } + if (hasPairwiseSessionRecord) { + establishedSessionMarkers.mark(accountPubkeyHex) + } + true + } catch (_: Throwable) { + Log.e(TAG, "Failed to persist NDR established-session marker") + configurationFailurePubkeyHex = accountPubkeyHex + teardownLocked() + false + } + } + + private fun schedulePublishRetryLocked( + runtime: NdrPairwiseRuntime, + runtimeEpoch: Long, + actionId: String + ) { + if (!isCurrentRuntimeLocked(runtime, runtimeEpoch) || + actionId in publishRetryTasks + ) return + val attempt = publishRetryAttempts[actionId] ?: 0 + val multiplier = 1L shl min(attempt, 5) + val delayMs = (PUBLISH_RETRY_INITIAL_DELAY_MS * multiplier) + .coerceAtMost(PUBLISH_RETRY_MAX_DELAY_MS) + publishRetryAttempts[actionId] = attempt + 1 + publishRetryTasks[actionId] = retryScheduler.schedule(delayMs) { + synchronized(this) { + if (!isCurrentRuntimeLocked(runtime, runtimeEpoch)) { + return@synchronized + } + publishRetryTasks.remove(actionId) + drainAndApplyPubSubEventsLocked() + } + } + } + + private fun clearPublishRetryLocked(actionId: String) { + publishRetryTasks.remove(actionId)?.cancel() + publishRetryAttempts.remove(actionId) + } + + private fun scheduleOutOfBandRetryLocked( + runtime: NdrPairwiseRuntime, + runtimeEpoch: Long, + actionId: String + ) { + if (!isCurrentRuntimeLocked(runtime, runtimeEpoch) || + actionId in outOfBandRetryTasks + ) return + val attempt = outOfBandRetryAttempts[actionId] ?: 0 + if (attempt >= OOB_RETRY_MAX_ATTEMPTS) return + val multiplier = 1L shl min(attempt, 4) + val delayMs = (OOB_RETRY_INITIAL_DELAY_MS * multiplier) + .coerceAtMost(OOB_RETRY_MAX_DELAY_MS) + outOfBandRetryAttempts[actionId] = attempt + 1 + outOfBandRetryTasks[actionId] = retryScheduler.schedule(delayMs) { + synchronized(this) { + if (!isCurrentRuntimeLocked(runtime, runtimeEpoch)) { + return@synchronized + } + outOfBandRetryTasks.remove(actionId) + drainAndApplyPubSubEventsLocked() + } + } + } + + private fun clearOutOfBandRetryLocked(actionId: String) { + outOfBandRetryTasks.remove(actionId)?.cancel() + outOfBandRetryAttempts.remove(actionId) + } + + @Synchronized + private fun onRelayConnectionAvailable() { + if (pairwiseRuntime == null || activeRuntimeEpoch == null) return + publishRetryTasks.values.forEach(NdrRetryCancellation::cancel) + publishRetryTasks.clear() + drainAndApplyPubSubEventsLocked() + } + + @Synchronized + private fun teardownLocked() { + val runtime = pairwiseRuntime + pairwiseRuntime = null + activeRuntimeEpoch = null + publishRetryTasks.values.forEach(NdrRetryCancellation::cancel) + publishRetryTasks.clear() + publishRetryAttempts.clear() + outOfBandRetryTasks.values.forEach(NdrRetryCancellation::cancel) + outOfBandRetryTasks.clear() + outOfBandRetryAttempts.clear() + val confirmedEventIds = inFlightRelayEventsByActionId.values.toSet() + inFlightRelayEventsByActionId.clear() + confirmedEventIds.forEach(relayManager::cancelConfirmedEvent) + activeSubIds.forEach { subId -> + runCatching { relayManager.unsubscribe(subId) } + .onFailure { Log.w(TAG, "Failed to unsubscribe NDR relay filter") } + } + activeSubIds.clear() + dispatchedDeliveryActionIds.clear() + dispatchedOutOfBandActionIds.clear() + knownActivePeerPubkeys.clear() + configuredForPubkeyHex = null + runCatching { runtime?.destroy() } + .onFailure { Log.w(TAG, "Failed to destroy NDR runtime") } + } + + /** Quiesce account-bound runtime work for process exit without wiping durable sessions. */ + @Synchronized + fun shutdownForProcessExit() { + processExitShutdown = true + NostrInboundAccountLifecycle.invalidate() + onDecryptedMessage = null + onOutOfBandPayload = null + teardownLocked() + } + + @Synchronized + fun resetForPanic(): Boolean { + NostrInboundAccountLifecycle.invalidate() + onDecryptedMessage = null + teardownLocked() + panicResetBlocked = true + + val quarantineEstablished = runCatching { + panicStorageQuarantine.begin() + }.onFailure { + Log.w(TAG, "Failed to establish NDR panic quarantine") + }.isSuccess + val retryMarkerPersisted = runCatching { + establishedSessionMarkers.markPanicWipeRequired() + }.onFailure { + Log.w(TAG, "Failed to persist NDR panic retry marker") + }.isSuccess + + val activeStorageCleared = runCatching(storageResetter) + .onFailure { Log.w(TAG, "Failed to delete NDR storage") } + .isSuccess + val quarantineCleared = if (quarantineEstablished) { + runCatching(panicStorageQuarantine::wipeNativeState) + .onFailure { Log.w(TAG, "Failed to wipe quarantined NDR storage") } + .isSuccess + } else { + true + } + val storageCleared = activeStorageCleared && quarantineCleared + val markersCleared = storageCleared && + runCatching(establishedSessionMarkers::clearEstablishedSessions) + .onFailure { Log.w(TAG, "Failed to delete NDR downgrade markers") } + .isSuccess + return (quarantineEstablished || retryMarkerPersisted) && markersCleared + } + + /** + * Clear the retry marker only after host identities and contact pins are wiped. + */ + @Synchronized + fun completePanicReset(): Boolean { + if (!panicResetBlocked) return true + val completed = runCatching { + establishedSessionMarkers.clearPanicWipeRequired() + panicStorageQuarantine.clear() + }.onFailure { + Log.w(TAG, "Failed to clear NDR panic retry state") + }.isSuccess + if (completed) { + panicResetBlocked = false + configurationFailurePubkeyHex = null + } + return completed + } + + private fun isDoubleRatchetInviteEvent(event: NostrEvent): Boolean { + if (event.kind != 30078) { + return false + } + return event.tags.any { tag -> + (tag.size >= 2 && tag[0] == "l" && tag[1] == "double-ratchet/invites") || + (tag.size >= 2 && tag[0] == "d" && tag[1].startsWith("double-ratchet/invites/")) + } + } + + private enum class OutOfBandInviteTransport { + EVENT_JSON, + URL + } + + private data class ParsedOutOfBandInvite( + val peerPubkeyHex: String, + val transport: OutOfBandInviteTransport + ) + + private fun parseOutOfBandInvite(payload: String): ParsedOutOfBandInvite? { + if (payload.isBlank()) return null + + if (payload.startsWith("{")) { + val event = NostrEvent.fromJsonString(payload) ?: return null + if (!isDoubleRatchetInviteEvent(event)) return null + val peerPubkeyHex = invitePeerResolver(payload)?.lowercase() ?: return null + return ParsedOutOfBandInvite( + peerPubkeyHex = peerPubkeyHex, + transport = OutOfBandInviteTransport.EVENT_JSON + ) + } + + val peerPubkeyHex = invitePeerResolver(payload)?.lowercase() ?: return null + return ParsedOutOfBandInvite( + peerPubkeyHex = peerPubkeyHex, + transport = OutOfBandInviteTransport.URL + ) + } + + private fun isPairwiseMessageSubscription(filter: NostrFilter): Boolean { + return filter.kinds == listOf(NDR_MESSAGE_KIND) && + filter.authors.orEmpty().isNotEmpty() && + filter.authors.orEmpty().all(NdrInputPolicy::isPubkeyHex) + } + + private fun hasRecipientFilter(filterJson: String): Boolean = + runCatching { + JsonParser.parseString(filterJson).asJsonObject.has("#p") + }.getOrDefault(true) + + private fun parseFilterJson(filterJson: String): NostrFilter { + val root = JsonParser.parseString(filterJson).asJsonObject + val builder = NostrFilter.Builder() + + root.strings("ids")?.let { if (it.isNotEmpty()) builder.ids(*it.toTypedArray()) } + root.strings("authors")?.let { if (it.isNotEmpty()) builder.authors(*it.toTypedArray()) } + root.ints("kinds")?.let { if (it.isNotEmpty()) builder.kinds(*it.toIntArray()) } + root.get("since")?.takeIf { !it.isJsonNull }?.asLong?.let { builder.since(it * 1000L) } + root.get("until")?.takeIf { !it.isJsonNull }?.asLong?.let { builder.until(it * 1000L) } + root.get("limit")?.takeIf { !it.isJsonNull }?.asInt?.let { builder.limit(it) } + + root.entrySet().forEach { (key, value) -> + if (!key.startsWith("#") || !value.isJsonArray) { + return@forEach + } + val tagValues = value.asJsonArray.mapNotNull { if (it.isJsonNull) null else it.asString } + if (tagValues.isNotEmpty()) { + builder.tag(key.removePrefix("#"), *tagValues.toTypedArray()) + } + } + + return builder.build() + } + + private fun JsonObject.strings(name: String): List? { + return getAsJsonArray(name)?.mapNotNull { if (it.isJsonNull) null else it.asString } + } + + private fun JsonObject.ints(name: String): List? { + return getAsJsonArray(name)?.mapNotNull { if (it.isJsonNull) null else it.asInt } + } +} + +private class UniffiNdrPairwiseRuntime( + private val manager: uniffi.ndr_ffi.PairwiseManager +) : NdrPairwiseRuntime { + override fun currentInviteEventJson(): String = manager.`currentInviteEventJson`() + + override fun currentInviteUrl(root: String): String = manager.`currentInviteUrl`(root) + + override fun acceptInviteFromEventJson( + eventJson: String, + expectedPeerPubkeyHex: String + ): NdrAcceptInviteResult { + val result = try { + manager.`acceptInviteFromEventJson`(eventJson, expectedPeerPubkeyHex) + } catch (t: uniffi.ndr_ffi.NdrException.SessionNotReady) { + throw NdrSessionNotReadyException(t.message, t) + } + val peer = result.peerPubkeyHex.lowercase() + check(peer == expectedPeerPubkeyHex.lowercase()) { + "Pairwise invite identity mismatch" + } + return NdrAcceptInviteResult( + peerPubkeyHex = peer, + createdNewSession = result.createdNewSession + ) + } + + override fun acceptInviteFromUrl( + inviteUrl: String, + expectedPeerPubkeyHex: String + ): NdrAcceptInviteResult { + val result = try { + manager.`acceptInviteFromUrl`(inviteUrl, expectedPeerPubkeyHex) + } catch (t: uniffi.ndr_ffi.NdrException.SessionNotReady) { + throw NdrSessionNotReadyException(t.message, t) + } + val peer = result.peerPubkeyHex.lowercase() + check(peer == expectedPeerPubkeyHex.lowercase()) { + "Pairwise invite identity mismatch" + } + return NdrAcceptInviteResult( + peerPubkeyHex = peer, + createdNewSession = result.createdNewSession + ) + } + + override fun processEvent(eventJson: String) { + manager.`processEvent`(eventJson) + } + + override fun processOutOfBandResponse( + eventJson: String, + expectedPeerPubkeyHex: String + ) { + manager.`processOutOfBandResponse`(eventJson, expectedPeerPubkeyHex) + } + + override fun pendingActions(nowSeconds: ULong): List = + manager.`pendingActionsAt`(nowSeconds).map { + NdrPubSubEvent( + actionId = it.actionId, + kind = it.kind, + sessionId = it.sessionId, + subid = it.subscriptionId, + filterJson = it.filterJson, + eventJson = it.eventJson, + peerPubkeyHex = it.peerPubkeyHex?.lowercase(), + senderPubkeyHex = it.peerPubkeyHex?.lowercase(), + content = it.innerEventJson, + eventId = it.innerEventId?.lowercase(), + expiresAtSeconds = it.expiresAtSeconds + ) + } + + override fun ackActions(actionIds: List) { + manager.`ackActions`(actionIds) + } + + override fun sessionInfo(peerPubkeyHex: String): NdrPairwiseSessionInfo? = + manager.`sessionInfo`(peerPubkeyHex)?.let { + NdrPairwiseSessionInfo( + sendReady = it.sendReady, + receiveReady = it.receiveReady, + trackedSenderPubkeys = it.trackedSenderPubkeys.map(String::lowercase) + ) + } + + override fun knownPeerPubkeys(): List = + manager.`knownPeerPubkeys`().map(String::lowercase) + + override fun retirePeer(peerPubkeyHex: String): Boolean = + manager.`retirePeer`(peerPubkeyHex) + + override fun sendText( + recipientPubkeyHex: String, + text: String, + expiresAtSeconds: ULong? + ): NdrPairwiseSendResult { + val result = manager.`sendText`(recipientPubkeyHex, text, expiresAtSeconds) + return NdrPairwiseSendResult( + innerEventId = result.innerEventId, + outerEventId = result.outerEventId + ) + } + + override fun getOurPubkeyHex(): String = manager.`getOurPubkeyHex`() + + override fun getTotalSessions(): ULong = manager.`getTotalSessions`() + + override fun destroy() = manager.destroy() +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NdrOutOfBandRoutePolicy.kt b/app/src/main/java/com/bitchat/android/nostr/NdrOutOfBandRoutePolicy.kt new file mode 100644 index 00000000..a31b73de --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NdrOutOfBandRoutePolicy.kt @@ -0,0 +1,28 @@ +package com.bitchat.android.nostr + +import com.bitchat.android.mesh.NdrMeshRoute + +internal data class NdrFavoriteRouteBinding( + val isMutual: Boolean, + val peerPubkeyHex: String? +) + +/** + * Rechecks both independent authorizations immediately before an OOB frame is encrypted: + * the exact Noise generation must still be live, and that generation's static key must still + * belong to the mutual favorite bound to the action's pairwise Nostr peer. + */ +internal object NdrOutOfBandRoutePolicy { + fun isAuthorized( + route: NdrMeshRoute, + expectedPeerPubkeyHex: String, + currentRoute: (peerID: String, transportId: String) -> NdrMeshRoute?, + favoriteBinding: (noisePublicKey: ByteArray) -> NdrFavoriteRouteBinding? + ): Boolean { + if (!NdrInputPolicy.isPubkeyHex(expectedPeerPubkeyHex)) return false + if (currentRoute(route.peerID, route.transportId) != route) return false + val binding = favoriteBinding(route.authenticatedSession.remoteStaticKey) ?: return false + return binding.isMutual && + binding.peerPubkeyHex?.equals(expectedPeerPubkeyHex, ignoreCase = true) == true + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NdrPanicStartupRecovery.kt b/app/src/main/java/com/bitchat/android/nostr/NdrPanicStartupRecovery.kt new file mode 100644 index 00000000..d18378f1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NdrPanicStartupRecovery.kt @@ -0,0 +1,75 @@ +package com.bitchat.android.nostr + +import android.content.Context +import android.util.Log +import com.bitchat.android.favorites.FavoritesPersistenceService +import com.bitchat.android.identity.SecureIdentityStateManager + +internal object NdrPanicStartupRecovery { + private const val TAG = "NdrPanicStartup" + + @Volatile + private var networkStartupAllowed = true + + fun recoverBeforeNetwork(context: Context): Boolean { + val filesDirectory = context.filesDir + val markerStore = FileNdrEstablishedSessionMarkerStore( + filesDirectory.resolve("ndr-established-sessions") + ) + val panicStorageQuarantine = FileNdrPanicStorageQuarantine( + filesDirectory.resolve("ndr") + ) + return recoverBeforeNetwork( + markerStore = markerStore, + panicStorageQuarantine = panicStorageQuarantine, + clearIdentity = { + SecureIdentityStateManager(context.applicationContext) + .clearIdentityData() + }, + clearFavorites = { + FavoritesPersistenceService.initialize(context.applicationContext) + FavoritesPersistenceService.shared.clearAllFavoritesAfterNdrReset() + } + ) + } + + internal fun recoverBeforeNetwork( + markerStore: NdrEstablishedSessionMarkerStore, + panicStorageQuarantine: NdrPanicStorageQuarantine, + clearIdentity: () -> Boolean, + clearFavorites: () -> Boolean + ): Boolean { + val retryRequired = runCatching { + val markerRetryRequired = markerStore.isPanicWipeRequired() + val quarantineRetryRequired = panicStorageQuarantine.isPending() + markerRetryRequired || quarantineRetryRequired + }.getOrElse { + networkStartupAllowed = false + return false + } + if (!retryRequired) { + networkStartupAllowed = true + return true + } + + val recovered = runCatching { + panicStorageQuarantine.begin() + panicStorageQuarantine.wipeNativeState() + markerStore.clearEstablishedSessions() + check(clearIdentity()) { "Failed to clear host identity state" } + check(clearFavorites()) { "Failed to clear NDR contact protection state" } + markerStore.clearPanicWipeRequired() + panicStorageQuarantine.clear() + }.onFailure { + Log.e(TAG, "Blocking network startup until panic wipe retry succeeds") + }.isSuccess + networkStartupAllowed = recovered + return recovered + } + + fun isNetworkStartupAllowed(): Boolean = networkStartupAllowed + + fun blockNetworkStartup() { + networkStartupAllowed = false + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NdrPanicStorageQuarantine.kt b/app/src/main/java/com/bitchat/android/nostr/NdrPanicStorageQuarantine.kt new file mode 100644 index 00000000..1c7f6fc0 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NdrPanicStorageQuarantine.kt @@ -0,0 +1,125 @@ +package com.bitchat.android.nostr + +import java.io.File +import java.io.IOException +import java.nio.channels.FileChannel +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.NoSuchFileException +import java.nio.file.StandardOpenOption +import java.nio.file.attribute.BasicFileAttributes + +/** + * A durable panic-wipe latch that is independent of the NDR marker store. + * + * The active NDR directory is renamed out of its normal path before any + * destructive work. The empty quarantine directory remains as retry evidence + * until native state, host identity, and contact protection state are all + * durably gone. + */ +interface NdrPanicStorageQuarantine { + fun isPending(): Boolean + fun begin() + fun wipeNativeState() + fun clear() +} + +internal class FileNdrPanicStorageQuarantine( + private val storageDirectory: File, + private val quarantineDirectory: File = + requireNotNull(storageDirectory.parentFile).resolve("ndr-panic-quarantine-v1") +) : NdrPanicStorageQuarantine { + private val parentDirectory: File = + requireNotNull(storageDirectory.parentFile) + + override fun isPending(): Boolean = + directoryExists(quarantineDirectory, "NDR panic quarantine") + + override fun begin() { + if (!directoryExists(parentDirectory, "NDR storage parent")) { + throw IOException("NDR storage parent is unavailable") + } + if (isPending()) { + syncDirectory(parentDirectory) + return + } + + val latchCreated = if ( + directoryExists(storageDirectory, "Active NDR storage") + ) { + storageDirectory.renameTo(quarantineDirectory) + } else { + quarantineDirectory.mkdir() + } + if (!latchCreated || !isPending()) { + throw IOException("Failed to establish NDR panic quarantine") + } + syncDirectory(parentDirectory) + } + + override fun wipeNativeState() { + if (!isPending()) { + throw IOException("NDR panic quarantine is not established") + } + + val quarantinedEntries = quarantineDirectory.listFiles() + ?: throw IOException("Failed to inspect NDR panic quarantine") + quarantinedEntries.forEach { entry -> + if (!entry.deleteRecursively() || pathExists(entry)) { + throw IOException("Failed to wipe quarantined NDR state") + } + } + syncDirectory(quarantineDirectory) + + if (pathExists(storageDirectory) && + (!storageDirectory.deleteRecursively() || pathExists(storageDirectory)) + ) { + throw IOException("Failed to wipe active NDR state during panic") + } + syncDirectory(parentDirectory) + } + + override fun clear() { + if (!isPending()) return + if (pathExists(storageDirectory)) { + throw IOException("Active NDR state exists while completing panic wipe") + } + val remaining = quarantineDirectory.listFiles() + ?: throw IOException("Failed to inspect NDR panic quarantine") + if (remaining.isNotEmpty()) { + throw IOException("Quarantined NDR state remains") + } + if (!quarantineDirectory.delete() || pathExists(quarantineDirectory)) { + throw IOException("Failed to clear NDR panic quarantine") + } + syncDirectory(parentDirectory) + } + + private fun directoryExists(file: File, description: String): Boolean = + readAttributesOrNull(file)?.let { attributes -> + if (!attributes.isDirectory) { + throw IOException("$description is not a directory") + } + true + } ?: false + + private fun pathExists(file: File): Boolean = + readAttributesOrNull(file) != null + + private fun readAttributesOrNull(file: File): BasicFileAttributes? = + try { + Files.readAttributes( + file.toPath(), + BasicFileAttributes::class.java, + LinkOption.NOFOLLOW_LINKS + ) + } catch (_: NoSuchFileException) { + null + } + + private fun syncDirectory(directory: File) { + FileChannel.open(directory.toPath(), StandardOpenOption.READ).use { channel -> + channel.force(true) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NdrTypes.kt b/app/src/main/java/com/bitchat/android/nostr/NdrTypes.kt new file mode 100644 index 00000000..ac136f6b --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NdrTypes.kt @@ -0,0 +1,155 @@ +package com.bitchat.android.nostr + +data class NdrPubSubEvent( + val kind: String, + val actionId: String = kind, + val subid: String? = null, + val filterJson: String? = null, + val eventJson: String? = null, + val peerPubkeyHex: String? = null, + val sessionId: String? = null, + val senderPubkeyHex: String? = null, + val content: String? = null, + val eventId: String? = null, + val expiresAtSeconds: ULong? = null +) + +data class NdrDecryptedMessage( + val content: String, + val senderPubkeyHex: String, + val eventId: String, + val actionId: String, + val expiresAtSeconds: ULong? = null +) + +enum class NdrDeliveryResult { + CONSUMED, + DUPLICATE, + REJECTED, + RETRY; + + val shouldAcknowledge: Boolean + get() = this != RETRY +} + +enum class NdrSendResult { + SENT, + NO_SESSION, + FAILED +} + +data class NdrOutOfBandPayload( + val actionId: String, + val eventJson: String, + val peerPubkeyHex: String, + internal val runtimeEpoch: Long? = null, + internal val runtime: NdrPairwiseRuntime? = null +) + +internal object NdrInputPolicy { + const val MAX_ENCODED_EVENT_BYTES = 64 * 1024 + private const val MAX_EVENT_TAGS = 64 + private const val MAX_EVENT_TAG_VALUES = 16 + private const val MAX_EVENT_TAG_VALUE_BYTES = 1024 + private val HEX_32 = Regex("^[0-9a-fA-F]{64}$") + + fun isPubkeyHex(value: String): Boolean = HEX_32.matches(value) + + fun isEventIdHex(value: String): Boolean = HEX_32.matches(value) + + fun isWithinEncodedEventLimit(value: String): Boolean = + value.length <= MAX_ENCODED_EVENT_BYTES && + value.toByteArray(Charsets.UTF_8).size <= MAX_ENCODED_EVENT_BYTES + + fun hasBoundedTags(event: NostrEvent): Boolean = runCatching { + event.tags.size <= MAX_EVENT_TAGS && + event.tags.all { tag -> + tag.size <= MAX_EVENT_TAG_VALUES && + tag.all { value -> + value.length <= MAX_EVENT_TAG_VALUE_BYTES && + value.toByteArray(Charsets.UTF_8).size <= MAX_EVENT_TAG_VALUE_BYTES + } + } + }.getOrDefault(false) +} + +data class NdrAcceptInviteResult( + val peerPubkeyHex: String, + val createdNewSession: Boolean +) + +data class NdrOutOfBandProcessResult( + val outboundPayloads: List, + val sessionLookupPubkeyHex: String? = null +) + +class NdrSessionNotReadyException( + message: String?, + cause: Throwable? = null +) : Exception(message, cause) + +interface NdrRelayManager { + fun subscribe(filter: NostrFilter, id: String, handler: (NostrEvent) -> Boolean) + fun unsubscribe(id: String) + fun sendEventConfirmed(event: NostrEvent, completion: (accepted: Boolean) -> Unit) + fun cancelConfirmedEvent(eventId: String) + fun setOnConnectionAvailable(handler: () -> Unit) +} + +fun interface NdrRetryCancellation { + fun cancel() +} + +fun interface NdrRetryScheduler { + fun schedule(delayMs: Long, task: () -> Unit): NdrRetryCancellation +} + +data class NdrPairwiseSessionInfo( + val sendReady: Boolean, + val receiveReady: Boolean, + val trackedSenderPubkeys: List +) { + val isActive: Boolean + get() = sendReady || receiveReady +} + +data class NdrPairwiseSendResult( + val innerEventId: String, + val outerEventId: String +) + +interface NdrPairwiseRuntime { + fun currentInviteEventJson(): String? + fun currentInviteUrl(root: String): String? + fun acceptInviteFromEventJson( + eventJson: String, + expectedPeerPubkeyHex: String + ): NdrAcceptInviteResult + fun acceptInviteFromUrl( + inviteUrl: String, + expectedPeerPubkeyHex: String + ): NdrAcceptInviteResult + fun processEvent(eventJson: String) + fun processOutOfBandResponse(eventJson: String, expectedPeerPubkeyHex: String) + fun pendingActions(nowSeconds: ULong): List + fun ackActions(actionIds: List) + fun sessionInfo(peerPubkeyHex: String): NdrPairwiseSessionInfo? + fun knownPeerPubkeys(): List + fun retirePeer(peerPubkeyHex: String): Boolean + fun sendText( + recipientPubkeyHex: String, + text: String, + expiresAtSeconds: ULong? = null + ): NdrPairwiseSendResult + fun getOurPubkeyHex(): String + fun getTotalSessions(): ULong + fun destroy() +} + +interface NdrPairwiseRuntimeFactory { + fun newWithStoragePath( + ourPubkeyHex: String, + ourIdentityPrivkeyHex: String, + storagePath: String + ): NdrPairwiseRuntime +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundEventProcessor.kt b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundEventProcessor.kt index 442840eb..1536e0b4 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundEventProcessor.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundEventProcessor.kt @@ -48,7 +48,6 @@ internal class NostrBackgroundEventProcessor( private val geohashMessageHandler = GeohashMessageHandler( application = application, repo = geohashRepository, - scope = scope, dataManager = dataManager, addChannelMessage = AppStateStore::addChannelMessage ) @@ -76,19 +75,44 @@ internal class NostrBackgroundEventProcessor( } } - fun onAccountDm(event: NostrEvent, identity: NostrIdentity) { - refreshBlockLists() - directMessageHandler.onGiftWrap(event, "", identity) + fun configureAccount(identity: NostrIdentity): NostrAccountEpoch { + val epoch = directMessageHandler.configureAccount(identity) + refreshBlockLists(epoch) + return epoch } - fun onGeohashMessage(event: NostrEvent, geohash: String) { - refreshBlockLists() - geohashMessageHandler.onEvent(event, geohash) + fun invalidateAccount() { + directMessageHandler.invalidateAccount() + geohashMessageHandler.clearAccountState() + geohashRepository.clearAll() } - fun onGeohashDm(event: NostrEvent, geohash: String, identity: NostrIdentity) { - refreshBlockLists() - directMessageHandler.onGiftWrap(event, geohash, identity) + fun onAccountDm( + event: NostrEvent, + identity: NostrIdentity, + accountEpoch: NostrAccountEpoch + ) { + if (!refreshBlockLists(accountEpoch)) return + directMessageHandler.onGiftWrap(event, "", identity, accountEpoch) + } + + fun onGeohashMessage( + event: NostrEvent, + geohash: String, + accountEpoch: NostrAccountEpoch + ) { + if (!refreshBlockLists(accountEpoch)) return + geohashMessageHandler.onEvent(event, geohash, accountEpoch) + } + + fun onGeohashDm( + event: NostrEvent, + geohash: String, + identity: NostrIdentity, + accountEpoch: NostrAccountEpoch + ) { + if (!refreshBlockLists(accountEpoch)) return + directMessageHandler.onGiftWrap(event, geohash, identity, accountEpoch) } fun conversationGeohash(conversationKey: String): String? = @@ -108,8 +132,9 @@ internal class NostrBackgroundEventProcessor( AppStateStore.updatePrivateMessageStatus(messageId, status) } - private fun refreshBlockLists() { - dataManager.loadBlockedUsers() - dataManager.loadGeohashBlockedUsers() - } + private fun refreshBlockLists(accountEpoch: NostrAccountEpoch): Boolean = + NostrInboundAccountLifecycle.runIfCurrent(accountEpoch) { + dataManager.loadBlockedUsers() + dataManager.loadGeohashBlockedUsers() + } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt index a895f612..c9c934b8 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt @@ -29,6 +29,14 @@ import kotlin.random.asKotlinRandom object NostrBackgroundRuntime { private const val TAG = "NostrBackground" + private data class ActiveAccount(val epoch: NostrAccountEpoch) + + private data class GeohashDmSubscription( + val geohash: String, + val id: String, + val liveLocationToken: Long? + ) + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val random = SecureRandom().asKotlinRandom() private val lock = Any() @@ -37,6 +45,12 @@ object NostrBackgroundRuntime { @Volatile private var activeGeohash: String? = null @Volatile private var activeGeohashLiveToken: Long? = null @Volatile private var conversationGeohash: String? = null + private var activeGeohashSubscriptionsEnabled = false + private var activeAccount: ActiveAccount? = null + private var subscriptionRevision = 0L + private val installedGeohashDmSubscriptions = + mutableMapOf() + private val pendingGeohashDmSubscriptions = mutableSetOf() private lateinit var application: Application private lateinit var subscriptions: NostrSubscriptionManager private lateinit var locationChannels: LocationChannelManager @@ -57,51 +71,88 @@ object NostrBackgroundRuntime { } subscriptions.connect() - subscribeAccountDm() + synchronized(lock) { + configureAccountLocked() + } observeSelectedChannel() startPresenceScheduler() } + /** + * Cancel every account-bound receive before account storage or identity is + * cleared. The relay connection remains process-owned and can be reused + * after the caller completes its account reset. + */ + fun invalidateAccount() { + synchronized(lock) { + if (!initialized) return + invalidateAccountLocked() + } + } + + /** + * Atomically replace process-owned subscriptions for the current identity. + * + * Ordered unsubscribe/subscribe operations and captured account epochs + * avoid the former delay-based CLOSE/REQ race. + */ fun resetSubscriptions() { - if (!initialized) return - subscriptions.unsubscribeAllOwned() - scope.launch { - // Let CLOSE frames be queued before replacing the deterministic IDs. - delay(100) - subscribeAccountDm() - activeGeohash?.let { geohash -> - subscribeSelectedGeohash(geohash, activeGeohashLiveToken) - } + synchronized(lock) { + if (!initialized) return + invalidateAccountLocked() + subscriptions.connect() + configureAccountLocked() } } fun ensureConversationDm(geohash: String) { - if (!initialized || geohash == conversationGeohash) return - val selectedLiveToken = activeGeohashLiveToken - val selectedChannelSubscriptionIsUsable = - geohash == activeGeohash && - (selectedLiveToken == null || - LiveLocationPrivacyGate.accepts(selectedLiveToken)) - if (selectedChannelSubscriptionIsUsable) return - conversationGeohash?.let { subscriptions.unsubscribe("geo-dm-conversation-$it") } - conversationGeohash = geohash - subscribeGeohashDm( - geohash = geohash, - subscriptionId = "geo-dm-conversation-$geohash", - liveLocationToken = null - ) + synchronized(lock) { + if (!initialized) return + val selectedLiveToken = activeGeohashLiveToken + val selectedChannelSubscriptionIsUsable = + activeGeohashSubscriptionsEnabled && + geohash == activeGeohash && + (selectedLiveToken == null || + LiveLocationPrivacyGate.accepts(selectedLiveToken)) + conversationGeohash = + if (selectedChannelSubscriptionIsUsable) null else geohash + rebuildGeohashDmSubscriptionsLocked() + } } - private fun subscribeAccountDm() { + internal fun currentAccountEpoch(): NostrAccountEpoch? = + synchronized(lock) { activeAccount?.epoch } + + private fun configureAccountLocked() { val identity = NostrIdentityBridge.getCurrentNostrIdentity(application) ?: return + val epoch = eventProcessor.configureAccount(identity) + if (!NostrInboundAccountLifecycle.isCurrent(epoch)) return + activeAccount = ActiveAccount(epoch) + subscriptions.subscribeGiftWraps( pubkey = identity.publicKeyHex, sinceMs = System.currentTimeMillis() - 172_800_000L, id = "chat-messages", handler = { event -> - eventProcessor.onAccountDm(event, identity) + eventProcessor.onAccountDm(event, identity, epoch) } ) + activeGeohash + ?.takeIf { activeGeohashSubscriptionsEnabled } + ?.let { subscribeSelectedGeohashLocked(it, activeGeohashLiveToken) } + rebuildGeohashDmSubscriptionsLocked() + } + + private fun invalidateAccountLocked() { + activeAccount = null + conversationGeohash = null + subscriptionRevision += 1 + pendingGeohashDmSubscriptions.clear() + // Invalidate first so an in-flight async derivation cannot install a + // subscription after unsubscribeAllOwned(). + eventProcessor.invalidateAccount() + subscriptions.unsubscribeAllOwned() + installedGeohashDmSubscriptions.clear() } private fun observeSelectedChannel() { @@ -112,74 +163,169 @@ object NostrBackgroundRuntime { val nextToken = locationChannel?.let { locationChannels.liveLocationTokenForSelectedChannel(it.channel) } - val previous = activeGeohash - if (previous == next && activeGeohashLiveToken == nextToken) { - return@collectLatest - } - - previous?.let { - subscriptions.unsubscribe("geohash-$it") - subscriptions.unsubscribe("geo-dm-$it") - } - activeGeohash = next - activeGeohashLiveToken = nextToken - if (conversationGeohash == next) { - subscriptions.unsubscribe("geo-dm-conversation-$next") - conversationGeohash = null - } - next?.let { geohash -> - val isLiveDerived = - locationChannels.isSelectedChannelLiveDerived(locationChannel.channel) - if (!isLiveDerived || nextToken != null) { - subscribeSelectedGeohash(geohash, nextToken) + synchronized(lock) { + val previous = activeGeohash + if (previous == next && activeGeohashLiveToken == nextToken) { + return@synchronized } + + previous?.let { + subscriptions.unsubscribe("geohash-$it") + } + activeGeohash = next + activeGeohashLiveToken = nextToken + activeGeohashSubscriptionsEnabled = + locationChannel == null || + !locationChannels + .isSelectedChannelLiveDerived(locationChannel.channel) || + nextToken != null + if (conversationGeohash == next) { + conversationGeohash = null + } + next + ?.takeIf { activeGeohashSubscriptionsEnabled } + ?.let { geohash -> + subscribeSelectedGeohashLocked(geohash, nextToken) + } + rebuildGeohashDmSubscriptionsLocked() } } } } - private fun subscribeSelectedGeohash( + private fun subscribeSelectedGeohashLocked( geohash: String, liveLocationToken: Long? ) { + val account = activeAccount ?: return subscriptions.subscribeGeohashMessages( geohash = geohash, sinceMs = System.currentTimeMillis() - 3_600_000L, limit = 200, id = "geohash-$geohash", - handler = { event -> eventProcessor.onGeohashMessage(event, geohash) }, + handler = { event -> + eventProcessor.onGeohashMessage(event, geohash, account.epoch) + }, liveLocationToken = liveLocationToken ) - subscribeGeohashDm(geohash, "geo-dm-$geohash", liveLocationToken) } - private fun subscribeGeohashDm( - geohash: String, - subscriptionId: String, - liveLocationToken: Long? - ) { - scope.launch { - val subscribe = { - val identity = NostrIdentityBridge.deriveIdentity(geohash, application) - subscriptions.subscribeGiftWraps( - pubkey = identity.publicKeyHex, - sinceMs = System.currentTimeMillis() - 172_800_000L, - id = subscriptionId, - handler = { event -> - eventProcessor.onGeohashDm(event, geohash, identity) - }, - liveLocationToken = liveLocationToken - ) - GeohashAliasRegistry.put( - "nostr_${identity.publicKeyHex.take(16)}", - identity.publicKeyHex + private fun rebuildGeohashDmSubscriptionsLocked() { + val account = activeAccount + subscriptionRevision += 1 + val revision = subscriptionRevision + pendingGeohashDmSubscriptions.clear() + + val required = if (account == null) { + emptyMap() + } else { + requiredGeohashDmSubscriptionsLocked() + } + installedGeohashDmSubscriptions.keys + .filter { id -> installedGeohashDmSubscriptions[id] != required[id] } + .forEach { id -> + subscriptions.unsubscribe(id) + installedGeohashDmSubscriptions.remove(id) + } + if (account == null) return + + required.values.forEach { request -> + if (installedGeohashDmSubscriptions[request.id] == request) return@forEach + pendingGeohashDmSubscriptions.add(request.id) + scheduleGeohashDmSubscriptionLocked(account, request, revision) + } + } + + private fun requiredGeohashDmSubscriptionsLocked(): + Map { + val required = linkedMapOf() + val selected = activeGeohash + ?.takeIf { activeGeohashSubscriptionsEnabled } + ?.let { geohash -> + GeohashDmSubscription( + geohash = geohash, + id = "geo-dm-$geohash", + liveLocationToken = activeGeohashLiveToken ) } + if (selected != null) { + required[selected.id] = selected + } - if (liveLocationToken == null) { - subscribe() - } else { - LiveLocationPrivacyGate.runIfAllowed(liveLocationToken, subscribe) + val conversation = conversationGeohash + val selectedCoversConversation = + conversation != null && + selected?.geohash == conversation && + (selected.liveLocationToken == null || + LiveLocationPrivacyGate.accepts(selected.liveLocationToken)) + if (conversation != null && !selectedCoversConversation) { + val request = GeohashDmSubscription( + geohash = conversation, + id = "geo-dm-conversation-$conversation", + liveLocationToken = null + ) + required[request.id] = request + } + return required + } + + private fun scheduleGeohashDmSubscriptionLocked( + account: ActiveAccount, + request: GeohashDmSubscription, + revision: Long + ) { + val accountContext = + NostrInboundAccountLifecycle.contextFor(account.epoch) + ?: return + accountContext.receiveScope.launch { + synchronized(lock) { + if (revision != subscriptionRevision || + activeAccount != account || + requiredGeohashDmSubscriptionsLocked()[request.id] != request + ) { + return@synchronized + } + var didSubscribe = false + val installed = + NostrInboundAccountLifecycle.runIfCurrent(account.epoch) { + val subscribe = { + val identity = NostrIdentityBridge.deriveIdentity( + request.geohash, + application + ) + subscriptions.subscribeGiftWraps( + pubkey = identity.publicKeyHex, + sinceMs = System.currentTimeMillis() - 172_800_000L, + id = request.id, + handler = { event -> + eventProcessor.onGeohashDm( + event, + request.geohash, + identity, + account.epoch + ) + }, + liveLocationToken = request.liveLocationToken + ) + didSubscribe = true + GeohashAliasRegistry.put( + "nostr_${identity.publicKeyHex.take(16)}", + identity.publicKeyHex + ) + } + if (request.liveLocationToken == null) { + subscribe() + } else { + LiveLocationPrivacyGate.runIfAllowed( + request.liveLocationToken, + subscribe + ) + } + } + pendingGeohashDmSubscriptions.remove(request.id) + if (installed && didSubscribe) { + installedGeohashDmSubscriptions[request.id] = request + } } } } @@ -216,32 +362,56 @@ object NostrBackgroundRuntime { // Send every target in one wake window; do not spread the batch over seconds. targets.forEach { geohash -> try { - val token = LiveLocationPrivacyGate.captureToken() - ?: return@forEach - if (geohash !in GeohashNostrPrivacyPolicy.livePresenceTargets( - availableChannels = locationChannels.availableChannels.value, - liveLocationEnabled = true - ) - ) return@forEach - + val account = + synchronized(lock) { activeAccount } + ?: return@forEach + var token: Long? = null var identity: NostrIdentity? = null - LiveLocationPrivacyGate.runIfAllowed(token) { - identity = NostrIdentityBridge.deriveIdentity(geohash, application) - } - val preparedIdentity = identity ?: return@forEach - if (!LiveLocationPrivacyGate.accepts(token)) return@forEach + val prepared = + NostrInboundAccountLifecycle.runIfCurrent( + account.epoch + ) mutation@{ + val capturedToken = + LiveLocationPrivacyGate.captureToken() + ?: return@mutation + token = capturedToken + if (geohash !in + GeohashNostrPrivacyPolicy.livePresenceTargets( + availableChannels = + locationChannels.availableChannels.value, + liveLocationEnabled = true + ) + ) return@mutation + + LiveLocationPrivacyGate.runIfAllowed(capturedToken) { + identity = NostrIdentityBridge.deriveIdentity( + geohash, + application + ) + } + } + val liveToken = token + val preparedIdentity = identity + if (!prepared || + liveToken == null || + preparedIdentity == null || + !LiveLocationPrivacyGate.accepts(liveToken) + ) return@forEach val event = NostrProtocol.createGeohashPresenceEvent( geohash, preparedIdentity ) - LiveLocationPrivacyGate.runIfAllowed(token) { - NostrRelayManager.getInstance(application).sendEventToGeohash( - event = event, - geohash = geohash, - includeDefaults = false, - nRelays = 5, - liveLocationToken = token - ) + NostrInboundAccountLifecycle.runIfCurrent(account.epoch) { + LiveLocationPrivacyGate.runIfAllowed(liveToken) { + NostrRelayManager.getInstance(application) + .sendEventToGeohash( + event = event, + geohash = geohash, + includeDefaults = false, + nRelays = 5, + liveLocationToken = liveToken + ) + } } } catch (e: CancellationException) { throw e diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt index c1488037..34022f5f 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt @@ -79,6 +79,8 @@ class NostrClient private constructor(private val context: Context) { fun shutdown() { Log.d(TAG, "Shutting down Nostr client") relayManager.disconnect() + currentIdentity = null + _currentNpub.value = null _isInitialized.value = false } @@ -91,13 +93,22 @@ class NostrClient private constructor(private val context: Context) { onSuccess: (() -> Unit)? = null, onError: ((String) -> Unit)? = null ) { - val identity = currentIdentity + val accountGeneration = relayManager.captureAccountGeneration() + if (!relayManager.isAccountGenerationCurrent(accountGeneration)) { + onError?.invoke("Nostr account is resetting") + return + } + val identity = NostrIdentityBridge.getCurrentNostrIdentity(context) if (identity == null) { onError?.invoke("Nostr client not initialized") return } scope.launch { + if (!relayManager.isAccountGenerationCurrent(accountGeneration)) { + onError?.invoke("Nostr account changed before send") + return@launch + } try { // Decode recipient npub to hex pubkey val (hrp, pubkeyBytes) = Bech32.decode(recipientNpub) @@ -116,9 +127,18 @@ class NostrClient private constructor(private val context: Context) { ) // Track and send all gift wraps - giftWraps.forEach { wrap -> - NostrRelayManager.registerPendingGiftWrap(wrap.id) - relayManager.sendEvent(wrap) + val admitted = giftWraps.all { wrap -> + relayManager.registerPendingGiftWrap( + wrap.id, + accountGeneration + ) && relayManager.sendEvent( + event = wrap, + expectedAccountGeneration = accountGeneration + ) + } + if (!admitted) { + onError?.invoke("Nostr account changed before send") + return@launch } Log.i(TAG, "📤 Sent private message to ${recipientNpub.take(16)}...") @@ -135,7 +155,9 @@ class NostrClient private constructor(private val context: Context) { * Subscribe to private messages for current identity */ fun subscribeToPrivateMessages(handler: (content: String, senderNpub: String, timestamp: Int) -> Unit) { - val identity = currentIdentity + val accountGeneration = relayManager.captureAccountGeneration() + if (!relayManager.isAccountGenerationCurrent(accountGeneration)) return + val identity = NostrIdentityBridge.getCurrentNostrIdentity(context) if (identity == null) { Log.e(TAG, "Cannot subscribe to private messages: client not initialized") return @@ -146,11 +168,18 @@ class NostrClient private constructor(private val context: Context) { since = System.currentTimeMillis() - 172800000L // Last 48 hours (align with NIP-17 randomization) ) - relayManager.subscribe(filter, "private-messages", { giftWrap -> - scope.launch { - handlePrivateMessage(giftWrap, handler) - } - }) + relayManager.subscribe( + filter = filter, + id = "private-messages", + handler = { giftWrap -> + scope.launch { + if (relayManager.isAccountGenerationCurrent(accountGeneration)) { + handlePrivateMessage(giftWrap, handler) + } + } + }, + expectedAccountGeneration = accountGeneration + ) Log.i(TAG, "🔑 Subscribed to private messages for: ${identity.getShortNpub()}") } @@ -165,7 +194,12 @@ class NostrClient private constructor(private val context: Context) { onSuccess: (() -> Unit)? = null, onError: ((String) -> Unit)? = null ) { + val accountGeneration = relayManager.captureAccountGeneration() scope.launch { + if (!relayManager.isAccountGenerationCurrent(accountGeneration)) { + onError?.invoke("Nostr account changed before send") + return@launch + } try { // Derive geohash-specific identity val geohashIdentity = NostrIdentityBridge.deriveIdentity(geohash, context) @@ -178,7 +212,14 @@ class NostrClient private constructor(private val context: Context) { nickname = nickname ) - relayManager.sendEvent(event) + if (!relayManager.sendEvent( + event = event, + expectedAccountGeneration = accountGeneration + ) + ) { + onError?.invoke("Nostr account changed before send") + return@launch + } Log.i(TAG, "📤 Sent geohash message") onSuccess?.invoke() @@ -197,17 +238,26 @@ class NostrClient private constructor(private val context: Context) { geohash: String, handler: (content: String, senderPubkey: String, nickname: String?, timestamp: Int) -> Unit ) { + val accountGeneration = relayManager.captureAccountGeneration() + if (!relayManager.isAccountGenerationCurrent(accountGeneration)) return val filter = NostrFilter.geohashEphemeral( geohash = geohash, since = System.currentTimeMillis() - 3600000L, // Last hour limit = 200 ) - relayManager.subscribe(filter, "geohash-$geohash", { event -> - scope.launch { - handleGeohashMessage(event, handler) - } - }) + relayManager.subscribe( + filter = filter, + id = "geohash-$geohash", + handler = { event -> + scope.launch { + if (relayManager.isAccountGenerationCurrent(accountGeneration)) { + handleGeohashMessage(event, handler) + } + } + }, + expectedAccountGeneration = accountGeneration + ) Log.i(TAG, "🌍 Subscribed to geohash channel") } @@ -223,7 +273,8 @@ class NostrClient private constructor(private val context: Context) { /** * Get current identity information */ - fun getCurrentIdentity(): NostrIdentity? = currentIdentity + fun getCurrentIdentity(): NostrIdentity? = + NostrIdentityBridge.getCurrentNostrIdentity(context) /** * Get relay connection status @@ -248,7 +299,8 @@ class NostrClient private constructor(private val context: Context) { return } - val identity = currentIdentity ?: return + val identity = NostrIdentityBridge.getCurrentNostrIdentity(context) + ?: return try { val decryptResult = NostrProtocol.decryptPrivateMessage(giftWrap, identity) diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt index 4125d5ca..fbcec3a4 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -7,6 +7,7 @@ import com.bitchat.android.favorites.FavoritesPersistenceService import com.bitchat.android.model.BitchatFilePacket import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus +import com.bitchat.android.model.NdrFeatureGate import com.bitchat.android.model.NoisePayload import com.bitchat.android.model.NoisePayloadType import com.bitchat.android.model.PrivateMessagePacket @@ -17,8 +18,10 @@ import com.bitchat.android.services.SeenMessageStore import com.bitchat.android.ui.ChatState import com.bitchat.android.ui.PrivateChatManager import com.bitchat.android.ui.PrivateMessageOrigin +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.Date @@ -33,17 +36,24 @@ class NostrDirectMessageHandler( private val dataManager: com.bitchat.android.ui.DataManager, private val seenStoreProvider: () -> SeenMessageStore = { SeenMessageStore.getInstance(application) + }, + private val legacyNostrInboundAllowed: (String) -> Boolean = { senderPubkey -> + FavoritesPersistenceService.shared + .isLegacyNostrInboundAllowed(senderPubkey) } ) { companion object { private const val TAG = "NostrDirectMessageHandler" } private val seenStore by lazy(seenStoreProvider) + private val ndrService by lazy { NdrNostrService.getInstance(application) } + private val accountLock = Any() // Simple event deduplication private val processedIds = ArrayDeque() private val seen = HashSet() private val max = 2000 + @Synchronized private fun dedupe(id: String): Boolean { if (seen.contains(id)) return true seen.add(id) @@ -55,10 +65,109 @@ class NostrDirectMessageHandler( return false } - fun onGiftWrap(giftWrap: NostrEvent, geohash: String, identity: NostrIdentity) { - scope.launch { + @Synchronized + private fun hasProcessed(id: String): Boolean = id in seen + + private fun markProcessed(id: String) { + dedupe(id) + } + + /** + * Begin a fresh account-wide receive epoch before installing any account or + * derived-geohash subscription. The returned epoch must be captured by the + * subscription handler so a delayed old subscription cannot join a newer + * account merely because its event arrives later. + */ + internal fun configureAccount(identity: NostrIdentity): NostrAccountEpoch = + synchronized(accountLock) { + val accountContext = NostrInboundAccountLifecycle.begin( + accountPubkeyHex = identity.publicKeyHex, + parentJob = scope.coroutineContext[Job] + ) + // A prior account's callback must not consume and discard pending + // deliveries while the replacement runtime is initialized. + ndrService.onDecryptedMessage = null + val configured = ndrService.configureIfNeeded(identity) { + NostrInboundAccountLifecycle.isCurrent(accountContext.epoch) + } + if (!configured || + !NdrFeatureGate.isEnabled() || + !NostrInboundAccountLifecycle.isCurrent(accountContext.epoch) + ) { + return@synchronized accountContext.epoch + } + + val callback = callback@{ + message: NdrDecryptedMessage, + completion: (NdrDeliveryResult) -> Unit -> + if (!NdrFeatureGate.isEnabled() || + !NostrInboundAccountLifecycle.isCurrent(accountContext.epoch) + ) { + completion(NdrDeliveryResult.REJECTED) + return@callback + } + val currentIdentity = + NostrIdentityBridge.getCurrentNostrIdentity(application) + if (currentIdentity == null) { + completion(NdrDeliveryResult.RETRY) + return@callback + } + if (!currentIdentity.publicKeyHex.equals( + accountContext.epoch.accountPubkeyHex, + ignoreCase = true + ) + ) { + completion(NdrDeliveryResult.REJECTED) + return@callback + } + onDoubleRatchetMessage( + message, + currentIdentity, + accountContext.epoch, + accountContext.receiveScope, + completion + ) + } + ndrService.onDecryptedMessage = callback + if (!NostrInboundAccountLifecycle.isCurrent(accountContext.epoch)) { + ndrService.onDecryptedMessage = null + return@synchronized accountContext.epoch + } + accountContext.epoch + } + + internal fun currentAccountEpoch(): NostrAccountEpoch? = + NostrInboundAccountLifecycle.currentEpoch() + + fun invalidateAccount() { + synchronized(accountLock) { + NostrInboundAccountLifecycle.invalidate() + ndrService.onDecryptedMessage = null + synchronized(this) { + processedIds.clear() + seen.clear() + } + } + } + + internal fun onGiftWrap( + giftWrap: NostrEvent, + geohash: String, + identity: NostrIdentity, + accountEpoch: NostrAccountEpoch + ): Job? { + val accountContext = + NostrInboundAccountLifecycle.contextFor(accountEpoch) + ?: return null + return accountContext.receiveScope.launch { try { - if (dedupe(giftWrap.id)) return@launch + if (!isAccountEpochCurrent(accountEpoch)) return@launch + var duplicate = false + if (!runIfAccountMutationCurrent(accountEpoch, null) { + duplicate = dedupe(giftWrap.id) + } + ) return@launch + if (duplicate) return@launch val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt if (messageAge > 173700) return@launch // 48 hours + 15 mins @@ -71,81 +180,241 @@ class NostrDirectMessageHandler( val (content, rawSenderPubkey, rumorTimestamp) = decryptResult val senderPubkey = rawSenderPubkey.lowercase() + if (!isAccountEpochCurrent(accountEpoch)) return@launch + val legacyAllowed = runCatching { + legacyNostrInboundAllowed(senderPubkey) + }.getOrDefault(false) + if (!legacyAllowed) { + Log.w(TAG, "Rejecting legacy DM for an NDR-pinned contact") + return@launch + } + if (!isAccountEpochCurrent(accountEpoch)) return@launch // If sender is blocked for geohash contexts, drop any events from this pubkey // Applies to both geohash DMs (geohash != "") and account DMs (geohash == "") if (dataManager.isGeohashUserBlocked(senderPubkey)) return@launch - if (!content.startsWith("bitchat1:")) return@launch + processEmbeddedBitChatContent( + content = content, + senderPubkey = senderPubkey, + timestamp = Date(rumorTimestamp * 1000L), + geohash = geohash, + recipientIdentity = identity, + accountEpoch = accountEpoch + ) - val base64Content = content.removePrefix("bitchat1:") - val packetData = base64URLDecode(base64Content) ?: return@launch - val packet = BitchatPacket.fromBinaryData(packetData) ?: return@launch - - if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch - - val noisePayload = NoisePayload.decode(packet.payload) ?: return@launch - val messageTimestamp = Date(rumorTimestamp * 1000L) - val convKey = "nostr_${senderPubkey.take(16)}" - repo.putNostrKeyMapping(convKey, senderPubkey) - com.bitchat.android.nostr.GeohashAliasRegistry.put(convKey, senderPubkey) - if (geohash.isNotEmpty()) { - // Remember which geohash this conversation belongs to so we can subscribe on-demand - repo.setConversationGeohash(convKey, geohash) - GeohashConversationRegistry.set(convKey, geohash) - } - - // Ensure sender appears in geohash people list even if they haven't posted publicly yet - if (geohash.isNotEmpty()) { - // Cache a best-effort nickname and mark as participant - val cached = repo.getCachedNickname(senderPubkey) - if (cached == null) { - val base = repo.displayNameForNostrPubkeyUI(senderPubkey).substringBefore("#") - repo.cacheNickname(senderPubkey, base) - } - repo.updateParticipant(geohash, senderPubkey, messageTimestamp) - } - - val senderNickname = repo.displayNameForNostrPubkeyUI(senderPubkey) - val conversationID = ContactDirectory.canonicalConversationId(convKey) - - processNoisePayload(noisePayload, conversationID, senderNickname, messageTimestamp, senderPubkey, identity) - - } catch (e: Exception) { - Log.e(TAG, "onGiftWrap error: ${e.message}") + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + Log.e(TAG, "Failed to process gift wrap") } } } + private fun onDoubleRatchetMessage( + message: NdrDecryptedMessage, + identity: NostrIdentity, + accountEpoch: NostrAccountEpoch, + receiveScope: CoroutineScope, + completion: (NdrDeliveryResult) -> Unit + ) { + receiveScope.launch { + var result = NdrDeliveryResult.RETRY + try { + if (!NdrFeatureGate.isEnabled() || + !isAccountEpochCurrent(accountEpoch) + ) { + result = NdrDeliveryResult.REJECTED + return@launch + } + val dedupeId = message.eventId + if (seenStore.hasProcessedNdr(dedupeId) || hasProcessed(dedupeId)) { + result = NdrDeliveryResult.DUPLICATE + return@launch + } + + // The pairwise FFI returns a v1 unsigned kind-14 rumor. + // Bind that rumor to the ratchet-authenticated peer before + // allowing any inner fields into the application. + val applicationMessage = NdrApplicationMessageDecoder.decode(message) + if (applicationMessage == null) { + result = NdrDeliveryResult.REJECTED + return@launch + } + val senderPubkey = message.senderPubkeyHex.lowercase() + val senderIsCurrent = runCatching { + FavoritesPersistenceService.shared + .isCurrentNdrPeerAuthorized(senderPubkey) + }.getOrDefault(false) + if (!senderIsCurrent) { + result = NdrDeliveryResult.REJECTED + return@launch + } + if (dataManager.isGeohashUserBlocked(senderPubkey)) { + result = NdrDeliveryResult.REJECTED + return@launch + } + if (!NdrFeatureGate.isEnabled() || + !isAccountEpochCurrent(accountEpoch) + ) { + result = NdrDeliveryResult.REJECTED + return@launch + } + if (applicationMessage.isExpiredAt(System.currentTimeMillis() / 1_000L)) { + result = NdrDeliveryResult.REJECTED + return@launch + } + + result = processEmbeddedBitChatContent( + content = applicationMessage.content, + senderPubkey = senderPubkey, + timestamp = Date(applicationMessage.timestampMs), + geohash = "", + recipientIdentity = identity, + accountEpoch = accountEpoch, + ndrEventId = dedupeId, + expiresAtSeconds = applicationMessage.expiresAtSeconds + ) + } catch (_: CancellationException) { + result = NdrDeliveryResult.REJECTED + } catch (_: Exception) { + Log.e(TAG, "Failed to process double-ratchet message") + result = NdrDeliveryResult.RETRY + } finally { + if (!isAccountEpochCurrent(accountEpoch)) { + result = NdrDeliveryResult.REJECTED + } else if (result.shouldAcknowledge) { + var marked = false + val mutationApplied = + runIfAccountMutationCurrent(accountEpoch, null) { + marked = seenStore.markProcessedNdr(message.eventId) + if (marked) { + markProcessed(message.eventId) + } + } + if (!mutationApplied) { + result = NdrDeliveryResult.REJECTED + } else if (!marked) { + result = NdrDeliveryResult.RETRY + } + } + completion(result) + } + } + } + + private suspend fun processEmbeddedBitChatContent( + content: String, + senderPubkey: String, + timestamp: Date, + geohash: String, + recipientIdentity: NostrIdentity, + accountEpoch: NostrAccountEpoch, + ndrEventId: String? = null, + expiresAtSeconds: Long? = null + ): NdrDeliveryResult { + if (!isAccountEpochCurrent(accountEpoch)) return NdrDeliveryResult.REJECTED + if (isExpired(expiresAtSeconds)) return NdrDeliveryResult.REJECTED + if (!content.startsWith("bitchat1:")) return NdrDeliveryResult.REJECTED + + val packetData = base64URLDecode(content.removePrefix("bitchat1:")) + ?: return NdrDeliveryResult.REJECTED + val packet = BitchatPacket.fromBinaryData(packetData) + ?: return NdrDeliveryResult.REJECTED + if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) { + return NdrDeliveryResult.REJECTED + } + + val noisePayload = NoisePayload.decode(packet.payload) + ?: return NdrDeliveryResult.REJECTED + val convKey = "nostr_${senderPubkey.take(16)}" + if (!runIfAccountMutationCurrent(accountEpoch, expiresAtSeconds) { + repo.putNostrKeyMapping(convKey, senderPubkey) + GeohashAliasRegistry.put(convKey, senderPubkey) + + if (geohash.isNotEmpty()) { + repo.setConversationGeohash(convKey, geohash) + GeohashConversationRegistry.set(convKey, geohash) + if (repo.getCachedNickname(senderPubkey) == null) { + val base = + repo.displayNameForNostrPubkeyUI(senderPubkey).substringBefore("#") + repo.cacheNickname(senderPubkey, base) + } + repo.updateParticipant(geohash, senderPubkey, timestamp) + } + } + ) return NdrDeliveryResult.REJECTED + + return processNoisePayload( + payload = noisePayload, + conversationID = ContactDirectory.canonicalConversationId(convKey), + senderNickname = repo.displayNameForNostrPubkeyUI(senderPubkey), + timestamp = timestamp, + senderPubkey = senderPubkey, + recipientIdentity = recipientIdentity, + allowAccountNdr = geohash.isEmpty(), + accountEpoch = accountEpoch, + ndrEventId = ndrEventId, + expiresAtSeconds = expiresAtSeconds + ) + } + private suspend fun processNoisePayload( payload: NoisePayload, conversationID: String, senderNickname: String, timestamp: Date, senderPubkey: String, - recipientIdentity: NostrIdentity - ) { - when (payload.type) { + recipientIdentity: NostrIdentity, + allowAccountNdr: Boolean, + accountEpoch: NostrAccountEpoch, + ndrEventId: String? = null, + expiresAtSeconds: Long? = null + ): NdrDeliveryResult { + if (!isAccountEpochCurrent(accountEpoch) || isExpired(expiresAtSeconds)) { + return NdrDeliveryResult.REJECTED + } + return when (payload.type) { NoisePayloadType.PRIVATE_MESSAGE -> { - val pm = PrivateMessagePacket.decode(payload.data) ?: return + val pm = PrivateMessagePacket.decode(payload.data) + ?: return NdrDeliveryResult.REJECTED val existingMessages = state.getPrivateChatsValue()[conversationID] ?: emptyList() - if (existingMessages.any { it.id == pm.messageID }) return + if (existingMessages.any { it.id == pm.messageID }) { + return NdrDeliveryResult.DUPLICATE + } val favoriteControl = FavoriteControlMessage.parse(pm.content) if (favoriteControl != null) { - val admitted = handleFavoriteControl( + if (!isAccountEpochCurrent(accountEpoch) || + isExpired(expiresAtSeconds) + ) { + return NdrDeliveryResult.REJECTED + } + val favoriteResult = handleFavoriteControl( favoriteControl, conversationID, senderNickname, timestamp, - senderPubkey + senderPubkey, + accountEpoch, + ndrEventId, + expiresAtSeconds ) - if (!admitted) return - if (!seenStore.hasDelivered(pm.messageID)) { - val nostrTransport = NostrTransport.getInstance(application) - nostrTransport.sendDeliveryAckGeohash(pm.messageID, senderPubkey, recipientIdentity) - seenStore.markDelivered(pm.messageID) - } - return + if (favoriteResult != NdrDeliveryResult.CONSUMED && + favoriteResult != NdrDeliveryResult.DUPLICATE + ) return favoriteResult + if (!runIfAccountMutationCurrent(accountEpoch, expiresAtSeconds) { + if (!seenStore.hasDelivered(pm.messageID)) { + sendDeliveryAck( + pm.messageID, + senderPubkey, + recipientIdentity, + allowAccountNdr + ) + seenStore.markDelivered(pm.messageID) + } + } + ) return NdrDeliveryResult.REJECTED + return favoriteResult } val message = BitchatMessage( @@ -158,92 +427,227 @@ class NostrDirectMessageHandler( recipientNickname = state.getNicknameValue(), senderPeerID = conversationID, senderNostrPubkey = senderPubkey, - deliveryStatus = DeliveryStatus.Delivered(to = state.getNicknameValue() ?: "Unknown", at = Date()) + deliveryStatus = + DeliveryStatus.Delivered( + to = state.getNicknameValue(), + at = Date() + ) ) val isViewing = state.getSelectedPrivateChatPeerValue() == conversationID val suppressUnread = seenStore.hasBeenReadLocally(pm.messageID) - val admitted = withContext(Dispatchers.Main) { - privateChatManager.handleIncomingPrivateMessageDurably( - message = message, - suppressUnread = suppressUnread, - origin = PrivateMessageOrigin.NOSTR - ) - } - if (!admitted) return - - if (!seenStore.hasDelivered(pm.messageID)) { - val nostrTransport = NostrTransport.getInstance(application) - nostrTransport.sendDeliveryAckGeohash(pm.messageID, senderPubkey, recipientIdentity) - seenStore.markDelivered(pm.messageID) - } - - if (isViewing && !suppressUnread) { - val nostrTransport = NostrTransport.getInstance(application) - nostrTransport.sendReadReceiptGeohash(pm.messageID, senderPubkey, recipientIdentity) - seenStore.markReadLocally(pm.messageID) - seenStore.markReadReceiptSent(pm.messageID) - } - } - NoisePayloadType.DELIVERED -> { - val messageId = String(payload.data, Charsets.UTF_8) - withContext(Dispatchers.Main) { - updateDeliveryStatus( - messageId, - DeliveryStatus.Delivered(conversationID, Date()) - ) - } - } - NoisePayloadType.READ_RECEIPT -> { - val messageId = String(payload.data, Charsets.UTF_8) - withContext(Dispatchers.Main) { - updateDeliveryStatus( - messageId, - DeliveryStatus.Read(conversationID, Date()) - ) - } - } - NoisePayloadType.FILE_TRANSFER -> { - // Properly handle encrypted file transfer - val file = BitchatFilePacket.decode(payload.data) - if (file != null) { - val uniqueMsgId = java.util.UUID.randomUUID().toString().uppercase() - val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(application, file) - val message = BitchatMessage( - id = uniqueMsgId, - sender = senderNickname, - content = savedPath, - type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType), - timestamp = timestamp, - isRelay = false, - isPrivate = true, - recipientNickname = state.getNicknameValue(), - senderPeerID = conversationID, - senderNostrPubkey = senderPubkey - ) - Log.d(TAG, "📄 Saved Nostr encrypted incoming file to $savedPath (msgId=$uniqueMsgId)") - val admitted = withContext(Dispatchers.Main) { + val messageAccepted = withContext(Dispatchers.Main) { + if (!isAccountEpochCurrent(accountEpoch) || isExpired(expiresAtSeconds)) { + false + } else { privateChatManager.handleIncomingPrivateMessageDurably( message = message, - suppressUnread = false, + suppressUnread = suppressUnread, origin = PrivateMessageOrigin.NOSTR ) } - if (!admitted) { - com.bitchat.android.features.file.FileUtils.deleteStoredMediaPaths( - application, - listOf(savedPath) + } + if (!messageAccepted) { + return if (com.bitchat.android.services.AppStateStore + .hasSeenMessage(pm.messageID) + ) { + NdrDeliveryResult.DUPLICATE + } else { + NdrDeliveryResult.RETRY + } + } + + runCatching { + runIfAccountMutationCurrent(accountEpoch, expiresAtSeconds) { + if (!seenStore.hasDelivered(pm.messageID)) { + sendDeliveryAck( + pm.messageID, + senderPubkey, + recipientIdentity, + allowAccountNdr + ) + seenStore.markDelivered(pm.messageID) + } + + if (isViewing && !suppressUnread) { + val nostrTransport = NostrTransport.getInstance(application) + val targetPeerID = resolvePeerIDForNostr(senderPubkey) + .takeIf { allowAccountNdr } + if (targetPeerID != null) { + nostrTransport.sendReadReceipt( + com.bitchat.android.model.ReadReceipt(pm.messageID), + targetPeerID + ) + } else { + nostrTransport.sendReadReceiptGeohash( + pm.messageID, + senderPubkey, + recipientIdentity + ) + } + seenStore.markReadLocally(pm.messageID) + seenStore.markReadReceiptSent(pm.messageID) + } + } + } + NdrDeliveryResult.CONSUMED + } + NoisePayloadType.DELIVERED -> { + val messageId = String(payload.data, Charsets.UTF_8) + var consumed = false + withContext(Dispatchers.Main) { + runIfAccountMutationCurrent(accountEpoch, expiresAtSeconds) { + updateDeliveryStatus( + messageId, + DeliveryStatus.Delivered(conversationID, Date()) ) + consumed = true + } + } + if (consumed) NdrDeliveryResult.CONSUMED else NdrDeliveryResult.REJECTED + } + NoisePayloadType.READ_RECEIPT -> { + val messageId = String(payload.data, Charsets.UTF_8) + var consumed = false + withContext(Dispatchers.Main) { + runIfAccountMutationCurrent(accountEpoch, expiresAtSeconds) { + updateDeliveryStatus( + messageId, + DeliveryStatus.Read(conversationID, Date()) + ) + consumed = true + } + } + if (consumed) NdrDeliveryResult.CONSUMED else NdrDeliveryResult.REJECTED + } + NoisePayloadType.FILE_TRANSFER -> { + val file = BitchatFilePacket.decode(payload.data) + if (file != null) { + var savedPath: String? = null + var retained = false + try { + if (ndrEventId != null && + com.bitchat.android.services.AppStateStore + .hasSeenMessage(ndrEventId) + ) { + return NdrDeliveryResult.DUPLICATE + } + if (!isAccountEpochCurrent(accountEpoch) || isExpired(expiresAtSeconds)) { + return NdrDeliveryResult.REJECTED + } + + val path = com.bitchat.android.features.file.FileUtils.saveIncomingFile( + context = application, + file = file, + stableId = ndrEventId + ) + savedPath = path + val message = BitchatMessage( + id = ndrEventId + ?: java.util.UUID.randomUUID().toString().uppercase(), + sender = senderNickname, + content = path, + type = com.bitchat.android.features.file.FileUtils + .messageTypeForMime(file.mimeType), + timestamp = timestamp, + isRelay = false, + isPrivate = true, + recipientNickname = state.getNicknameValue(), + senderPeerID = conversationID, + senderNostrPubkey = senderPubkey + ) + val admitted = withContext(Dispatchers.Main) { + if (!isAccountEpochCurrent(accountEpoch) || + isExpired(expiresAtSeconds) + ) { + false + } else { + privateChatManager.handleIncomingPrivateMessageDurably( + message = message, + suppressUnread = false, + origin = PrivateMessageOrigin.NOSTR + ) + } + } + if (!admitted) { + return if (com.bitchat.android.services.AppStateStore + .hasSeenMessage(message.id) + ) { + NdrDeliveryResult.DUPLICATE + } else { + NdrDeliveryResult.RETRY + } + } + retained = true + NdrDeliveryResult.CONSUMED + } finally { + if (!retained) { + savedPath?.let { + com.bitchat.android.features.file.FileUtils + .deleteStoredMediaPaths(application, listOf(it)) + } + } } } else { Log.w(TAG, "Failed to decode Nostr file transfer from $conversationID") + NdrDeliveryResult.REJECTED } } NoisePayloadType.VERIFY_CHALLENGE, NoisePayloadType.VERIFY_RESPONSE, NoisePayloadType.VOICE_FRAME, - NoisePayloadType.PEER_STATE -> Unit // Peer state is bound to a live mesh Noise generation. + NoisePayloadType.PEER_STATE, + NoisePayloadType.NDR_EVENT -> + NdrDeliveryResult.REJECTED // Transport controls never arrive inside relay DMs. + } + } + + private fun isAccountEpochCurrent(epoch: NostrAccountEpoch): Boolean = + NostrInboundAccountLifecycle.isCurrent(epoch) + + private fun isExpired(expiresAtSeconds: Long?): Boolean = + expiresAtSeconds?.let { it <= System.currentTimeMillis() / 1_000L } == true + + private fun runIfAccountMutationCurrent( + epoch: NostrAccountEpoch, + expiresAtSeconds: Long?, + mutation: () -> Unit + ): Boolean { + if (isExpired(expiresAtSeconds)) return false + var applied = false + val epochCurrent = NostrInboundAccountLifecycle.runIfCurrent(epoch) { + if (!isExpired(expiresAtSeconds)) { + mutation() + applied = true + } + } + return epochCurrent && applied + } + + private fun sendDeliveryAck( + messageId: String, + senderPubkey: String, + recipientIdentity: NostrIdentity, + allowAccountNdr: Boolean + ) { + val nostrTransport = NostrTransport.getInstance(application) + val targetPeerID = resolvePeerIDForNostr(senderPubkey) + .takeIf { allowAccountNdr } + if (targetPeerID != null) { + nostrTransport.sendDeliveryAck(messageId, targetPeerID) + } else { + nostrTransport.sendDeliveryAckGeohash(messageId, senderPubkey, recipientIdentity) + } + } + + private fun resolvePeerIDForNostr(senderPubkey: String): String? { + return try { + FavoritesPersistenceService.shared.findPeerIDForNostrPubkey(senderPubkey) + ?: FavoritesPersistenceService.shared.findNoiseKey(senderPubkey) + ?.let(ContactIdentityResolver::noiseKeyHex) + } catch (_: Exception) { + null } } @@ -252,56 +656,84 @@ class NostrDirectMessageHandler( conversationID: String, senderNickname: String, timestamp: Date, - senderPubkey: String - ): Boolean { + senderPubkey: String, + accountEpoch: NostrAccountEpoch, + ndrEventId: String? = null, + expiresAtSeconds: Long? = null + ): NdrDeliveryResult { return try { + if (isExpired(expiresAtSeconds)) return NdrDeliveryResult.REJECTED + val targetConversationID = ContactDirectory.canonicalConversationId(conversationID) + if (ndrEventId != null && + com.bitchat.android.services.AppStateStore.hasSeenMessage(ndrEventId) + ) { + return NdrDeliveryResult.DUPLICATE + } val senderNpub = control.npub ?: ContactIdentityResolver.npubFromHex(senderPubkey) val noiseKey = senderNpub?.let { FavoritesPersistenceService.shared.findNoiseKey(it) } ?: FavoritesPersistenceService.shared.findNoiseKey(senderPubkey) if (noiseKey == null) { Log.w(TAG, "Favorite notification from Nostr sender without known Noise key: ${senderPubkey.take(16)}...") - return false + return NdrDeliveryResult.REJECTED } - FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, control.isFavorite) - senderNpub?.let { FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, it) } - val targetConversationID = ContactDirectory.canonicalConversationId(conversationID) - - val relationship = FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey) - val displayName = relationship - ?.peerNickname - ?.takeUnless { it.equals("Unknown", ignoreCase = true) } - ?: senderNickname - val guidance = if (control.isFavorite) { - if (relationship?.isFavorite == true) { - " - mutual! You can continue DMs via Nostr when out of mesh." - } else { - " - favorite back to continue DMs later." + var systemMessage: BitchatMessage? = null + if (!runIfAccountMutationCurrent(accountEpoch, expiresAtSeconds) { + FavoritesPersistenceService.shared.updatePeerFavoritedUs( + noiseKey, + control.isFavorite + ) + senderNpub?.let { + FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, it) + } + val relationship = FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey) + val displayName = relationship + ?.peerNickname + ?.takeUnless { it.equals("Unknown", ignoreCase = true) } + ?: senderNickname + val guidance = if (control.isFavorite) { + if (relationship?.isFavorite == true) { + " - mutual! You can continue DMs via Nostr when out of mesh." + } else { + " - favorite back to continue DMs later." + } + } else { + ". DMs over Nostr will pause unless you both favorite again." + } + val action = if (control.isFavorite) "favorited" else "unfavorited" + systemMessage = BitchatMessage( + id = ndrEventId ?: java.util.UUID.randomUUID().toString().uppercase(), + sender = "system", + content = "$displayName $action you$guidance", + timestamp = timestamp, + isRelay = false, + isPrivate = true, + senderPeerID = targetConversationID + ) } - } else { - ". DMs over Nostr will pause unless you both favorite again." - } - val action = if (control.isFavorite) "favorited" else "unfavorited" - val systemMessage = BitchatMessage( - sender = "system", - content = "$displayName $action you$guidance", - timestamp = timestamp, - isRelay = false, - isPrivate = true, - senderPeerID = targetConversationID - ) + ) return NdrDeliveryResult.REJECTED + var consumed = false + val pendingMessage = systemMessage ?: return NdrDeliveryResult.REJECTED withContext(Dispatchers.Main) { - privateChatManager.handleIncomingPrivateMessageDurably( - message = systemMessage, - suppressUnread = true, - origin = PrivateMessageOrigin.NOSTR - ) + if (isAccountEpochCurrent(accountEpoch) && !isExpired(expiresAtSeconds)) { + consumed = privateChatManager.handleIncomingPrivateMessageDurably( + message = pendingMessage, + suppressUnread = true, + origin = PrivateMessageOrigin.NOSTR + ) + } + } + when { + consumed -> NdrDeliveryResult.CONSUMED + com.bitchat.android.services.AppStateStore + .hasSeenMessage(pendingMessage.id) -> NdrDeliveryResult.DUPLICATE + else -> NdrDeliveryResult.RETRY } } catch (e: Exception) { Log.w(TAG, "Failed to handle Nostr favorite notification: ${e.message}") - false + NdrDeliveryResult.RETRY } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrEventDeduplicator.kt b/app/src/main/java/com/bitchat/android/nostr/NostrEventDeduplicator.kt index 0638eafd..9283dc59 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrEventDeduplicator.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrEventDeduplicator.kt @@ -54,7 +54,9 @@ class NostrEventDeduplicator( private val tail = LRUNode("TAIL") // Dummy tail node // Lock for thread-safe LRU operations - private val lruLock = Any() + @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") + private val lruLock = java.lang.Object() + private val eventIdsBeingProcessed = mutableSetOf() // Statistics @Volatile @@ -123,6 +125,55 @@ class NostrEventDeduplicator( false } } + + /** + * Runs [processor] without consuming the event ID first. The ID enters the + * dedupe cache only if the processor reports a successful durable commit. + */ + fun processEventAfterSuccess( + event: NostrEvent, + processor: (NostrEvent) -> Boolean + ): Boolean { + totalChecks++ + synchronized(lruLock) { + while (event.id in eventIdsBeingProcessed) { + try { + lruLock.wait() + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + return false + } + } + nodeMap[event.id]?.let { existing -> + moveToFront(existing) + duplicateCount++ + return false + } + eventIdsBeingProcessed += event.id + } + + var committed = false + try { + committed = processor(event) + return committed + } finally { + synchronized(lruLock) { + if (committed) { + val existing = nodeMap[event.id] + if (existing != null) { + moveToFront(existing) + } else { + addToFront(event.id) + if (nodeMap.size > maxCapacity) { + evictOldest() + } + } + } + eventIdsBeingProcessed.remove(event.id) + lruLock.notifyAll() + } + } + } /** * Get current statistics about the deduplicator diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrInboundAccountLifecycle.kt b/app/src/main/java/com/bitchat/android/nostr/NostrInboundAccountLifecycle.kt new file mode 100644 index 00000000..c4afada8 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrInboundAccountLifecycle.kt @@ -0,0 +1,64 @@ +package com.bitchat.android.nostr + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob + +internal data class NostrInboundAccountContext( + val epoch: NostrAccountEpoch, + val receiveJob: Job +) { + val receiveScope = CoroutineScope(Dispatchers.IO + receiveJob) +} + +/** + * Process-wide lifetime for all account-bound Nostr receive work. + * + * Subscription handlers capture an epoch when they are installed. Beginning a + * replacement account invalidates those handlers and cancels their launched + * work. Invalidation is also a mutation barrier: after it returns, an old + * receive can finish non-sensitive parsing but cannot mutate application state. + */ +internal object NostrInboundAccountLifecycle { + private val lifecycleLock = Any() + private val epochs = NostrAccountEpochGuard() + private var currentContext: NostrInboundAccountContext? = null + + fun begin( + accountPubkeyHex: String, + parentJob: Job? + ): NostrInboundAccountContext = synchronized(lifecycleLock) { + val epoch = epochs.begin(accountPubkeyHex) + currentContext?.receiveJob?.cancel() + NostrInboundAccountContext( + epoch = epoch, + receiveJob = SupervisorJob(parentJob) + ).also { currentContext = it } + } + + fun contextFor(epoch: NostrAccountEpoch): NostrInboundAccountContext? = + synchronized(lifecycleLock) { + currentContext?.takeIf { + it.epoch == epoch + } + } + + fun currentEpoch(): NostrAccountEpoch? = + synchronized(lifecycleLock) { currentContext?.epoch } + + fun isCurrent(epoch: NostrAccountEpoch): Boolean = epochs.isCurrent(epoch) + + fun runIfCurrent( + epoch: NostrAccountEpoch, + mutation: () -> Unit + ): Boolean = epochs.runIfCurrent(epoch, mutation) + + fun invalidate() { + synchronized(lifecycleLock) { + epochs.invalidate() + currentContext?.receiveJob?.cancel() + currentContext = null + } + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt b/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt index dd9e16d1..1e120b05 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt @@ -16,14 +16,16 @@ internal class NostrPendingEventQueue( data class Delivery( val queueId: Long, val event: NostrEvent, - val liveLocationToken: Long? + val liveLocationToken: Long?, + val accountGeneration: Long ) private data class Entry( val queueId: Long, val event: NostrEvent, val pendingRelayUrls: MutableSet, - val liveLocationToken: Long? + val liveLocationToken: Long?, + val accountGeneration: Long ) private val lock = Any() @@ -33,7 +35,8 @@ internal class NostrPendingEventQueue( fun enqueue( event: NostrEvent, relayUrls: Collection, - liveLocationToken: Long? + liveLocationToken: Long?, + accountGeneration: Long ): Long? { val pendingRelays = relayUrls.filterTo(linkedSetOf()) { it.isNotBlank() } if (pendingRelays.isEmpty()) return null @@ -46,18 +49,32 @@ internal class NostrPendingEventQueue( queueId = queueId, event = event, pendingRelayUrls = pendingRelays, - liveLocationToken = liveLocationToken + liveLocationToken = liveLocationToken, + accountGeneration = accountGeneration ) ) queueId } } - fun pendingForRelay(relayUrl: String): List = synchronized(lock) { + fun pendingForRelay( + relayUrl: String, + accountGeneration: Long + ): List = synchronized(lock) { entries .asSequence() - .filter { relayUrl in it.pendingRelayUrls } - .map { Delivery(it.queueId, it.event, it.liveLocationToken) } + .filter { + it.accountGeneration == accountGeneration && + relayUrl in it.pendingRelayUrls + } + .map { + Delivery( + queueId = it.queueId, + event = it.event, + liveLocationToken = it.liveLocationToken, + accountGeneration = it.accountGeneration + ) + } .toList() } @@ -74,9 +91,12 @@ internal class NostrPendingEventQueue( } } - fun removeLiveLocationEvents() { + fun removeLiveLocationEvents(accountGeneration: Long) { synchronized(lock) { - entries.removeAll { it.liveLocationToken != null } + entries.removeAll { + it.accountGeneration == accountGeneration && + it.liveLocationToken != null + } } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt index 772e2a60..90108a6d 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -14,19 +14,33 @@ import okhttp3.* import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong import kotlin.math.min import kotlin.math.pow +internal fun isNip20ConfirmedSuccess(accepted: Boolean, message: String?): Boolean = + accepted || message?.startsWith("duplicate:") == true + +internal class RelayAccountResetToken internal constructor( + internal val generation: Long +) + /** * Manages WebSocket connections to Nostr relays * Compatible with iOS implementation with Android-specific optimizations */ -class NostrRelayManager private constructor() { +class NostrRelayManager internal constructor( + private val scope: CoroutineScope = + CoroutineScope(Dispatchers.IO + SupervisorJob()), + private val eventDeduplicator: NostrEventDeduplicator = + NostrEventDeduplicator.getInstance(), + private val webSocketFactory: ((Request, WebSocketListener) -> WebSocket)? = null +) { companion object { @JvmStatic val shared = NostrRelayManager() - + private const val TAG = "NostrRelayManager" private const val MAX_QUEUED_EVENTS = 500 const val OWNER_LEGACY = "legacy" @@ -53,13 +67,7 @@ class NostrRelayManager private constructor() { private const val MAX_BACKOFF_INTERVAL = com.bitchat.android.util.AppConstants.Nostr.MAX_BACKOFF_INTERVAL_MS // 5 minutes private const val BACKOFF_MULTIPLIER = com.bitchat.android.util.AppConstants.Nostr.BACKOFF_MULTIPLIER private const val MAX_RECONNECT_ATTEMPTS = com.bitchat.android.util.AppConstants.Nostr.MAX_RECONNECT_ATTEMPTS - - // Track gift-wraps we initiated for logging - private val pendingGiftWrapIDs = ConcurrentHashMap.newKeySet() - - fun registerPendingGiftWrap(id: String) { - pendingGiftWrapIDs.add(id) - } + private const val CONFIRMED_PUBLISH_TIMEOUT_MS = 15_000L fun defaultRelays(): List = DEFAULT_RELAYS } @@ -91,8 +99,12 @@ class NostrRelayManager private constructor() { private val connections = ConcurrentHashMap() private val reconnectJobs = ConcurrentHashMap() private val desiredConnected = AtomicBoolean(false) + private val connectionEpoch = AtomicLong(0L) private val subscriptions = ConcurrentHashMap>() // relay URL -> subscription IDs private val messageHandlers = ConcurrentHashMap Unit>() + private val commitAwareMessageHandlers = + ConcurrentHashMap Boolean>() + private val pendingGiftWrapGenerations = ConcurrentHashMap() // Persistent subscription tracking for robust reconnection private val activeSubscriptions = ConcurrentHashMap() // subscription ID -> info @@ -108,17 +120,47 @@ class NostrRelayManager private constructor() { val createdAt: Long = System.currentTimeMillis(), val originGeohash: String? = null, val owner: String = OWNER_LEGACY, - val liveLocationToken: Long? = null + val liveLocationToken: Long? = null, + val accountGeneration: Long ) - - // Event deduplication system - private val eventDeduplicator = NostrEventDeduplicator.getInstance() - + // Bounded per-relay delivery queue for reconnect reliability. private val messageQueue = NostrPendingEventQueue(MAX_QUEUED_EVENTS) - - // Coroutine scope for background operations - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val accountGeneration = AtomicLong(0L) + private val accountGenerationLock = Any() + private val accountResetOperationLock = Any() + @Volatile private var accountResetBlocked = false + private var preparedResetGeneration = Long.MIN_VALUE + private var discardedResetGeneration = Long.MIN_VALUE + + private data class ConfirmedPublish( + val awaitingRelayUrls: MutableSet, + val completion: (Boolean) -> Unit, + val accountGeneration: Long, + @Volatile var timeoutJob: Job? = null + ) + + private data class EventDispatch( + val subscriptionInfo: SubscriptionInfo, + val commitAwareHandler: ((NostrEvent) -> Boolean)?, + val ordinaryHandler: ((NostrEvent) -> Unit)? + ) + + private val confirmedPublishes = ConcurrentHashMap() + + private data class AccountResetCleanup( + val jobs: List, + val sockets: List, + val confirmedPublishes: List + ) + + private data class NdrConnectionHandler( + val accountGeneration: Long, + val callback: () -> Unit + ) + + @Volatile private var ndrConnectionAvailableHandler: NdrConnectionHandler? = null // Subscription validation timer private var subscriptionValidationJob: Job? = null @@ -148,8 +190,11 @@ class NostrRelayManager private constructor() { geohash: String, nRelays: Int = 5, includeDefaults: Boolean = false, - liveLocationToken: Long? = null + liveLocationToken: Long? = null, + expectedAccountGeneration: Long = accountGeneration.get() ) { + val generation = expectedAccountGeneration + if (!isCurrentAccountGeneration(generation)) return if (!isNetworkActionAllowed(liveLocationToken)) return try { val nearest = RelayDirectory.closestRelaysForGeohash(geohash, nRelays) @@ -161,19 +206,29 @@ class NostrRelayManager private constructor() { return } runNetworkAction(liveLocationToken) { - geohashToRelays[geohash] = selected - if (liveLocationToken == null) { - liveGeohashTokens.remove(geohash) - nonLiveRelayUrls.addAll(selected) - } else { - liveGeohashTokens[geohash] = liveLocationToken - selected.forEach { relayUrl -> - if (relayUrl !in nonLiveRelayUrls) { - liveLocationRelayTokens[relayUrl] = liveLocationToken + val registered = synchronized(accountGenerationLock) { + if (!isCurrentAccountGeneration(generation)) { + false + } else { + geohashToRelays[geohash] = selected + if (liveLocationToken == null) { + liveGeohashTokens.remove(geohash) + nonLiveRelayUrls.addAll(selected) + } else { + liveGeohashTokens[geohash] = liveLocationToken + selected.forEach { relayUrl -> + if (relayUrl !in nonLiveRelayUrls) { + liveLocationRelayTokens[relayUrl] = + liveLocationToken + } + } } + true } } - ensureConnectionsFor(selected, liveLocationToken) + if (registered) { + ensureConnectionsFor(selected, liveLocationToken, generation) + } } } catch (e: Exception) { Log.e(TAG, "Failed to ensure geohash relays") @@ -198,16 +253,22 @@ class NostrRelayManager private constructor() { includeDefaults: Boolean = false, nRelays: Int = 5, owner: String = OWNER_LEGACY, - liveLocationToken: Long? = null + liveLocationToken: Long? = null, + expectedAccountGeneration: Long = accountGeneration.get() ): String { + val generation = expectedAccountGeneration + if (!isCurrentAccountGeneration(generation)) return id if (!isNetworkActionAllowed(liveLocationToken)) return id ensureGeohashRelaysConnected( geohash, nRelays, includeDefaults, - liveLocationToken + liveLocationToken, + generation ) - if (!isNetworkActionAllowed(liveLocationToken)) return id + if (!isCurrentAccountGeneration(generation) || + !isNetworkActionAllowed(liveLocationToken) + ) return id val relayUrls = getRelaysForGeohash(geohash) return subscribe( filter = filter, @@ -215,13 +276,10 @@ class NostrRelayManager private constructor() { handler = handler, targetRelayUrls = relayUrls, owner = owner, - liveLocationToken = liveLocationToken - ).also { subscriptionId -> - activeSubscriptions[subscriptionId]?.let { subscription -> - activeSubscriptions[subscriptionId] = - subscription.copy(originGeohash = geohash) - } - } + liveLocationToken = liveLocationToken, + expectedAccountGeneration = generation, + originGeohash = geohash + ) } /** @@ -232,23 +290,34 @@ class NostrRelayManager private constructor() { geohash: String, includeDefaults: Boolean = false, nRelays: Int = 5, - liveLocationToken: Long? = null + liveLocationToken: Long? = null, + expectedAccountGeneration: Long = accountGeneration.get() ) { + val generation = expectedAccountGeneration + if (!isCurrentAccountGeneration(generation)) return if (!isNetworkActionAllowed(liveLocationToken)) return ensureGeohashRelaysConnected( geohash, nRelays, includeDefaults, - liveLocationToken + liveLocationToken, + generation ) - if (!isNetworkActionAllowed(liveLocationToken)) return + if (!isCurrentAccountGeneration(generation) || + !isNetworkActionAllowed(liveLocationToken) + ) return val relayUrls = getRelaysForGeohash(geohash) if (relayUrls.isEmpty()) { Log.w(TAG, "No target relays for geohash event; falling back to defaults") - sendEvent(event, Companion.defaultRelays(), liveLocationToken) + sendEvent( + event, + Companion.defaultRelays(), + liveLocationToken, + generation + ) return } - sendEvent(event, relayUrls, liveLocationToken) + sendEvent(event, relayUrls, liveLocationToken, generation) } // --- Internal helpers --- @@ -266,13 +335,20 @@ class NostrRelayManager private constructor() { LiveLocationPrivacyGate.runIfAllowed(liveLocationToken, action) } + private fun isCurrentConnectionEpoch(epoch: Long): Boolean = + desiredConnected.get() && connectionEpoch.get() == epoch + /** * Privacy teardown is allowed to bypass an already-revoked token solely to stop * server-side delivery. Live subscription IDs are opaque, so CLOSE carries no * geohash. If a CLOSE cannot be queued, fail closed by dropping that socket. */ - private fun closeSubscriptionsOnConnectedRelays(subscriptionIds: Set) { + private fun closeSubscriptionsOnConnectedRelays( + subscriptionIds: Set, + generation: Long + ) { if (subscriptionIds.isEmpty()) return + if (!isCurrentAccountGeneration(generation)) return val closeTargets = NostrLiveSubscriptionPrivacy.closeTargets( liveSubscriptionIds = subscriptionIds, @@ -281,6 +357,9 @@ class NostrRelayManager private constructor() { closeTargets.forEach { (relayUrl, relaySubscriptionIds) -> val webSocket = connections[relayUrl] ?: return@forEach for (subscriptionId in relaySubscriptionIds) { + if (!isCurrentAccountGeneration(generation) || + connections[relayUrl] !== webSocket + ) return val request = NostrRequest.Close(subscriptionId) val message = gson.toJson(request, NostrRequest::class.java) val closeQueued = runCatching { webSocket.send(message) } @@ -294,8 +373,19 @@ class NostrRelayManager private constructor() { isConnected = false, error = IllegalStateException("Failed to close revoked subscription") ) - if (desiredConnected.get() && relayUrl in nonLiveRelayUrls) { - scope.launch { connectToRelay(relayUrl, liveLocationToken = null) } + if (desiredConnected.get() && + relayUrl in nonLiveRelayUrls && + isCurrentAccountGeneration(generation) + ) { + val epoch = connectionEpoch.get() + scope.launch { + connectToRelay( + relayUrl, + liveLocationToken = null, + generation = generation, + expectedConnectionEpoch = epoch + ) + } } break } @@ -304,70 +394,114 @@ class NostrRelayManager private constructor() { } private fun revokeLiveLocationAccess() { - liveLocationConnectionJobs.forEach(Job::cancel) - liveLocationConnectionJobs.clear() + val generation = accountGeneration.get() + if (!isCurrentAccountGeneration(generation)) return val liveSubscriptionIds = activeSubscriptions.values .filter { it.liveLocationToken != null } .mapTo(mutableSetOf()) { it.id } - closeSubscriptionsOnConnectedRelays(liveSubscriptionIds) - liveSubscriptionIds.forEach { id -> - activeSubscriptions.remove(id) - messageHandlers.remove(id) - } - subscriptions.replaceAll { _, ids -> ids - liveSubscriptionIds } + closeSubscriptionsOnConnectedRelays(liveSubscriptionIds, generation) + val (socketsToCancel, reconnectsToCancel, connectionJobsToCancel) = + synchronized(accountGenerationLock) { + if (!isCurrentAccountGeneration(generation)) return + liveSubscriptionIds.forEach { id -> + val subscription = activeSubscriptions[id] + if (subscription?.accountGeneration == generation) { + activeSubscriptions.remove(id, subscription) + messageHandlers.remove(id) + commitAwareMessageHandlers.remove(id) + } + } + subscriptions.replaceAll { _, ids -> ids - liveSubscriptionIds } - messageQueue.removeLiveLocationEvents() + messageQueue.removeLiveLocationEvents(generation) - liveGeohashTokens.keys.forEach(geohashToRelays::remove) - liveGeohashTokens.clear() + liveGeohashTokens.keys.forEach(geohashToRelays::remove) + liveGeohashTokens.clear() - val liveOnlyRelayUrls = liveLocationRelayTokens.keys - .filterNotTo(mutableSetOf()) { it in nonLiveRelayUrls } - liveOnlyRelayUrls.forEach { relayUrl -> - connections.remove(relayUrl)?.cancel() - subscriptions.remove(relayUrl) - reconnectJobs.remove(relayUrl)?.cancel() - } - synchronized(relaysList) { - relaysList.removeAll { it.url in liveOnlyRelayUrls } - } - liveLocationRelayTokens.clear() + val liveOnlyRelayUrls = liveLocationRelayTokens.keys + .filterNotTo(mutableSetOf()) { it in nonLiveRelayUrls } + val sockets = liveOnlyRelayUrls.mapNotNull { relayUrl -> + subscriptions.remove(relayUrl) + connections.remove(relayUrl) + } + val reconnects = liveOnlyRelayUrls.mapNotNull { relayUrl -> + reconnectJobs.remove(relayUrl) + } + synchronized(relaysList) { + relaysList.removeAll { it.url in liveOnlyRelayUrls } + } + liveLocationRelayTokens.clear() + val connectionJobs = liveLocationConnectionJobs.toList() + liveLocationConnectionJobs.clear() + Triple(sockets, reconnects, connectionJobs) + } + connectionJobsToCancel.forEach(Job::cancel) + reconnectsToCancel.forEach(Job::cancel) + socketsToCancel.forEach { it.cancel() } updateRelaysList() updateConnectionStatus() } private fun ensureConnectionsFor( relayUrls: Set, - liveLocationToken: Long? = null + liveLocationToken: Long? = null, + expectedAccountGeneration: Long = accountGeneration.get() ) { + val generation = expectedAccountGeneration + if (!isCurrentAccountGeneration(generation)) return if (!isNetworkActionAllowed(liveLocationToken)) return - // Ensure relays are tracked for UI/status - relayUrls.forEach { url -> - if (relaysList.none { it.url == url }) { - relaysList.add(Relay(url)) + // Ensure relays are tracked for UI/status. + val tracked = synchronized(accountGenerationLock) { + if (!isCurrentAccountGeneration(generation)) { + false + } else { + synchronized(relaysList) { + relayUrls.forEach { url -> + if (relaysList.none { it.url == url }) { + relaysList.add(Relay(url)) + } + } + } + true } } + if (!tracked) return updateRelaysList() - if (!desiredConnected.get()) return + val epoch = connectionEpoch.get() + if (!isCurrentConnectionEpoch(epoch)) return val job = scope.launch { - if (!desiredConnected.get() || + if (!isCurrentConnectionEpoch(epoch) || + !isCurrentAccountGeneration(generation) || !isNetworkActionAllowed(liveLocationToken) ) return@launch relayUrls.forEach { relayUrl -> launch { - if (desiredConnected.get() && + if (isCurrentConnectionEpoch(epoch) && + isCurrentAccountGeneration(generation) && !connections.containsKey(relayUrl) && isNetworkActionAllowed(liveLocationToken) ) { - connectToRelay(relayUrl, liveLocationToken) + connectToRelay( + relayUrl, + liveLocationToken, + generation, + epoch + ) } } } } if (liveLocationToken != null) { - liveLocationConnectionJobs.add(job) + val registered = synchronized(accountGenerationLock) { + isCurrentAccountGeneration(generation) && + liveLocationConnectionJobs.add(job) + } + if (!registered) { + job.cancel() + return + } job.invokeOnCompletion { liveLocationConnectionJobs.remove(job) } } } @@ -398,22 +532,35 @@ class NostrRelayManager private constructor() { * Connect to all configured relays */ fun connect() { - desiredConnected.set(true) - Log.i(TAG, "Connecting to ${relaysList.size} Nostr relays") + val (generation, epoch, relayUrls) = synchronized(accountGenerationLock) { + val current = accountGeneration.get() + if (!isCurrentAccountGeneration(current)) return + desiredConnected.set(true) + Triple(current, connectionEpoch.get(), synchronized(relaysList) { + relaysList.map { it.url } + }) + } + Log.i(TAG, "Connecting to ${relayUrls.size} Nostr relays") scope.launch { - relaysList.forEach { relay -> + if (!isCurrentConnectionEpoch(epoch) || + !isCurrentAccountGeneration(generation) + ) return@launch + relayUrls.forEach { relayUrl -> launch { - val liveToken = liveLocationRelayTokens[relay.url] - ?.takeIf { relay.url !in nonLiveRelayUrls } - if (liveToken == null || LiveLocationPrivacyGate.accepts(liveToken)) { - connectToRelay(relay.url, liveToken) + val liveToken = liveLocationRelayTokens[relayUrl] + ?.takeIf { relayUrl !in nonLiveRelayUrls } + if (isCurrentConnectionEpoch(epoch) && + isCurrentAccountGeneration(generation) && + (liveToken == null || LiveLocationPrivacyGate.accepts(liveToken)) + ) { + connectToRelay(relayUrl, liveToken, generation, epoch) } } } } // Start periodic subscription validation - startSubscriptionValidation() + startSubscriptionValidation(generation, epoch) } /** @@ -421,7 +568,10 @@ class NostrRelayManager private constructor() { */ fun disconnect() { Log.i(TAG, "Disconnecting from all Nostr relays") - desiredConnected.set(false) + synchronized(accountGenerationLock) { + desiredConnected.set(false) + connectionEpoch.incrementAndGet() + } // Stop subscription validation stopSubscriptionValidation() @@ -436,6 +586,10 @@ class NostrRelayManager private constructor() { webSocket.close(1000, "Manual disconnect") } + confirmedPublishes.entries.toList().forEach { (eventId, tracker) -> + completeConfirmedPublish(eventId, tracker, accepted = false) + } + // Preserve logical subscriptions for controlled resets, but forget per-socket state. subscriptions.clear() relaysList.forEach { @@ -452,32 +606,154 @@ class NostrRelayManager private constructor() { fun sendEvent( event: NostrEvent, relayUrls: List? = null, - liveLocationToken: Long? = null - ) { - val targetRelays = (relayUrls ?: relaysList.map { it.url }) + liveLocationToken: Long? = null, + expectedAccountGeneration: Long = accountGeneration.get() + ): Boolean { + val generation = expectedAccountGeneration + if (!isCurrentAccountGeneration(generation)) return false + val configuredRelays = relayUrls ?: synchronized(relaysList) { + relaysList.map { it.url } + } + val targetRelays = configuredRelays .filter { it.isNotBlank() } .distinct() - if (targetRelays.isEmpty()) return + if (targetRelays.isEmpty()) return false - val queued = runNetworkAction(liveLocationToken) { - val queueId = messageQueue.enqueue( - event = event, - relayUrls = targetRelays, - liveLocationToken = liveLocationToken - ) ?: return@runNetworkAction + var queueId: Long? = null + val allowed = runNetworkAction(liveLocationToken) { + queueId = synchronized(accountGenerationLock) { + if (!isCurrentAccountGeneration(generation)) { + null + } else { + messageQueue.enqueue( + event = event, + relayUrls = targetRelays, + liveLocationToken = liveLocationToken, + accountGeneration = generation + ) + } + } + val admittedQueueId = queueId ?: return@runNetworkAction scope.launch { - if (!isNetworkActionAllowed(liveLocationToken)) return@launch + if (!isCurrentAccountGeneration(generation) || + !isNetworkActionAllowed(liveLocationToken) + ) return@launch targetRelays.forEach { relayUrl -> val webSocket = connections[relayUrl] if (webSocket != null) { - if (sendToRelay(event, webSocket, relayUrl, liveLocationToken)) { - messageQueue.markDelivered(queueId, relayUrl) + if (sendToRelay( + event = event, + webSocket = webSocket, + relayUrl = relayUrl, + liveLocationToken = liveLocationToken, + generation = generation + ) + ) { + messageQueue.markDelivered(admittedQueueId, relayUrl) } } } } } - if (!queued) return + return allowed && queueId != null + } + + /** + * Publish without the retry queue and complete after one relay confirms the + * event, or after every connected target rejects/disconnects. + */ + fun sendEventConfirmed( + event: NostrEvent, + relayUrls: List? = null, + completion: (Boolean) -> Unit + ) { + val generation = accountGeneration.get() + if (!isCurrentAccountGeneration(generation)) { + completion(false) + return + } + + val configuredRelays = relayUrls ?: synchronized(relaysList) { + relaysList.map { it.url } + } + val requestedRelays = configuredRelays + .filter { it.isNotBlank() } + .toSet() + val connectedTargets = requestedRelays.filterTo(linkedSetOf()) { + connections.containsKey(it) + } + if (connectedTargets.isEmpty()) { + completion(false) + return + } + + val tracker = ConfirmedPublish( + awaitingRelayUrls = ConcurrentHashMap.newKeySet().apply { + addAll(connectedTargets) + }, + completion = completion, + accountGeneration = generation + ) + val registered = synchronized(accountGenerationLock) { + isCurrentAccountGeneration(generation) && + confirmedPublishes.putIfAbsent(event.id, tracker) == null + } + if (!registered) { + completion(false) + return + } + + tracker.timeoutJob = scope.launch { + delay(CONFIRMED_PUBLISH_TIMEOUT_MS) + completeConfirmedPublish(event.id, tracker, accepted = false) + } + + connectedTargets.forEach { relayUrl -> + val webSocket = connections[relayUrl] + if (webSocket == null || + !sendToRelay( + event = event, + webSocket = webSocket, + relayUrl = relayUrl, + generation = generation + ) + ) { + tracker.awaitingRelayUrls.remove(relayUrl) + } + } + if (tracker.awaitingRelayUrls.isEmpty()) { + completeConfirmedPublish(event.id, tracker, accepted = false) + } + } + + fun cancelConfirmedEvent(eventId: String) { + val tracker = confirmedPublishes.remove(eventId) ?: return + tracker.timeoutJob?.cancel() + runCatching { tracker.completion(false) } + .onFailure { Log.w(TAG, "Confirmed publish cancellation callback failed") } + } + + fun setNdrConnectionAvailableHandler(handler: () -> Unit) { + val generation = accountGeneration.get() + synchronized(accountGenerationLock) { + if (isCurrentAccountGeneration(generation)) { + ndrConnectionAvailableHandler = + NdrConnectionHandler(generation, handler) + } + } + } + + private fun completeConfirmedPublish( + eventId: String, + tracker: ConfirmedPublish, + accepted: Boolean + ) { + if (!confirmedPublishes.remove(eventId, tracker)) return + tracker.timeoutJob?.cancel() + val acceptedForCurrentAccount = + accepted && isCurrentAccountGeneration(tracker.accountGeneration) + runCatching { tracker.completion(acceptedForCurrentAccount) } + .onFailure { Log.w(TAG, "Confirmed publish callback failed") } } /** @@ -490,46 +766,122 @@ class NostrRelayManager private constructor() { handler: (NostrEvent) -> Unit, targetRelayUrls: List? = null, owner: String = OWNER_LEGACY, - liveLocationToken: Long? = null + liveLocationToken: Long? = null, + expectedAccountGeneration: Long = accountGeneration.get(), + originGeohash: String? = null ): String { + val generation = expectedAccountGeneration val subscriptionInfo = SubscriptionInfo( id = id, filter = filter, handler = handler, targetRelayUrls = targetRelayUrls?.toSet(), + originGeohash = originGeohash, owner = owner, - liveLocationToken = liveLocationToken + liveLocationToken = liveLocationToken, + accountGeneration = generation + ) + registerSubscription( + subscriptionInfo = subscriptionInfo, + ordinaryHandler = handler, + expectedAccountGeneration = generation ) - - runNetworkAction(liveLocationToken) { - activeSubscriptions[id] = subscriptionInfo - messageHandlers[id] = handler - sendSubscriptionToRelays(subscriptionInfo) - } - return id } + + /** + * NDR relay copies become deduplicated only after durable processing. A + * rejected registration is reported so the native action remains pending. + */ + fun subscribeAfterSuccessfulProcessing( + filter: NostrFilter, + id: String, + targetRelayUrls: List? = null, + expectedAccountGeneration: Long = accountGeneration.get(), + handler: (NostrEvent) -> Boolean + ): Boolean { + val subscriptionInfo = SubscriptionInfo( + id = id, + filter = filter, + handler = {}, + targetRelayUrls = targetRelayUrls?.toSet(), + accountGeneration = expectedAccountGeneration + ) + return registerSubscription( + subscriptionInfo = subscriptionInfo, + commitAwareHandler = handler, + expectedAccountGeneration = expectedAccountGeneration + ) + } + + /** + * Install the complete handler mode before any REQ can synchronously deliver + * a cached event from a test or WebSocket implementation. + */ + private fun registerSubscription( + subscriptionInfo: SubscriptionInfo, + ordinaryHandler: ((NostrEvent) -> Unit)? = null, + commitAwareHandler: ((NostrEvent) -> Boolean)? = null, + expectedAccountGeneration: Long + ): Boolean { + require((ordinaryHandler == null) != (commitAwareHandler == null)) + val generation = expectedAccountGeneration + var registered = false + runNetworkAction(subscriptionInfo.liveLocationToken) { + synchronized(accountGenerationLock) { + if (!isCurrentAccountGeneration(generation)) return@synchronized + activeSubscriptions[subscriptionInfo.id] = subscriptionInfo + if (commitAwareHandler != null) { + commitAwareMessageHandlers[subscriptionInfo.id] = + commitAwareHandler + messageHandlers.remove(subscriptionInfo.id) + } else { + messageHandlers[subscriptionInfo.id] = + requireNotNull(ordinaryHandler) + commitAwareMessageHandlers.remove(subscriptionInfo.id) + } + registered = true + } + if (registered) { + sendSubscriptionToRelays(subscriptionInfo, generation) + } + } + return registered + } /** * Send a subscription to the appropriate relays */ - private fun sendSubscriptionToRelays(subscriptionInfo: SubscriptionInfo) { - if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) return + private fun sendSubscriptionToRelays( + subscriptionInfo: SubscriptionInfo, + generation: Long + ) { + if (!isCurrentAccountGeneration(generation) || + subscriptionInfo.accountGeneration != generation || + !isNetworkActionAllowed(subscriptionInfo.liveLocationToken) + ) return val request = NostrRequest.Subscribe(subscriptionInfo.id, listOf(subscriptionInfo.filter)) val message = gson.toJson(request, NostrRequest::class.java) scope.launch { - if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) return@launch + if (!isCurrentAccountGeneration(generation) || + !isNetworkActionAllowed(subscriptionInfo.liveLocationToken) + ) return@launch val targetRelays = subscriptionInfo.targetRelayUrls?.toList() ?: connections.keys.toList() targetRelays.forEach { relayUrl -> + if (!isCurrentAccountGeneration(generation)) return@launch val webSocket = connections[relayUrl] if (webSocket != null) { try { var success = false runNetworkAction(subscriptionInfo.liveLocationToken) { - success = webSocket.send(message) - if (success) { + if (isCurrentAccountGeneration(generation) && + connections[relayUrl] === webSocket + ) { + success = webSocket.send(message) + } + if (success && isCurrentAccountGeneration(generation)) { val currentSubs = subscriptions[relayUrl] ?: emptySet() subscriptions[relayUrl] = currentSubs + subscriptionInfo.id @@ -553,10 +905,25 @@ class NostrRelayManager private constructor() { /** * Unsubscribe from a subscription */ - fun unsubscribe(id: String) { + fun unsubscribe( + id: String, + expectedAccountGeneration: Long = accountGeneration.get() + ) { + val generation = expectedAccountGeneration // Remove from persistent tracking - val subscriptionInfo = activeSubscriptions.remove(id) - messageHandlers.remove(id) + val subscriptionInfo = synchronized(accountGenerationLock) { + if (!isCurrentAccountGeneration(generation)) { + null + } else { + activeSubscriptions[id] + ?.takeIf { it.accountGeneration == generation } + ?.also { + activeSubscriptions.remove(id, it) + messageHandlers.remove(id) + commitAwareMessageHandlers.remove(id) + } + } + } if (subscriptionInfo == null) { return @@ -565,7 +932,7 @@ class NostrRelayManager private constructor() { if (subscriptionInfo.liveLocationToken != null && !isNetworkActionAllowed(subscriptionInfo.liveLocationToken) ) { - closeSubscriptionsOnConnectedRelays(setOf(id)) + closeSubscriptionsOnConnectedRelays(setOf(id), generation) subscriptions.replaceAll { _, ids -> ids - id } return } @@ -574,19 +941,27 @@ class NostrRelayManager private constructor() { val message = gson.toJson(request, NostrRequest::class.java) scope.launch { + if (!isCurrentAccountGeneration(generation)) return@launch if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) { - closeSubscriptionsOnConnectedRelays(setOf(id)) + closeSubscriptionsOnConnectedRelays(setOf(id), generation) subscriptions.replaceAll { _, ids -> ids - id } return@launch } connections.forEach { (relayUrl, webSocket) -> + if (!isCurrentAccountGeneration(generation)) return@launch val currentSubs = subscriptions[relayUrl] if (currentSubs?.contains(id) == true) { try { runNetworkAction(subscriptionInfo.liveLocationToken) { - webSocket.send(message) + if (isCurrentAccountGeneration(generation) && + connections[relayUrl] === webSocket + ) { + webSocket.send(message) + } + } + if (isCurrentAccountGeneration(generation)) { + subscriptions[relayUrl] = currentSubs - id } - subscriptions[relayUrl] = currentSubs - id } catch (e: Exception) { Log.e(TAG, "Failed to unsubscribe from relay") } @@ -595,19 +970,32 @@ class NostrRelayManager private constructor() { } } - fun unsubscribeOwner(owner: String) { + fun unsubscribeOwner( + owner: String, + expectedAccountGeneration: Long = accountGeneration.get() + ) { + if (!isCurrentAccountGeneration(expectedAccountGeneration)) return activeSubscriptions.values - .filter { it.owner == owner } + .filter { + it.owner == owner && + it.accountGeneration == expectedAccountGeneration + } .map { it.id } - .forEach(::unsubscribe) + .forEach { unsubscribe(it, expectedAccountGeneration) } } /** * Manually retry connection to a specific relay */ fun retryConnection(relayUrl: String) { + val generation = accountGeneration.get() + if (!isCurrentAccountGeneration(generation)) return val relay = relaysList.find { it.url == relayUrl } ?: return - desiredConnected.set(true) + val epoch = synchronized(accountGenerationLock) { + if (!isCurrentAccountGeneration(generation)) return + desiredConnected.set(true) + connectionEpoch.get() + } val liveToken = liveLocationRelayTokens[relayUrl] ?.takeIf { relayUrl !in nonLiveRelayUrls } if (!isNetworkActionAllowed(liveToken)) return @@ -622,7 +1010,11 @@ class NostrRelayManager private constructor() { // Attempt immediate reconnection scope.launch { - connectToRelay(relayUrl, liveToken) + if (isCurrentConnectionEpoch(epoch) && + isCurrentAccountGeneration(generation) + ) { + connectToRelay(relayUrl, liveToken, generation, epoch) + } } } @@ -631,6 +1023,8 @@ class NostrRelayManager private constructor() { * This will automatically restore all subscriptions when reconnected */ fun resetAllConnections() { + val generation = accountGeneration.get() + if (!isCurrentAccountGeneration(generation)) return val shouldReconnect = desiredConnected.get() disconnect() @@ -642,7 +1036,7 @@ class NostrRelayManager private constructor() { } // Reconnect only when connectivity was desired before the controlled reset. - if (shouldReconnect) connect() + if (shouldReconnect && isCurrentAccountGeneration(generation)) connect() } /** @@ -650,9 +1044,20 @@ class NostrRelayManager private constructor() { * Useful for ensuring subscription consistency after network issues */ fun reestablishAllSubscriptions() { + val generation = accountGeneration.get() + if (!isCurrentAccountGeneration(generation)) return scope.launch { + if (!isCurrentAccountGeneration(generation)) return@launch connections.forEach { (relayUrl, webSocket) -> - restoreSubscriptionsForRelay(relayUrl, webSocket) + if (isCurrentAccountGeneration(generation) && + connections[relayUrl] === webSocket + ) { + restoreSubscriptionsForRelay( + relayUrl, + webSocket, + generation + ) + } } } } @@ -666,6 +1071,7 @@ class NostrRelayManager private constructor() { // Clear persistent subscription tracking activeSubscriptions.clear() messageHandlers.clear() + commitAwareMessageHandlers.clear() subscriptions.clear() // Clear routing caches (per-geohash relay selections) @@ -680,6 +1086,176 @@ class NostrRelayManager private constructor() { } } + /** + * Refuse fresh work, then discard every process-local relay artifact owned + * by the old account. The opaque token prevents one reset from reopening a + * newer reset. + */ + internal fun discardForAccountReset(): RelayAccountResetToken { + val resetToken = beginAccountReset() + discardForAccountReset(resetToken) + return resetToken + } + + internal fun discardForAccountReset( + resetToken: RelayAccountResetToken + ): Boolean { + val cleanup = prepareAccountResetCleanup(resetToken) ?: return false + + cleanup.jobs.forEach(Job::cancel) + cleanup.sockets.forEach { socket -> + runCatching { socket.close(1000, "Account reset") } + .onFailure { socket.cancel() } + } + cleanup.confirmedPublishes.forEach { tracker -> + tracker.timeoutJob?.cancel() + runCatching { tracker.completion(false) } + .onFailure { + Log.w(TAG, "Confirmed publish reset callback failed") + } + } + + return synchronized(accountResetOperationLock) { + synchronized(accountGenerationLock) { + val generation = resetToken.generation + if (!accountResetBlocked || + accountGeneration.get() != generation || + preparedResetGeneration != generation + ) { + false + } else { + discardedResetGeneration = generation + true + } + } + } + } + + private fun prepareAccountResetCleanup( + resetToken: RelayAccountResetToken + ): AccountResetCleanup? = synchronized(accountResetOperationLock) { + synchronized(accountGenerationLock) generationCheck@{ + val generation = resetToken.generation + if (!accountResetBlocked || + accountGeneration.get() != generation + ) return@generationCheck null + + desiredConnected.set(false) + connectionEpoch.incrementAndGet() + val jobs = buildList { + subscriptionValidationJob?.let(::add) + addAll(reconnectJobs.values) + addAll(liveLocationConnectionJobs) + } + subscriptionValidationJob = null + reconnectJobs.clear() + liveLocationConnectionJobs.clear() + + val sockets = connections.values.toList() + connections.clear() + val pendingConfirmations = + confirmedPublishes.values.toList() + confirmedPublishes.clear() + + activeSubscriptions.clear() + messageHandlers.clear() + commitAwareMessageHandlers.clear() + subscriptions.clear() + messageQueue.clear() + pendingGiftWrapGenerations.clear() + ndrConnectionAvailableHandler = null + eventDeduplicator.clear() + + geohashToRelays.clear() + liveGeohashTokens.clear() + liveLocationRelayTokens.clear() + nonLiveRelayUrls.clear() + nonLiveRelayUrls.addAll(DEFAULT_RELAYS) + synchronized(relaysList) { + relaysList.removeAll { it.url !in DEFAULT_RELAYS } + DEFAULT_RELAYS.forEach { url -> + val relay = relaysList.find { it.url == url } + if (relay == null) { + relaysList.add(Relay(url)) + } else { + relay.isConnected = false + relay.lastError = null + relay.nextReconnectTime = null + relay.reconnectAttempts = 0 + } + } + } + preparedResetGeneration = generation + discardedResetGeneration = Long.MIN_VALUE + updateRelaysList() + updateConnectionStatus() + + AccountResetCleanup( + jobs = jobs, + sockets = sockets, + confirmedPublishes = pendingConfirmations + ) + } + } + + internal fun beginAccountReset(): RelayAccountResetToken = + synchronized(accountGenerationLock) { + accountResetBlocked = true + preparedResetGeneration = Long.MIN_VALUE + discardedResetGeneration = Long.MIN_VALUE + RelayAccountResetToken(accountGeneration.incrementAndGet()) + } + + internal fun completeAccountReset( + resetToken: RelayAccountResetToken + ): Boolean = + synchronized(accountResetOperationLock) { + synchronized(accountGenerationLock) { + val generation = resetToken.generation + if (!accountResetBlocked || + accountGeneration.get() != generation || + discardedResetGeneration != generation + ) { + false + } else { + // Work captured while admission was blocked must not become + // valid when the replacement account is opened. + accountGeneration.incrementAndGet() + accountResetBlocked = false + preparedResetGeneration = Long.MIN_VALUE + discardedResetGeneration = Long.MIN_VALUE + true + } + } + } + + internal fun queuedEventCountForTesting(): Int = messageQueue.size() + + internal fun pendingGiftWrapCountForTesting(): Int = + pendingGiftWrapGenerations.size + + internal fun accountGenerationForTesting(): Long = accountGeneration.get() + + internal fun captureAccountGeneration(): Long = accountGeneration.get() + + internal fun registerPendingGiftWrap( + id: String, + expectedAccountGeneration: Long + ): Boolean = synchronized(accountGenerationLock) { + if (!isCurrentAccountGeneration(expectedAccountGeneration)) { + false + } else { + pendingGiftWrapGenerations[id] = expectedAccountGeneration + true + } + } + + internal fun isAccountGenerationCurrent(generation: Long): Boolean = + isCurrentAccountGeneration(generation) + + private fun isCurrentAccountGeneration(generation: Long): Boolean = + !accountResetBlocked && accountGeneration.get() == generation + /** * Clear all subscription tracking, deduplication cache, message queue, and connections for panic mode. */ @@ -778,15 +1354,26 @@ class NostrRelayManager private constructor() { /** * Start periodic subscription validation to ensure robustness */ - private fun startSubscriptionValidation() { + private fun startSubscriptionValidation( + generation: Long, + connectionEpoch: Long + ) { + if (!isCurrentAccountGeneration(generation) || + !isCurrentConnectionEpoch(connectionEpoch) + ) return stopSubscriptionValidation() // Stop any existing validation - + subscriptionValidationJob = scope.launch { + if (!isCurrentAccountGeneration(generation) || + !isCurrentConnectionEpoch(connectionEpoch) + ) return@launch val manager = powerManager if (manager == null) { runSubscriptionValidationLoop( - com.bitchat.android.util.AppConstants.Nostr - .SUBSCRIPTION_VALIDATION_INTERVAL_MS + intervalMs = com.bitchat.android.util.AppConstants.Nostr + .SUBSCRIPTION_VALIDATION_INTERVAL_MS, + generation = generation, + connectionEpoch = connectionEpoch ) return@launch } @@ -794,35 +1381,66 @@ class NostrRelayManager private constructor() { manager.profile .map { it.nostr.subscriptionValidationMs } .distinctUntilChanged() - .collectLatest(::runSubscriptionValidationLoop) + .collectLatest { intervalMs -> + runSubscriptionValidationLoop( + intervalMs, + generation, + connectionEpoch + ) + } } } - private suspend fun runSubscriptionValidationLoop(intervalMs: Long) { - while (currentCoroutineContext().isActive && desiredConnected.get()) { + private suspend fun runSubscriptionValidationLoop( + intervalMs: Long, + generation: Long, + connectionEpoch: Long + ) { + while (currentCoroutineContext().isActive && + isCurrentConnectionEpoch(connectionEpoch) && + isCurrentAccountGeneration(generation) + ) { delay(intervalMs) - if (!desiredConnected.get()) break - validateAndRepairSubscriptions() + if (!isCurrentConnectionEpoch(connectionEpoch) || + !isCurrentAccountGeneration(generation) + ) break + validateAndRepairSubscriptions(generation, connectionEpoch) } } - private fun validateAndRepairSubscriptions() { + private fun validateAndRepairSubscriptions( + generation: Long, + connectionEpoch: Long + ) { + if (!isCurrentAccountGeneration(generation) || + !isCurrentConnectionEpoch(connectionEpoch) + ) return try { val report = validateSubscriptionConsistency() if (report.isConsistent || report.connectedRelayCount == 0) return Log.w(TAG, "Nostr subscription inconsistencies detected") connections.forEach { (relayUrl, webSocket) -> + if (!isCurrentAccountGeneration(generation) || + !isCurrentConnectionEpoch(connectionEpoch) + ) return val currentSubs = subscriptions[relayUrl] ?: emptySet() val expectedSubs = activeSubscriptions.keys.filter { subId -> val subInfo = activeSubscriptions[subId] - subInfo?.targetRelayUrls == null || - subInfo.targetRelayUrls.contains(relayUrl) + subInfo?.accountGeneration == generation && + (subInfo.targetRelayUrls == null || + subInfo.targetRelayUrls.contains(relayUrl)) }.toSet() - if ((expectedSubs - currentSubs).isNotEmpty()) { + if ((expectedSubs - currentSubs).isNotEmpty() && + connections[relayUrl] === webSocket + ) { Log.i(TAG, "Auto-repairing missing subscriptions") - restoreSubscriptionsForRelay(relayUrl, webSocket) + restoreSubscriptionsForRelay( + relayUrl, + webSocket, + generation + ) } } } catch (e: Exception) { @@ -842,12 +1460,16 @@ class NostrRelayManager private constructor() { private suspend fun connectToRelay( urlString: String, - liveLocationToken: Long? = null + liveLocationToken: Long? = null, + generation: Long = accountGeneration.get(), + expectedConnectionEpoch: Long = connectionEpoch.get() ) { - if (!desiredConnected.get()) return val connectionToken = liveLocationToken ?.takeIf { urlString !in nonLiveRelayUrls } - if (!isNetworkActionAllowed(connectionToken)) return + if (!isCurrentConnectionEpoch(expectedConnectionEpoch) || + !isCurrentAccountGeneration(generation) || + !isNetworkActionAllowed(connectionToken) + ) return // Skip if we already have a connection if (connections.containsKey(urlString)) { return @@ -859,14 +1481,20 @@ class NostrRelayManager private constructor() { .build() val started = runNetworkAction(connectionToken) { - val webSocket = httpClient.newWebSocket( - request, - RelayWebSocketListener(urlString, connectionToken) + val listener = RelayWebSocketListener( + relayUrl = urlString, + liveLocationToken = connectionToken, + generation = generation, + connectionEpoch = expectedConnectionEpoch ) + val webSocket = webSocketFactory?.invoke(request, listener) + ?: httpClient.newWebSocket(request, listener) val existing = connections.putIfAbsent(urlString, webSocket) when { existing != null -> webSocket.close(1000, "Duplicate connection") - !desiredConnected.get() -> { + !isCurrentConnectionEpoch(expectedConnectionEpoch) || + !isCurrentAccountGeneration(generation) || + !isNetworkActionAllowed(connectionToken) -> { connections.remove(urlString, webSocket) webSocket.close(1000, "Connection no longer desired") } @@ -876,7 +1504,13 @@ class NostrRelayManager private constructor() { } catch (e: Exception) { Log.e(TAG, "Failed to create WebSocket connection") - handleConnectionCreationFailure(urlString, e, connectionToken) + handleConnectionCreationFailure( + relayUrl = urlString, + error = e, + liveLocationToken = connectionToken, + generation = generation, + connectionEpoch = expectedConnectionEpoch + ) } } @@ -884,18 +1518,29 @@ class NostrRelayManager private constructor() { event: NostrEvent, webSocket: WebSocket, relayUrl: String, - liveLocationToken: Long? = null + liveLocationToken: Long? = null, + generation: Long = accountGeneration.get() ): Boolean { - if (!isNetworkActionAllowed(liveLocationToken)) return false + if (!isCurrentAccountGeneration(generation) || + !isNetworkActionAllowed(liveLocationToken) || + connections[relayUrl] !== webSocket + ) return false return try { val request = NostrRequest.Event(event) val message = gson.toJson(request, NostrRequest::class.java) var success = false runNetworkAction(liveLocationToken) { - success = webSocket.send(message) + if (isCurrentAccountGeneration(generation) && + connections[relayUrl] === webSocket + ) { + success = webSocket.send(message) + } } - if (success) { + if (success && + isCurrentAccountGeneration(generation) && + connections[relayUrl] === webSocket + ) { // Update relay stats relaysList.find { it.url == relayUrl }?.let { relay -> relay.messagesSent += 1 @@ -912,7 +1557,12 @@ class NostrRelayManager private constructor() { } } - private fun handleMessage(message: String, relayUrl: String) { + private fun handleMessage( + message: String, + relayUrl: String, + generation: Long + ) { + if (!isCurrentAccountGeneration(generation)) return try { val jsonElement = JsonParser.parseString(message) if (!jsonElement.isJsonArray) { @@ -929,11 +1579,14 @@ class NostrRelayManager private constructor() { relay.messagesReceived += 1 } updateRelaysList() - + // CLIENT-SIDE FILTER ENFORCEMENT: Ensure this event matches the subscription's filter val subscriptionInfo = activeSubscriptions[response.subscriptionId] ?: return - if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) return + if (subscriptionInfo.accountGeneration != generation || + !isCurrentAccountGeneration(generation) || + !isNetworkActionAllowed(subscriptionInfo.liveLocationToken) + ) return subscriptionInfo.let { subInfo -> val matches = try { subInfo.filter.matches(response.event) } catch (e: Exception) { true } if (!matches) { @@ -942,18 +1595,43 @@ class NostrRelayManager private constructor() { } } - // DEDUPLICATION: Check if we've already processed this event - eventDeduplicator.processEvent(response.event) { event -> - // Call handler for new events only - val handler = messageHandlers[response.subscriptionId] - if (handler != null) { - scope.launch(Dispatchers.Main) { - if (isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) { - handler(event) - } + val dispatch = EventDispatch( + subscriptionInfo = subscriptionInfo, + commitAwareHandler = + commitAwareMessageHandlers[response.subscriptionId], + ordinaryHandler = + messageHandlers[response.subscriptionId] + ) + val commitAwareHandler = dispatch.commitAwareHandler + if (commitAwareHandler != null) { + scope.launch { + if (!isCurrentAccountGeneration(generation)) { + return@launch + } + eventDeduplicator.processEventAfterSuccess( + response.event + ) { event -> + isCurrentAccountGeneration(generation) && + commitAwareHandler(event) + } + } + return + } + + val handler = dispatch.ordinaryHandler + if (handler == null) { + Log.w(TAG, "⚠️ No handler for Nostr subscription") + return + } + eventDeduplicator.processEvent(response.event) { event -> + scope.launch(Dispatchers.Main) { + if (isCurrentAccountGeneration(generation) && + isNetworkActionAllowed( + subscriptionInfo.liveLocationToken + ) + ) { + handler(event) } - } else { - Log.w(TAG, "⚠️ No handler for Nostr subscription") } } @@ -964,8 +1642,37 @@ class NostrRelayManager private constructor() { } is NostrResponse.Ok -> { - val wasGiftWrap = pendingGiftWrapIDs.remove(response.eventId) - if (!response.accepted) { + val wasGiftWrap = pendingGiftWrapGenerations.remove( + response.eventId, + generation + ) + val tracker = confirmedPublishes[response.eventId] + ?.takeIf { it.accountGeneration == generation } + if (tracker != null) { + when { + isNip20ConfirmedSuccess( + response.accepted, + response.message + ) -> completeConfirmedPublish( + response.eventId, + tracker, + accepted = true + ) + + tracker.awaitingRelayUrls.remove(relayUrl) && + tracker.awaitingRelayUrls.isEmpty() -> + completeConfirmedPublish( + response.eventId, + tracker, + accepted = false + ) + } + } + if (!isNip20ConfirmedSuccess( + response.accepted, + response.message + ) + ) { val level = if (wasGiftWrap) Log.WARN else Log.ERROR Log.println(level, TAG, "Event rejected by relay: ${response.message ?: "no reason"}") } @@ -988,77 +1695,126 @@ class NostrRelayManager private constructor() { relayUrl: String, webSocket: WebSocket, error: Throwable, - liveLocationToken: Long? = null + liveLocationToken: Long? = null, + generation: Long, + connectionEpoch: Long ) { + if (!isCurrentAccountGeneration(generation) || + !isCurrentConnectionEpoch(connectionEpoch) + ) { + connections.remove(relayUrl, webSocket) + return + } // Ignore callbacks from intentionally closed or replaced sockets. They must not remove a // newer socket or schedule a reconnect after a controlled disconnect/privacy revocation. if (!connections.remove(relayUrl, webSocket)) return subscriptions.remove(relayUrl) - handleCurrentDisconnection(relayUrl, error, liveLocationToken) + handleCurrentDisconnection( + relayUrl, + error, + liveLocationToken, + generation, + connectionEpoch + ) } private fun handleConnectionCreationFailure( relayUrl: String, error: Throwable, - liveLocationToken: Long? + liveLocationToken: Long?, + generation: Long, + connectionEpoch: Long ) { - if (!desiredConnected.get()) return - handleCurrentDisconnection(relayUrl, error, liveLocationToken) + if (!isCurrentConnectionEpoch(connectionEpoch) || + !isCurrentAccountGeneration(generation) || + connections.containsKey(relayUrl) + ) return + handleCurrentDisconnection( + relayUrl, + error, + liveLocationToken, + generation, + connectionEpoch + ) } private fun handleCurrentDisconnection( relayUrl: String, error: Throwable, - liveLocationToken: Long? + liveLocationToken: Long?, + generation: Long, + connectionEpoch: Long ) { + if (!isCurrentAccountGeneration(generation) || + !isCurrentConnectionEpoch(connectionEpoch) + ) return val connectionToken = liveLocationToken ?.takeIf { relayUrl !in nonLiveRelayUrls } updateRelayStatus(relayUrl, false, error) - if (!desiredConnected.get() || + confirmedPublishes.entries.toList().forEach { (eventId, tracker) -> + if (tracker.accountGeneration == generation && + tracker.awaitingRelayUrls.remove(relayUrl) && + tracker.awaitingRelayUrls.isEmpty() + ) { + completeConfirmedPublish( + eventId, + tracker, + accepted = false + ) + } + } + if (!isCurrentConnectionEpoch(connectionEpoch) || + !isCurrentAccountGeneration(generation) || !isNetworkActionAllowed(connectionToken) ) return - + // Check if this is a DNS error val errorMessage = error.message?.lowercase() ?: "" - if (errorMessage.contains("hostname could not be found") || + if (errorMessage.contains("hostname could not be found") || errorMessage.contains("dns") || errorMessage.contains("unable to resolve host")) { - + val relay = relaysList.find { it.url == relayUrl } if (relay?.lastError == null) { Log.w(TAG, "Nostr relay DNS failure; not retrying") } return } - + // Implement exponential backoff for non-DNS errors val relay = relaysList.find { it.url == relayUrl } ?: return relay.reconnectAttempts++ - + // Stop attempting after max attempts if (relay.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { Log.w(TAG, "Max Nostr relay reconnection attempts reached") return } - + // Calculate backoff interval val backoffInterval = min( INITIAL_BACKOFF_INTERVAL * BACKOFF_MULTIPLIER.pow(relay.reconnectAttempts - 1.0), MAX_BACKOFF_INTERVAL.toDouble() ).toLong() - + relay.nextReconnectTime = System.currentTimeMillis() + backoffInterval - + Log.d(TAG, "Scheduling Nostr relay reconnection") - + reconnectJobs.remove(relayUrl)?.cancel() val reconnectJob = scope.launch { delay(backoffInterval) - if (desiredConnected.get() && + if (isCurrentConnectionEpoch(connectionEpoch) && + isCurrentAccountGeneration(generation) && isNetworkActionAllowed(connectionToken) ) { - connectToRelay(relayUrl, connectionToken) + connectToRelay( + relayUrl, + connectionToken, + generation, + connectionEpoch + ) } } reconnectJobs[relayUrl] = reconnectJob @@ -1101,10 +1857,18 @@ class NostrRelayManager private constructor() { /** * Restore all active subscriptions for a specific relay that just reconnected */ - private fun restoreSubscriptionsForRelay(relayUrl: String, webSocket: WebSocket) { + private fun restoreSubscriptionsForRelay( + relayUrl: String, + webSocket: WebSocket, + generation: Long + ) { + if (!isCurrentAccountGeneration(generation) || + connections[relayUrl] !== webSocket + ) return val subscriptionsToRestore = activeSubscriptions.values.filter { subscriptionInfo -> // Include subscription if it targets all relays or specifically targets this relay - isNetworkActionAllowed(subscriptionInfo.liveLocationToken) && + subscriptionInfo.accountGeneration == generation && + isNetworkActionAllowed(subscriptionInfo.liveLocationToken) && (subscriptionInfo.targetRelayUrls == null || subscriptionInfo.targetRelayUrls.contains(relayUrl)) } @@ -1114,14 +1878,21 @@ class NostrRelayManager private constructor() { } subscriptionsToRestore.forEach { subscriptionInfo -> + if (!isCurrentAccountGeneration(generation) || + connections[relayUrl] !== webSocket + ) return try { val request = NostrRequest.Subscribe(subscriptionInfo.id, listOf(subscriptionInfo.filter)) val message = gson.toJson(request, NostrRequest::class.java) var success = false runNetworkAction(subscriptionInfo.liveLocationToken) { - success = webSocket.send(message) - if (success) { + if (isCurrentAccountGeneration(generation) && + connections[relayUrl] === webSocket + ) { + success = webSocket.send(message) + } + if (success && isCurrentAccountGeneration(generation)) { val currentSubs = subscriptions[relayUrl] ?: emptySet() subscriptions[relayUrl] = currentSubs + subscriptionInfo.id @@ -1141,11 +1912,14 @@ class NostrRelayManager private constructor() { */ private inner class RelayWebSocketListener( private val relayUrl: String, - private val liveLocationToken: Long? + private val liveLocationToken: Long?, + private val generation: Long, + private val connectionEpoch: Long ) : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { - if (!desiredConnected.get() || + if (!isCurrentConnectionEpoch(connectionEpoch) || + !isCurrentAccountGeneration(generation) || connections[relayUrl] !== webSocket || !isNetworkActionAllowed(liveLocationToken) ) { @@ -1155,29 +1929,49 @@ class NostrRelayManager private constructor() { } reconnectJobs.remove(relayUrl)?.cancel() updateRelayStatus(relayUrl, true) - + // Restore all active subscriptions for this relay - restoreSubscriptionsForRelay(relayUrl, webSocket) - + restoreSubscriptionsForRelay(relayUrl, webSocket, generation) + // Process only events still pending for this relay, outside the queue lock. - val queuedForRelay = messageQueue.pendingForRelay(relayUrl) - .filter { isNetworkActionAllowed(it.liveLocationToken) } + val queuedForRelay = messageQueue.pendingForRelay( + relayUrl = relayUrl, + accountGeneration = generation + ).filter { isNetworkActionAllowed(it.liveLocationToken) } queuedForRelay.forEach { delivery -> - if (sendToRelay( + if (delivery.accountGeneration == generation && + isCurrentAccountGeneration(generation) && + connections[relayUrl] === webSocket && + sendToRelay( delivery.event, webSocket, relayUrl, - delivery.liveLocationToken + delivery.liveLocationToken, + generation ) ) { messageQueue.markDelivered(delivery.queueId, relayUrl) } } + + val connectionHandler = ndrConnectionAvailableHandler + if (connectionHandler?.accountGeneration == generation && + isCurrentAccountGeneration(generation) && + connections[relayUrl] === webSocket + ) { + runCatching { connectionHandler.callback() } + .onFailure { + Log.w(TAG, "NDR reconnect callback failed") + } + } } override fun onMessage(webSocket: WebSocket, text: String) { - if (connections[relayUrl] !== webSocket) return - handleMessage(text, relayUrl) + if (!isCurrentAccountGeneration(generation) || + !isCurrentConnectionEpoch(connectionEpoch) || + connections[relayUrl] !== webSocket + ) return + handleMessage(text, relayUrl, generation) } override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { @@ -1186,12 +1980,26 @@ class NostrRelayManager private constructor() { override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { val error = Exception("WebSocket closed: $code $reason") - handleDisconnection(relayUrl, webSocket, error, liveLocationToken) + handleDisconnection( + relayUrl, + webSocket, + error, + liveLocationToken, + generation, + connectionEpoch + ) } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { Log.e(TAG, "Nostr WebSocket failure") - handleDisconnection(relayUrl, webSocket, t, liveLocationToken) + handleDisconnection( + relayUrl, + webSocket, + t, + liveLocationToken, + generation, + connectionEpoch + ) } } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt index 19ebeb9c..bb8bca17 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt @@ -3,6 +3,7 @@ package com.bitchat.android.nostr import android.app.Application import android.util.Log import com.bitchat.android.geohash.LiveLocationPrivacyGate +import kotlinx.coroutines.CoroutineScope /** * NostrSubscriptionManager @@ -14,6 +15,8 @@ import com.bitchat.android.geohash.LiveLocationPrivacyGate */ class NostrSubscriptionManager( private val application: Application, + @Suppress("UNUSED_PARAMETER") + private val scope: CoroutineScope? = null, private val owner: String = NostrRelayManager.OWNER_LEGACY ) { companion object { private const val TAG = "NostrSubscriptionManager" } @@ -38,13 +41,15 @@ class NostrSubscriptionManager( liveLocationToken: Long? = null ) { if (!isAllowed(liveLocationToken)) return + val generation = relayManager.captureAccountGeneration() val filter = NostrFilter.giftWrapsFor(pubkey, sinceMs) relayManager.subscribe( filter = filter, id = id, handler = handler, owner = owner, - liveLocationToken = liveLocationToken + liveLocationToken = liveLocationToken, + expectedAccountGeneration = generation ) } @@ -58,6 +63,7 @@ class NostrSubscriptionManager( liveLocationToken: Long? = null ) { if (!isAllowed(liveLocationToken)) return + val generation = relayManager.captureAccountGeneration() val filter = NostrFilter.geohashMessages(geohash, sinceMs, limit) relayManager.subscribeForGeohash( geohash, @@ -67,7 +73,8 @@ class NostrSubscriptionManager( includeDefaults = false, nRelays = 5, owner = owner, - liveLocationToken = liveLocationToken + liveLocationToken = liveLocationToken, + expectedAccountGeneration = generation ) } @@ -81,6 +88,7 @@ class NostrSubscriptionManager( liveLocationToken: Long? = null ) { if (!isAllowed(liveLocationToken)) return + val generation = relayManager.captureAccountGeneration() val filter = NostrFilter.geohashPresence(geohash, sinceMs, limit) relayManager.subscribeForGeohash( geohash, @@ -90,16 +98,29 @@ class NostrSubscriptionManager( includeDefaults = false, nRelays = 5, owner = owner, - liveLocationToken = liveLocationToken + liveLocationToken = liveLocationToken, + expectedAccountGeneration = generation ) } fun unsubscribe(id: String) { - runCatching { relayManager.unsubscribe(id) } + val generation = relayManager.captureAccountGeneration() + runCatching { + relayManager.unsubscribe( + id, + expectedAccountGeneration = generation + ) + } } fun unsubscribeAllOwned() { - runCatching { relayManager.unsubscribeOwner(owner) } + val generation = relayManager.captureAccountGeneration() + runCatching { + relayManager.unsubscribeOwner( + owner, + expectedAccountGeneration = generation + ) + } } private fun isAllowed(liveLocationToken: Long?): Boolean = diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt index f39324fd..afb92450 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt @@ -4,19 +4,80 @@ import android.content.Context import android.util.Log import com.bitchat.android.favorites.FavoriteControlMessage import com.bitchat.android.model.ReadReceipt +import com.bitchat.android.model.NdrFeatureGate import com.bitchat.android.model.NoisePayloadType import com.bitchat.android.services.ContactDirectory import com.bitchat.android.services.ContactIdentityResolver import kotlinx.coroutines.* import java.util.* import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicBoolean + +enum class NostrSendAdmission { + /** The legacy relay handoff or durable pairwise NDR state now owns delivery. */ + ADMITTED, + + /** No transport accepted the message yet, but unchanged input may succeed later. */ + RETRYABLE, + + /** The payload or recipient is invalid and cannot succeed unchanged. */ + TERMINAL_FAILED +} + +internal enum class NdrSendDisposition { + ADMITTED, + LEGACY_FALLBACK, + RETRYABLE +} + +internal fun ndrSendDisposition( + result: NdrSendResult, + ndrRequired: Boolean = false, + rebindBlocked: Boolean = false, + pairwiseOnly: Boolean = false +): NdrSendDisposition = when { + result == NdrSendResult.SENT -> NdrSendDisposition.ADMITTED + rebindBlocked -> NdrSendDisposition.RETRYABLE + result == NdrSendResult.NO_SESSION && !ndrRequired && !pairwiseOnly -> + NdrSendDisposition.LEGACY_FALLBACK + else -> NdrSendDisposition.RETRYABLE +} + +internal fun shouldUseLegacyNostrFallback( + result: NdrSendResult, + ndrRequired: Boolean = false, + rebindBlocked: Boolean = false +): Boolean = + ndrSendDisposition( + result = result, + ndrRequired = ndrRequired, + rebindBlocked = rebindBlocked + ) == NdrSendDisposition.LEGACY_FALLBACK + +internal fun isLegacyNostrAllowedWhenNdrDisabled( + ndrRequired: Boolean, + rebindBlocked: Boolean +): Boolean = !ndrRequired && !rebindBlocked + +private data class NdrRecipientResolution( + val peerPubkeyHex: String, + val ndrRequired: Boolean, + val rebindBlocked: Boolean +) + +@JvmInline +internal value class NostrTransportResetToken(val epoch: Long) /** * Nostr transport for offline private messages and receipts. */ class NostrTransport( private val context: Context, - var senderPeerID: String = "" + var senderPeerID: String = "", + private val transportScope: CoroutineScope = + CoroutineScope(Dispatchers.IO + SupervisorJob()), + private val relayManager: NostrRelayManager = + NostrRelayManager.getInstance(context) ) { companion object { @@ -25,6 +86,8 @@ class NostrTransport( @Volatile private var INSTANCE: NostrTransport? = null + + fun tryGetInstance(): NostrTransport? = INSTANCE fun getInstance(context: Context): NostrTransport { return INSTANCE ?: synchronized(this) { @@ -34,127 +97,251 @@ class NostrTransport( } // Throttle READ receipts to avoid relay rate limits (like iOS) + private data class AccountToken( + val transportEpoch: Long, + val relayGeneration: Long + ) + private data class QueuedRead( val receipt: ReadReceipt, - val peerID: String + val peerID: String, + val sequence: Long, + val accountToken: AccountToken ) private val readQueue = ConcurrentLinkedQueue() - private var isSendingReadAcks = false - private val transportScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val accountStateLock = Any() + private var transportAccountEpoch = 0L + private var accountResetBlocked = false + private var nextReadSequence = 0L + private var activeReadSequence: Long? = null + private val ndrService by lazy { NdrNostrService.getInstance(context) } // MARK: - Transport Interface Methods val myPeerID: String get() = senderPeerID + + private fun captureAccountToken(): AccountToken? = synchronized(accountStateLock) { + if (accountResetBlocked) { + null + } else { + AccountToken( + transportEpoch = transportAccountEpoch, + relayGeneration = relayManager.captureAccountGeneration() + ) + } + } + + private fun isAccountTokenCurrent(token: AccountToken): Boolean = + synchronized(accountStateLock) { + !accountResetBlocked && + token.transportEpoch == transportAccountEpoch && + relayManager.isAccountGenerationCurrent(token.relayGeneration) + } + + /** + * Start an account-lifetime barrier. Already-launched work keeps its captured + * token and is refused at the final relay handoff; throttled receipts are + * discarded immediately. + */ + internal fun discardForAccountReset(): NostrTransportResetToken = + synchronized(accountStateLock) { + accountResetBlocked = true + transportAccountEpoch += 1 + readQueue.clear() + activeReadSequence = null + NostrTransportResetToken(transportAccountEpoch) + } + + /** + * Allow fresh work only when the caller still owns the latest reset. + * A later panic/quit must not be reopened by an older reset finishing late. + */ + internal fun completeAccountReset( + resetToken: NostrTransportResetToken + ): Boolean = + synchronized(accountStateLock) { + if (resetToken.epoch != transportAccountEpoch) { + return@synchronized false + } + accountResetBlocked = false + true + } + + internal fun queuedReadCountForTesting(): Int = readQueue.size + + internal fun activeReadCountForTesting(): Int = synchronized(accountStateLock) { + if (activeReadSequence == null) 0 else 1 + } fun sendPrivateMessage( content: String, to: String, recipientNickname: String, - messageID: String + messageID: String, + expiresAtSeconds: ULong? = null, + completion: (NostrSendAdmission) -> Unit = {} ) { - transportScope.launch { - try { - val recipientNostrPubkey = resolveNostrPublicKey(to) - - if (recipientNostrPubkey == null) { - Log.w(TAG, "No Nostr public key found for peerID: $to") - return@launch - } - - val senderIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context) - if (senderIdentity == null) { - Log.e(TAG, "No Nostr identity available") - return@launch - } - - val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey) - if (recipientHex == null) { - Log.e(TAG, "NostrTransport: recipient key is not a valid Nostr pubkey") - return@launch - } + val accountToken = captureAccountToken() + if (accountToken == null) { + runCatching { completion(NostrSendAdmission.RETRYABLE) } + .onFailure { Log.w(TAG, "Nostr private-message admission callback failed") } + return + } + val completed = AtomicBoolean(false) + fun completeOnce(admission: NostrSendAdmission) { + if (!completed.compareAndSet(false, true)) return + runCatching { completion(admission) } + .onFailure { Log.w(TAG, "Nostr private-message admission callback failed") } + } - val recipientPeerIDForEmbed = try { - com.bitchat.android.favorites.FavoritesPersistenceService.shared - .findPeerIDForNostrPubkey(recipientNostrPubkey) - } catch (_: Exception) { null } - if (recipientPeerIDForEmbed.isNullOrBlank()) { - Log.e(TAG, "NostrTransport: no peerID stored for recipient npub; cannot embed PM") - return@launch - } - val embedded = NostrEmbeddedBitChat.encodePMForNostr( + val job = transportScope.launch { + val admission = try { + prepareAndSendPrivateMessage( content = content, + to = to, messageID = messageID, - recipientPeerID = recipientPeerIDForEmbed, - senderPeerID = senderPeerID + expiresAtSeconds = expiresAtSeconds, + accountToken = accountToken ) - - - if (embedded == null) { - Log.e(TAG, "NostrTransport: failed to embed PM packet") - return@launch - } - - val giftWraps = NostrProtocol.createPrivateMessage( - content = embedded, - recipientPubkey = recipientHex, - senderIdentity = senderIdentity - ) - - giftWraps.forEach { event -> - NostrRelayManager.registerPendingGiftWrap(event.id) - NostrRelayManager.getInstance(context).sendEvent(event) - } - } catch (e: Exception) { Log.e(TAG, "Failed to send private message via Nostr: ${e.message}") + NostrSendAdmission.RETRYABLE + } + completeOnce(admission) + } + job.invokeOnCompletion { cause -> + if (cause != null) { + completeOnce(NostrSendAdmission.RETRYABLE) } } } + + private fun prepareAndSendPrivateMessage( + content: String, + to: String, + messageID: String, + expiresAtSeconds: ULong?, + accountToken: AccountToken + ): NostrSendAdmission { + if (!isAccountTokenCurrent(accountToken)) { + return NostrSendAdmission.RETRYABLE + } + if (expiresAtSeconds != null && + expiresAtSeconds <= (System.currentTimeMillis() / 1_000L).toULong() + ) { + Log.e(TAG, "NostrTransport: refusing an already-expired private message") + return NostrSendAdmission.TERMINAL_FAILED + } + val recipientNostrPubkey = resolveNostrPublicKey(to) + if (recipientNostrPubkey == null) { + Log.w(TAG, "No Nostr public key found for peerID: $to") + return NostrSendAdmission.RETRYABLE + } + + val senderIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context) + if (senderIdentity == null) { + Log.e(TAG, "No Nostr identity available") + return NostrSendAdmission.RETRYABLE + } + + val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey) + if (recipientHex == null) { + Log.e(TAG, "NostrTransport: recipient key is not a valid Nostr pubkey") + return NostrSendAdmission.TERMINAL_FAILED + } + val ndrRecipient = resolveNdrRecipient(to, recipientHex) + + val recipientPeerIDForEmbed = try { + com.bitchat.android.favorites.FavoritesPersistenceService.shared + .findPeerIDForNostrPubkey(recipientNostrPubkey) + } catch (_: Exception) { + null + } + if (recipientPeerIDForEmbed.isNullOrBlank()) { + Log.e(TAG, "NostrTransport: no peerID stored for recipient npub; cannot embed PM") + return NostrSendAdmission.RETRYABLE + } + val embedded = NostrEmbeddedBitChat.encodePMForNostr( + content = content, + messageID = messageID, + recipientPeerID = recipientPeerIDForEmbed, + senderPeerID = senderPeerID + ) + if (embedded == null) { + Log.e(TAG, "NostrTransport: failed to embed PM packet") + return NostrSendAdmission.TERMINAL_FAILED + } + + return sendWrappedMessage( + content = embedded, + fallbackRecipientHex = recipientHex, + senderIdentity = senderIdentity, + ndrRecipient = ndrRecipient, + expiresAtSeconds = expiresAtSeconds, + accountToken = accountToken + ) + } fun sendReadReceipt(receipt: ReadReceipt, to: String) { + val accountToken = captureAccountToken() ?: return // Enqueue and process with throttling to avoid relay rate limits - readQueue.offer(QueuedRead(receipt, to)) + val queuedRead = synchronized(accountStateLock) { + if (!isAccountTokenCurrent(accountToken)) { + null + } else { + QueuedRead( + receipt = receipt, + peerID = to, + sequence = ++nextReadSequence, + accountToken = accountToken + ).also(readQueue::offer) + } + } ?: return + if (!isAccountTokenCurrent(queuedRead.accountToken)) return processReadQueueIfNeeded() } private fun processReadQueueIfNeeded() { - if (isSendingReadAcks) return - if (readQueue.isEmpty()) return - - isSendingReadAcks = true - sendNextReadAck() + val item = synchronized(accountStateLock) { + if (accountResetBlocked || activeReadSequence != null) return + var next = readQueue.poll() + while (next != null && !isAccountTokenCurrent(next.accountToken)) { + next = readQueue.poll() + } + next?.also { activeReadSequence = it.sequence } + } ?: return + sendReadAck(item) } - private fun sendNextReadAck() { - val item = readQueue.poll() - if (item == null) { - isSendingReadAcks = false - return - } - + private fun sendReadAck(item: QueuedRead) { transportScope.launch { try { + if (!isAccountTokenCurrent(item.accountToken)) { + finishReadAck(item) + return@launch + } val recipientNostrPubkey = resolveNostrPublicKey(item.peerID) if (recipientNostrPubkey == null) { Log.w(TAG, "No Nostr public key found for read receipt to: ${item.peerID}") - scheduleNextReadAck() + finishReadAck(item) return@launch } val senderIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context) if (senderIdentity == null) { Log.e(TAG, "No Nostr identity available for read receipt") - scheduleNextReadAck() + finishReadAck(item) return@launch } val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey) if (recipientHex == null) { - scheduleNextReadAck() + finishReadAck(item) return@launch } + val ndrRecipient = resolveNdrRecipient(item.peerID, recipientHex) val ack = NostrEmbeddedBitChat.encodeAckForNostr( type = NoisePayloadType.READ_RECEIPT, @@ -165,41 +352,43 @@ class NostrTransport( if (ack == null) { Log.e(TAG, "NostrTransport: failed to embed READ ack") - scheduleNextReadAck() + finishReadAck(item) return@launch } - val giftWraps = NostrProtocol.createPrivateMessage( + sendWrappedMessage( content = ack, - recipientPubkey = recipientHex, - senderIdentity = senderIdentity + fallbackRecipientHex = recipientHex, + senderIdentity = senderIdentity, + ndrRecipient = ndrRecipient, + accountToken = item.accountToken ) - giftWraps.forEach { event -> - NostrRelayManager.registerPendingGiftWrap(event.id) - NostrRelayManager.getInstance(context).sendEvent(event) - } - - scheduleNextReadAck() + finishReadAck(item) } catch (e: Exception) { Log.e(TAG, "Failed to send read receipt via Nostr: ${e.message}") - scheduleNextReadAck() + finishReadAck(item) } } } - private fun scheduleNextReadAck() { + private fun finishReadAck(item: QueuedRead) { transportScope.launch { delay(READ_ACK_INTERVAL) - isSendingReadAcks = false + synchronized(accountStateLock) { + if (activeReadSequence != item.sequence) return@launch + activeReadSequence = null + } processReadQueueIfNeeded() } } fun sendFavoriteNotification(to: String, isFavorite: Boolean) { + val accountToken = captureAccountToken() ?: return transportScope.launch { try { + if (!isAccountTokenCurrent(accountToken)) return@launch val recipientNostrPubkey = resolveNostrPublicKey(to) if (recipientNostrPubkey == null) { @@ -219,7 +408,8 @@ class NostrTransport( if (recipientHex == null) { return@launch } - + val ndrRecipient = resolveNdrRecipient(to, recipientHex) + val embedded = NostrEmbeddedBitChat.encodePMForNostr( content = content, messageID = UUID.randomUUID().toString(), @@ -232,17 +422,14 @@ class NostrTransport( return@launch } - val giftWraps = NostrProtocol.createPrivateMessage( + sendWrappedMessage( content = embedded, - recipientPubkey = recipientHex, - senderIdentity = senderIdentity + fallbackRecipientHex = recipientHex, + senderIdentity = senderIdentity, + ndrRecipient = ndrRecipient, + accountToken = accountToken ) - giftWraps.forEach { event -> - NostrRelayManager.registerPendingGiftWrap(event.id) - NostrRelayManager.getInstance(context).sendEvent(event) - } - } catch (e: Exception) { Log.e(TAG, "Failed to send favorite notification via Nostr: ${e.message}") } @@ -250,8 +437,10 @@ class NostrTransport( } fun sendDeliveryAck(messageID: String, to: String) { + val accountToken = captureAccountToken() ?: return transportScope.launch { try { + if (!isAccountTokenCurrent(accountToken)) return@launch val recipientNostrPubkey = resolveNostrPublicKey(to) if (recipientNostrPubkey == null) { @@ -269,6 +458,7 @@ class NostrTransport( if (recipientHex == null) { return@launch } + val ndrRecipient = resolveNdrRecipient(to, recipientHex) val ack = NostrEmbeddedBitChat.encodeAckForNostr( type = NoisePayloadType.DELIVERED, @@ -282,17 +472,14 @@ class NostrTransport( return@launch } - val giftWraps = NostrProtocol.createPrivateMessage( + sendWrappedMessage( content = ack, - recipientPubkey = recipientHex, - senderIdentity = senderIdentity + fallbackRecipientHex = recipientHex, + senderIdentity = senderIdentity, + ndrRecipient = ndrRecipient, + accountToken = accountToken ) - giftWraps.forEach { event -> - NostrRelayManager.registerPendingGiftWrap(event.id) - NostrRelayManager.getInstance(context).sendEvent(event) - } - } catch (e: Exception) { Log.e(TAG, "Failed to send delivery ack via Nostr: ${e.message}") } @@ -306,8 +493,10 @@ class NostrTransport( toRecipientHex: String, fromIdentity: NostrIdentity ) { + val accountToken = captureAccountToken() ?: return transportScope.launch { try { + if (!isAccountTokenCurrent(accountToken)) return@launch val embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient( type = NoisePayloadType.DELIVERED, messageID = messageID, @@ -322,11 +511,7 @@ class NostrTransport( senderIdentity = fromIdentity ) - // Register pending gift wrap for deduplication and send all - giftWraps.forEach { event -> - NostrRelayManager.registerPendingGiftWrap(event.id) - NostrRelayManager.getInstance(context).sendEvent(event) - } + sendLegacyGiftWraps(giftWraps, accountToken) } catch (e: Exception) { Log.e(TAG, "Failed to send geohash delivery ack: ${e.message}") @@ -339,8 +524,10 @@ class NostrTransport( toRecipientHex: String, fromIdentity: NostrIdentity ) { + val accountToken = captureAccountToken() ?: return transportScope.launch { try { + if (!isAccountTokenCurrent(accountToken)) return@launch val embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient( type = NoisePayloadType.READ_RECEIPT, messageID = messageID, @@ -355,11 +542,7 @@ class NostrTransport( senderIdentity = fromIdentity ) - // Register pending gift wrap for deduplication and send all - giftWraps.forEach { event -> - NostrRelayManager.registerPendingGiftWrap(event.id) - NostrRelayManager.getInstance(context).sendEvent(event) - } + sendLegacyGiftWraps(giftWraps, accountToken) } catch (e: Exception) { Log.e(TAG, "Failed to send geohash read receipt: ${e.message}") @@ -375,6 +558,7 @@ class NostrTransport( messageID: String, sourceGeohash: String? = null ) { + val accountToken = captureAccountToken() ?: return // Use provided geohash or derive from current location val geohash = sourceGeohash ?: run { val selected = try { @@ -396,6 +580,7 @@ class NostrTransport( transportScope.launch { try { + if (!isAccountTokenCurrent(accountToken)) return@launch if (toRecipientHex.isEmpty()) return@launch // Build embedded BitChat packet without recipient peer ID @@ -414,10 +599,7 @@ class NostrTransport( senderIdentity = fromIdentity ) - giftWraps.forEach { event -> - NostrRelayManager.registerPendingGiftWrap(event.id) - NostrRelayManager.getInstance(context).sendEvent(event) - } + sendLegacyGiftWraps(giftWraps, accountToken) } catch (e: Exception) { Log.e(TAG, "Failed to send geohash private message: ${e.message}") } @@ -425,6 +607,131 @@ class NostrTransport( } // MARK: - Helper Methods + + private fun sendWrappedMessage( + content: String, + fallbackRecipientHex: String, + senderIdentity: NostrIdentity, + ndrRecipient: NdrRecipientResolution = NdrRecipientResolution( + peerPubkeyHex = fallbackRecipientHex, + ndrRequired = false, + rebindBlocked = false + ), + expiresAtSeconds: ULong? = null, + accountToken: AccountToken + ): NostrSendAdmission { + if (!isAccountTokenCurrent(accountToken)) { + return NostrSendAdmission.RETRYABLE + } + if (ndrRecipient.rebindBlocked) { + Log.e(TAG, "NostrTransport: recipient rebind is quarantined") + return NostrSendAdmission.RETRYABLE + } + if (NdrFeatureGate.isEnabled()) { + val configured = ndrService.configureIfNeeded(senderIdentity) { + isAccountTokenCurrent(accountToken) + } + if (!configured) { + return NostrSendAdmission.RETRYABLE + } + val sendResult = ndrService.sendIfPossible( + text = content, + peerPubkeyHex = ndrRecipient.peerPubkeyHex, + expiresAtSeconds = expiresAtSeconds, + accountGuard = { isAccountTokenCurrent(accountToken) } + ) + when ( + ndrSendDisposition( + result = sendResult, + ndrRequired = ndrRecipient.ndrRequired, + rebindBlocked = ndrRecipient.rebindBlocked, + pairwiseOnly = expiresAtSeconds != null + ) + ) { + NdrSendDisposition.ADMITTED -> return NostrSendAdmission.ADMITTED + NdrSendDisposition.RETRYABLE -> { + Log.e(TAG, "NostrTransport: pairwise send not admitted; refusing legacy downgrade") + return NostrSendAdmission.RETRYABLE + } + NdrSendDisposition.LEGACY_FALLBACK -> Unit + } + } else if (expiresAtSeconds != null || + !isLegacyNostrAllowedWhenNdrDisabled( + ndrRequired = ndrRecipient.ndrRequired, + rebindBlocked = ndrRecipient.rebindBlocked + ) + ) { + Log.e(TAG, "NostrTransport: pairwise transport is required") + return NostrSendAdmission.RETRYABLE + } + + val events = NostrProtocol.createPrivateMessage( + content = content, + recipientPubkey = fallbackRecipientHex, + senderIdentity = senderIdentity + ) + if (events.isEmpty()) { + Log.e(TAG, "NostrTransport: failed to create legacy gift wrap") + return NostrSendAdmission.RETRYABLE + } + return if (sendLegacyGiftWraps(events, accountToken)) { + NostrSendAdmission.ADMITTED + } else { + NostrSendAdmission.RETRYABLE + } + } + + private fun sendLegacyGiftWraps( + events: List, + accountToken: AccountToken + ): Boolean = synchronized(accountStateLock) { + if (!isAccountTokenCurrent(accountToken)) return@synchronized false + events.all { event -> + relayManager.registerPendingGiftWrap( + event.id, + accountToken.relayGeneration + ) && relayManager.sendEvent( + event = event, + expectedAccountGeneration = accountToken.relayGeneration + ) + } + } + + private fun resolveNdrRecipient( + target: String, + fallbackRecipientHex: String + ): NdrRecipientResolution { + return try { + val favorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared + if (!favorites.isNdrProtectionStateReadable()) { + return NdrRecipientResolution( + peerPubkeyHex = fallbackRecipientHex, + ndrRequired = true, + rebindBlocked = true + ) + } + val relationship = favorites.getFavoriteStatus(target) + ?: return NdrRecipientResolution( + peerPubkeyHex = fallbackRecipientHex, + ndrRequired = false, + rebindBlocked = false + ) + NdrRecipientResolution( + peerPubkeyHex = + favorites.findNdrSessionPubkeyHex(relationship.peerNoisePublicKey) + ?: fallbackRecipientHex, + ndrRequired = favorites.isNdrRequired(relationship.peerNoisePublicKey), + rebindBlocked = + favorites.isNdrRebindBlocked(relationship.peerNoisePublicKey) + ) + } catch (_: Exception) { + NdrRecipientResolution( + peerPubkeyHex = fallbackRecipientHex, + ndrRequired = true, + rebindBlocked = true + ) + } + } /** * Resolve Nostr public key for a peer ID diff --git a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt index cdef409f..6dc93a9d 100644 --- a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt +++ b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt @@ -9,13 +9,11 @@ import com.bitchat.android.net.TorMode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull -import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicBoolean /** * Coordinates a full application shutdown: @@ -27,15 +25,9 @@ import java.util.concurrent.atomic.AtomicLong */ object AppShutdownCoordinator { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - private val shutdownToken = AtomicLong(0L) - @Volatile - private var shutdownJob: Job? = null + private val shutdownGate = ShutdownGate() - fun cancelPendingShutdown() { - shutdownToken.incrementAndGet() - shutdownJob?.cancel() - shutdownJob = null - } + fun isShutdownCommitted(): Boolean = shutdownGate.isCommitted() fun requestFullShutdownAndKill( app: Application, @@ -44,60 +36,83 @@ object AppShutdownCoordinator { stopForeground: () -> Unit, stopService: () -> Unit ) { - val token = shutdownToken.incrementAndGet() - shutdownJob?.cancel() - val job = scope.launch { - // Signal UI to finish gracefully before we kill the process - try { - val intent = android.content.Intent(com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH) - .setPackage(app.packageName) - app.sendBroadcast(intent, com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH) - } catch (_: Exception) { } + if (!shutdownGate.commit()) return - // Stop mesh (best-effort) - try { mesh?.stopServices() } catch (_: Exception) { } - try { com.bitchat.android.nostr.NostrRelayManager.shared.disconnect() } catch (_: Exception) { } - try { com.bitchat.android.mesh.PowerManager.getInstance(app).shutdown() } catch (_: Exception) { } - - // Stop Tor temporarily (do not change user setting) - val torProvider = ArtiTorManager.getInstance() - val torStop = async { - try { torProvider.applyMode(app, TorMode.OFF) } catch (_: Exception) { } + val terminated = AtomicBoolean(false) + val terminateProcess = { + if (terminated.compareAndSet(false, true)) { + try { stopService() } catch (_: Exception) { } + try { Process.killProcess(Process.myPid()) } catch (_: Exception) { } + try { System.exit(0) } catch (_: Exception) { } } - val conversationFlush = async { - try { - com.bitchat.android.services.AppStateStore - .awaitConversationPersistence() - } catch (_: Exception) { } - } - - // Clear AppState in-memory store - try { com.bitchat.android.services.AppStateStore.clear() } catch (_: Exception) { } - - // Stop foreground and clear notification - try { stopForeground() } catch (_: Exception) { } - try { notificationManager.cancel(10001) } catch (_: Exception) { } - - // Wait up to 5 seconds for shutdown tasks - withTimeoutOrNull(5000) { - try { torStop.await() } catch (_: Exception) { } - try { conversationFlush.await() } catch (_: Exception) { } - delay(100) - } - - // Stop the service itself - if (!isActive || shutdownToken.get() != token) return@launch - try { stopService() } catch (_: Exception) { } - - // Hard kill the app process - if (!isActive || shutdownToken.get() != token) return@launch - try { Process.killProcess(Process.myPid()) } catch (_: Exception) { } - try { System.exit(0) } catch (_: Exception) { } } - shutdownJob = job - job.invokeOnCompletion { - if (shutdownJob === job) { - shutdownJob = null + scope.launch { + delay(5_000) + terminateProcess() + } + scope.launch { + try { + // Quit is an account-lifetime boundary, not a transient service + // pause: no queued plaintext or relay event may survive it. + val accountReset = runCatching { + com.bitchat.android.nostr.AccountResetCoordinator.begin( + application = app, + terminal = true + ) + }.getOrNull() + try { + com.bitchat.android.nostr.NdrNostrService + .getInstance(app) + .shutdownForProcessExit() + } catch (_: Exception) { } + if (accountReset != null) { + runCatching { + com.bitchat.android.nostr.AccountResetCoordinator + .discardRelay(accountReset) + } + } + + // Signal UI to finish gracefully before we kill the process + try { + val intent = android.content.Intent(com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH) + .setPackage(app.packageName) + app.sendBroadcast(intent, com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH) + } catch (_: Exception) { } + + // Stop mesh (best-effort) + try { mesh?.stopServices() } catch (_: Exception) { } + try { + com.bitchat.android.mesh.PowerManager + .getInstance(app) + .shutdown() + } catch (_: Exception) { } + + // Stop Tor temporarily (do not change user setting) + val torProvider = ArtiTorManager.getInstance() + val torStop = async { + try { torProvider.applyMode(app, TorMode.OFF) } catch (_: Exception) { } + } + val conversationFlush = async { + try { + com.bitchat.android.services.AppStateStore + .awaitConversationPersistence() + } catch (_: Exception) { } + } + + // Clear AppState in-memory store + try { com.bitchat.android.services.AppStateStore.clear() } catch (_: Exception) { } + + // Stop foreground and clear notification + try { stopForeground() } catch (_: Exception) { } + try { notificationManager.cancel(10001) } catch (_: Exception) { } + + withTimeoutOrNull(5_000) { + try { torStop.await() } catch (_: Exception) { } + try { conversationFlush.await() } catch (_: Exception) { } + delay(100) + } + } finally { + terminateProcess() } } } diff --git a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt index 42feca39..d533b719 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt @@ -100,6 +100,17 @@ class MeshForegroundService : Service() { override fun onCreate() { super.onCreate() + if (AppShutdownCoordinator.isShutdownCommitted()) { + stopSelf() + return + } + if (!com.bitchat.android.nostr.NdrPanicStartupRecovery + .isNetworkStartupAllowed() + ) { + discardOldAccountNetworkWork() + stopSelf() + return + } notificationManager = NotificationManagerCompat.from(this) peerAvailabilityNotifier = PeerAvailabilityNotifier( context = applicationContext, @@ -143,9 +154,17 @@ class MeshForegroundService : Service() { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (isShuttingDown && intent?.action == ACTION_START) { - AppShutdownCoordinator.cancelPendingShutdown() - isShuttingDown = false + if (AppShutdownCoordinator.isShutdownCommitted()) { + isShuttingDown = true + stopSelf() + return START_NOT_STICKY + } + if (!com.bitchat.android.nostr.NdrPanicStartupRecovery + .isNetworkStartupAllowed() + ) { + discardOldAccountNetworkWork() + stopSelf() + return START_NOT_STICKY } if (isShuttingDown && intent?.action != ACTION_QUIT) { return START_NOT_STICKY @@ -158,7 +177,7 @@ class MeshForegroundService : Service() { try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.stopOutboxScheduler() } catch (_: Exception) { } try { unifiedMeshService?.stopServices() ?: meshService?.stopServices() } catch (_: Exception) { } try { MeshServiceHolder.clear() } catch (_: Exception) { } - try { stopForeground(true) } catch (_: Exception) { } + try { stopForeground(STOP_FOREGROUND_REMOVE) } catch (_: Exception) { } clearMeshNotifications() isInForeground = false stopSelf() @@ -168,7 +187,7 @@ class MeshForegroundService : Service() { isShuttingDown = true updateJob?.cancel() updateJob = null - try { stopForeground(true) } catch (_: Exception) { } + try { stopForeground(STOP_FOREGROUND_REMOVE) } catch (_: Exception) { } clearMeshNotifications() isInForeground = false // Fully stop all background activity, stop Tor (without changing setting), then kill the app @@ -177,7 +196,7 @@ class MeshForegroundService : Service() { mesh = unifiedMeshService, notificationManager = notificationManager, stopForeground = { - try { stopForeground(true) } catch (_: Exception) { } + try { stopForeground(STOP_FOREGROUND_REMOVE) } catch (_: Exception) { } isInForeground = false }, stopService = { stopSelf() } @@ -240,13 +259,19 @@ class MeshForegroundService : Service() { } val count = getUnifiedActivePeerCount() if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) { + if (Build.VERSION.SDK_INT >= 33 && + androidx.core.content.ContextCompat.checkSelfPermission( + this, + android.Manifest.permission.POST_NOTIFICATIONS + ) != android.content.pm.PackageManager.PERMISSION_GRANTED + ) return if (lastNotifiedPeerCount != count) { startForegroundCompat(buildNotification(count)) lastNotifiedPeerCount = count } } else if (force) { // If disabled and forced, make sure to remove any prior foreground state - try { stopForeground(false) } catch (_: Exception) { } + try { stopForeground(STOP_FOREGROUND_DETACH) } catch (_: Exception) { } clearMeshNotifications() isInForeground = false lastNotifiedPeerCount = null @@ -367,17 +392,44 @@ class MeshForegroundService : Service() { } override fun onDestroy() { + // Service teardown is normally transient: pause retries but preserve + // the outbox for a later service rebind. Panic/quit discard explicitly. + try { + com.bitchat.android.services.MessageRouter + .tryGetInstance() + ?.stopOutboxScheduler() + } catch (_: Exception) { } updateJob?.cancel() updateJob = null // Cancel the service coroutine scope to prevent leaks try { serviceJob.cancel() } catch (_: Exception) { } // Best-effort ensure we are not marked foreground if (isInForeground) { - try { stopForeground(true) } catch (_: Exception) { } + try { stopForeground(STOP_FOREGROUND_REMOVE) } catch (_: Exception) { } isInForeground = false } super.onDestroy() } + private fun discardOldAccountNetworkWork() { + val accountReset = runCatching { + com.bitchat.android.nostr.AccountResetCoordinator.begin( + application = application, + terminal = true + ) + }.getOrNull() + try { + com.bitchat.android.nostr.NdrNostrService + .getInstance(applicationContext) + .shutdownForProcessExit() + } catch (_: Exception) { } + if (accountReset != null) { + runCatching { + com.bitchat.android.nostr.AccountResetCoordinator + .discardRelay(accountReset) + } + } + } + override fun onBind(intent: Intent?): IBinder? = null } diff --git a/app/src/main/java/com/bitchat/android/service/ShutdownGate.kt b/app/src/main/java/com/bitchat/android/service/ShutdownGate.kt new file mode 100644 index 00000000..d53a3f3d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/ShutdownGate.kt @@ -0,0 +1,21 @@ +package com.bitchat.android.service + +/** + * Makes a destructive application quit irreversible once committed. + */ +internal class ShutdownGate { + private val lock = Any() + private var committed = false + + fun commit(): Boolean = + synchronized(lock) { + if (committed) { + false + } else { + committed = true + true + } + } + + fun isCommitted(): Boolean = synchronized(lock) { committed } +} diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt index c4f52675..712876b8 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -428,15 +428,18 @@ object AppStateStore { val idx = list.indexOfFirst { it.id == messageID } if (idx >= 0) { val current = list[idx].deliveryStatus - // Do not downgrade (e.g., Read -> Delivered) - val mayReplace = when { - status is DeliveryStatus.Failed -> - current !is DeliveryStatus.Delivered && - current !is DeliveryStatus.Read - current is DeliveryStatus.Failed -> true - else -> statusPriority(status) >= statusPriority(current) - } - if (mayReplace) { + val acceptsLocalFailure = status is DeliveryStatus.Failed && + (current == null || + current is DeliveryStatus.Sending || + current is DeliveryStatus.Failed) + val rejectsLateFailure = status is DeliveryStatus.Failed && + !acceptsLocalFailure + // Locally terminal/expired work may move Sending -> Failed, + // but a late failure must not overwrite Sent/Delivered/Read. + if (!rejectsLateFailure && + (acceptsLocalFailure || + statusPriority(status) >= statusPriority(current)) + ) { list[idx] = list[idx].copy(deliveryStatus = status) map[peer] = list changed = true diff --git a/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt b/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt index 84420ec0..918e1223 100644 --- a/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt +++ b/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt @@ -876,12 +876,13 @@ internal class ConversationDatabase( } if (!found) return - val mayReplace = when { - status is DeliveryStatus.Failed -> - existing !is DeliveryStatus.Delivered && existing !is DeliveryStatus.Read - existing is DeliveryStatus.Failed -> true - else -> statusPriority(status) >= statusPriority(existing) - } + val acceptsLocalFailure = status is DeliveryStatus.Failed && + (existing == null || + existing is DeliveryStatus.Sending || + existing is DeliveryStatus.Failed) + val rejectsLateFailure = status is DeliveryStatus.Failed && !acceptsLocalFailure + val mayReplace = !rejectsLateFailure && + (acceptsLocalFailure || statusPriority(status) >= statusPriority(existing)) if (!mayReplace) return db.inTransaction { val current = rawQuery( diff --git a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt index dc6ad4d2..cc3bfe59 100644 --- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt +++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt @@ -5,6 +5,7 @@ import android.util.Log import com.bitchat.android.favorites.FavoriteControlMessage import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.ReadReceipt +import com.bitchat.android.nostr.NostrSendAdmission import com.bitchat.android.nostr.NostrTransport import com.bitchat.android.util.AppConstants import kotlinx.coroutines.CoroutineScope @@ -19,14 +20,39 @@ import java.util.concurrent.ConcurrentHashMap /** * Routes messages between local mesh transports and Nostr, matching iOS behavior. */ -class MessageRouter private constructor( +internal fun interface NostrPrivateMessageSender { + fun sendPrivateMessage( + content: String, + to: String, + recipientNickname: String, + messageID: String, + completion: (NostrSendAdmission) -> Unit + ) +} + +@JvmInline +internal value class MessageRouterResetToken(val epoch: Long) + +class MessageRouter internal constructor( private val context: Context, private var mesh: MeshService, - private val nostr: NostrTransport + private val nostr: NostrTransport, + private val privateNostrSender: NostrPrivateMessageSender = + NostrPrivateMessageSender { content, to, recipientNickname, messageID, completion -> + nostr.sendPrivateMessage( + content = content, + to = to, + recipientNickname = recipientNickname, + messageID = messageID, + completion = completion + ) + }, + private val canSendViaNostrOverride: ((String) -> Boolean)? = null ) { enum class RouteResult { MESH, NOSTR, + NOSTR_PENDING, QUEUED, DROPPED } @@ -43,6 +69,12 @@ class MessageRouter private constructor( val nextHandshakeAttemptAtMs: Long ) + private data class InFlightNostrAttempt( + val messageID: String, + val generation: Long, + val outboxEpoch: Long + ) + companion object { private const val TAG = "MessageRouter" private const val OUTBOX_TICK_MS = AppConstants.Router.OUTBOX_TICK_MS @@ -86,6 +118,13 @@ class MessageRouter private constructor( // Per-conversation handshake retry state for queued messages private val retryState = ConcurrentHashMap() + // A Nostr/NDR ratchet send must have one owner. Only one queued message per + // conversation may cross the asynchronous transport boundary at a time. + private val inFlightNostrAttempts = mutableMapOf() + private var nextNostrAttemptGeneration = 0L + private var outboxEpoch = 0L + private var accountResetBlocked = false + private val schedulerScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private var schedulerJob: kotlinx.coroutines.Job? = null @@ -94,6 +133,8 @@ class MessageRouter private constructor( // Called with the messageID of queued messages that expired or were evicted var onMessageExpired: ((String) -> Unit)? = null + var onMessageAdmitted: ((String) -> Unit)? = null + var onMessageFailed: ((String, String) -> Unit)? = null init { startOutboxScheduler() @@ -116,7 +157,12 @@ class MessageRouter private constructor( } } + @Synchronized fun sendPrivate(content: String, toPeerID: String, recipientNickname: String, messageID: String): RouteResult { + if (accountResetBlocked) { + notifyFailed(messageID, "Account reset in progress") + return RouteResult.DROPPED + } val resolution = ContactDirectory.resolve(toPeerID) val conversationID = resolution.conversationID val meshTarget = resolution.meshPeerID ?: toPeerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) } @@ -140,18 +186,24 @@ class MessageRouter private constructor( return RouteResult.MESH } else if (canSendViaNostr(nostrTarget)) { Log.d(TAG, "Routing PM via Nostr to ${conversationID.take(32)}… msg_id=${messageID.take(8)}…") - nostr.sendPrivateMessage(content, nostrTarget, recipientNickname, messageID) - return RouteResult.NOSTR + enqueue( + conversationID, + QueuedMessage(content, recipientNickname, messageID, clock()) + ) + flushOutboxFor(conversationID) + return RouteResult.NOSTR_PENDING } else { Log.d(TAG, "Queued PM for ${conversationID} (no mesh, no Nostr mapping) msg_id=${messageID.take(8)}…") enqueue(conversationID, QueuedMessage(content, recipientNickname, messageID, clock())) Log.d(TAG, "Initiating noise handshake after queueing PM for ${conversationID.take(16)}…") - if (hasMesh) meshTarget?.let { kickHandshake(conversationID, it, immediate = true) } + if (hasMesh) kickHandshake(conversationID, meshTarget, immediate = true) return RouteResult.QUEUED } } + @Synchronized fun sendReadReceipt(receipt: ReadReceipt, toPeerID: String) { + if (accountResetBlocked) return val resolution = ContactDirectory.resolve(toPeerID) val meshTarget = resolution.meshPeerID ?: toPeerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) } val nostrTarget = resolution.noiseKeyHex ?: toPeerID @@ -164,7 +216,9 @@ class MessageRouter private constructor( } } + @Synchronized fun sendDeliveryAck(messageID: String, toPeerID: String) { + if (accountResetBlocked) return // Mesh delivery ACKs are sent by the receiver automatically. // Only route via Nostr when mesh path isn't available or when this is a geohash alias if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(toPeerID)) { @@ -181,7 +235,9 @@ class MessageRouter private constructor( } } + @Synchronized fun sendFavoriteNotification(toPeerID: String, isFavorite: Boolean) { + if (accountResetBlocked) return val resolution = ContactDirectory.resolve(toPeerID) val meshTarget = resolution.meshPeerID ?: toPeerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) } if (meshTarget != null && mesh.getPeerInfo(meshTarget)?.isConnected == true && mesh.hasEstablishedSession(meshTarget)) { @@ -200,31 +256,179 @@ class MessageRouter private constructor( @Synchronized fun flushOutboxFor(peerID: String) { val conversationID = ContactDirectory.canonicalConversationId(peerID) - val queued = outbox[conversationID] ?: outbox[peerID] ?: return + val queued = canonicalizeQueueKey(conversationID, peerID) ?: return if (queued.isEmpty()) return + if (findInFlightAttempt(conversationID, peerID, queued) != null) return Log.d(TAG, "Flushing outbox for ${conversationID.take(16)}… count=${queued.size}") - val iterator = queued.iterator() - while (iterator.hasNext()) { - val entry = iterator.next() - val resolution = ContactDirectory.resolve(conversationID) - val meshTarget = resolution.meshPeerID - val nostrTarget = resolution.noiseKeyHex ?: conversationID - if (meshTarget != null && isReady(mesh, meshTarget)) { + val resolution = ContactDirectory.resolve(conversationID) + val meshTarget = resolution.meshPeerID + val nostrTarget = resolution.noiseKeyHex ?: conversationID + if (meshTarget != null && isReady(mesh, meshTarget)) { + queued.forEach { entry -> mesh.sendPrivateMessage(entry.content, meshTarget, entry.nickname, entry.messageID) - iterator.remove() - } else if (canSendViaNostr(nostrTarget)) { - nostr.sendPrivateMessage(entry.content, nostrTarget, entry.nickname, entry.messageID) - iterator.remove() } + queued.clear() + } else if (canSendViaNostr(nostrTarget)) { + startNostrAttempt( + conversationID = conversationID, + target = nostrTarget, + entry = queued.first() + ) + return } if (queued.isEmpty()) { - outbox.remove(conversationID, queued) - outbox.remove(peerID, queued) - retryState.remove(conversationID) - retryState.remove(peerID) + removeEmptyQueue(conversationID, peerID, queued) } } + @Synchronized + private fun startNostrAttempt( + conversationID: String, + target: String, + entry: QueuedMessage + ) { + val queued = outbox[conversationID] ?: return + if (findInFlightAttempt(conversationID, conversationID, queued) != null) return + val attempt = InFlightNostrAttempt( + messageID = entry.messageID, + generation = ++nextNostrAttemptGeneration, + outboxEpoch = outboxEpoch + ) + inFlightNostrAttempts[conversationID] = attempt + privateNostrSender.sendPrivateMessage( + entry.content, + target, + entry.nickname, + entry.messageID + ) { admission -> + finishNostrAttempt(conversationID, attempt, admission) + } + } + + @Synchronized + private fun finishNostrAttempt( + conversationID: String, + attempt: InFlightNostrAttempt, + admission: NostrSendAdmission + ) { + if (attempt.outboxEpoch != outboxEpoch) return + val attemptEntry = inFlightNostrAttempts.entries + .firstOrNull { it.value === attempt } + ?: return + inFlightNostrAttempts.remove(attemptEntry.key, attempt) + val queueKey = outbox.entries + .firstOrNull { (_, queue) -> + queue.any { it.messageID == attempt.messageID } + } + ?.key + ?: conversationID + val currentConversationID = + ContactDirectory.canonicalConversationId(queueKey) + if (queueKey != currentConversationID) { + canonicalizeQueueKey(currentConversationID, queueKey) + } + + when (admission) { + NostrSendAdmission.ADMITTED -> { + removeQueuedMessage(currentConversationID, attempt.messageID) + notifyAdmitted(attempt.messageID) + flushOutboxFor(currentConversationID) + } + + NostrSendAdmission.RETRYABLE -> Unit + + NostrSendAdmission.TERMINAL_FAILED -> { + removeQueuedMessage(currentConversationID, attempt.messageID) + notifyFailed(attempt.messageID, "Message could not be sent") + flushOutboxFor(currentConversationID) + } + } + } + + private fun removeQueuedMessage(conversationID: String, messageID: String) { + val queued = outbox[conversationID] ?: return + queued.removeAll { it.messageID == messageID } + if (queued.isEmpty()) { + removeEmptyQueue(conversationID, conversationID, queued) + } + } + + private fun canonicalizeQueueKey( + conversationID: String, + peerID: String + ): MutableList? { + val canonical = outbox[conversationID] + val alias = outbox[peerID] + val queue = when { + canonical == null && alias != null -> { + outbox[conversationID] = alias + if (peerID != conversationID) { + outbox.remove(peerID, alias) + } + alias + } + + canonical != null && alias != null && canonical !== alias -> { + canonical.addAll(alias) + canonical.sortBy(QueuedMessage::enqueuedAtMs) + outbox.remove(peerID, alias) + canonical + } + + else -> canonical + } + if (queue != null) { + migrateInFlightOwnership(conversationID, peerID, queue) + } + return queue + } + + private fun findInFlightAttempt( + conversationID: String, + peerID: String, + queue: List + ): Map.Entry? { + val queuedMessageIDs = queue.asSequence() + .map(QueuedMessage::messageID) + .toSet() + return inFlightNostrAttempts.entries.firstOrNull { (key, attempt) -> + key == conversationID || + key == peerID || + ContactDirectory.canonicalConversationId(key) == conversationID || + attempt.messageID in queuedMessageIDs + } + } + + private fun migrateInFlightOwnership( + conversationID: String, + peerID: String, + queue: List + ) { + val matches = inFlightNostrAttempts.entries.filter { (key, attempt) -> + key == peerID || + ContactDirectory.canonicalConversationId(key) == conversationID || + queue.any { it.messageID == attempt.messageID } + } + if (matches.size != 1 || inFlightNostrAttempts.containsKey(conversationID)) { + return + } + val match = matches.single() + if (inFlightNostrAttempts.remove(match.key, match.value)) { + inFlightNostrAttempts[conversationID] = match.value + } + } + + private fun removeEmptyQueue( + conversationID: String, + peerID: String, + queued: MutableList + ) { + outbox.remove(conversationID, queued) + outbox.remove(peerID, queued) + retryState.remove(conversationID) + retryState.remove(peerID) + } + // Flush everything (rarely used) fun flushAllOutbox() { outbox.keys.toList().forEach { flushOutboxFor(it) } @@ -233,9 +437,21 @@ class MessageRouter private constructor( @Synchronized private fun enqueue(conversationID: String, entry: QueuedMessage) { val queue = outbox.getOrPut(conversationID) { mutableListOf() } + if (queue.any { it.messageID == entry.messageID }) return queue.add(entry) while (queue.size > OUTBOX_MAX_PER_PEER) { - val evicted = queue.removeAt(0) + val inFlightMessageIDs = inFlightNostrAttempts + .filter { (key, attempt) -> + key == conversationID || + ContactDirectory.canonicalConversationId(key) == conversationID || + queue.any { it.messageID == attempt.messageID } + } + .values + .mapTo(mutableSetOf(), InFlightNostrAttempt::messageID) + val evictionIndex = + queue.indexOfFirst { it.messageID !in inFlightMessageIDs } + if (evictionIndex < 0) break + val evicted = queue.removeAt(evictionIndex) Log.w(TAG, "Outbox full for ${conversationID.take(16)}…; evicting oldest msg_id=${evicted.messageID.take(8)}…") notifyExpired(evicted.messageID) } @@ -245,6 +461,14 @@ class MessageRouter private constructor( try { onMessageExpired?.invoke(messageID) } catch (_: Exception) { } } + private fun notifyAdmitted(messageID: String) { + try { onMessageAdmitted?.invoke(messageID) } catch (_: Exception) { } + } + + private fun notifyFailed(messageID: String, reason: String) { + try { onMessageFailed?.invoke(messageID, reason) } catch (_: Exception) { } + } + /** * Initiate a Noise handshake for a conversation with queued messages, applying * exponential backoff between attempts. [immediate] resets the backoff (peer just @@ -291,6 +515,40 @@ class MessageRouter private constructor( schedulerJob = null } + /** Panic/reset must invalidate callbacks from the previous account and drop plaintext. */ + @Synchronized + internal fun discardForAccountReset(): MessageRouterResetToken { + stopOutboxScheduler() + accountResetBlocked = true + outboxEpoch += 1 + inFlightNostrAttempts.clear() + outbox.clear() + retryState.clear() + return MessageRouterResetToken(outboxEpoch) + } + + @Synchronized + internal fun completeAccountReset(resetToken: MessageRouterResetToken): Boolean { + if (resetToken.epoch != outboxEpoch) return false + accountResetBlocked = false + startOutboxScheduler() + return true + } + + @Synchronized + internal fun installReplacementMeshForAccountReset(replacement: MeshService) { + check(accountResetBlocked) { + "Replacement mesh may only be installed behind an account reset barrier" + } + mesh = replacement + } + + internal val queuedMessageCount: Int + @Synchronized get() = outbox.values.sumOf { it.size } + + internal val inFlightNostrAttemptCount: Int + @Synchronized get() = inFlightNostrAttempts.size + internal val isSchedulerRunning: Boolean get() = schedulerJob?.isActive == true /** @@ -325,10 +583,20 @@ class MessageRouter private constructor( private fun expireOldEntries(conversationID: String, nowMs: Long) { val queued = outbox[conversationID] ?: return + val inFlightMessageIDs = inFlightNostrAttempts + .filter { (key, attempt) -> + key == conversationID || + ContactDirectory.canonicalConversationId(key) == conversationID || + queued.any { it.messageID == attempt.messageID } + } + .values + .mapTo(mutableSetOf(), InFlightNostrAttempt::messageID) val iterator = queued.iterator() while (iterator.hasNext()) { val entry = iterator.next() - if (nowMs - entry.enqueuedAtMs > OUTBOX_MESSAGE_TTL_MS) { + if (entry.messageID !in inFlightMessageIDs && + nowMs - entry.enqueuedAtMs > OUTBOX_MESSAGE_TTL_MS + ) { Log.w(TAG, "Expiring queued PM for ${conversationID.take(16)}… msg_id=${entry.messageID.take(8)}…") iterator.remove() notifyExpired(entry.messageID) @@ -341,6 +609,7 @@ class MessageRouter private constructor( } private fun canSendViaNostr(peerID: String): Boolean { + canSendViaNostrOverride?.let { return it(peerID) } return try { val resolution = ContactDirectory.resolve(peerID) if (resolution.isMutualFavorite && resolution.nostrPubkey != null) return true diff --git a/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt b/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt index 0701645a..ae6ea1b5 100644 --- a/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt +++ b/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt @@ -9,7 +9,8 @@ import com.google.gson.annotations.SerializedName /** * Persistent store for message IDs we've already acknowledged as delivered, read locally, or - * admitted to a completed read-receipt send window. + * admitted to a completed read-receipt send window, plus pairwise-ratchet events + * durably committed before acknowledgement. * * Local read state must not be used as proof that a read-receipt packet reached the sender. * Transport delivery is best-effort and retryable, while local read state drives unread UI. @@ -37,12 +38,14 @@ class SeenMessageStore private constructor(private val context: Context) { private val delivered = LinkedHashSet(MAX_IDS) private val locallyRead = LinkedHashSet(MAX_IDS) private val readReceiptsSent = LinkedHashSet(MAX_IDS) + private val ndrProcessed = LinkedHashSet(MAX_IDS) init { load() } @Synchronized fun hasDelivered(id: String) = delivered.contains(id) @Synchronized fun hasBeenReadLocally(id: String) = locallyRead.contains(id) @Synchronized fun hasReadReceiptBeenSent(id: String) = readReceiptsSent.contains(id) + @Synchronized fun hasProcessedNdr(id: String) = ndrProcessed.contains(id) @Synchronized fun markDelivered(id: String) { if (delivered.remove(id)) delivered.add(id) else { @@ -69,11 +72,28 @@ class SeenMessageStore private constructor(private val context: Context) { persist() } + /** + * Returns only after the processed marker is committed to encrypted + * preferences. The pairwise action must not be acknowledged when this + * returns false. + */ + @Synchronized fun markProcessedNdr(id: String): Boolean { + if (ndrProcessed.contains(id)) return true + val previous = ndrProcessed.toList() + ndrProcessed.add(id) + trim(ndrProcessed) + if (persistSynchronously()) return true + ndrProcessed.clear() + ndrProcessed.addAll(previous) + return false + } + @Synchronized fun remove(ids: Set) { if (ids.isEmpty()) return delivered.removeAll(ids) locallyRead.removeAll(ids) readReceiptsSent.removeAll(ids) + ndrProcessed.removeAll(ids) persist() } @@ -81,6 +101,7 @@ class SeenMessageStore private constructor(private val context: Context) { delivered.clear() locallyRead.clear() readReceiptsSent.clear() + ndrProcessed.clear() persist() } @@ -96,18 +117,22 @@ class SeenMessageStore private constructor(private val context: Context) { try { val json = secure.getSecureValue(STORAGE_KEY) ?: return val data = gson.fromJson(json, StorePayload::class.java) ?: return - delivered.clear(); locallyRead.clear(); readReceiptsSent.clear() - data.delivered.takeLast(MAX_IDS).forEach { delivered.add(it) } - data.locallyRead.takeLast(MAX_IDS).forEach { locallyRead.add(it) } + delivered.clear() + locallyRead.clear() + readReceiptsSent.clear() + ndrProcessed.clear() + data.delivered.orEmpty().takeLast(MAX_IDS).forEach { delivered.add(it) } + data.locallyRead.orEmpty().takeLast(MAX_IDS).forEach { locallyRead.add(it) } // Older payloads used the local-read set to suppress receipt sends. Seed the new // explicit set once during migration to avoid replaying an entire chat history. - (data.readReceiptsSent ?: data.locallyRead) + (data.readReceiptsSent ?: data.locallyRead.orEmpty()) .takeLast(MAX_IDS) .forEach { readReceiptsSent.add(it) } + data.ndrProcessed.orEmpty().takeLast(MAX_IDS).forEach { ndrProcessed.add(it) } Log.d( TAG, "Loaded delivered=${delivered.size}, locallyRead=${locallyRead.size}, " + - "readReceiptsSent=${readReceiptsSent.size}" + "readReceiptsSent=${readReceiptsSent.size}, ndr=${ndrProcessed.size}" ) } catch (e: Exception) { Log.e(TAG, "Failed to load SeenMessageStore: ${e.message}") @@ -116,11 +141,7 @@ class SeenMessageStore private constructor(private val context: Context) { @Synchronized private fun persist() { try { - val payload = StorePayload( - delivered = delivered.toList(), - locallyRead = locallyRead.toList(), - readReceiptsSent = readReceiptsSent.toList() - ) + val payload = currentPayload() val json = gson.toJson(payload) secure.storeSecureValue(STORAGE_KEY, json) } catch (e: Exception) { @@ -128,12 +149,27 @@ class SeenMessageStore private constructor(private val context: Context) { } } + @Synchronized private fun persistSynchronously(): Boolean = try { + secure.storeSecureValueSynchronously(STORAGE_KEY, gson.toJson(currentPayload())) + } catch (e: Exception) { + Log.e(TAG, "Failed to durably persist SeenMessageStore: ${e.message}") + false + } + + private fun currentPayload() = StorePayload( + delivered = delivered.toList(), + locallyRead = locallyRead.toList(), + readReceiptsSent = readReceiptsSent.toList(), + ndrProcessed = ndrProcessed.toList() + ) + private data class StorePayload( - val delivered: List = emptyList(), + val delivered: List? = emptyList(), // Keep the existing JSON field name for backward-compatible secure-store migration. @SerializedName("read") - val locallyRead: List = emptyList(), + val locallyRead: List? = emptyList(), @SerializedName("read_receipts_sent") - val readReceiptsSent: List? = null + val readReceiptsSent: List? = null, + val ndrProcessed: List? = emptyList() ) } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index bf9e2f7c..fe0d8898 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -20,9 +20,24 @@ import kotlinx.coroutines.Job import com.bitchat.android.mesh.BluetoothMeshDelegate import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.mesh.MeshService +import com.bitchat.android.mesh.NdrMeshRoute import com.bitchat.android.service.MeshServiceHolder import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.model.NdrFeatureGate +import com.bitchat.android.model.PeerCapabilities +import com.bitchat.android.nostr.AccountResetCoordinator +import com.bitchat.android.nostr.NdrBootstrapAction +import com.bitchat.android.nostr.NdrBootstrapDecider +import com.bitchat.android.nostr.NdrBootstrapTriggerCoordinator +import com.bitchat.android.nostr.NdrFavoriteRouteBinding +import com.bitchat.android.nostr.NdrInviteRetryCoordinator +import com.bitchat.android.nostr.NdrInviteRetryRequest +import com.bitchat.android.nostr.NdrInviteRetryToken +import com.bitchat.android.nostr.NdrNostrService +import com.bitchat.android.nostr.NdrOutOfBandPayload +import com.bitchat.android.nostr.NdrOutOfBandRoutePolicy +import com.bitchat.android.nostr.NostrEvent import com.bitchat.android.nostr.NostrIdentityBridge import com.bitchat.android.nostr.GeohashConversationRegistry import com.bitchat.android.protocol.BitchatPacket @@ -32,6 +47,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.delay import java.util.Date +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean import kotlin.random.Random import com.bitchat.android.services.VerificationService import com.bitchat.android.identity.SecureIdentityStateManager @@ -72,6 +89,8 @@ class ChatViewModel( private const val CONVERSATION_DISCONNECT_GRACE_MS = 3_000L } + private val panicResetInProgress = AtomicBoolean(false) + fun sendVoiceNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) { mediaSendingManager.sendVoiceNote(toPeerIDOrNull, channelOrNull, filePath) } @@ -216,6 +235,69 @@ class ChatViewModel( dataManager = dataManager, notificationManager = notificationManager ) + private val ndrService by lazy { NdrNostrService.getInstance(getApplication()) } + private val ndrBootstrapAttemptMs = ConcurrentHashMap() + private val ndrNoiseHandshakeAttemptMs = ConcurrentHashMap() + private val ndrAvailablePeers = ConcurrentHashMap.newKeySet() + private val ndrInviteRetries = NdrInviteRetryCoordinator( + scope = viewModelScope, + isStillValid = { request -> + val token = request.token + !ndrService.hasPairwiseSession(token.peerPubkeyHex) && + NostrEvent.fromJsonString(ndrService.currentInviteEventJson() ?: "") + ?.id == token.inviteEventId && + isCurrentNdrRouteAuthorized(token.route, token.peerPubkeyHex) + }, + send = { request, completion -> + val token = request.token + mesh.sendNdrEvent( + route = token.route, + payload = request.eventJson, + isStillAuthorized = { + !ndrService.hasPairwiseSession(token.peerPubkeyHex) && + NostrEvent.fromJsonString(ndrService.currentInviteEventJson() ?: "") + ?.id == token.inviteEventId && + isCurrentNdrRouteAuthorized(token.route, token.peerPubkeyHex) + }, + completion = completion + ) + }, + onAdmitted = { request -> + ndrBootstrapAttemptMs[request.token.peerID] = System.currentTimeMillis() + } + ) + private val ndrOutOfBandDeliveryHandler: ( + NdrOutOfBandPayload, + (Boolean) -> Unit + ) -> Unit = { payload, completion -> + routeNdrOutOfBandPayload(payload, completion) + } + private val ndrBootstrapTriggers = NdrBootstrapTriggerCoordinator( + connectedPeerIDs = { state.getConnectedPeersValue() }, + noiseKeyHexForPeer = { peerID -> + runCatching { + mesh.getPeerInfo(peerID)?.noisePublicKey?.hexEncodedString() + }.getOrNull() + }, + requestBootstrap = ::maybeBootstrapDoubleRatchetIfNeeded + ) + private val ndrFavoriteListener = object : FavoritesChangeListener { + override fun onFavoriteChanged(noiseKeyHex: String) { + viewModelScope.launch { + ndrBootstrapTriggers.onFavoriteChanged(noiseKeyHex) + ndrService.onOutOfBandTransportAvailable() + } + } + + override fun onAllCleared() { + viewModelScope.launch { + ndrInviteRetries.cancelAll() + ndrBootstrapAttemptMs.clear() + ndrNoiseHandshakeAttemptMs.clear() + ndrAvailablePeers.clear() + } + } + } @@ -426,14 +508,30 @@ class ChatViewModel( com.bitchat.android.services.AppStateStore.reloadConversationPersistence( getApplication() ) - // Mark queued private messages as failed when the router gives up on them + // Reflect asynchronous router admission/failure into the durable local echo. try { - com.bitchat.android.services.MessageRouter.getInstance(getApplication(), mesh).onMessageExpired = { messageID -> + val router = com.bitchat.android.services.MessageRouter.getInstance( + getApplication(), + mesh + ) + router.onMessageExpired = { messageID -> messageManager.updateMessageDeliveryStatus( messageID, com.bitchat.android.model.DeliveryStatus.Failed("Message expired before delivery") ) } + router.onMessageAdmitted = { messageID -> + messageManager.updateMessageDeliveryStatus( + messageID, + com.bitchat.android.model.DeliveryStatus.Sent + ) + } + router.onMessageFailed = { messageID, reason -> + messageManager.updateMessageDeliveryStatus( + messageID, + com.bitchat.android.model.DeliveryStatus.Failed(reason) + ) + } } catch (_: Exception) { } // Hydrate UI state from process-wide AppStateStore to survive Activity recreation viewModelScope.launch { @@ -610,6 +708,8 @@ class ChatViewModel( // Initialize favorites persistence service com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication()) + FavoritesPersistenceService.shared.addListener(ndrFavoriteListener) + ndrService.onOutOfBandPayload = ndrOutOfBandDeliveryHandler // Reflect "they favorited us" changes into reactive UI state (drives star celebrations) refreshPeerFavoritedUs() @@ -636,6 +736,13 @@ class ChatViewModel( } override fun onCleared() { + runCatching { + FavoritesPersistenceService.shared.removeListener(ndrFavoriteListener) + } + if (ndrService.onOutOfBandPayload === ndrOutOfBandDeliveryHandler) { + ndrService.onOutOfBandPayload = null + } + ndrInviteRetries.cancelAll() if (favoriteRelationshipListenerRegistered) { runCatching { FavoritesPersistenceService.shared.removeListener( @@ -1005,6 +1112,8 @@ class ChatViewModel( recipientNicknameParam, messageId ) + // Geohash DMs retain their legacy fire-and-forget admission semantics. + // Standard Nostr/NDR sends are marked Sent only by onMessageAdmitted. if (route == com.bitchat.android.services.MessageRouter.RouteResult.NOSTR) { messageManager.updateMessageDeliveryStatus( messageId, @@ -1243,6 +1352,7 @@ class ChatViewModel( if (sessionStateForPeer(peerID) is NoiseSession.NoiseSessionState.Established) { verificationHandler.sendPendingVerificationIfNeeded(peerID) } + maybeBootstrapDoubleRatchetIfNeeded(peerID) } } @@ -1387,6 +1497,18 @@ class ChatViewModel( override fun didUpdatePeerList(peers: List) { meshDelegateHandler.didUpdatePeerList(peers) + val currentPeers = peers.toSet() + val routeBecameAvailable = currentPeers.any(ndrAvailablePeers::add) + ndrAvailablePeers.retainAll(currentPeers) + ndrInviteRetries.retainPeers(currentPeers) + if (routeBecameAvailable) { + ndrService.onOutOfBandTransportAvailable() + } + peers.forEach { peerID -> + viewModelScope.launch { + maybeBootstrapDoubleRatchetIfNeeded(peerID) + } + } } override fun didReceiveChannelLeave(channel: String, fromPeer: String) { @@ -1409,8 +1531,71 @@ class ChatViewModel( verificationHandler.didReceiveVerifyResponse(peerID, payload) } + override fun didReceiveNdrEvent( + route: NdrMeshRoute, + payload: ByteArray, + timestampMs: Long + ) { + if (!NdrFeatureGate.isEnabled()) return + val eventPayload = payload.toString(Charsets.UTF_8) + if (eventPayload.isBlank()) return + + val peerID = route.peerID + val noiseKey = route.authenticatedSession.remoteStaticKey + val relationship = FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey) + if (relationship?.isMutual != true) { + Log.d(TAG, "Ignoring NDR OOB event without mutual favorite") + return + } + + val identity = NostrIdentityBridge.getCurrentNostrIdentity(getApplication()) ?: return + ndrService.configureIfNeeded(identity) + val expectedPeerPubkeyHex = + FavoritesPersistenceService.shared.findNdrSessionPubkeyHex(noiseKey) ?: return + if (!isCurrentNdrRouteAuthorized(route, expectedPeerPubkeyHex)) { + Log.d(TAG, "Ignoring NDR OOB event from a replaced or rebound Noise generation") + return + } + if (!FavoritesPersistenceService.shared.updateNdrSessionPubkeyHex( + noiseKey, + expectedPeerPubkeyHex + ) + ) { + Log.e(TAG, "Refusing NDR OOB processing before its downgrade pin is durable") + return + } + val result = ndrService.processOutOfBandEventJson( + eventPayload, + expectedPeerPubkeyHex + ) + val sessionLookupPubkeyHex = listOfNotNull( + result.sessionLookupPubkeyHex, + expectedPeerPubkeyHex + ).firstOrNull(ndrService::hasPairwiseSession) + + if (sessionLookupPubkeyHex != null) { + val bindingCommitted = + FavoritesPersistenceService.shared.updateNdrSessionPubkeyHex( + noiseKey, + sessionLookupPubkeyHex + ) + if (!bindingCommitted) { + Log.e(TAG, "Refusing to advance NDR bootstrap after session rebind failed") + return + } + ndrInviteRetries.cancel(peerID) + ndrBootstrapAttemptMs.remove(peerID) + ndrNoiseHandshakeAttemptMs.remove(peerID) + } + ndrService.replayPendingOutOfBandPayloads() + } + override fun didResolvePrivateMediaPolicy(peerID: String) { mediaSendingManager.retryPendingPrivateMedia(peerID) + ndrService.onOutOfBandTransportAvailable() + viewModelScope.launch { + ndrBootstrapTriggers.onAuthenticatedPolicyResolved(peerID) + } } override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { @@ -1424,25 +1609,182 @@ class ChatViewModel( override fun isFavorite(peerID: String): Boolean { return meshDelegateHandler.isFavorite(peerID) } + + private fun maybeBootstrapDoubleRatchetIfNeeded(peerID: String) { + if (!NdrFeatureGate.isEnabled()) { + ndrInviteRetries.cancel(peerID) + return + } + val peerInfo = mesh.getPeerInfo(peerID) + val noiseKey = peerInfo?.noisePublicKey + val relationship = noiseKey?.let(FavoritesPersistenceService.shared::getFavoriteStatus) + if (noiseKey == null || relationship?.isMutual != true) { + ndrInviteRetries.cancel(peerID) + return + } + if (!mesh.peerSupportsAuthenticatedCapability( + peerID, + PeerCapabilities.NOSTR_DOUBLE_RATCHET + ) + ) { + ndrInviteRetries.cancel(peerID) + return + } + + val peerPubkeyHex = + FavoritesPersistenceService.shared.findNdrSessionPubkeyHex(noiseKey) + if (peerPubkeyHex == null) { + ndrInviteRetries.cancel(peerID) + return + } + ndrService.replayPendingOutOfBandPayloads() + val identity = NostrIdentityBridge.getCurrentNostrIdentity(getApplication()) ?: return + ndrService.configureIfNeeded(identity) + + val hasPairwiseSession = ndrService.hasPairwiseSession(peerPubkeyHex) + if (hasPairwiseSession) { + if (!FavoritesPersistenceService.shared.updateNdrSessionPubkeyHex( + noiseKey, + peerPubkeyHex + ) + ) { + Log.e(TAG, "Existing NDR session remains quarantined until its pin is durable") + return + } + ndrInviteRetries.cancel(peerID) + ndrBootstrapAttemptMs.remove(peerID) + ndrNoiseHandshakeAttemptMs.remove(peerID) + return + } + + val now = System.currentTimeMillis() + val hasEstablishedNoiseSession = + mesh.getSessionState(peerID) is NoiseSession.NoiseSessionState.Established + + when ( + NdrBootstrapDecider.decide( + hasActiveDoubleRatchet = hasPairwiseSession, + hasEstablishedNoiseSession = hasEstablishedNoiseSession, + nowMs = now, + lastInviteAttemptMs = ndrBootstrapAttemptMs[peerID] ?: 0L, + lastHandshakeAttemptMs = ndrNoiseHandshakeAttemptMs[peerID] ?: 0L + ) + ) { + NdrBootstrapAction.NONE -> return + NdrBootstrapAction.START_NOISE_HANDSHAKE -> { + ndrNoiseHandshakeAttemptMs[peerID] = now + mesh.initiateNoiseHandshake(peerID) + return + } + NdrBootstrapAction.SEND_OOB_INVITE -> Unit + } + + val invitePayload = ndrService.currentInviteEventJson() ?: return + val inviteEventId = NostrEvent.fromJsonString(invitePayload) + ?.id + ?.takeIf { it.length == 64 } + ?: return + ndrNoiseHandshakeAttemptMs.remove(peerID) + val route = authorizedNdrRoute(peerID, peerPubkeyHex) ?: return + ndrInviteRetries.start( + NdrInviteRetryRequest( + token = NdrInviteRetryToken( + peerID = peerID, + peerPubkeyHex = peerPubkeyHex, + inviteEventId = inviteEventId, + route = route + ), + eventJson = invitePayload + ) + ) + } + + private fun routeNdrOutOfBandPayload( + payload: NdrOutOfBandPayload, + completion: (Boolean) -> Unit + ) { + if (!NdrFeatureGate.isEnabled() || payload.eventJson.isBlank()) { + completion(false) + return + } + val peerPubkeyHex = payload.peerPubkeyHex.lowercase() + val candidatePeerIDs = linkedSetOf() + FavoritesPersistenceService.shared + .findPeerIDForNostrPubkey(peerPubkeyHex) + ?.let(candidatePeerIDs::add) + candidatePeerIDs.addAll(state.getConnectedPeersValue()) + candidatePeerIDs.addAll(mesh.getPeerNicknames().keys) + + val route = candidatePeerIDs + .asSequence() + .mapNotNull { authorizedNdrRoute(it, peerPubkeyHex) } + .firstOrNull() + if (route == null) { + completion(false) + return + } + mesh.sendNdrEvent( + route = route, + payload = payload.eventJson, + isStillAuthorized = { + isCurrentNdrRouteAuthorized(route, peerPubkeyHex) + }, + completion = completion + ) + } + + private fun authorizedNdrRoute( + peerID: String, + peerPubkeyHex: String + ): NdrMeshRoute? { + val route = mesh.currentNdrRoute(peerID) ?: return null + return route.takeIf { + isCurrentNdrRouteAuthorized(it, peerPubkeyHex) + } + } + + private fun isCurrentNdrRouteAuthorized( + route: NdrMeshRoute, + peerPubkeyHex: String + ): Boolean { + if (!NdrFeatureGate.isEnabled()) return false + return NdrOutOfBandRoutePolicy.isAuthorized( + route = route, + expectedPeerPubkeyHex = peerPubkeyHex, + currentRoute = mesh::currentNdrRoute, + favoriteBinding = { noiseKey -> + val favorites = FavoritesPersistenceService.shared + val relationship = favorites.getStoredFavoriteForNdrRoute(noiseKey) + ?: return@isAuthorized null + NdrFavoriteRouteBinding( + isMutual = relationship.isMutual, + peerPubkeyHex = relationship.peerNdrSessionPubkeyHex + ?: relationship.peerNostrPublicKey + ?.let(ContactIdentityResolver::nostrPubkeyHex) + ) + } + ) + } // MARK: - Emergency Clear - private var panicClearInProgress = false - fun panicClearAllData() { - if (panicClearInProgress) return - panicClearInProgress = true - viewModelScope.launch { + if (!panicResetInProgress.compareAndSet(false, true)) return + viewModelScope.launch(Dispatchers.Default) { try { performPanicClearAllData() } finally { - panicClearInProgress = false + panicResetInProgress.set(false) } } } private suspend fun performPanicClearAllData() { Log.w(TAG, "🚨 PANIC MODE ACTIVATED - Clearing all sensitive data") + val resetLease = AccountResetCoordinator.begin(getApplication()) ?: run { + Log.w(TAG, "Panic reset ignored after terminal application shutdown") + return + } try { com.bitchat.android.geohash.LocationChannelManager .getInstance(getApplication()) @@ -1453,13 +1795,28 @@ class ChatViewModel( // become actionable against the fresh post-wipe identity. mediaSendingManager.clearPendingPrivateMediaConsent() - // Stop all message admission before wiping storage. The AppStateStore gate also rejects - // any transport callback already in flight until the fresh identity is ready. + // Stop message admission before wiping storage. The AppStateStore gate also rejects + // transport callbacks already in flight until the replacement identity is ready. clearAllMeshServiceData() val conversationsCleared = com.bitchat.android.services.AppStateStore .panicClearPrivateConversations() + val ndrResetSucceeded = ndrService.resetForPanic() + if (!ndrResetSucceeded) { + Log.e(TAG, "NDR storage wipe was incomplete; NDR remains disabled for this process") + } + // NDR reset is synchronized and therefore quiesces any native send that + // entered before panic. Advance the relay generation only afterwards, + // then clear every event produced by that old runtime. + if (!AccountResetCoordinator.discardRelay(resetLease)) { + Log.w(TAG, "A newer account reset superseded relay cleanup") + return + } + ndrBootstrapAttemptMs.clear() + ndrNoiseHandshakeAttemptMs.clear() + ndrInviteRetries.cancelAll() + // Clear all UI managers com.bitchat.android.services.AppStateStore.clear() messageManager.clearAllMessages() @@ -1475,9 +1832,44 @@ class ChatViewModel( try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.clearAll() } catch (_: Exception) { } - + + if (!ndrResetSucceeded) { + com.bitchat.android.nostr.NdrPanicStartupRecovery.blockNetworkStartup() + try { + mesh.stopServices() + } catch (_: Exception) { } + try { + com.bitchat.android.nostr.NostrRelayManager + .getInstance(getApplication()) + .disconnect() + } catch (_: Exception) { } + clearAllMeshServiceData() + clearAllCryptographicData(ndrResetSucceeded = false) + notificationManager.clearAllNotifications() + com.bitchat.android.features.file.FileUtils.clearAllMedia(getApplication()) + Log.e( + TAG, + "PANIC MODE INCOMPLETE - identity recreation blocked until NDR wipe retry" + ) + return + } + // Clear all cryptographic data - clearAllCryptographicData() + val cryptographicClearSucceeded = + clearAllCryptographicData(ndrResetSucceeded = true) + if (!cryptographicClearSucceeded || !ndrService.completePanicReset()) { + com.bitchat.android.nostr.NdrPanicStartupRecovery.blockNetworkStartup() + try { + mesh.stopServices() + } catch (_: Exception) { } + try { + com.bitchat.android.nostr.NostrRelayManager + .getInstance(getApplication()) + .disconnect() + } catch (_: Exception) { } + Log.e(TAG, "PANIC MODE INCOMPLETE - final wipe commit failed") + return + } // Clear all notifications notificationManager.clearAllNotifications(removeConversationShortcuts = true) @@ -1515,10 +1907,28 @@ class ChatViewModel( return } - // Recreate mesh service with fresh identity - com.bitchat.android.services.AppStateStore - .resumePrivateConversationsAfterPanic() - recreateMeshServiceAfterPanic() + try { + val reopened = AccountResetCoordinator.complete( + lease = resetLease, + installReplacement = { + recreateMeshServiceAfterPanic() + mesh + }, + startReplacement = { replacement -> + com.bitchat.android.services.AppStateStore + .resumePrivateConversationsAfterPanic() + replacement.startServices() + replacement.sendBroadcastAnnounce() + } + ) + if (!reopened) { + Log.w(TAG, "A newer account reset superseded panic reinitialization") + return + } + } catch (e: Exception) { + Log.e(TAG, "Failed to reopen network transports after panic: ${e.message}") + return + } Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${mesh.myPeerID}") } @@ -1542,10 +1952,6 @@ class ChatViewModel( unifiedMeshService = freshUnifiedMeshService mesh.delegate = this - // Restart mesh operations with new identity - mesh.startServices() - mesh.sendBroadcastAnnounce() - Log.d( TAG, "✅ Mesh service recreated. Old peerID: $oldPeerID, New peerID: ${mesh.myPeerID}" @@ -1569,33 +1975,46 @@ class ChatViewModel( /** * Clear all cryptographic data including persistent identity */ - private fun clearAllCryptographicData() { - try { + private fun clearAllCryptographicData(ndrResetSucceeded: Boolean): Boolean { + return try { + var completed = true // Clear encryption service persistent identity (Ed25519 signing keys) mesh.clearAllEncryptionData() // Clear secure identity state (if used) try { val identityManager = SecureIdentityStateManager(getApplication()) - identityManager.clearIdentityData() - // Also clear secure values used by FavoritesPersistenceService (favorites + peerID index) - try { - identityManager.clearSecureValues("favorite_relationships", "favorite_peerid_index") - } catch (_: Exception) { } - Log.d(TAG, "✅ Cleared secure identity state and secure favorites store") + if (identityManager.clearIdentityData()) { + Log.d(TAG, "✅ Cleared secure identity state") + } else { + Log.e(TAG, "Secure identity wipe was not durably committed") + completed = false + } } catch (e: Exception) { Log.d(TAG, "SecureIdentityStateManager not available or already cleared: ${e.message}") + completed = false } - // Clear FavoritesPersistenceService persistent relationships - try { - FavoritesPersistenceService.shared.clearAllFavorites() - Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships") - } catch (_: Exception) { } + if (ndrResetSucceeded) { + try { + if (FavoritesPersistenceService.shared.clearAllFavoritesAfterNdrReset()) { + Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships") + } else { + Log.e(TAG, "Favorites clear was not durably committed") + completed = false + } + } catch (_: Exception) { + completed = false + } + } else { + Log.e(TAG, "Preserving NDR contact pins because native state wipe failed") + } Log.d(TAG, "✅ Cleared all cryptographic data") + completed } catch (e: Exception) { Log.e(TAG, "❌ Error clearing cryptographic data: ${e.message}") + false } } diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index 63a5c0a7..a3fc4424 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -47,7 +47,6 @@ class GeohashViewModel( private val geohashMessageHandler = GeohashMessageHandler( application = application, repo = repo, - scope = viewModelScope, dataManager = dataManager, addChannelMessage = messageManager::addChannelMessage ) @@ -124,8 +123,14 @@ class GeohashViewModel( } fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) { + val relayManager = NostrRelayManager.getInstance(getApplication()) + val accountGeneration = relayManager.captureAccountGeneration() + if (!relayManager.isAccountGenerationCurrent(accountGeneration)) return viewModelScope.launch { try { + if (!relayManager.isAccountGenerationCurrent(accountGeneration)) { + return@launch + } val canUseChannel = locationChannelManager ?.canUseSelectedLocationChannel(channel) == true if (!canUseChannel) { @@ -137,6 +142,9 @@ class GeohashViewModel( val liveLocationToken = locationChannelManager ?.liveLocationTokenForSelectedChannel(channel) if (isLiveDerived && liveLocationToken == null) return@launch + if (!relayManager.isAccountGenerationCurrent(accountGeneration)) { + return@launch + } val tempId = "temp_${System.currentTimeMillis()}_${kotlin.random.Random.nextInt(1000)}" val pow = PoWPreferenceManager.getCurrentSettings() val localMsg = com.bitchat.android.model.BitchatMessage( @@ -163,13 +171,13 @@ class GeohashViewModel( nickname, teleported ) - val relayManager = NostrRelayManager.getInstance(getApplication()) relayManager.sendEventToGeohash( event, channel.geohash, includeDefaults = false, nRelays = 5, - liveLocationToken = liveLocationToken + liveLocationToken = liveLocationToken, + expectedAccountGeneration = accountGeneration ) } catch (e: Exception) { Log.e(TAG, "Failed to send geohash message: ${e.message}") @@ -394,13 +402,16 @@ class GeohashViewModel( geohash: String, liveLocationToken: Long? ) { + val accountEpoch = NostrBackgroundRuntime.currentAccountEpoch() ?: return val subId = "geohash-presence-${UUID.randomUUID()}"; currentGeohashPresenceSubId = subId subscriptionManager.subscribeGeohashPresence( geohash = geohash, sinceMs = System.currentTimeMillis() - 3600000L, limit = 200, id = subId, - handler = { event -> geohashMessageHandler.onEvent(event, geohash) }, + handler = { event -> + geohashMessageHandler.onEvent(event, geohash, accountEpoch) + }, liveLocationToken = liveLocationToken ) } @@ -416,7 +427,6 @@ class GeohashViewModel( override fun onCleared() { shutdownUiSubscriptions() - super.onCleared() } fun shutdownUiSubscriptions() { @@ -472,6 +482,7 @@ class GeohashViewModel( } private fun performSubscribeSampling(geohash: String) { + val accountEpoch = NostrBackgroundRuntime.currentAccountEpoch() ?: return val subscriptionId = samplingSubscriptionIds.getOrPut(geohash) { "sampling-${UUID.randomUUID()}" } @@ -484,7 +495,9 @@ class GeohashViewModel( sinceMs = System.currentTimeMillis() - 86400000L, limit = 200, id = subscriptionId, - handler = { event -> geohashMessageHandler.onEvent(event, geohash) } + handler = { event -> + geohashMessageHandler.onEvent(event, geohash, accountEpoch) + } ) } @@ -504,7 +517,9 @@ class GeohashViewModel( sinceMs = System.currentTimeMillis() - 86400000L, limit = 200, id = subscriptionId, - handler = { event -> geohashMessageHandler.onEvent(event, geohash) }, + handler = { event -> + geohashMessageHandler.onEvent(event, geohash, accountEpoch) + }, liveLocationToken = token ) } diff --git a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt index d5db98bb..7760a9c1 100644 --- a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt @@ -628,8 +628,9 @@ class MediaSendingManager( senderPeerID = meshService.myPeerID ) - // Preparation already built and admitted the exact final packet. Map - // progress before commit so the first asynchronous event cannot race us. + // Preparation already built the exact final packet. Map the transfer before commit so the + // first asynchronous event cannot race us, but keep the local status at Sending until a + // real progress event proves transport admission. if (!messageManager.addPrivateMessageDurably(conversationID, msg, forceRead = true)) { Log.e(TAG, "Prepared private-media message could not be persisted; send aborted") addPrivateMediaSystemMessage( @@ -644,7 +645,7 @@ class MediaSendingManager( } messageManager.updateMessageDeliveryStatus( msg.id, - com.bitchat.android.model.DeliveryStatus.PartiallyDelivered(0, 100) + com.bitchat.android.model.DeliveryStatus.Sending ) if (!preparation.transfer.commit()) { @@ -708,10 +709,10 @@ class MediaSendingManager( messageTransferMap[message.id] = transferId } - // Seed progress so animations start immediately + // Seed the pending state without fabricating delivery progress. messageManager.updateMessageDeliveryStatus( message.id, - com.bitchat.android.model.DeliveryStatus.PartiallyDelivered(0, 100) + com.bitchat.android.model.DeliveryStatus.Sending ) withContext(mediaWorkDispatcher) { diff --git a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt index 31e7d1af..179c2fa4 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt @@ -291,15 +291,17 @@ class MessageManager(private val state: ChatState) { } private fun chooseStatus(old: DeliveryStatus?, new: DeliveryStatus): DeliveryStatus? { - // A send failure may replace an in-flight state, but never a confirmed delivery/read. - return when { - new is DeliveryStatus.Failed && - old !is DeliveryStatus.Delivered && - old !is DeliveryStatus.Read -> new - old is DeliveryStatus.Failed -> new - statusPriority(new) >= statusPriority(old) -> new - else -> old + if (new is DeliveryStatus.Failed) { + // A locally queued message may fail before any transport admits it. + // Never let a late local failure overwrite evidence of handoff/delivery. + return if (old == null || old is DeliveryStatus.Sending || old is DeliveryStatus.Failed) { + new + } else { + old + } } + // Never downgrade (e.g., Read -> Delivered). Keep the higher priority. + return if (statusPriority(new) >= statusPriority(old)) new else old } fun updateMessageDeliveryStatus(messageID: String, status: DeliveryStatus) { diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index 02bfb2d3..d47bb732 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -16,6 +16,8 @@ import com.bitchat.android.crypto.EncryptionService import com.bitchat.android.mesh.DirectLinkAnnouncementPolicy import com.bitchat.android.mesh.FragmentingPacketSender import com.bitchat.android.mesh.MeshCore +import com.bitchat.android.mesh.NdrMeshRoute +import com.bitchat.android.mesh.NdrTransportTarget import com.bitchat.android.mesh.MeshService import com.bitchat.android.mesh.MeshTransport import com.bitchat.android.mesh.PeerInfo @@ -1401,6 +1403,18 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor meshCore.sendVerifyResponse(peerID, noiseKeyHex, nonceA) } + override fun currentNdrRoute(peerID: String, transportId: String?): NdrMeshRoute? = + meshCore.currentNdrRoute(peerID, transportId) + + override fun sendNdrEvent( + route: NdrMeshRoute, + payload: String, + isStillAuthorized: () -> Boolean, + completion: (admitted: Boolean) -> Unit + ) { + meshCore.sendNdrEvent(route, payload, isStillAuthorized, completion) + } + /** * Broadcasts a file (TLV payload) to all peers. Uses protocol version 2 to support * large payloads and generates a deterministic transferId (sha256 of payload) for UI/state. @@ -1496,6 +1510,11 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor */ override fun getPeerInfo(peerID: String): PeerInfo? = meshCore.getPeerInfo(peerID) + override fun peerSupportsAuthenticatedCapability( + peerID: String, + capability: com.bitchat.android.model.PeerCapabilities + ): Boolean = meshCore.peerSupportsAuthenticatedCapability(peerID, capability) + /** * Updates local metadata for a peer and returns whether the change was applied. * @@ -1618,6 +1637,47 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor override fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean { return this@WifiAwareMeshService.sendPacketToPeer(peerID, packet) } + + override fun currentNdrTransportTarget(peerID: String): NdrTransportTarget? { + val canonicalPeerID = connectionTracker.canonicalPeerId(peerID) + val socket = connectionTracker.getSocketForPeer(canonicalPeerID) ?: return null + return NdrTransportTarget( + endpointId = canonicalPeerID, + generationToken = socket + ) + } + + override fun sendPacketToNdrTargetConfirmed( + peerID: String, + target: NdrTransportTarget, + routed: RoutedPacket, + preflight: () -> Boolean, + completion: (Boolean) -> Unit + ) { + val expectedSocket = target.generationToken as? SyncedSocket + if (expectedSocket == null) { + completion(false) + return + } + fragmentingSender.sendConfirmed( + routed = routed, + description = "Wi-Fi Aware NDR peer ${peerID.take(8)}", + preflight = preflight, + sendSingle = sendSingle@{ single -> + if (connectionTracker.getSocketForPeer(target.endpointId) !== expectedSocket) { + return@sendSingle false + } + val data = single.packet.toBinaryData() ?: return@sendSingle false + try { + expectedSocket.write(data) + true + } catch (_: IOException) { + false + } + }, + completion = completion + ) + } override fun sendPacketToLink( relayAddress: String, ingressLinkID: String, diff --git a/app/src/main/java/uniffi/ndr_ffi/ndr_ffi.kt b/app/src/main/java/uniffi/ndr_ffi/ndr_ffi.kt new file mode 100644 index 00000000..d44fc682 --- /dev/null +++ b/app/src/main/java/uniffi/ndr_ffi/ndr_ffi.kt @@ -0,0 +1,2663 @@ +// This file was autogenerated by some hot garbage in the `uniffi` crate. +// Trust me, you don't want to mess with it! + +@file:Suppress("NAME_SHADOWING") + +package uniffi.ndr_ffi + +// Common helper code. +// +// Ideally this would live in a separate .kt file where it can be unittested etc +// in isolation, and perhaps even published as a re-useable package. +// +// However, it's important that the details of how this helper code works (e.g. the +// way that different builtin types are passed across the FFI) exactly match what's +// expected by the Rust code on the other side of the interface. In practice right +// now that means coming from the exact some version of `uniffi` that was used to +// compile the Rust component. The easiest way to ensure this is to bundle the Kotlin +// helpers directly inline like we're doing here. + +import com.sun.jna.Library +import com.sun.jna.IntegerType +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.Structure +import com.sun.jna.Callback +import com.sun.jna.ptr.* +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.CharBuffer +import java.nio.charset.CodingErrorAction +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.ConcurrentHashMap +import android.os.Build +import androidx.annotation.RequiresApi +import java.util.concurrent.atomic.AtomicBoolean + +// This is a helper for safely working with byte buffers returned from the Rust code. +// A rust-owned buffer is represented by its capacity, its current length, and a +// pointer to the underlying data. + +/** + * @suppress + */ +@Structure.FieldOrder("capacity", "len", "data") +open class RustBuffer : Structure() { + // Note: `capacity` and `len` are actually `ULong` values, but JVM only supports signed values. + // When dealing with these fields, make sure to call `toULong()`. + @JvmField var capacity: Long = 0 + @JvmField var len: Long = 0 + @JvmField var data: Pointer? = null + + class ByValue: RustBuffer(), Structure.ByValue + class ByReference: RustBuffer(), Structure.ByReference + + internal fun setValue(other: RustBuffer) { + capacity = other.capacity + len = other.len + data = other.data + } + + companion object { + internal fun alloc(size: ULong = 0UL) = uniffiRustCall() { status -> + // Note: need to convert the size to a `Long` value to make this work with JVM. + UniffiLib.INSTANCE.ffi_ndr_ffi_rustbuffer_alloc(size.toLong(), status) + }.also { + if(it.data == null) { + throw RuntimeException("RustBuffer.alloc() returned null data pointer (size=${size})") + } + } + + internal fun create(capacity: ULong, len: ULong, data: Pointer?): RustBuffer.ByValue { + var buf = RustBuffer.ByValue() + buf.capacity = capacity.toLong() + buf.len = len.toLong() + buf.data = data + return buf + } + + internal fun free(buf: RustBuffer.ByValue) = uniffiRustCall() { status -> + UniffiLib.INSTANCE.ffi_ndr_ffi_rustbuffer_free(buf, status) + } + } + + @Suppress("TooGenericExceptionThrown") + fun asByteBuffer() = + this.data?.getByteBuffer(0, this.len.toLong())?.also { + it.order(ByteOrder.BIG_ENDIAN) + } +} + +/** + * The equivalent of the `*mut RustBuffer` type. + * Required for callbacks taking in an out pointer. + * + * Size is the sum of all values in the struct. + * + * @suppress + */ +class RustBufferByReference : ByReference(16) { + /** + * Set the pointed-to `RustBuffer` to the given value. + */ + fun setValue(value: RustBuffer.ByValue) { + // NOTE: The offsets are as they are in the C-like struct. + val pointer = getPointer() + pointer.setLong(0, value.capacity) + pointer.setLong(8, value.len) + pointer.setPointer(16, value.data) + } + + /** + * Get a `RustBuffer.ByValue` from this reference. + */ + fun getValue(): RustBuffer.ByValue { + val pointer = getPointer() + val value = RustBuffer.ByValue() + value.writeField("capacity", pointer.getLong(0)) + value.writeField("len", pointer.getLong(8)) + value.writeField("data", pointer.getLong(16)) + + return value + } +} + +// This is a helper for safely passing byte references into the rust code. +// It's not actually used at the moment, because there aren't many things that you +// can take a direct pointer to in the JVM, and if we're going to copy something +// then we might as well copy it into a `RustBuffer`. But it's here for API +// completeness. + +@Structure.FieldOrder("len", "data") +internal open class ForeignBytes : Structure() { + @JvmField var len: Int = 0 + @JvmField var data: Pointer? = null + + class ByValue : ForeignBytes(), Structure.ByValue +} +/** + * The FfiConverter interface handles converter types to and from the FFI + * + * All implementing objects should be public to support external types. When a + * type is external we need to import it's FfiConverter. + * + * @suppress + */ +public interface FfiConverter { + // Convert an FFI type to a Kotlin type + fun lift(value: FfiType): KotlinType + + // Convert an Kotlin type to an FFI type + fun lower(value: KotlinType): FfiType + + // Read a Kotlin type from a `ByteBuffer` + fun read(buf: ByteBuffer): KotlinType + + // Calculate bytes to allocate when creating a `RustBuffer` + // + // This must return at least as many bytes as the write() function will + // write. It can return more bytes than needed, for example when writing + // Strings we can't know the exact bytes needed until we the UTF-8 + // encoding, so we pessimistically allocate the largest size possible (3 + // bytes per codepoint). Allocating extra bytes is not really a big deal + // because the `RustBuffer` is short-lived. + fun allocationSize(value: KotlinType): ULong + + // Write a Kotlin type to a `ByteBuffer` + fun write(value: KotlinType, buf: ByteBuffer) + + // Lower a value into a `RustBuffer` + // + // This method lowers a value into a `RustBuffer` rather than the normal + // FfiType. It's used by the callback interface code. Callback interface + // returns are always serialized into a `RustBuffer` regardless of their + // normal FFI type. + fun lowerIntoRustBuffer(value: KotlinType): RustBuffer.ByValue { + val rbuf = RustBuffer.alloc(allocationSize(value)) + try { + val bbuf = rbuf.data!!.getByteBuffer(0, rbuf.capacity).also { + it.order(ByteOrder.BIG_ENDIAN) + } + write(value, bbuf) + rbuf.writeField("len", bbuf.position().toLong()) + return rbuf + } catch (e: Throwable) { + RustBuffer.free(rbuf) + throw e + } + } + + // Lift a value from a `RustBuffer`. + // + // This here mostly because of the symmetry with `lowerIntoRustBuffer()`. + // It's currently only used by the `FfiConverterRustBuffer` class below. + fun liftFromRustBuffer(rbuf: RustBuffer.ByValue): KotlinType { + val byteBuf = rbuf.asByteBuffer()!! + try { + val item = read(byteBuf) + if (byteBuf.hasRemaining()) { + throw RuntimeException("junk remaining in buffer after lifting, something is very wrong!!") + } + return item + } finally { + RustBuffer.free(rbuf) + } + } +} + +/** + * FfiConverter that uses `RustBuffer` as the FfiType + * + * @suppress + */ +public interface FfiConverterRustBuffer: FfiConverter { + override fun lift(value: RustBuffer.ByValue) = liftFromRustBuffer(value) + override fun lower(value: KotlinType) = lowerIntoRustBuffer(value) +} +// A handful of classes and functions to support the generated data structures. +// This would be a good candidate for isolating in its own ffi-support lib. + +internal const val UNIFFI_CALL_SUCCESS = 0.toByte() +internal const val UNIFFI_CALL_ERROR = 1.toByte() +internal const val UNIFFI_CALL_UNEXPECTED_ERROR = 2.toByte() + +@Structure.FieldOrder("code", "error_buf") +internal open class UniffiRustCallStatus : Structure() { + @JvmField var code: Byte = 0 + @JvmField var error_buf: RustBuffer.ByValue = RustBuffer.ByValue() + + class ByValue: UniffiRustCallStatus(), Structure.ByValue + + fun isSuccess(): Boolean { + return code == UNIFFI_CALL_SUCCESS + } + + fun isError(): Boolean { + return code == UNIFFI_CALL_ERROR + } + + fun isPanic(): Boolean { + return code == UNIFFI_CALL_UNEXPECTED_ERROR + } + + companion object { + fun create(code: Byte, errorBuf: RustBuffer.ByValue): UniffiRustCallStatus.ByValue { + val callStatus = UniffiRustCallStatus.ByValue() + callStatus.code = code + callStatus.error_buf = errorBuf + return callStatus + } + } +} + +class InternalException(message: String) : kotlin.Exception(message) + +/** + * Each top-level error class has a companion object that can lift the error from the call status's rust buffer + * + * @suppress + */ +interface UniffiRustCallStatusErrorHandler { + fun lift(error_buf: RustBuffer.ByValue): E; +} + +// Helpers for calling Rust +// In practice we usually need to be synchronized to call this safely, so it doesn't +// synchronize itself + +// Call a rust function that returns a Result<>. Pass in the Error class companion that corresponds to the Err +private inline fun uniffiRustCallWithError(errorHandler: UniffiRustCallStatusErrorHandler, callback: (UniffiRustCallStatus) -> U): U { + var status = UniffiRustCallStatus() + val return_value = callback(status) + uniffiCheckCallStatus(errorHandler, status) + return return_value +} + +// Check UniffiRustCallStatus and throw an error if the call wasn't successful +private fun uniffiCheckCallStatus(errorHandler: UniffiRustCallStatusErrorHandler, status: UniffiRustCallStatus) { + if (status.isSuccess()) { + return + } else if (status.isError()) { + throw errorHandler.lift(status.error_buf) + } else if (status.isPanic()) { + // when the rust code sees a panic, it tries to construct a rustbuffer + // with the message. but if that code panics, then it just sends back + // an empty buffer. + if (status.error_buf.len > 0) { + throw InternalException(FfiConverterString.lift(status.error_buf)) + } else { + throw InternalException("Rust panic") + } + } else { + throw InternalException("Unknown rust call status: $status.code") + } +} + +/** + * UniffiRustCallStatusErrorHandler implementation for times when we don't expect a CALL_ERROR + * + * @suppress + */ +object UniffiNullRustCallStatusErrorHandler: UniffiRustCallStatusErrorHandler { + override fun lift(error_buf: RustBuffer.ByValue): InternalException { + RustBuffer.free(error_buf) + return InternalException("Unexpected CALL_ERROR") + } +} + +// Call a rust function that returns a plain value +private inline fun uniffiRustCall(callback: (UniffiRustCallStatus) -> U): U { + return uniffiRustCallWithError(UniffiNullRustCallStatusErrorHandler, callback) +} + +internal inline fun uniffiTraitInterfaceCall( + callStatus: UniffiRustCallStatus, + makeCall: () -> T, + writeReturn: (T) -> Unit, +) { + try { + writeReturn(makeCall()) + } catch(e: kotlin.Exception) { + callStatus.code = UNIFFI_CALL_UNEXPECTED_ERROR + callStatus.error_buf = FfiConverterString.lower(e.toString()) + } +} + +internal inline fun uniffiTraitInterfaceCallWithError( + callStatus: UniffiRustCallStatus, + makeCall: () -> T, + writeReturn: (T) -> Unit, + lowerError: (E) -> RustBuffer.ByValue +) { + try { + writeReturn(makeCall()) + } catch(e: kotlin.Exception) { + if (e is E) { + callStatus.code = UNIFFI_CALL_ERROR + callStatus.error_buf = lowerError(e) + } else { + callStatus.code = UNIFFI_CALL_UNEXPECTED_ERROR + callStatus.error_buf = FfiConverterString.lower(e.toString()) + } + } +} +// Map handles to objects +// +// This is used pass an opaque 64-bit handle representing a foreign object to the Rust code. +internal class UniffiHandleMap { + private val map = ConcurrentHashMap() + private val counter = java.util.concurrent.atomic.AtomicLong(0) + + val size: Int + get() = map.size + + // Insert a new object into the handle map and get a handle for it + fun insert(obj: T): Long { + val handle = counter.getAndAdd(1) + map.put(handle, obj) + return handle + } + + // Get an object from the handle map + fun get(handle: Long): T { + return map.get(handle) ?: throw InternalException("UniffiHandleMap.get: Invalid handle") + } + + // Remove an entry from the handlemap and get the Kotlin object back + fun remove(handle: Long): T { + return map.remove(handle) ?: throw InternalException("UniffiHandleMap: Invalid handle") + } +} + +// Contains loading, initialization code, +// and the FFI Function declarations in a com.sun.jna.Library. +@Synchronized +private fun findLibraryName(componentName: String): String { + val libOverride = System.getProperty("uniffi.component.$componentName.libraryOverride") + if (libOverride != null) { + return libOverride + } + return "ndr_ffi" +} + +private inline fun loadIndirect( + componentName: String +): Lib { + return Native.load(findLibraryName(componentName), Lib::class.java) +} + +// Define FFI callback types +internal interface UniffiRustFutureContinuationCallback : com.sun.jna.Callback { + fun callback(`data`: Long,`pollResult`: Byte,) +} +internal interface UniffiForeignFutureFree : com.sun.jna.Callback { + fun callback(`handle`: Long,) +} +internal interface UniffiCallbackInterfaceFree : com.sun.jna.Callback { + fun callback(`handle`: Long,) +} +@Structure.FieldOrder("handle", "free") +internal open class UniffiForeignFuture( + @JvmField internal var `handle`: Long = 0.toLong(), + @JvmField internal var `free`: UniffiForeignFutureFree? = null, +) : Structure() { + class UniffiByValue( + `handle`: Long = 0.toLong(), + `free`: UniffiForeignFutureFree? = null, + ): UniffiForeignFuture(`handle`,`free`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFuture) { + `handle` = other.`handle` + `free` = other.`free` + } + +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructU8( + @JvmField internal var `returnValue`: Byte = 0.toByte(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Byte = 0.toByte(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructU8(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructU8) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteU8 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructU8.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructI8( + @JvmField internal var `returnValue`: Byte = 0.toByte(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Byte = 0.toByte(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructI8(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructI8) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteI8 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructI8.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructU16( + @JvmField internal var `returnValue`: Short = 0.toShort(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Short = 0.toShort(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructU16(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructU16) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteU16 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructU16.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructI16( + @JvmField internal var `returnValue`: Short = 0.toShort(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Short = 0.toShort(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructI16(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructI16) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteI16 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructI16.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructU32( + @JvmField internal var `returnValue`: Int = 0, + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Int = 0, + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructU32(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructU32) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteU32 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructU32.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructI32( + @JvmField internal var `returnValue`: Int = 0, + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Int = 0, + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructI32(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructI32) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteI32 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructI32.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructU64( + @JvmField internal var `returnValue`: Long = 0.toLong(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Long = 0.toLong(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructU64(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructU64) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteU64 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructU64.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructI64( + @JvmField internal var `returnValue`: Long = 0.toLong(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Long = 0.toLong(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructI64(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructI64) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteI64 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructI64.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructF32( + @JvmField internal var `returnValue`: Float = 0.0f, + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Float = 0.0f, + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructF32(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructF32) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteF32 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructF32.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructF64( + @JvmField internal var `returnValue`: Double = 0.0, + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Double = 0.0, + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructF64(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructF64) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteF64 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructF64.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructPointer( + @JvmField internal var `returnValue`: Pointer = Pointer.NULL, + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Pointer = Pointer.NULL, + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructPointer(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructPointer) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompletePointer : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructPointer.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructRustBuffer( + @JvmField internal var `returnValue`: RustBuffer.ByValue = RustBuffer.ByValue(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: RustBuffer.ByValue = RustBuffer.ByValue(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructRustBuffer(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructRustBuffer) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteRustBuffer : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructRustBuffer.UniffiByValue,) +} +@Structure.FieldOrder("callStatus") +internal open class UniffiForeignFutureStructVoid( + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructVoid(`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructVoid) { + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteVoid : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructVoid.UniffiByValue,) +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +// For large crates we prevent `MethodTooLargeException` (see #2340) +// N.B. the name of the extension is very misleading, since it is +// rather `InterfaceTooLargeException`, caused by too many methods +// in the interface for large crates. +// +// By splitting the otherwise huge interface into two parts +// * UniffiLib +// * IntegrityCheckingUniffiLib (this) +// we allow for ~2x as many methods in the UniffiLib interface. +// +// The `ffi_uniffi_contract_version` method and all checksum methods are put +// into `IntegrityCheckingUniffiLib` and these methods are called only once, +// when the library is loaded. +internal interface IntegrityCheckingUniffiLib : Library { + // Integrity check functions only + fun uniffi_ndr_ffi_checksum_func_derive_public_key( +): Short +fun uniffi_ndr_ffi_checksum_func_generate_keypair( +): Short +fun uniffi_ndr_ffi_checksum_func_version( +): Short +fun uniffi_ndr_ffi_checksum_method_pairwiseinvite_get_peer_pubkey_hex( +): Short +fun uniffi_ndr_ffi_checksum_method_pairwiseinvite_to_url( +): Short +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_accept_invite_from_event_json( +): Short +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_accept_invite_from_url( +): Short +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_ack_actions( +): Short +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_current_invite_event_json( +): Short +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_current_invite_url( +): Short +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_get_our_pubkey_hex( +): Short +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_get_total_sessions( +): Short +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_known_peer_pubkeys( +): Short +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_pending_actions( +): Short +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_pending_actions_at( +): Short +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_process_event( +): Short +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_process_out_of_band_response( +): Short +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_retire_peer( +): Short +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_send_text( +): Short +fun uniffi_ndr_ffi_checksum_method_pairwisemanager_session_info( +): Short +fun uniffi_ndr_ffi_checksum_constructor_pairwiseinvite_from_event_json( +): Short +fun uniffi_ndr_ffi_checksum_constructor_pairwiseinvite_from_url( +): Short +fun uniffi_ndr_ffi_checksum_constructor_pairwisemanager_new_with_storage_path( +): Short +fun ffi_ndr_ffi_uniffi_contract_version( +): Int + +} + +// A JNA Library to expose the extern-C FFI definitions. +// This is an implementation detail which will be called internally by the public API. +internal interface UniffiLib : Library { + companion object { + internal val INSTANCE: UniffiLib by lazy { + val componentName = "ndr_ffi" + // For large crates we prevent `MethodTooLargeException` (see #2340) + // N.B. the name of the extension is very misleading, since it is + // rather `InterfaceTooLargeException`, caused by too many methods + // in the interface for large crates. + // + // By splitting the otherwise huge interface into two parts + // * UniffiLib (this) + // * IntegrityCheckingUniffiLib + // And all checksum methods are put into `IntegrityCheckingUniffiLib` + // we allow for ~2x as many methods in the UniffiLib interface. + // + // Thus we first load the library with `loadIndirect` as `IntegrityCheckingUniffiLib` + // so that we can (optionally!) call `uniffiCheckApiChecksums`... + loadIndirect(componentName) + .also { lib: IntegrityCheckingUniffiLib -> + uniffiCheckContractApiVersion(lib) + uniffiCheckApiChecksums(lib) + } + // ... and then we load the library as `UniffiLib` + // N.B. we cannot use `loadIndirect` once and then try to cast it to `UniffiLib` + // => results in `java.lang.ClassCastException: com.sun.proxy.$Proxy cannot be cast to ...` + // error. So we must call `loadIndirect` twice. For crates large enough + // to trigger this issue, the performance impact is negligible, running on + // a macOS M1 machine the `loadIndirect` call takes ~50ms. + val lib = loadIndirect(componentName) + // No need to check the contract version and checksums, since + // we already did that with `IntegrityCheckingUniffiLib` above. + // Loading of library with integrity check done. + lib + } + + // The Cleaner for the whole library + internal val CLEANER: UniffiCleaner by lazy { + UniffiCleaner.create() + } + } + + // FFI functions + fun uniffi_ndr_ffi_fn_clone_pairwiseinvite(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +): Pointer +fun uniffi_ndr_ffi_fn_free_pairwiseinvite(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +): Unit +fun uniffi_ndr_ffi_fn_constructor_pairwiseinvite_from_event_json(`eventJson`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): Pointer +fun uniffi_ndr_ffi_fn_constructor_pairwiseinvite_from_url(`url`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): Pointer +fun uniffi_ndr_ffi_fn_method_pairwiseinvite_get_peer_pubkey_hex(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +fun uniffi_ndr_ffi_fn_method_pairwiseinvite_to_url(`ptr`: Pointer,`root`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +fun uniffi_ndr_ffi_fn_clone_pairwisemanager(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +): Pointer +fun uniffi_ndr_ffi_fn_free_pairwisemanager(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +): Unit +fun uniffi_ndr_ffi_fn_constructor_pairwisemanager_new_with_storage_path(`ourPubkeyHex`: RustBuffer.ByValue,`ourIdentityPrivateKeyHex`: RustBuffer.ByValue,`storagePath`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): Pointer +fun uniffi_ndr_ffi_fn_method_pairwisemanager_accept_invite_from_event_json(`ptr`: Pointer,`eventJson`: RustBuffer.ByValue,`authenticatedPeerPubkeyHex`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +fun uniffi_ndr_ffi_fn_method_pairwisemanager_accept_invite_from_url(`ptr`: Pointer,`inviteUrl`: RustBuffer.ByValue,`authenticatedPeerPubkeyHex`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +fun uniffi_ndr_ffi_fn_method_pairwisemanager_ack_actions(`ptr`: Pointer,`actionIds`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): Unit +fun uniffi_ndr_ffi_fn_method_pairwisemanager_current_invite_event_json(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +fun uniffi_ndr_ffi_fn_method_pairwisemanager_current_invite_url(`ptr`: Pointer,`root`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +fun uniffi_ndr_ffi_fn_method_pairwisemanager_get_our_pubkey_hex(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +fun uniffi_ndr_ffi_fn_method_pairwisemanager_get_total_sessions(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +): Long +fun uniffi_ndr_ffi_fn_method_pairwisemanager_known_peer_pubkeys(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +fun uniffi_ndr_ffi_fn_method_pairwisemanager_pending_actions(`ptr`: Pointer,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +fun uniffi_ndr_ffi_fn_method_pairwisemanager_pending_actions_at(`ptr`: Pointer,`nowSeconds`: Long,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +fun uniffi_ndr_ffi_fn_method_pairwisemanager_process_event(`ptr`: Pointer,`eventJson`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): Unit +fun uniffi_ndr_ffi_fn_method_pairwisemanager_process_out_of_band_response(`ptr`: Pointer,`eventJson`: RustBuffer.ByValue,`authenticatedPeerPubkeyHex`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): Unit +fun uniffi_ndr_ffi_fn_method_pairwisemanager_retire_peer(`ptr`: Pointer,`peerPubkeyHex`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): Byte +fun uniffi_ndr_ffi_fn_method_pairwisemanager_send_text(`ptr`: Pointer,`peerPubkeyHex`: RustBuffer.ByValue,`text`: RustBuffer.ByValue,`expiresAtSeconds`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +fun uniffi_ndr_ffi_fn_method_pairwisemanager_session_info(`ptr`: Pointer,`peerPubkeyHex`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +fun uniffi_ndr_ffi_fn_func_derive_public_key(`privateKeyHex`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +fun uniffi_ndr_ffi_fn_func_generate_keypair(uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +fun uniffi_ndr_ffi_fn_func_version(uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +fun ffi_ndr_ffi_rustbuffer_alloc(`size`: Long,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +fun ffi_ndr_ffi_rustbuffer_from_bytes(`bytes`: ForeignBytes.ByValue,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +fun ffi_ndr_ffi_rustbuffer_free(`buf`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, +): Unit +fun ffi_ndr_ffi_rustbuffer_reserve(`buf`: RustBuffer.ByValue,`additional`: Long,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +fun ffi_ndr_ffi_rust_future_poll_u8(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_cancel_u8(`handle`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_free_u8(`handle`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_complete_u8(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +): Byte +fun ffi_ndr_ffi_rust_future_poll_i8(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_cancel_i8(`handle`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_free_i8(`handle`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_complete_i8(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +): Byte +fun ffi_ndr_ffi_rust_future_poll_u16(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_cancel_u16(`handle`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_free_u16(`handle`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_complete_u16(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +): Short +fun ffi_ndr_ffi_rust_future_poll_i16(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_cancel_i16(`handle`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_free_i16(`handle`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_complete_i16(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +): Short +fun ffi_ndr_ffi_rust_future_poll_u32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_cancel_u32(`handle`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_free_u32(`handle`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_complete_u32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +): Int +fun ffi_ndr_ffi_rust_future_poll_i32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_cancel_i32(`handle`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_free_i32(`handle`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_complete_i32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +): Int +fun ffi_ndr_ffi_rust_future_poll_u64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_cancel_u64(`handle`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_free_u64(`handle`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_complete_u64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +): Long +fun ffi_ndr_ffi_rust_future_poll_i64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_cancel_i64(`handle`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_free_i64(`handle`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_complete_i64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +): Long +fun ffi_ndr_ffi_rust_future_poll_f32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_cancel_f32(`handle`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_free_f32(`handle`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_complete_f32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +): Float +fun ffi_ndr_ffi_rust_future_poll_f64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_cancel_f64(`handle`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_free_f64(`handle`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_complete_f64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +): Double +fun ffi_ndr_ffi_rust_future_poll_pointer(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_cancel_pointer(`handle`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_free_pointer(`handle`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_complete_pointer(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +): Pointer +fun ffi_ndr_ffi_rust_future_poll_rust_buffer(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_cancel_rust_buffer(`handle`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_free_rust_buffer(`handle`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_complete_rust_buffer(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +): RustBuffer.ByValue +fun ffi_ndr_ffi_rust_future_poll_void(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_cancel_void(`handle`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_free_void(`handle`: Long, +): Unit +fun ffi_ndr_ffi_rust_future_complete_void(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, +): Unit + +} + +private fun uniffiCheckContractApiVersion(lib: IntegrityCheckingUniffiLib) { + // Get the bindings contract version from our ComponentInterface + val bindings_contract_version = 29 + // Get the scaffolding contract version by calling the into the dylib + val scaffolding_contract_version = lib.ffi_ndr_ffi_uniffi_contract_version() + if (bindings_contract_version != scaffolding_contract_version) { + throw RuntimeException("UniFFI contract version mismatch: try cleaning and rebuilding your project") + } +} +@Suppress("UNUSED_PARAMETER") +private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { + if (lib.uniffi_ndr_ffi_checksum_func_derive_public_key() != 22065.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_ndr_ffi_checksum_func_generate_keypair() != 57537.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_ndr_ffi_checksum_func_version() != 35402.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_ndr_ffi_checksum_method_pairwiseinvite_get_peer_pubkey_hex() != 25596.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_ndr_ffi_checksum_method_pairwiseinvite_to_url() != 1141.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_accept_invite_from_event_json() != 42574.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_accept_invite_from_url() != 29995.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_ack_actions() != 17265.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_current_invite_event_json() != 41966.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_current_invite_url() != 50454.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_get_our_pubkey_hex() != 24347.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_get_total_sessions() != 5478.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_known_peer_pubkeys() != 21367.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_pending_actions() != 4469.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_pending_actions_at() != 26221.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_process_event() != 51097.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_process_out_of_band_response() != 48382.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_retire_peer() != 12247.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_send_text() != 20592.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_ndr_ffi_checksum_method_pairwisemanager_session_info() != 49395.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_ndr_ffi_checksum_constructor_pairwiseinvite_from_event_json() != 9371.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_ndr_ffi_checksum_constructor_pairwiseinvite_from_url() != 12100.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_ndr_ffi_checksum_constructor_pairwisemanager_new_with_storage_path() != 24319.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } +} + +/** + * @suppress + */ +public fun uniffiEnsureInitialized() { + UniffiLib.INSTANCE +} + +// Async support + +// Public interface members begin here. + + +// Interface implemented by anything that can contain an object reference. +// +// Such types expose a `destroy()` method that must be called to cleanly +// dispose of the contained objects. Failure to call this method may result +// in memory leaks. +// +// The easiest way to ensure this method is called is to use the `.use` +// helper method to execute a block and destroy the object at the end. +interface Disposable { + fun destroy() + companion object { + fun destroy(vararg args: Any?) { + for (arg in args) { + when (arg) { + is Disposable -> arg.destroy() + is ArrayList<*> -> { + for (idx in arg.indices) { + val element = arg[idx] + if (element is Disposable) { + element.destroy() + } + } + } + is Map<*, *> -> { + for (element in arg.values) { + if (element is Disposable) { + element.destroy() + } + } + } + is Iterable<*> -> { + for (element in arg) { + if (element is Disposable) { + element.destroy() + } + } + } + } + } + } + } +} + +/** + * @suppress + */ +inline fun T.use(block: (T) -> R) = + try { + block(this) + } finally { + try { + // N.B. our implementation is on the nullable type `Disposable?`. + this?.destroy() + } catch (e: Throwable) { + // swallow + } + } + +/** + * Used to instantiate an interface without an actual pointer, for fakes in tests, mostly. + * + * @suppress + * */ +object NoPointer +/** + * The cleaner interface for Object finalization code to run. + * This is the entry point to any implementation that we're using. + * + * The cleaner registers objects and returns cleanables, so now we are + * defining a `UniffiCleaner` with a `UniffiClenaer.Cleanable` to abstract the + * different implmentations available at compile time. + * + * @suppress + */ +interface UniffiCleaner { + interface Cleanable { + fun clean() + } + + fun register(value: Any, cleanUpTask: Runnable): UniffiCleaner.Cleanable + + companion object +} + +// The fallback Jna cleaner, which is available for both Android, and the JVM. +private class UniffiJnaCleaner : UniffiCleaner { + private val cleaner = com.sun.jna.internal.Cleaner.getCleaner() + + override fun register(value: Any, cleanUpTask: Runnable): UniffiCleaner.Cleanable = + UniffiJnaCleanable(cleaner.register(value, cleanUpTask)) +} + +private class UniffiJnaCleanable( + private val cleanable: com.sun.jna.internal.Cleaner.Cleanable, +) : UniffiCleaner.Cleanable { + override fun clean() = cleanable.clean() +} + + +// We decide at uniffi binding generation time whether we were +// using Android or not. +// There are further runtime checks to chose the correct implementation +// of the cleaner. + + +private fun UniffiCleaner.Companion.create(): UniffiCleaner = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + AndroidSystemCleaner() + } else { + UniffiJnaCleaner() + } + +// The SystemCleaner, available from API Level 33. +// Some API Level 33 OSes do not support using it, so we require API Level 34. +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +private class AndroidSystemCleaner : UniffiCleaner { + val cleaner = android.system.SystemCleaner.cleaner() + + override fun register(value: Any, cleanUpTask: Runnable): UniffiCleaner.Cleanable = + AndroidSystemCleanable(cleaner.register(value, cleanUpTask)) +} + +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +private class AndroidSystemCleanable( + private val cleanable: java.lang.ref.Cleaner.Cleanable, +) : UniffiCleaner.Cleanable { + override fun clean() = cleanable.clean() +} + +/** + * @suppress + */ +public object FfiConverterULong: FfiConverter { + override fun lift(value: Long): ULong { + return value.toULong() + } + + override fun read(buf: ByteBuffer): ULong { + return lift(buf.getLong()) + } + + override fun lower(value: ULong): Long { + return value.toLong() + } + + override fun allocationSize(value: ULong) = 8UL + + override fun write(value: ULong, buf: ByteBuffer) { + buf.putLong(value.toLong()) + } +} + +/** + * @suppress + */ +public object FfiConverterBoolean: FfiConverter { + override fun lift(value: Byte): Boolean { + return value.toInt() != 0 + } + + override fun read(buf: ByteBuffer): Boolean { + return lift(buf.get()) + } + + override fun lower(value: Boolean): Byte { + return if (value) 1.toByte() else 0.toByte() + } + + override fun allocationSize(value: Boolean) = 1UL + + override fun write(value: Boolean, buf: ByteBuffer) { + buf.put(lower(value)) + } +} + +/** + * @suppress + */ +public object FfiConverterString: FfiConverter { + // Note: we don't inherit from FfiConverterRustBuffer, because we use a + // special encoding when lowering/lifting. We can use `RustBuffer.len` to + // store our length and avoid writing it out to the buffer. + override fun lift(value: RustBuffer.ByValue): String { + try { + val byteArr = ByteArray(value.len.toInt()) + value.asByteBuffer()!!.get(byteArr) + return byteArr.toString(Charsets.UTF_8) + } finally { + RustBuffer.free(value) + } + } + + override fun read(buf: ByteBuffer): String { + val len = buf.getInt() + val byteArr = ByteArray(len) + buf.get(byteArr) + return byteArr.toString(Charsets.UTF_8) + } + + fun toUtf8(value: String): ByteBuffer { + // Make sure we don't have invalid UTF-16, check for lone surrogates. + return Charsets.UTF_8.newEncoder().run { + onMalformedInput(CodingErrorAction.REPORT) + encode(CharBuffer.wrap(value)) + } + } + + override fun lower(value: String): RustBuffer.ByValue { + val byteBuf = toUtf8(value) + // Ideally we'd pass these bytes to `ffi_bytebuffer_from_bytes`, but doing so would require us + // to copy them into a JNA `Memory`. So we might as well directly copy them into a `RustBuffer`. + val rbuf = RustBuffer.alloc(byteBuf.limit().toULong()) + rbuf.asByteBuffer()!!.put(byteBuf) + return rbuf + } + + // We aren't sure exactly how many bytes our string will be once it's UTF-8 + // encoded. Allocate 3 bytes per UTF-16 code unit which will always be + // enough. + override fun allocationSize(value: String): ULong { + val sizeForLength = 4UL + val sizeForString = value.length.toULong() * 3UL + return sizeForLength + sizeForString + } + + override fun write(value: String, buf: ByteBuffer) { + val byteBuf = toUtf8(value) + buf.putInt(byteBuf.limit()) + buf.put(byteBuf) + } +} + + +// This template implements a class for working with a Rust struct via a Pointer/Arc +// to the live Rust struct on the other side of the FFI. +// +// Each instance implements core operations for working with the Rust `Arc` and the +// Kotlin Pointer to work with the live Rust struct on the other side of the FFI. +// +// There's some subtlety here, because we have to be careful not to operate on a Rust +// struct after it has been dropped, and because we must expose a public API for freeing +// theq Kotlin wrapper object in lieu of reliable finalizers. The core requirements are: +// +// * Each instance holds an opaque pointer to the underlying Rust struct. +// Method calls need to read this pointer from the object's state and pass it in to +// the Rust FFI. +// +// * When an instance is no longer needed, its pointer should be passed to a +// special destructor function provided by the Rust FFI, which will drop the +// underlying Rust struct. +// +// * Given an instance, calling code is expected to call the special +// `destroy` method in order to free it after use, either by calling it explicitly +// or by using a higher-level helper like the `use` method. Failing to do so risks +// leaking the underlying Rust struct. +// +// * We can't assume that calling code will do the right thing, and must be prepared +// to handle Kotlin method calls executing concurrently with or even after a call to +// `destroy`, and to handle multiple (possibly concurrent!) calls to `destroy`. +// +// * We must never allow Rust code to operate on the underlying Rust struct after +// the destructor has been called, and must never call the destructor more than once. +// Doing so may trigger memory unsafety. +// +// * To mitigate many of the risks of leaking memory and use-after-free unsafety, a `Cleaner` +// is implemented to call the destructor when the Kotlin object becomes unreachable. +// This is done in a background thread. This is not a panacea, and client code should be aware that +// 1. the thread may starve if some there are objects that have poorly performing +// `drop` methods or do significant work in their `drop` methods. +// 2. the thread is shared across the whole library. This can be tuned by using `android_cleaner = true`, +// or `android = true` in the [`kotlin` section of the `uniffi.toml` file](https://mozilla.github.io/uniffi-rs/kotlin/configuration.html). +// +// If we try to implement this with mutual exclusion on access to the pointer, there is the +// possibility of a race between a method call and a concurrent call to `destroy`: +// +// * Thread A starts a method call, reads the value of the pointer, but is interrupted +// before it can pass the pointer over the FFI to Rust. +// * Thread B calls `destroy` and frees the underlying Rust struct. +// * Thread A resumes, passing the already-read pointer value to Rust and triggering +// a use-after-free. +// +// One possible solution would be to use a `ReadWriteLock`, with each method call taking +// a read lock (and thus allowed to run concurrently) and the special `destroy` method +// taking a write lock (and thus blocking on live method calls). However, we aim not to +// generate methods with any hidden blocking semantics, and a `destroy` method that might +// block if called incorrectly seems to meet that bar. +// +// So, we achieve our goals by giving each instance an associated `AtomicLong` counter to track +// the number of in-flight method calls, and an `AtomicBoolean` flag to indicate whether `destroy` +// has been called. These are updated according to the following rules: +// +// * The initial value of the counter is 1, indicating a live object with no in-flight calls. +// The initial value for the flag is false. +// +// * At the start of each method call, we atomically check the counter. +// If it is 0 then the underlying Rust struct has already been destroyed and the call is aborted. +// If it is nonzero them we atomically increment it by 1 and proceed with the method call. +// +// * At the end of each method call, we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// * When `destroy` is called, we atomically flip the flag from false to true. +// If the flag was already true we silently fail. +// Otherwise we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// Astute readers may observe that this all sounds very similar to the way that Rust's `Arc` works, +// and indeed it is, with the addition of a flag to guard against multiple calls to `destroy`. +// +// The overall effect is that the underlying Rust struct is destroyed only when `destroy` has been +// called *and* all in-flight method calls have completed, avoiding violating any of the expectations +// of the underlying Rust code. +// +// This makes a cleaner a better alternative to _not_ calling `destroy()` as +// and when the object is finished with, but the abstraction is not perfect: if the Rust object's `drop` +// method is slow, and/or there are many objects to cleanup, and it's on a low end Android device, then the cleaner +// thread may be starved, and the app will leak memory. +// +// In this case, `destroy`ing manually may be a better solution. +// +// The cleaner can live side by side with the manual calling of `destroy`. In the order of responsiveness, uniffi objects +// with Rust peers are reclaimed: +// +// 1. By calling the `destroy` method of the object, which calls `rustObject.free()`. If that doesn't happen: +// 2. When the object becomes unreachable, AND the Cleaner thread gets to call `rustObject.free()`. If the thread is starved then: +// 3. The memory is reclaimed when the process terminates. +// +// [1] https://stackoverflow.com/questions/24376768/can-java-finalize-an-object-when-it-is-still-in-scope/24380219 +// + + +public interface PairwiseInviteInterface { + + fun `getPeerPubkeyHex`(): kotlin.String + + fun `toUrl`(`root`: kotlin.String): kotlin.String + + companion object +} + +open class PairwiseInvite: Disposable, AutoCloseable, PairwiseInviteInterface +{ + + constructor(pointer: Pointer) { + this.pointer = pointer + this.cleanable = UniffiLib.CLEANER.register(this, UniffiCleanAction(pointer)) + } + + /** + * This constructor can be used to instantiate a fake object. Only used for tests. Any + * attempt to actually use an object constructed this way will fail as there is no + * connected Rust object. + */ + @Suppress("UNUSED_PARAMETER") + constructor(noPointer: NoPointer) { + this.pointer = null + this.cleanable = UniffiLib.CLEANER.register(this, UniffiCleanAction(pointer)) + } + + protected val pointer: Pointer? + protected val cleanable: UniffiCleaner.Cleanable + + private val wasDestroyed = AtomicBoolean(false) + private val callCounter = AtomicLong(1) + + override fun destroy() { + // Only allow a single call to this method. + // TODO: maybe we should log a warning if called more than once? + if (this.wasDestroyed.compareAndSet(false, true)) { + // This decrement always matches the initial count of 1 given at creation time. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable.clean() + } + } + } + + @Synchronized + override fun close() { + this.destroy() + } + + internal inline fun callWithPointer(block: (ptr: Pointer) -> R): R { + // Check and increment the call counter, to keep the object alive. + // This needs a compare-and-set retry loop in case of concurrent updates. + do { + val c = this.callCounter.get() + if (c == 0L) { + throw IllegalStateException("${this.javaClass.simpleName} object has already been destroyed") + } + if (c == Long.MAX_VALUE) { + throw IllegalStateException("${this.javaClass.simpleName} call counter would overflow") + } + } while (! this.callCounter.compareAndSet(c, c + 1L)) + // Now we can safely do the method call without the pointer being freed concurrently. + try { + return block(this.uniffiClonePointer()) + } finally { + // This decrement always matches the increment we performed above. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable.clean() + } + } + } + + // Use a static inner class instead of a closure so as not to accidentally + // capture `this` as part of the cleanable's action. + private class UniffiCleanAction(private val pointer: Pointer?) : Runnable { + override fun run() { + pointer?.let { ptr -> + uniffiRustCall { status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_free_pairwiseinvite(ptr, status) + } + } + } + } + + fun uniffiClonePointer(): Pointer { + return uniffiRustCall() { status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_clone_pairwiseinvite(pointer!!, status) + } + } + + override fun `getPeerPubkeyHex`(): kotlin.String { + return FfiConverterString.lift( + callWithPointer { + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwiseinvite_get_peer_pubkey_hex( + it, _status) +} + } + ) + } + + + + @Throws(NdrException::class)override fun `toUrl`(`root`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + callWithPointer { + uniffiRustCallWithError(NdrException) { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwiseinvite_to_url( + it, FfiConverterString.lower(`root`),_status) +} + } + ) + } + + + + + + companion object { + + @Throws(NdrException::class) fun `fromEventJson`(`eventJson`: kotlin.String): PairwiseInvite { + return FfiConverterTypePairwiseInvite.lift( + uniffiRustCallWithError(NdrException) { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_constructor_pairwiseinvite_from_event_json( + FfiConverterString.lower(`eventJson`),_status) +} + ) + } + + + + @Throws(NdrException::class) fun `fromUrl`(`url`: kotlin.String): PairwiseInvite { + return FfiConverterTypePairwiseInvite.lift( + uniffiRustCallWithError(NdrException) { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_constructor_pairwiseinvite_from_url( + FfiConverterString.lower(`url`),_status) +} + ) + } + + + + } + +} + +/** + * @suppress + */ +public object FfiConverterTypePairwiseInvite: FfiConverter { + + override fun lower(value: PairwiseInvite): Pointer { + return value.uniffiClonePointer() + } + + override fun lift(value: Pointer): PairwiseInvite { + return PairwiseInvite(value) + } + + override fun read(buf: ByteBuffer): PairwiseInvite { + // The Rust code always writes pointers as 8 bytes, and will + // fail to compile if they don't fit. + return lift(Pointer(buf.getLong())) + } + + override fun allocationSize(value: PairwiseInvite) = 8UL + + override fun write(value: PairwiseInvite, buf: ByteBuffer) { + // The Rust code always expects pointers written as 8 bytes, + // and will fail to compile if they don't fit. + buf.putLong(Pointer.nativeValue(lower(value))) + } +} + + +// This template implements a class for working with a Rust struct via a Pointer/Arc +// to the live Rust struct on the other side of the FFI. +// +// Each instance implements core operations for working with the Rust `Arc` and the +// Kotlin Pointer to work with the live Rust struct on the other side of the FFI. +// +// There's some subtlety here, because we have to be careful not to operate on a Rust +// struct after it has been dropped, and because we must expose a public API for freeing +// theq Kotlin wrapper object in lieu of reliable finalizers. The core requirements are: +// +// * Each instance holds an opaque pointer to the underlying Rust struct. +// Method calls need to read this pointer from the object's state and pass it in to +// the Rust FFI. +// +// * When an instance is no longer needed, its pointer should be passed to a +// special destructor function provided by the Rust FFI, which will drop the +// underlying Rust struct. +// +// * Given an instance, calling code is expected to call the special +// `destroy` method in order to free it after use, either by calling it explicitly +// or by using a higher-level helper like the `use` method. Failing to do so risks +// leaking the underlying Rust struct. +// +// * We can't assume that calling code will do the right thing, and must be prepared +// to handle Kotlin method calls executing concurrently with or even after a call to +// `destroy`, and to handle multiple (possibly concurrent!) calls to `destroy`. +// +// * We must never allow Rust code to operate on the underlying Rust struct after +// the destructor has been called, and must never call the destructor more than once. +// Doing so may trigger memory unsafety. +// +// * To mitigate many of the risks of leaking memory and use-after-free unsafety, a `Cleaner` +// is implemented to call the destructor when the Kotlin object becomes unreachable. +// This is done in a background thread. This is not a panacea, and client code should be aware that +// 1. the thread may starve if some there are objects that have poorly performing +// `drop` methods or do significant work in their `drop` methods. +// 2. the thread is shared across the whole library. This can be tuned by using `android_cleaner = true`, +// or `android = true` in the [`kotlin` section of the `uniffi.toml` file](https://mozilla.github.io/uniffi-rs/kotlin/configuration.html). +// +// If we try to implement this with mutual exclusion on access to the pointer, there is the +// possibility of a race between a method call and a concurrent call to `destroy`: +// +// * Thread A starts a method call, reads the value of the pointer, but is interrupted +// before it can pass the pointer over the FFI to Rust. +// * Thread B calls `destroy` and frees the underlying Rust struct. +// * Thread A resumes, passing the already-read pointer value to Rust and triggering +// a use-after-free. +// +// One possible solution would be to use a `ReadWriteLock`, with each method call taking +// a read lock (and thus allowed to run concurrently) and the special `destroy` method +// taking a write lock (and thus blocking on live method calls). However, we aim not to +// generate methods with any hidden blocking semantics, and a `destroy` method that might +// block if called incorrectly seems to meet that bar. +// +// So, we achieve our goals by giving each instance an associated `AtomicLong` counter to track +// the number of in-flight method calls, and an `AtomicBoolean` flag to indicate whether `destroy` +// has been called. These are updated according to the following rules: +// +// * The initial value of the counter is 1, indicating a live object with no in-flight calls. +// The initial value for the flag is false. +// +// * At the start of each method call, we atomically check the counter. +// If it is 0 then the underlying Rust struct has already been destroyed and the call is aborted. +// If it is nonzero them we atomically increment it by 1 and proceed with the method call. +// +// * At the end of each method call, we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// * When `destroy` is called, we atomically flip the flag from false to true. +// If the flag was already true we silently fail. +// Otherwise we atomically decrement and check the counter. +// If it has reached zero then we destroy the underlying Rust struct. +// +// Astute readers may observe that this all sounds very similar to the way that Rust's `Arc` works, +// and indeed it is, with the addition of a flag to guard against multiple calls to `destroy`. +// +// The overall effect is that the underlying Rust struct is destroyed only when `destroy` has been +// called *and* all in-flight method calls have completed, avoiding violating any of the expectations +// of the underlying Rust code. +// +// This makes a cleaner a better alternative to _not_ calling `destroy()` as +// and when the object is finished with, but the abstraction is not perfect: if the Rust object's `drop` +// method is slow, and/or there are many objects to cleanup, and it's on a low end Android device, then the cleaner +// thread may be starved, and the app will leak memory. +// +// In this case, `destroy`ing manually may be a better solution. +// +// The cleaner can live side by side with the manual calling of `destroy`. In the order of responsiveness, uniffi objects +// with Rust peers are reclaimed: +// +// 1. By calling the `destroy` method of the object, which calls `rustObject.free()`. If that doesn't happen: +// 2. When the object becomes unreachable, AND the Cleaner thread gets to call `rustObject.free()`. If the thread is starved then: +// 3. The memory is reclaimed when the process terminates. +// +// [1] https://stackoverflow.com/questions/24376768/can-java-finalize-an-object-when-it-is-still-in-scope/24380219 +// + + +public interface PairwiseManagerInterface { + + fun `acceptInviteFromEventJson`(`eventJson`: kotlin.String, `authenticatedPeerPubkeyHex`: kotlin.String): PairwiseAcceptResult + + fun `acceptInviteFromUrl`(`inviteUrl`: kotlin.String, `authenticatedPeerPubkeyHex`: kotlin.String): PairwiseAcceptResult + + fun `ackActions`(`actionIds`: List) + + fun `currentInviteEventJson`(): kotlin.String + + fun `currentInviteUrl`(`root`: kotlin.String): kotlin.String + + fun `getOurPubkeyHex`(): kotlin.String + + fun `getTotalSessions`(): kotlin.ULong + + fun `knownPeerPubkeys`(): List + + fun `pendingActions`(): List + + fun `pendingActionsAt`(`nowSeconds`: kotlin.ULong): List + + fun `processEvent`(`eventJson`: kotlin.String) + + fun `processOutOfBandResponse`(`eventJson`: kotlin.String, `authenticatedPeerPubkeyHex`: kotlin.String) + + fun `retirePeer`(`peerPubkeyHex`: kotlin.String): kotlin.Boolean + + fun `sendText`(`peerPubkeyHex`: kotlin.String, `text`: kotlin.String, `expiresAtSeconds`: kotlin.ULong?): PairwiseSendResult + + fun `sessionInfo`(`peerPubkeyHex`: kotlin.String): PairwiseSessionInfo? + + companion object +} + +open class PairwiseManager: Disposable, AutoCloseable, PairwiseManagerInterface +{ + + constructor(pointer: Pointer) { + this.pointer = pointer + this.cleanable = UniffiLib.CLEANER.register(this, UniffiCleanAction(pointer)) + } + + /** + * This constructor can be used to instantiate a fake object. Only used for tests. Any + * attempt to actually use an object constructed this way will fail as there is no + * connected Rust object. + */ + @Suppress("UNUSED_PARAMETER") + constructor(noPointer: NoPointer) { + this.pointer = null + this.cleanable = UniffiLib.CLEANER.register(this, UniffiCleanAction(pointer)) + } + + protected val pointer: Pointer? + protected val cleanable: UniffiCleaner.Cleanable + + private val wasDestroyed = AtomicBoolean(false) + private val callCounter = AtomicLong(1) + + override fun destroy() { + // Only allow a single call to this method. + // TODO: maybe we should log a warning if called more than once? + if (this.wasDestroyed.compareAndSet(false, true)) { + // This decrement always matches the initial count of 1 given at creation time. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable.clean() + } + } + } + + @Synchronized + override fun close() { + this.destroy() + } + + internal inline fun callWithPointer(block: (ptr: Pointer) -> R): R { + // Check and increment the call counter, to keep the object alive. + // This needs a compare-and-set retry loop in case of concurrent updates. + do { + val c = this.callCounter.get() + if (c == 0L) { + throw IllegalStateException("${this.javaClass.simpleName} object has already been destroyed") + } + if (c == Long.MAX_VALUE) { + throw IllegalStateException("${this.javaClass.simpleName} call counter would overflow") + } + } while (! this.callCounter.compareAndSet(c, c + 1L)) + // Now we can safely do the method call without the pointer being freed concurrently. + try { + return block(this.uniffiClonePointer()) + } finally { + // This decrement always matches the increment we performed above. + if (this.callCounter.decrementAndGet() == 0L) { + cleanable.clean() + } + } + } + + // Use a static inner class instead of a closure so as not to accidentally + // capture `this` as part of the cleanable's action. + private class UniffiCleanAction(private val pointer: Pointer?) : Runnable { + override fun run() { + pointer?.let { ptr -> + uniffiRustCall { status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_free_pairwisemanager(ptr, status) + } + } + } + } + + fun uniffiClonePointer(): Pointer { + return uniffiRustCall() { status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_clone_pairwisemanager(pointer!!, status) + } + } + + + @Throws(NdrException::class)override fun `acceptInviteFromEventJson`(`eventJson`: kotlin.String, `authenticatedPeerPubkeyHex`: kotlin.String): PairwiseAcceptResult { + return FfiConverterTypePairwiseAcceptResult.lift( + callWithPointer { + uniffiRustCallWithError(NdrException) { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_accept_invite_from_event_json( + it, FfiConverterString.lower(`eventJson`),FfiConverterString.lower(`authenticatedPeerPubkeyHex`),_status) +} + } + ) + } + + + + @Throws(NdrException::class)override fun `acceptInviteFromUrl`(`inviteUrl`: kotlin.String, `authenticatedPeerPubkeyHex`: kotlin.String): PairwiseAcceptResult { + return FfiConverterTypePairwiseAcceptResult.lift( + callWithPointer { + uniffiRustCallWithError(NdrException) { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_accept_invite_from_url( + it, FfiConverterString.lower(`inviteUrl`),FfiConverterString.lower(`authenticatedPeerPubkeyHex`),_status) +} + } + ) + } + + + + @Throws(NdrException::class)override fun `ackActions`(`actionIds`: List) + = + callWithPointer { + uniffiRustCallWithError(NdrException) { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_ack_actions( + it, FfiConverterSequenceString.lower(`actionIds`),_status) +} + } + + + + + @Throws(NdrException::class)override fun `currentInviteEventJson`(): kotlin.String { + return FfiConverterString.lift( + callWithPointer { + uniffiRustCallWithError(NdrException) { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_current_invite_event_json( + it, _status) +} + } + ) + } + + + + @Throws(NdrException::class)override fun `currentInviteUrl`(`root`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + callWithPointer { + uniffiRustCallWithError(NdrException) { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_current_invite_url( + it, FfiConverterString.lower(`root`),_status) +} + } + ) + } + + + + @Throws(NdrException::class)override fun `getOurPubkeyHex`(): kotlin.String { + return FfiConverterString.lift( + callWithPointer { + uniffiRustCallWithError(NdrException) { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_get_our_pubkey_hex( + it, _status) +} + } + ) + } + + + + @Throws(NdrException::class)override fun `getTotalSessions`(): kotlin.ULong { + return FfiConverterULong.lift( + callWithPointer { + uniffiRustCallWithError(NdrException) { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_get_total_sessions( + it, _status) +} + } + ) + } + + + + @Throws(NdrException::class)override fun `knownPeerPubkeys`(): List { + return FfiConverterSequenceString.lift( + callWithPointer { + uniffiRustCallWithError(NdrException) { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_known_peer_pubkeys( + it, _status) +} + } + ) + } + + + + @Throws(NdrException::class)override fun `pendingActions`(): List { + return FfiConverterSequenceTypePairwiseAction.lift( + callWithPointer { + uniffiRustCallWithError(NdrException) { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_pending_actions( + it, _status) +} + } + ) + } + + + + @Throws(NdrException::class)override fun `pendingActionsAt`(`nowSeconds`: kotlin.ULong): List { + return FfiConverterSequenceTypePairwiseAction.lift( + callWithPointer { + uniffiRustCallWithError(NdrException) { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_pending_actions_at( + it, FfiConverterULong.lower(`nowSeconds`),_status) +} + } + ) + } + + + + @Throws(NdrException::class)override fun `processEvent`(`eventJson`: kotlin.String) + = + callWithPointer { + uniffiRustCallWithError(NdrException) { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_process_event( + it, FfiConverterString.lower(`eventJson`),_status) +} + } + + + + + @Throws(NdrException::class)override fun `processOutOfBandResponse`(`eventJson`: kotlin.String, `authenticatedPeerPubkeyHex`: kotlin.String) + = + callWithPointer { + uniffiRustCallWithError(NdrException) { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_process_out_of_band_response( + it, FfiConverterString.lower(`eventJson`),FfiConverterString.lower(`authenticatedPeerPubkeyHex`),_status) +} + } + + + + + @Throws(NdrException::class)override fun `retirePeer`(`peerPubkeyHex`: kotlin.String): kotlin.Boolean { + return FfiConverterBoolean.lift( + callWithPointer { + uniffiRustCallWithError(NdrException) { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_retire_peer( + it, FfiConverterString.lower(`peerPubkeyHex`),_status) +} + } + ) + } + + + + @Throws(NdrException::class)override fun `sendText`(`peerPubkeyHex`: kotlin.String, `text`: kotlin.String, `expiresAtSeconds`: kotlin.ULong?): PairwiseSendResult { + return FfiConverterTypePairwiseSendResult.lift( + callWithPointer { + uniffiRustCallWithError(NdrException) { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_send_text( + it, FfiConverterString.lower(`peerPubkeyHex`),FfiConverterString.lower(`text`),FfiConverterOptionalULong.lower(`expiresAtSeconds`),_status) +} + } + ) + } + + + + @Throws(NdrException::class)override fun `sessionInfo`(`peerPubkeyHex`: kotlin.String): PairwiseSessionInfo? { + return FfiConverterOptionalTypePairwiseSessionInfo.lift( + callWithPointer { + uniffiRustCallWithError(NdrException) { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_method_pairwisemanager_session_info( + it, FfiConverterString.lower(`peerPubkeyHex`),_status) +} + } + ) + } + + + + + + companion object { + + @Throws(NdrException::class) fun `newWithStoragePath`(`ourPubkeyHex`: kotlin.String, `ourIdentityPrivateKeyHex`: kotlin.String, `storagePath`: kotlin.String): PairwiseManager { + return FfiConverterTypePairwiseManager.lift( + uniffiRustCallWithError(NdrException) { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_constructor_pairwisemanager_new_with_storage_path( + FfiConverterString.lower(`ourPubkeyHex`),FfiConverterString.lower(`ourIdentityPrivateKeyHex`),FfiConverterString.lower(`storagePath`),_status) +} + ) + } + + + + } + +} + +/** + * @suppress + */ +public object FfiConverterTypePairwiseManager: FfiConverter { + + override fun lower(value: PairwiseManager): Pointer { + return value.uniffiClonePointer() + } + + override fun lift(value: Pointer): PairwiseManager { + return PairwiseManager(value) + } + + override fun read(buf: ByteBuffer): PairwiseManager { + // The Rust code always writes pointers as 8 bytes, and will + // fail to compile if they don't fit. + return lift(Pointer(buf.getLong())) + } + + override fun allocationSize(value: PairwiseManager) = 8UL + + override fun write(value: PairwiseManager, buf: ByteBuffer) { + // The Rust code always expects pointers written as 8 bytes, + // and will fail to compile if they don't fit. + buf.putLong(Pointer.nativeValue(lower(value))) + } +} + + + +data class FfiKeyPair ( + var `publicKeyHex`: kotlin.String, + var `privateKeyHex`: kotlin.String +) { + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeFfiKeyPair: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): FfiKeyPair { + return FfiKeyPair( + FfiConverterString.read(buf), + FfiConverterString.read(buf), + ) + } + + override fun allocationSize(value: FfiKeyPair) = ( + FfiConverterString.allocationSize(value.`publicKeyHex`) + + FfiConverterString.allocationSize(value.`privateKeyHex`) + ) + + override fun write(value: FfiKeyPair, buf: ByteBuffer) { + FfiConverterString.write(value.`publicKeyHex`, buf) + FfiConverterString.write(value.`privateKeyHex`, buf) + } +} + + + +data class PairwiseAcceptResult ( + var `peerPubkeyHex`: kotlin.String, + var `createdNewSession`: kotlin.Boolean +) { + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypePairwiseAcceptResult: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): PairwiseAcceptResult { + return PairwiseAcceptResult( + FfiConverterString.read(buf), + FfiConverterBoolean.read(buf), + ) + } + + override fun allocationSize(value: PairwiseAcceptResult) = ( + FfiConverterString.allocationSize(value.`peerPubkeyHex`) + + FfiConverterBoolean.allocationSize(value.`createdNewSession`) + ) + + override fun write(value: PairwiseAcceptResult, buf: ByteBuffer) { + FfiConverterString.write(value.`peerPubkeyHex`, buf) + FfiConverterBoolean.write(value.`createdNewSession`, buf) + } +} + + + +data class PairwiseAction ( + var `actionId`: kotlin.String, + var `kind`: kotlin.String, + var `sessionId`: kotlin.String?, + var `subscriptionId`: kotlin.String?, + var `filterJson`: kotlin.String?, + var `eventJson`: kotlin.String?, + var `peerPubkeyHex`: kotlin.String?, + var `innerEventJson`: kotlin.String?, + var `innerEventId`: kotlin.String?, + var `outerEventId`: kotlin.String?, + var `expiresAtSeconds`: kotlin.ULong? +) { + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypePairwiseAction: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): PairwiseAction { + return PairwiseAction( + FfiConverterString.read(buf), + FfiConverterString.read(buf), + FfiConverterOptionalString.read(buf), + FfiConverterOptionalString.read(buf), + FfiConverterOptionalString.read(buf), + FfiConverterOptionalString.read(buf), + FfiConverterOptionalString.read(buf), + FfiConverterOptionalString.read(buf), + FfiConverterOptionalString.read(buf), + FfiConverterOptionalString.read(buf), + FfiConverterOptionalULong.read(buf), + ) + } + + override fun allocationSize(value: PairwiseAction) = ( + FfiConverterString.allocationSize(value.`actionId`) + + FfiConverterString.allocationSize(value.`kind`) + + FfiConverterOptionalString.allocationSize(value.`sessionId`) + + FfiConverterOptionalString.allocationSize(value.`subscriptionId`) + + FfiConverterOptionalString.allocationSize(value.`filterJson`) + + FfiConverterOptionalString.allocationSize(value.`eventJson`) + + FfiConverterOptionalString.allocationSize(value.`peerPubkeyHex`) + + FfiConverterOptionalString.allocationSize(value.`innerEventJson`) + + FfiConverterOptionalString.allocationSize(value.`innerEventId`) + + FfiConverterOptionalString.allocationSize(value.`outerEventId`) + + FfiConverterOptionalULong.allocationSize(value.`expiresAtSeconds`) + ) + + override fun write(value: PairwiseAction, buf: ByteBuffer) { + FfiConverterString.write(value.`actionId`, buf) + FfiConverterString.write(value.`kind`, buf) + FfiConverterOptionalString.write(value.`sessionId`, buf) + FfiConverterOptionalString.write(value.`subscriptionId`, buf) + FfiConverterOptionalString.write(value.`filterJson`, buf) + FfiConverterOptionalString.write(value.`eventJson`, buf) + FfiConverterOptionalString.write(value.`peerPubkeyHex`, buf) + FfiConverterOptionalString.write(value.`innerEventJson`, buf) + FfiConverterOptionalString.write(value.`innerEventId`, buf) + FfiConverterOptionalString.write(value.`outerEventId`, buf) + FfiConverterOptionalULong.write(value.`expiresAtSeconds`, buf) + } +} + + + +data class PairwiseSendResult ( + var `innerEventId`: kotlin.String, + var `outerEventId`: kotlin.String +) { + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypePairwiseSendResult: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): PairwiseSendResult { + return PairwiseSendResult( + FfiConverterString.read(buf), + FfiConverterString.read(buf), + ) + } + + override fun allocationSize(value: PairwiseSendResult) = ( + FfiConverterString.allocationSize(value.`innerEventId`) + + FfiConverterString.allocationSize(value.`outerEventId`) + ) + + override fun write(value: PairwiseSendResult, buf: ByteBuffer) { + FfiConverterString.write(value.`innerEventId`, buf) + FfiConverterString.write(value.`outerEventId`, buf) + } +} + + + +data class PairwiseSessionInfo ( + var `sendReady`: kotlin.Boolean, + var `receiveReady`: kotlin.Boolean, + var `trackedSenderPubkeys`: List +) { + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypePairwiseSessionInfo: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): PairwiseSessionInfo { + return PairwiseSessionInfo( + FfiConverterBoolean.read(buf), + FfiConverterBoolean.read(buf), + FfiConverterSequenceString.read(buf), + ) + } + + override fun allocationSize(value: PairwiseSessionInfo) = ( + FfiConverterBoolean.allocationSize(value.`sendReady`) + + FfiConverterBoolean.allocationSize(value.`receiveReady`) + + FfiConverterSequenceString.allocationSize(value.`trackedSenderPubkeys`) + ) + + override fun write(value: PairwiseSessionInfo, buf: ByteBuffer) { + FfiConverterBoolean.write(value.`sendReady`, buf) + FfiConverterBoolean.write(value.`receiveReady`, buf) + FfiConverterSequenceString.write(value.`trackedSenderPubkeys`, buf) + } +} + + + + + +sealed class NdrException: kotlin.Exception() { + + class InvalidKey( + + val v1: kotlin.String + ) : NdrException() { + override val message + get() = "v1=${ v1 }" + } + + class InvalidEvent( + + val v1: kotlin.String + ) : NdrException() { + override val message + get() = "v1=${ v1 }" + } + + class PeerMismatch( + + val v1: kotlin.String + ) : NdrException() { + override val message + get() = "v1=${ v1 }" + } + + class SessionNotReady( + + val v1: kotlin.String + ) : NdrException() { + override val message + get() = "v1=${ v1 }" + } + + class QueueFull( + + val v1: kotlin.String + ) : NdrException() { + override val message + get() = "v1=${ v1 }" + } + + class Storage( + + val v1: kotlin.String + ) : NdrException() { + override val message + get() = "v1=${ v1 }" + } + + class CryptoFailure( + + val v1: kotlin.String + ) : NdrException() { + override val message + get() = "v1=${ v1 }" + } + + + companion object ErrorHandler : UniffiRustCallStatusErrorHandler { + override fun lift(error_buf: RustBuffer.ByValue): NdrException = FfiConverterTypeNdrError.lift(error_buf) + } + + +} + +/** + * @suppress + */ +public object FfiConverterTypeNdrError : FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): NdrException { + + + return when(buf.getInt()) { + 1 -> NdrException.InvalidKey( + FfiConverterString.read(buf), + ) + 2 -> NdrException.InvalidEvent( + FfiConverterString.read(buf), + ) + 3 -> NdrException.PeerMismatch( + FfiConverterString.read(buf), + ) + 4 -> NdrException.SessionNotReady( + FfiConverterString.read(buf), + ) + 5 -> NdrException.QueueFull( + FfiConverterString.read(buf), + ) + 6 -> NdrException.Storage( + FfiConverterString.read(buf), + ) + 7 -> NdrException.CryptoFailure( + FfiConverterString.read(buf), + ) + else -> throw RuntimeException("invalid error enum value, something is very wrong!!") + } + } + + override fun allocationSize(value: NdrException): ULong { + return when(value) { + is NdrException.InvalidKey -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is NdrException.InvalidEvent -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is NdrException.PeerMismatch -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is NdrException.SessionNotReady -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is NdrException.QueueFull -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is NdrException.Storage -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + is NdrException.CryptoFailure -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) + } + } + + override fun write(value: NdrException, buf: ByteBuffer) { + when(value) { + is NdrException.InvalidKey -> { + buf.putInt(1) + FfiConverterString.write(value.v1, buf) + Unit + } + is NdrException.InvalidEvent -> { + buf.putInt(2) + FfiConverterString.write(value.v1, buf) + Unit + } + is NdrException.PeerMismatch -> { + buf.putInt(3) + FfiConverterString.write(value.v1, buf) + Unit + } + is NdrException.SessionNotReady -> { + buf.putInt(4) + FfiConverterString.write(value.v1, buf) + Unit + } + is NdrException.QueueFull -> { + buf.putInt(5) + FfiConverterString.write(value.v1, buf) + Unit + } + is NdrException.Storage -> { + buf.putInt(6) + FfiConverterString.write(value.v1, buf) + Unit + } + is NdrException.CryptoFailure -> { + buf.putInt(7) + FfiConverterString.write(value.v1, buf) + Unit + } + }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } + } + +} + + + + +/** + * @suppress + */ +public object FfiConverterOptionalULong: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): kotlin.ULong? { + if (buf.get().toInt() == 0) { + return null + } + return FfiConverterULong.read(buf) + } + + override fun allocationSize(value: kotlin.ULong?): ULong { + if (value == null) { + return 1UL + } else { + return 1UL + FfiConverterULong.allocationSize(value) + } + } + + override fun write(value: kotlin.ULong?, buf: ByteBuffer) { + if (value == null) { + buf.put(0) + } else { + buf.put(1) + FfiConverterULong.write(value, buf) + } + } +} + + + + +/** + * @suppress + */ +public object FfiConverterOptionalString: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): kotlin.String? { + if (buf.get().toInt() == 0) { + return null + } + return FfiConverterString.read(buf) + } + + override fun allocationSize(value: kotlin.String?): ULong { + if (value == null) { + return 1UL + } else { + return 1UL + FfiConverterString.allocationSize(value) + } + } + + override fun write(value: kotlin.String?, buf: ByteBuffer) { + if (value == null) { + buf.put(0) + } else { + buf.put(1) + FfiConverterString.write(value, buf) + } + } +} + + + + +/** + * @suppress + */ +public object FfiConverterOptionalTypePairwiseSessionInfo: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): PairwiseSessionInfo? { + if (buf.get().toInt() == 0) { + return null + } + return FfiConverterTypePairwiseSessionInfo.read(buf) + } + + override fun allocationSize(value: PairwiseSessionInfo?): ULong { + if (value == null) { + return 1UL + } else { + return 1UL + FfiConverterTypePairwiseSessionInfo.allocationSize(value) + } + } + + override fun write(value: PairwiseSessionInfo?, buf: ByteBuffer) { + if (value == null) { + buf.put(0) + } else { + buf.put(1) + FfiConverterTypePairwiseSessionInfo.write(value, buf) + } + } +} + + + + +/** + * @suppress + */ +public object FfiConverterSequenceString: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { + val len = buf.getInt() + return List(len) { + FfiConverterString.read(buf) + } + } + + override fun allocationSize(value: List): ULong { + val sizeForLength = 4UL + val sizeForItems = value.map { FfiConverterString.allocationSize(it) }.sum() + return sizeForLength + sizeForItems + } + + override fun write(value: List, buf: ByteBuffer) { + buf.putInt(value.size) + value.iterator().forEach { + FfiConverterString.write(it, buf) + } + } +} + + + + +/** + * @suppress + */ +public object FfiConverterSequenceTypePairwiseAction: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { + val len = buf.getInt() + return List(len) { + FfiConverterTypePairwiseAction.read(buf) + } + } + + override fun allocationSize(value: List): ULong { + val sizeForLength = 4UL + val sizeForItems = value.map { FfiConverterTypePairwiseAction.allocationSize(it) }.sum() + return sizeForLength + sizeForItems + } + + override fun write(value: List, buf: ByteBuffer) { + buf.putInt(value.size) + value.iterator().forEach { + FfiConverterTypePairwiseAction.write(it, buf) + } + } +} + @Throws(NdrException::class) fun `derivePublicKey`(`privateKeyHex`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + uniffiRustCallWithError(NdrException) { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_func_derive_public_key( + FfiConverterString.lower(`privateKeyHex`),_status) +} + ) + } + + fun `generateKeypair`(): FfiKeyPair { + return FfiConverterTypeFfiKeyPair.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_func_generate_keypair( + _status) +} + ) + } + + fun `version`(): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_ndr_ffi_fn_func_version( + _status) +} + ) + } diff --git a/app/src/main/ndr-ffi/SOURCE_REVISION b/app/src/main/ndr-ffi/SOURCE_REVISION new file mode 100644 index 00000000..9c18dcab --- /dev/null +++ b/app/src/main/ndr-ffi/SOURCE_REVISION @@ -0,0 +1 @@ +0fe8caf2d4e24e2030ffae195597a2764613a659 diff --git a/app/src/main/ndr-ffi/VENDORED_FROM.md b/app/src/main/ndr-ffi/VENDORED_FROM.md new file mode 100644 index 00000000..e7315d6c --- /dev/null +++ b/app/src/main/ndr-ffi/VENDORED_FROM.md @@ -0,0 +1,30 @@ +# Android NDR FFI provenance + +The Android bindings are generated from the pinned +`vendor/nostr-double-ratchet` submodule. + +- Source repository: `https://github.com/irislib/nostr-double-ratchet.git` +- Source commit: `0fe8caf2d4e24e2030ffae195597a2764613a659` +- Upstream base: `master` at `c93f76a2b947f4288d2c7bcbecabe70ce197da5f` +- Crate: `ndr-pairwise-ffi` (library `ndr_ffi`) +- Runtime: durable single-identity pairwise sessions only; no AppKeys, + linked-device, sibling-sync, or group runtime +- Rust toolchain: `1.95.0` +- `cargo-ndk`: `4.1.2` +- Android NDK: `28.2.13676358` + +Run `app/src/main/ndr-ffi/build-android.sh` after initializing submodules. The +script uses Cargo's checked-in lockfiles, builds all four Android ABIs, and +regenerates `app/src/main/java/uniffi/ndr_ffi/ndr_ffi.kt` with UniFFI's +Android cleaner configuration. + +The generated `libndr_ffi.so` files are intentionally ignored. CI and release +jobs build them from the pinned source before Gradle runs. + +## Rollout sequencing + +Rollout remains disabled until iOS and Android enable the pairwise protocol +together. Capability bit 11 and Noise payload `0x22` are accepted only for an +authenticated Noise peer with an exact current Nostr identity binding and a +mutual favorite advertising the same capability. The independent kind-1402 +fallback-envelope migration can land before or after this work. diff --git a/app/src/main/ndr-ffi/build-android.sh b/app/src/main/ndr-ffi/build-android.sh new file mode 100755 index 00000000..f81ef4ca --- /dev/null +++ b/app/src/main/ndr-ffi/build-android.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" +SOURCE_DIR="${NOSTR_DOUBLE_RATCHET_DIR:-${REPO_ROOT}/vendor/nostr-double-ratchet}" +CRATE_DIR="${SOURCE_DIR}/rust/crates/ndr-pairwise-ffi" +CRATE_MANIFEST="${CRATE_DIR}/Cargo.toml" +SOURCE_REVISION="$(tr -d '[:space:]' < "${SCRIPT_DIR}/SOURCE_REVISION")" +JNI_DIR="${REPO_ROOT}/app/src/main/jniLibs" +KOTLIN_DIR="${REPO_ROOT}/app/src/main/java/uniffi/ndr_ffi" +BUILD_DIR="$(mktemp -d "${TMPDIR:-/tmp}/bitchat-ndr-android.XXXXXX")" + +cleanup() { + rm -rf "${BUILD_DIR}" +} +trap cleanup EXIT + +if [[ ! -f "${CRATE_MANIFEST}" ]]; then + echo "nostr-double-ratchet pairwise FFI source not found at ${CRATE_DIR}" >&2 + echo "Run: git submodule update --init --checkout vendor/nostr-double-ratchet" >&2 + exit 1 +fi + +if ! git -C "${SOURCE_DIR}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "nostr-double-ratchet source must be the pinned Git submodule at ${SOURCE_DIR}" >&2 + exit 1 +fi +SOURCE_WORKTREE="$(cd "${SOURCE_DIR}" && pwd -P)" +SOURCE_GIT_ROOT="$(git -C "${SOURCE_DIR}" rev-parse --show-toplevel)" +if [[ "${SOURCE_GIT_ROOT}" != "${SOURCE_WORKTREE}" ]]; then + echo "nostr-double-ratchet Git root is ${SOURCE_GIT_ROOT}; expected ${SOURCE_WORKTREE}" >&2 + exit 1 +fi +ACTUAL_REVISION="$(git -C "${SOURCE_DIR}" rev-parse HEAD)" +if [[ "${ACTUAL_REVISION}" != "${SOURCE_REVISION}" ]]; then + echo "nostr-double-ratchet is at ${ACTUAL_REVISION}; expected ${SOURCE_REVISION}" >&2 + exit 1 +fi +if [[ -n "$(git -C "${SOURCE_DIR}" status --porcelain --untracked-files=all)" ]]; then + echo "nostr-double-ratchet source has local changes; refusing an unreproducible build" >&2 + exit 1 +fi + +command -v cargo >/dev/null +command -v cargo-ndk >/dev/null + +EXPECTED_NDK_REVISION="28.2.13676358" +NDR_ANDROID_NDK="${ANDROID_NDK_HOME:-${NDK_HOME:-}}" +if [[ -f "${NDR_ANDROID_NDK}/source.properties" ]] && + ! grep -q "^Pkg\\.Revision = ${EXPECTED_NDK_REVISION}$" "${NDR_ANDROID_NDK}/source.properties"; then + NDR_ANDROID_NDK="" +fi +if [[ ! -f "${NDR_ANDROID_NDK}/source.properties" ]]; then + NDR_ANDROID_SDK="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}" + if [[ -d "${NDR_ANDROID_SDK}/ndk/${EXPECTED_NDK_REVISION}" ]]; then + NDR_ANDROID_NDK="${NDR_ANDROID_SDK}/ndk/${EXPECTED_NDK_REVISION}" + fi +fi +if [[ ! -f "${NDR_ANDROID_NDK}/source.properties" ]]; then + echo "Android NDK ${EXPECTED_NDK_REVISION} not found; install it or set ANDROID_NDK_HOME" >&2 + exit 1 +fi +export ANDROID_NDK_HOME="${NDR_ANDROID_NDK}" +export NDK_HOME="${NDR_ANDROID_NDK}" +# A user-level Cargo config may point at a sandbox-inaccessible compiler cache. +# CI can explicitly set a working wrapper after invoking this script if desired. +export RUSTC_WRAPPER="" + +NDR_CARGO_BIN_DIR="$(cd "$(dirname "$(command -v cargo)")" && pwd -P)" +NDR_CARGO_HOME="${CARGO_HOME:-$(cd "${NDR_CARGO_BIN_DIR}/.." && pwd -P)}" +NDR_RUSTFLAGS=( + "-C" "link-arg=-Wl,-z,max-page-size=16384" + "--remap-path-prefix=${REPO_ROOT}=/usr/src/bitchat-android" + "--remap-path-prefix=${NDR_CARGO_HOME}=/usr/local/cargo" +) +NDR_RUSTFLAGS_STRING="${NDR_RUSTFLAGS[*]}" + +mkdir -p "${BUILD_DIR}/jni" "${BUILD_DIR}/bindings" + +( + cd "${CRATE_DIR}" + RUSTFLAGS="${NDR_RUSTFLAGS_STRING}" cargo ndk \ + -t arm64-v8a \ + -t armeabi-v7a \ + -t x86_64 \ + -t x86 \ + -o "${BUILD_DIR}/jni" \ + build \ + --locked \ + --lib \ + --release +) + +( + cd "${CRATE_DIR}" + cargo run \ + --locked \ + --manifest-path "${CRATE_MANIFEST}" \ + --features bindgen \ + --bin uniffi-bindgen \ + -- \ + generate \ + --library "${BUILD_DIR}/jni/arm64-v8a/libndr_ffi.so" \ + --language kotlin \ + --config "${SCRIPT_DIR}/uniffi.toml" \ + --out-dir "${BUILD_DIR}/bindings" +) + +GENERATED_KOTLIN="${BUILD_DIR}/bindings/uniffi/ndr_ffi/ndr_ffi.kt" +if [[ ! -f "${GENERATED_KOTLIN}" ]]; then + echo "UniFFI did not generate ${GENERATED_KOTLIN}" >&2 + exit 1 +fi + +for ABI in arm64-v8a armeabi-v7a x86_64 x86; do + mkdir -p "${JNI_DIR}/${ABI}" + cp "${BUILD_DIR}/jni/${ABI}/libndr_ffi.so" "${JNI_DIR}/${ABI}/libndr_ffi.so" +done + +LLVM_READELF_CANDIDATES=( + "${NDR_ANDROID_NDK}"/toolchains/llvm/prebuilt/*/bin/llvm-readelf +) +if [[ "${#LLVM_READELF_CANDIDATES[@]}" -ne 1 ]] || + [[ ! -x "${LLVM_READELF_CANDIDATES[0]}" ]]; then + echo "Unable to locate llvm-readelf in Android NDK ${EXPECTED_NDK_REVISION}" >&2 + exit 1 +fi +LLVM_READELF="${LLVM_READELF_CANDIDATES[0]}" +for ABI in arm64-v8a armeabi-v7a x86_64 x86; do + LIBRARY="${JNI_DIR}/${ABI}/libndr_ffi.so" + LOAD_ALIGNMENTS="$( + "${LLVM_READELF}" -lW "${LIBRARY}" | + awk '$1 == "LOAD" { print $NF }' + )" + if [[ -z "${LOAD_ALIGNMENTS}" ]]; then + echo "No ELF LOAD segments found in ${LIBRARY}" >&2 + exit 1 + fi + while IFS= read -r ALIGNMENT; do + if (( ALIGNMENT < 0x4000 )); then + echo "${LIBRARY} has non-16KiB LOAD alignment ${ALIGNMENT}" >&2 + exit 1 + fi + done <<< "${LOAD_ALIGNMENTS}" +done + +mkdir -p "${KOTLIN_DIR}" +cp "${GENERATED_KOTLIN}" "${KOTLIN_DIR}/ndr_ffi.kt" +perl -pi -e 's/[ \t]+$//' "${KOTLIN_DIR}/ndr_ffi.kt" +perl -0777 -pi -e 's/\s+\z/\n/' "${KOTLIN_DIR}/ndr_ffi.kt" + +echo "Built Android pairwise NDR FFI from nostr-double-ratchet ${SOURCE_REVISION}" diff --git a/app/src/main/ndr-ffi/uniffi.toml b/app/src/main/ndr-ffi/uniffi.toml new file mode 100644 index 00000000..53549aa7 --- /dev/null +++ b/app/src/main/ndr-ffi/uniffi.toml @@ -0,0 +1,2 @@ +[bindings.kotlin] +android = true diff --git a/app/src/test/kotlin/com/bitchat/android/favorites/FavoritesNdrRebindTest.kt b/app/src/test/kotlin/com/bitchat/android/favorites/FavoritesNdrRebindTest.kt new file mode 100644 index 00000000..21a8e651 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/favorites/FavoritesNdrRebindTest.kt @@ -0,0 +1,639 @@ +package com.bitchat.android.favorites + +import android.content.Context +import android.content.SharedPreferences +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.services.ContactIdentityResolver +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.Date +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread + +@RunWith(RobolectricTestRunner::class) +class FavoritesNdrRebindTest { + private lateinit var service: FavoritesPersistenceService + private lateinit var preferences: FaultInjectingSharedPreferences + private val noiseA = ByteArray(32) { 1 } + private val noiseB = ByteArray(32) { 2 } + private val oldPeer = "11".repeat(32) + private val newPeer = "22".repeat(32) + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + preferences = FaultInjectingSharedPreferences( + context.getSharedPreferences( + "favorites-ndr-rebind-${System.nanoTime()}", + Context.MODE_PRIVATE + ) + ) + service = newService() + } + + private fun newService(): FavoritesPersistenceService { + service = FavoritesPersistenceService( + stateManager = SecureIdentityStateManager(preferences, testOnly = true), + testOnly = true + ) + return service + } + + @Test + fun persistenceFailureBeforeRebindDoesNotRetireOrReportSuccess() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + assertTrue(service.updateNdrSessionPubkeyHex(noiseA, oldPeer)) + var retireCalls = 0 + service.setNdrPeerRetirementGuard { + retireCalls += 1 + true + } + + preferences.failNextWrite = true + assertFalse(service.updateNdrSessionPubkeyHex(noiseA, newPeer)) + + assertEquals(0, retireCalls) + val restarted = newService() + assertEquals(oldPeer, restarted.findNdrSessionPubkeyHex(noiseA)) + assertTrue(restarted.isNdrRequired(noiseA)) + } + + @Test + fun failedTargetPersistenceKeepsJournalAndExposesOnlyTargetRoute() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + assertTrue(service.updateNdrSessionPubkeyHex(noiseA, oldPeer)) + var retireCalls = 0 + service.setNdrPeerRetirementGuard { + retireCalls += 1 + true + } + + preferences.successfulWritesBeforeFailure = 1 + assertFalse(service.updateNdrSessionPubkeyHex(noiseA, newPeer)) + + assertEquals(1, retireCalls) + assertTrue(service.isNdrRebindBlocked(noiseA)) + assertTrue(service.isNdrRequired(noiseA)) + assertEquals(newPeer, service.findNdrSessionPubkeyHex(noiseA)) + assertEquals(noiseA.toList(), service.findNoiseKey(newPeer)?.toList()) + assertFalse(service.isCurrentNdrPeerAuthorized(oldPeer)) + assertNull(service.getStoredFavoriteForNdrRoute(noiseA)) + } + + @Test + fun restartCompletesJournalBeforeUnblockingTarget() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + assertTrue(service.updateNdrSessionPubkeyHex(noiseA, oldPeer)) + var failedRetireCalls = 0 + service.setNdrPeerRetirementGuard { + failedRetireCalls += 1 + false + } + + assertFalse(service.updateNdrSessionPubkeyHex(noiseA, newPeer)) + assertEquals(1, failedRetireCalls) + assertEquals(newPeer, service.findNdrSessionPubkeyHex(noiseA)) + assertTrue(service.isNdrRequired(noiseA)) + assertTrue(service.isNdrRebindBlocked(noiseA)) + + val retiredAfterRestart = mutableListOf() + val restarted = newService() + restarted.setNdrPeerRetirementGuard { + retiredAfterRestart += it + true + } + + assertEquals(listOf(oldPeer), retiredAfterRestart) + assertEquals(newPeer, restarted.findNdrSessionPubkeyHex(noiseA)) + assertTrue(restarted.isNdrRequired(noiseA)) + assertFalse(restarted.isNdrRebindBlocked(noiseA)) + + val verifiedRestart = newService() + assertEquals(newPeer, verifiedRestart.findNdrSessionPubkeyHex(noiseA)) + assertTrue(verifiedRestart.isNdrRequired(noiseA)) + assertFalse(verifiedRestart.isNdrRebindBlocked(noiseA)) + } + + @Test + fun failedJournalRemovalRetriesIdempotentRetirementAfterRestart() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + assertTrue(service.updateNdrSessionPubkeyHex(noiseA, oldPeer)) + val retired = mutableListOf() + service.setNdrPeerRetirementGuard { + retired += it + true + } + preferences.failNextRemovalOf = + FavoritesPersistenceService.NDR_REBIND_JOURNAL_KEY + + assertFalse(service.updateNdrSessionPubkeyHex(noiseA, newPeer)) + assertEquals(listOf(oldPeer), retired) + assertTrue(service.isNdrRebindBlocked(noiseA)) + assertTrue(service.isNdrRequired(noiseA)) + assertEquals( + newPeer, + service.getStoredFavoriteForNdrRoute(noiseA) + ?.peerNdrSessionPubkeyHex + ) + assertTrue(service.isCurrentNdrPeerAuthorized(newPeer)) + + val restarted = newService() + restarted.setNdrPeerRetirementGuard { + retired += it + true + } + + assertEquals(listOf(oldPeer, oldPeer), retired) + assertEquals(newPeer, restarted.findNdrSessionPubkeyHex(noiseA)) + assertTrue(restarted.isNdrRequired(noiseA)) + assertFalse(restarted.isNdrRebindBlocked(noiseA)) + } + + @Test + fun initialIdentityPersistenceFailureDoesNotMutateMemory() { + preferences.failNextWrite = true + + assertFalse(service.updateNostrPublicKey(noiseA, oldPeer)) + + assertNull(service.getFavoriteStatus(noiseA)) + assertNull(newService().getFavoriteStatus(noiseA)) + } + + @Test + fun existingNativeSessionBackfillRetriesAfterPinMarkerWriteFailure() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + preferences.failNextWrite = true + + assertFalse(service.updateNdrSessionPubkeyHex(noiseA, oldPeer)) + assertFalse(service.isNdrProtectionStateReadable()) + assertTrue(service.isNdrRebindBlocked(noiseA)) + + val restarted = newService() + assertFalse(restarted.isNdrRequired(noiseA)) + assertTrue(restarted.updateNdrSessionPubkeyHex(noiseA, oldPeer)) + assertTrue(restarted.isNdrRequired(noiseA)) + assertFalse(restarted.isNdrRebindBlocked(noiseA)) + assertTrue(newService().isNdrRequired(noiseA)) + } + + @Test + fun pinOnlyJournalRecoversAfterCrashBeforeFavoriteCommit() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + preferences.successfulWritesBeforeFailure = 1 + + assertFalse(service.updateNdrSessionPubkeyHex(noiseA, oldPeer)) + assertTrue(service.isNdrRequired(noiseA)) + assertTrue(service.isNdrRebindBlocked(noiseA)) + assertNull(service.getStoredFavoriteForNdrRoute(noiseA)) + + val restarted = newService() + restarted.setNdrPeerRetirementGuard { + error("pin-only recovery must not retire") + } + + assertTrue(restarted.isNdrRequired(noiseA)) + assertFalse(restarted.isNdrRebindBlocked(noiseA)) + assertEquals( + oldPeer, + restarted.getStoredFavoriteForNdrRoute(noiseA) + ?.peerNdrSessionPubkeyHex + ) + } + + @Test + fun corruptJournalFailsClosed() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + assertTrue( + preferences.edit() + .putString( + FavoritesPersistenceService.NDR_REBIND_JOURNAL_KEY, + """{"version":1,"noiseKeyHex":"broken"}""" + ) + .commit() + ) + + val restarted = newService() + + assertTrue(restarted.isNdrRebindBlocked(noiseA)) + assertFalse(restarted.updateNdrSessionPubkeyHex(noiseA, newPeer)) + assertEquals(oldPeer, restarted.findNdrSessionPubkeyHex(noiseA)) + assertNull(restarted.getStoredFavoriteForNdrRoute(noiseA)) + } + + @Test + fun failedRetirementQuarantinesTargetWithoutHoldingFavoritesLock() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + assertTrue(service.updateNdrSessionPubkeyHex(noiseA, oldPeer)) + var favoritesLockWasAvailable = false + service.setNdrPeerRetirementGuard { + val completed = CountDownLatch(1) + thread { + service.getFavoriteStatus(noiseA) + completed.countDown() + } + favoritesLockWasAvailable = completed.await(1, TimeUnit.SECONDS) + false + } + + assertFalse(service.updateNostrPublicKey(noiseA, newPeer)) + + assertTrue(favoritesLockWasAvailable) + assertEquals( + newPeer, + service.findNdrSessionPubkeyHex(noiseA) + ) + assertTrue(service.isNdrRequired(noiseA)) + assertTrue(service.isNdrRebindBlocked(noiseA)) + } + + @Test + fun sameIdentityIsANoOpAndDoesNotRetire() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + var retireCalls = 0 + service.setNdrPeerRetirementGuard { + retireCalls += 1 + false + } + + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + + assertEquals(0, retireCalls) + assertEquals(oldPeer, service.findNdrSessionPubkeyHex(noiseA)) + } + + @Test + fun equivalentNpubAndHexAssignmentDoesNotWedgePinnedContact() { + val oldNpub = requireNotNull(ContactIdentityResolver.npubFromHex(oldPeer)) + assertTrue(service.updateNostrPublicKey(noiseA, oldNpub)) + assertTrue(service.updateNdrSessionPubkeyHex(noiseA, oldPeer)) + var retireCalls = 0 + service.setNdrPeerRetirementGuard { + retireCalls += 1 + true + } + + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer.uppercase())) + + assertEquals(0, retireCalls) + assertTrue(service.isNdrRequired(noiseA)) + assertFalse(service.isNdrRebindBlocked(noiseA)) + assertEquals(oldPeer, service.findNdrSessionPubkeyHex(noiseA)) + } + + @Test + fun preNdrNostrRebindDoesNotPinOrRetireLegacyContact() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + var retireCalls = 0 + service.setNdrPeerRetirementGuard { + retireCalls += 1 + true + } + + assertTrue(service.updateNostrPublicKey(noiseA, newPeer)) + + assertEquals(0, retireCalls) + assertFalse(service.isNdrRequired(noiseA)) + assertFalse(service.isNdrRebindBlocked(noiseA)) + assertEquals(newPeer, service.findNdrSessionPubkeyHex(noiseA)) + } + + @Test + fun sharedPinnedIdentityIsRetiredOnlyAfterItsLastFavoriteMoves() { + insertRelationship(noiseA, oldPeer, pinned = true) + insertRelationship(noiseB, oldPeer, pinned = true) + val retired = mutableListOf() + service.setNdrPeerRetirementGuard { + retired += it + true + } + + assertTrue(service.updateNostrPublicKey(noiseA, newPeer)) + assertTrue(retired.isEmpty()) + assertTrue(service.isNdrRequired(noiseA)) + + val finalPeer = "33".repeat(32) + assertTrue(service.updateNostrPublicKey(noiseB, finalPeer)) + assertEquals(listOf(oldPeer), retired) + assertEquals(newPeer, service.findNdrSessionPubkeyHex(noiseA)) + assertEquals(finalPeer, service.findNdrSessionPubkeyHex(noiseB)) + assertTrue(service.isNdrRequired(noiseB)) + } + + @Test + fun existingExplicitNdrBindingMigratesToDurablePin() { + val noiseHex = ContactIdentityResolver.noiseKeyHex(noiseA) + val oldNpub = requireNotNull(ContactIdentityResolver.npubFromHex(oldPeer)) + val legacyJson = """ + { + "$noiseHex": { + "peerNoisePublicKeyHex": "$noiseHex", + "peerNostrPublicKey": "$oldNpub", + "peerNdrSessionPubkeyHex": "$oldPeer", + "peerNickname": "legacy", + "isFavorite": true, + "theyFavoritedUs": true, + "favoritedAt": 1, + "lastUpdated": 1 + } + } + """.trimIndent() + assertTrue( + preferences.edit() + .putString(FavoritesPersistenceService.FAVORITES_KEY, legacyJson) + .commit() + ) + + val migrated = newService() + + assertTrue(migrated.isNdrRequired(noiseA)) + assertTrue( + requireNotNull( + preferences.getString(FavoritesPersistenceService.FAVORITES_KEY, null) + ).contains("\"ndrRequired\":true") + ) + assertTrue(newService().isNdrRequired(noiseA)) + } + + @Test + fun oldPeerIsRetiredOnlyAfterJournalWhileStoredBindingIsStillOld() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + assertTrue(service.updateNdrSessionPubkeyHex(noiseA, oldPeer)) + var storedBindingWasOld = false + var journalWasPresent = false + service.setNdrPeerRetirementGuard { + val stored = requireNotNull( + preferences.getString(FavoritesPersistenceService.FAVORITES_KEY, null) + ) + storedBindingWasOld = + stored.contains("\"peerNdrSessionPubkeyHex\":\"$oldPeer\"") && + !stored.contains("\"peerNdrSessionPubkeyHex\":\"$newPeer\"") + journalWasPresent = + preferences.contains(FavoritesPersistenceService.NDR_REBIND_JOURNAL_KEY) + true + } + + assertTrue(service.updateNdrSessionPubkeyHex(noiseA, newPeer)) + + assertTrue(journalWasPresent) + assertTrue(storedBindingWasOld) + assertEquals(newPeer, service.findNdrSessionPubkeyHex(noiseA)) + } + + @Test + fun journalCannotRetirePeerItDoesNotOwn() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + assertTrue(service.updateNdrSessionPubkeyHex(noiseA, oldPeer)) + val unrelated = "44".repeat(32) + val noiseHex = ContactIdentityResolver.noiseKeyHex(noiseA) + val invalidJournal = """ + { + "version": 1, + "noiseKeyHex": "$noiseHex", + "oldPeerPubkeyHex": "$unrelated", + "expectedNostrPubkeyHex": "$oldPeer", + "expectedNdrSessionPubkeyHex": "$oldPeer", + "expectedNdrRequired": true, + "targetNostrPubkeyHex": "$oldPeer", + "targetNdrSessionPubkeyHex": "$newPeer", + "targetNdrRequired": true, + "retireOldPeer": true + } + """.trimIndent() + assertTrue( + preferences.edit() + .putString( + FavoritesPersistenceService.NDR_REBIND_JOURNAL_KEY, + invalidJournal + ) + .commit() + ) + var retireCalls = 0 + val restarted = newService() + + restarted.setNdrPeerRetirementGuard { + retireCalls += 1 + true + } + + assertEquals(0, retireCalls) + assertTrue(restarted.isNdrRebindBlocked(noiseA)) + assertEquals(oldPeer, restarted.findNdrSessionPubkeyHex(noiseA)) + } + + @Test + fun targetIsRevalidatedAfterRetirementBeforeJournalClear() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + assertTrue(service.updateNdrSessionPubkeyHex(noiseA, oldPeer)) + service.setNdrPeerRetirementGuard { + insertRelationship(noiseA, "55".repeat(32), pinned = true) + true + } + + assertFalse(service.updateNdrSessionPubkeyHex(noiseA, newPeer)) + + assertTrue(service.isNdrRebindBlocked(noiseA)) + assertTrue( + preferences.contains(FavoritesPersistenceService.NDR_REBIND_JOURNAL_KEY) + ) + } + + @Test + fun stalePeerIsRejectedDuringRebindButSharedCurrentPeerRemainsAuthorized() { + insertRelationship(noiseA, oldPeer, pinned = true) + assertTrue(service.isCurrentNdrPeerAuthorized(oldPeer)) + service.setNdrPeerRetirementGuard { false } + + assertFalse(service.updateNdrSessionPubkeyHex(noiseA, newPeer)) + + assertFalse(service.isCurrentNdrPeerAuthorized(oldPeer)) + assertFalse(service.isCurrentNdrPeerAuthorized(newPeer)) + + insertRelationship(noiseB, oldPeer, pinned = true) + assertTrue(service.isCurrentNdrPeerAuthorized(oldPeer)) + } + + @Test + fun pendingTargetIsReservedAgainstEveryOtherFavorite() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + assertTrue(service.updateNdrSessionPubkeyHex(noiseA, oldPeer)) + service.setNdrPeerRetirementGuard { false } + assertFalse(service.updateNdrSessionPubkeyHex(noiseA, newPeer)) + + assertFalse(service.updateNostrPublicKey(noiseB, newPeer)) + assertNull(service.getFavoriteStatus(noiseB)) + assertEquals(noiseA.toList(), service.findNoiseKey(newPeer)?.toList()) + } + + @Test + fun legacyInboundIsRejectedForPinJournalAndUnreadableProtectionState() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + assertTrue(service.isLegacyNostrInboundAllowed(oldPeer)) + + assertTrue(service.updateNdrSessionPubkeyHex(noiseA, oldPeer)) + assertFalse(service.isLegacyNostrInboundAllowed(oldPeer)) + + service.setNdrPeerRetirementGuard { false } + assertFalse(service.updateNdrSessionPubkeyHex(noiseA, newPeer)) + assertFalse(service.isLegacyNostrInboundAllowed(oldPeer)) + assertFalse(service.isLegacyNostrInboundAllowed(newPeer)) + + assertTrue( + preferences.edit() + .remove(FavoritesPersistenceService.NDR_REBIND_JOURNAL_KEY) + .putString(FavoritesPersistenceService.FAVORITES_KEY, "{broken") + .commit() + ) + val unreadable = newService() + assertFalse(unreadable.isNdrProtectionStateReadable()) + assertFalse(unreadable.isLegacyNostrInboundAllowed("66".repeat(32))) + } + + @Test + fun successfulNativeResetAtomicallyClearsPinsAndJournal() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + assertTrue(service.updateNdrSessionPubkeyHex(noiseA, oldPeer)) + service.setNdrPeerRetirementGuard { false } + assertFalse(service.updateNdrSessionPubkeyHex(noiseA, newPeer)) + + assertTrue(service.clearAllFavoritesAfterNdrReset()) + + assertNull(service.getFavoriteStatus(noiseA)) + assertFalse(preferences.contains(FavoritesPersistenceService.FAVORITES_KEY)) + assertFalse(preferences.contains(FavoritesPersistenceService.PEERID_INDEX_KEY)) + assertFalse( + preferences.contains(FavoritesPersistenceService.NDR_REBIND_JOURNAL_KEY) + ) + } + + @Test + fun newIdentityCannotBeBoundToTwoFavorites() { + assertTrue(service.updateNostrPublicKey(noiseA, oldPeer)) + + assertFalse(service.updateNostrPublicKey(noiseB, oldPeer)) + assertEquals(null, service.getFavoriteStatus(noiseB)) + } + + @Suppress("UNCHECKED_CAST") + private fun insertRelationship( + noiseKey: ByteArray, + peerPubkeyHex: String, + pinned: Boolean + ) { + val field = FavoritesPersistenceService::class.java.getDeclaredField("favorites") + field.isAccessible = true + val favorites = field.get(service) as MutableMap + favorites[ContactIdentityResolver.noiseKeyHex(noiseKey)] = FavoriteRelationship( + peerNoisePublicKey = noiseKey, + peerNostrPublicKey = + requireNotNull(ContactIdentityResolver.npubFromHex(peerPubkeyHex)), + peerNdrSessionPubkeyHex = peerPubkeyHex.takeIf { pinned }, + ndrRequired = pinned, + peerNickname = "test", + isFavorite = true, + theyFavoritedUs = true, + favoritedAt = Date(1), + lastUpdated = Date(1) + ) + } + + private class FaultInjectingSharedPreferences( + private val delegate: SharedPreferences + ) : SharedPreferences by delegate { + var failNextWrite: Boolean = false + var successfulWritesBeforeFailure: Int? = null + var failNextRemovalOf: String? = null + + override fun edit(): SharedPreferences.Editor = + FaultInjectingEditor(delegate.edit()) + + private inner class FaultInjectingEditor( + private val delegateEditor: SharedPreferences.Editor + ) : SharedPreferences.Editor by delegateEditor { + private val removedKeys = mutableSetOf() + + override fun putString( + key: String?, + value: String? + ): SharedPreferences.Editor { + delegateEditor.putString(key, value) + return this + } + + override fun putStringSet( + key: String?, + values: MutableSet? + ): SharedPreferences.Editor { + delegateEditor.putStringSet(key, values) + return this + } + + override fun putInt(key: String?, value: Int): SharedPreferences.Editor { + delegateEditor.putInt(key, value) + return this + } + + override fun putLong(key: String?, value: Long): SharedPreferences.Editor { + delegateEditor.putLong(key, value) + return this + } + + override fun putFloat(key: String?, value: Float): SharedPreferences.Editor { + delegateEditor.putFloat(key, value) + return this + } + + override fun putBoolean(key: String?, value: Boolean): SharedPreferences.Editor { + delegateEditor.putBoolean(key, value) + return this + } + + override fun remove(key: String?): SharedPreferences.Editor { + delegateEditor.remove(key) + key?.let(removedKeys::add) + return this + } + + override fun clear(): SharedPreferences.Editor { + delegateEditor.clear() + return this + } + + override fun commit(): Boolean { + if (shouldFailWrite()) return false + return delegateEditor.commit() + } + + override fun apply() { + if (shouldFailWrite()) return + delegateEditor.apply() + } + + private fun shouldFailWrite(): Boolean { + val removalKey = failNextRemovalOf + if (removalKey != null && removalKey in removedKeys) { + failNextRemovalOf = null + return true + } + if (failNextWrite) { + failNextWrite = false + return true + } + val writesRemaining = successfulWritesBeforeFailure ?: return false + if (writesRemaining == 0) { + successfulWritesBeforeFailure = null + return true + } + successfulWritesBeforeFailure = writesRemaining - 1 + return false + } + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/identity/PrivateMediaCapabilityPinPersistenceTest.kt b/app/src/test/kotlin/com/bitchat/android/identity/PrivateMediaCapabilityPinPersistenceTest.kt index 6d865582..da70113c 100644 --- a/app/src/test/kotlin/com/bitchat/android/identity/PrivateMediaCapabilityPinPersistenceTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/identity/PrivateMediaCapabilityPinPersistenceTest.kt @@ -1,6 +1,7 @@ package com.bitchat.android.identity import android.content.Context +import android.content.SharedPreferences import com.bitchat.android.model.AuthenticatedPeerState import com.bitchat.android.model.PeerCapabilities import org.junit.Assert.assertArrayEquals @@ -70,4 +71,62 @@ class PrivateMediaCapabilityPinPersistenceTest { assertEquals(null, afterPanic.getAuthenticatedPeerState(fingerprint)) assertFalse(afterPanic.isPrivateMediaCapable(fingerprint)) } + + @Test + fun `identity panic wipe preserves NDR protection until native reset succeeds`() { + val protected = mapOf( + "favorite_relationships" to """{"contact":"pinned"}""", + "favorite_peerid_index" to """{"peer":"npub"}""", + "favorite_ndr_rebind_v1" to """{"version":1}""" + ) + protected.forEach { (key, value) -> + assertTrue(manager.storeSecureValueSynchronously(key, value)) + } + assertTrue(manager.storeSecureValueSynchronously("unrelated_secret", "remove-me")) + + assertTrue(manager.clearIdentityData()) + + protected.forEach { (key, value) -> + assertEquals(value, manager.getSecureValue(key)) + } + assertEquals(null, manager.getSecureValue("unrelated_secret")) + } + + @Test + fun `identity panic wipe reports a failed durable commit`() { + val failingPreferences = CommitFailingSharedPreferences(prefs) + val failingManager = SecureIdentityStateManager(failingPreferences, testOnly = true) + assertTrue(failingManager.storeSecureValueSynchronously("unrelated_secret", "keep")) + + failingPreferences.failCommits = true + + assertFalse(failingManager.clearIdentityData()) + assertEquals("keep", failingManager.getSecureValue("unrelated_secret")) + } + + private class CommitFailingSharedPreferences( + private val delegate: SharedPreferences + ) : SharedPreferences by delegate { + var failCommits = false + + override fun edit(): SharedPreferences.Editor = + CommitFailingEditor(delegate.edit()) + + private inner class CommitFailingEditor( + private val delegateEditor: SharedPreferences.Editor + ) : SharedPreferences.Editor by delegateEditor { + override fun clear(): SharedPreferences.Editor { + delegateEditor.clear() + return this + } + + override fun putString(key: String?, value: String?): SharedPreferences.Editor { + delegateEditor.putString(key, value) + return this + } + + override fun commit(): Boolean = + if (failCommits) false else delegateEditor.commit() + } + } } diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/FragmentingPacketSenderConfirmedSendTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/FragmentingPacketSenderConfirmedSendTest.kt new file mode 100644 index 00000000..2933f3a7 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/FragmentingPacketSenderConfirmedSendTest.kt @@ -0,0 +1,91 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class FragmentingPacketSenderConfirmedSendTest { + @Test + fun confirmedSendStaysPendingWhenExactRouteDisappearsBetweenFragments() = runTest { + val fragments = listOf(packet(1), packet(2), packet(3)) + val sender = FragmentingPacketSender( + scope = this, + fragmentManager = null, + logTag = "FragmentingPacketSenderTest", + interFragmentDelayMs = 0 + ) + var preflightCalls = 0 + val sent = mutableListOf() + var admitted: Boolean? = null + + sender.sendConfirmed( + routed = RoutedPacket( + packet = fragments.first(), + preparedPackets = fragments + ), + description = "exact generation", + preflight = { + preflightCalls += 1 + preflightCalls == 1 + }, + sendSingle = { + sent += it.packet.payload.single().toInt() + true + }, + completion = { admitted = it } + ) + advanceUntilIdle() + + assertEquals(listOf(1), sent) + assertFalse(admitted ?: true) + } + + @Test + fun confirmedSendAcknowledgesOnlyAfterEveryFragmentIsAdmitted() = runTest { + val fragments = listOf(packet(1), packet(2), packet(3)) + val sender = FragmentingPacketSender( + scope = this, + fragmentManager = null, + logTag = "FragmentingPacketSenderTest", + interFragmentDelayMs = 0 + ) + val sent = mutableListOf() + var admitted: Boolean? = null + + sender.sendConfirmed( + routed = RoutedPacket( + packet = fragments.first(), + preparedPackets = fragments + ), + description = "exact generation", + preflight = { true }, + sendSingle = { + sent += it.packet.payload.single().toInt() + true + }, + completion = { admitted = it } + ) + advanceUntilIdle() + + assertEquals(listOf(1, 2, 3), sent) + assertTrue(admitted == true) + } + + private fun packet(value: Int) = BitchatPacket( + version = 1u, + type = MessageType.MESSAGE.value, + senderID = ByteArray(8) { 1 }, + recipientID = ByteArray(8) { 2 }, + timestamp = 1u, + payload = byteArrayOf(value.toByte()), + ttl = 1u + ) +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerNdrTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerNdrTest.kt new file mode 100644 index 00000000..88947664 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerNdrTest.kt @@ -0,0 +1,160 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.NoisePayload +import com.bitchat.android.model.NoisePayloadType +import com.bitchat.android.model.NdrFeatureGate +import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import androidx.test.core.app.ApplicationProvider +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class MessageHandlerNdrTest { + @Before + fun enableNdrForTest() { + NdrFeatureGate.setEnabledForTests(true) + } + + @After + fun resetNdrGate() { + NdrFeatureGate.setEnabledForTests(false) + } + + @Test + fun ndrPayloadUsesCoordinatedWireValue() { + assertEquals(0x22u.toUByte(), NoisePayloadType.NDR_EVENT.value) + } + + @Test + fun handleNoiseEncryptedForwardsNdrPayloadToDelegate() { + val delegate = FakeDelegate() + val handler = MessageHandler( + myPeerID = "0011223344556677", + appContext = ApplicationProvider.getApplicationContext() + ) + handler.delegate = delegate + + val payload = NoisePayload( + type = NoisePayloadType.NDR_EVENT, + data = """{"id":"invite1","kind":30078}""".toByteArray() + ).encode() + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = byteArrayOf(0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17), + recipientID = byteArrayOf(0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77), + timestamp = 123uL, + payload = payload, + signature = null, + ttl = 7u + ) + + kotlinx.coroutines.runBlocking { + handler.handleNoiseEncrypted(RoutedPacket(packet = packet, peerID = "1011121314151617")) + } + + assertEquals("1011121314151617", delegate.ndrPeerID) + assertEquals("""{"id":"invite1","kind":30078}""", delegate.ndrPayload) + assertEquals(123L, delegate.ndrTimestampMs) + assertEquals(delegate.decryptionSession, delegate.ndrAuthenticatedSession) + } + + @Test + fun disabledRolloutGateDropsNdrPayload() { + NdrFeatureGate.setEnabledForTests(false) + val delegate = FakeDelegate() + val handler = MessageHandler( + myPeerID = "0011223344556677", + appContext = ApplicationProvider.getApplicationContext() + ) + handler.delegate = delegate + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = byteArrayOf(0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17), + recipientID = byteArrayOf(0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77), + timestamp = 123uL, + payload = NoisePayload( + type = NoisePayloadType.NDR_EVENT, + data = "event".toByteArray() + ).encode(), + signature = null, + ttl = 7u + ) + + kotlinx.coroutines.runBlocking { + handler.handleNoiseEncrypted(RoutedPacket(packet = packet, peerID = "1011121314151617")) + } + + assertNull(delegate.ndrPeerID) + } + + private class FakeDelegate : MessageHandlerDelegate { + var ndrPeerID: String? = null + var ndrPayload: String? = null + var ndrTimestampMs: Long? = null + val decryptionSession = com.bitchat.android.noise.AuthenticatedNoiseSession( + remoteStaticKey = ByteArray(32) { 1 }, + sessionToken = ByteArray(32) { 2 } + ) + var ndrAuthenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession? = null + + override fun addOrUpdatePeer(peerID: String, nickname: String): Boolean = false + override fun removePeer(peerID: String) = Unit + override fun updatePeerNickname(peerID: String, nickname: String) = Unit + override fun getPeerNickname(peerID: String): String? = null + override fun getNetworkSize(): Int = 0 + override fun getMyNickname(): String? = null + override fun getPeerInfo(peerID: String): PeerInfo? = null + override fun updatePeerInfoFromVerifiedAnnouncement( + peerID: String, + nickname: String, + noisePublicKey: ByteArray, + signingPublicKey: ByteArray, + isVerified: Boolean, + capabilities: com.bitchat.android.model.PeerCapabilities? + ): Boolean = false + override fun sendPacket(packet: BitchatPacket) = Unit + override fun relayPacket(routed: RoutedPacket) = Unit + override fun getBroadcastRecipient(): ByteArray = ByteArray(0) + override fun verifySignature(packet: BitchatPacket, peerID: String): Boolean = true + override fun encryptForPeer(data: ByteArray, recipientPeerID: String): ByteArray? = data + override fun decryptFromPeer( + encryptedData: ByteArray, + senderPeerID: String + ): com.bitchat.android.noise.NoiseDecryptionResult = + com.bitchat.android.noise.NoiseDecryptionResult( + plaintext = encryptedData, + authenticatedSession = decryptionSession + ) + override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean = true + override fun hasNoiseSession(peerID: String): Boolean = true + override fun initiateNoiseHandshake(peerID: String) = Unit + override fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray? = null + override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? = null + override fun onMessageReceived(message: com.bitchat.android.model.BitchatMessage) = Unit + override fun onChannelLeave(channel: String, fromPeer: String) = Unit + override fun onDeliveryAckReceived(messageID: String, peerID: String) = Unit + override fun onReadReceiptReceived(messageID: String, peerID: String) = Unit + override fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long) = Unit + override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) = Unit + override fun onNdrEventReceived( + peerID: String, + payload: ByteArray, + timestampMs: Long, + authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession + ) { + ndrPeerID = peerID + ndrPayload = String(payload) + ndrTimestampMs = timestampMs + ndrAuthenticatedSession = authenticatedSession + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt b/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt index 78d72bee..589b1cc8 100644 --- a/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt @@ -17,6 +17,14 @@ class IdentityAnnouncementTest { assertTrue(PeerCapabilities.decode(byteArrayOf(0x00, 0x01)).contains(PeerCapabilities.PRIVATE_MEDIA)) } + @Test + fun `double ratchet capability uses coordinated bit eleven`() { + assertArrayEquals( + byteArrayOf(0x00, 0x08), + PeerCapabilities.NOSTR_DOUBLE_RATCHET.encoded() + ) + } + @Test fun `legacy announcement without capability TLV still decodes`() { val legacy = IdentityAnnouncement(nickname, noiseKey, signingKey).encode()!! @@ -59,17 +67,35 @@ class IdentityAnnouncementTest { } @Test - fun `local announcement send advertises private media`() { + fun `local announcement keeps double ratchet dark by default`() { val encoded = IdentityAnnouncement.forLocalPeer(nickname, noiseKey, signingKey).encode()!! assertArrayEquals( byteArrayOf(0x05, 0x02, 0x00, 0x01), encoded.takeLast(4).toByteArray() ) - assertTrue( - IdentityAnnouncement.decode(encoded)!! - .capabilities!! - .contains(PeerCapabilities.PRIVATE_MEDIA) + val capabilities = IdentityAnnouncement.decode(encoded)!!.capabilities!! + assertTrue(capabilities.contains(PeerCapabilities.PRIVATE_MEDIA)) + org.junit.Assert.assertFalse( + capabilities.contains(PeerCapabilities.NOSTR_DOUBLE_RATCHET) ) } + + @Test + fun `local announcement can explicitly opt into coordinated double ratchet tests`() { + NdrFeatureGate.setEnabledForTests(true) + try { + val encoded = IdentityAnnouncement.forLocalPeer(nickname, noiseKey, signingKey).encode()!! + + assertArrayEquals( + byteArrayOf(0x05, 0x02, 0x00, 0x09), + encoded.takeLast(4).toByteArray() + ) + val capabilities = IdentityAnnouncement.decode(encoded)!!.capabilities!! + assertTrue(capabilities.contains(PeerCapabilities.PRIVATE_MEDIA)) + assertTrue(capabilities.contains(PeerCapabilities.NOSTR_DOUBLE_RATCHET)) + } finally { + NdrFeatureGate.setEnabledForTests(false) + } + } } diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/AccountResetGateTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/AccountResetGateTest.kt new file mode 100644 index 00000000..8bb9dfaf --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/AccountResetGateTest.kt @@ -0,0 +1,82 @@ +package com.bitchat.android.nostr + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +class AccountResetGateTest { + @Test + fun supersededLeaseCannotReopenOrRunOwnedMutation() { + val gate = AccountResetGate() + val first = gate.begin() + val second = gate.begin() + var firstMutationRan = false + + assertFalse( + gate.runIfCurrent(first) { + firstMutationRan = true + } + ) + assertFalse(firstMutationRan) + assertTrue(gate.runIfCurrent(second) {}) + } + + @Test + fun ownedMutationFinishesBeforeNextResetStarts() { + val gate = AccountResetGate() + val first = gate.begin() + val mutationStarted = CountDownLatch(1) + val releaseMutation = CountDownLatch(1) + val resetAttempted = CountDownLatch(1) + val resetFinished = CountDownLatch(1) + val executor = Executors.newFixedThreadPool(2) + + try { + executor.submit { + gate.runIfCurrent(first) { + mutationStarted.countDown() + assertTrue(releaseMutation.await(2, TimeUnit.SECONDS)) + } + } + assertTrue(mutationStarted.await(2, TimeUnit.SECONDS)) + + executor.submit { + resetAttempted.countDown() + gate.begin() + resetFinished.countDown() + } + + assertTrue(resetAttempted.await(2, TimeUnit.SECONDS)) + assertFalse(resetFinished.await(100, TimeUnit.MILLISECONDS)) + releaseMutation.countDown() + assertTrue(resetFinished.await(2, TimeUnit.SECONDS)) + } finally { + releaseMutation.countDown() + executor.shutdownNow() + } + } + + @Test(expected = IllegalStateException::class) + fun ownedMutationCannotRecursivelyBeginReset() { + val gate = AccountResetGate() + val lease = gate.begin() + + gate.runIfCurrent(lease) { + gate.begin() + } + } + + @Test + fun terminalResetRejectsAllLaterResets() { + val gate = AccountResetGate() + val terminal = gate.begin(terminal = true) + + assertTrue(terminal != null) + assertEquals(null, gate.begin(terminal = false)) + assertEquals(null, gate.begin(terminal = true)) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/FileNdrEstablishedSessionMarkerStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/FileNdrEstablishedSessionMarkerStoreTest.kt new file mode 100644 index 00000000..73f7a308 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/FileNdrEstablishedSessionMarkerStoreTest.kt @@ -0,0 +1,54 @@ +package com.bitchat.android.nostr + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException +import java.nio.file.Files + +class FileNdrEstablishedSessionMarkerStoreTest { + private val accountPubkey = "ab".repeat(32) + + @Test + fun establishedAndPanicMarkersHaveIndependentDurableLifetimes() { + val directory = Files.createTempDirectory("ndr-marker-store") + .resolve("markers") + .toFile() + val store = FileNdrEstablishedSessionMarkerStore(directory) + + assertFalse(store.contains(accountPubkey)) + assertFalse(store.isPanicWipeRequired()) + + store.mark(accountPubkey) + store.markPanicWipeRequired() + assertTrue(store.contains(accountPubkey)) + assertTrue(store.isPanicWipeRequired()) + + store.clearEstablishedSessions() + assertFalse(store.contains(accountPubkey)) + assertTrue(store.isPanicWipeRequired()) + + store.clearPanicWipeRequired() + assertFalse(store.isPanicWipeRequired()) + } + + @Test + fun unreadableMarkerDirectoryShapeFailsClosed() { + val directory = Files.createTempDirectory("ndr-marker-invalid") + .resolve("markers") + .toFile() + directory.writeText("not-a-directory") + val store = FileNdrEstablishedSessionMarkerStore(directory) + + assertThrows(IOException::class.java) { + store.contains(accountPubkey) + } + assertThrows(IOException::class.java) { + store.isPanicWipeRequired() + } + assertThrows(IOException::class.java) { + store.clearEstablishedSessions() + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/FileNdrPanicStorageQuarantineTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/FileNdrPanicStorageQuarantineTest.kt new file mode 100644 index 00000000..211fed7e --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/FileNdrPanicStorageQuarantineTest.kt @@ -0,0 +1,62 @@ +package com.bitchat.android.nostr + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.file.Files + +class FileNdrPanicStorageQuarantineTest { + @Test + fun activeStateIsRenamedBeforeWipeAndResidueSurvivesRestartUntilCompletion() { + val parent = Files.createTempDirectory("ndr-panic-quarantine").toFile() + val storage = parent.resolve("ndr").apply { + resolve("pairwise-v1/account/state").apply { + parentFile.mkdirs() + writeText("sensitive") + } + } + val quarantineDirectory = parent.resolve("quarantine") + val quarantine = FileNdrPanicStorageQuarantine(storage, quarantineDirectory) + + quarantine.begin() + + assertFalse(storage.exists()) + assertTrue(quarantineDirectory.resolve("pairwise-v1/account/state").isFile) + assertTrue( + FileNdrPanicStorageQuarantine(storage, quarantineDirectory).isPending() + ) + + quarantine.wipeNativeState() + + assertTrue(quarantine.isPending()) + assertTrue(quarantineDirectory.listFiles()?.isEmpty() == true) + + quarantine.clear() + + assertFalse(quarantine.isPending()) + assertFalse(quarantineDirectory.exists()) + } + + @Test + fun retryWipesAnyActiveStateCreatedBesideExistingQuarantine() { + val parent = Files.createTempDirectory("ndr-panic-retry").toFile() + val storage = parent.resolve("ndr") + val quarantineDirectory = parent.resolve("quarantine").apply { mkdirs() } + storage.resolve("late/state").apply { + parentFile.mkdirs() + writeText("sensitive") + } + quarantineDirectory.resolve("old/state").apply { + parentFile.mkdirs() + writeText("sensitive") + } + val quarantine = FileNdrPanicStorageQuarantine(storage, quarantineDirectory) + + quarantine.begin() + quarantine.wipeNativeState() + + assertFalse(storage.exists()) + assertTrue(quarantineDirectory.listFiles()?.isEmpty() == true) + assertTrue(quarantine.isPending()) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NdrAccountEpochGuardTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NdrAccountEpochGuardTest.kt new file mode 100644 index 00000000..5ff84ba4 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NdrAccountEpochGuardTest.kt @@ -0,0 +1,54 @@ +package com.bitchat.android.nostr + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread + +class NdrAccountEpochGuardTest { + @Test + fun `panic invalidation rejects old account mutations`() { + val guard = NdrAccountEpochGuard() + val oldEpoch = guard.begin("aa".repeat(32)) + + guard.invalidate() + val newEpoch = guard.begin("bb".repeat(32)) + + assertFalse(guard.runIfCurrent(oldEpoch) {}) + assertTrue(guard.runIfCurrent(newEpoch) {}) + } + + @Test + fun `invalidation waits for an in-flight mutation before advancing epoch`() { + val guard = NdrAccountEpochGuard() + val epoch = guard.begin("aa".repeat(32)) + val mutationEntered = CountDownLatch(1) + val releaseMutation = CountDownLatch(1) + val invalidationFinished = CountDownLatch(1) + + val mutationThread = thread { + guard.runIfCurrent(epoch) { + mutationEntered.countDown() + releaseMutation.await(2, TimeUnit.SECONDS) + } + } + assertTrue(mutationEntered.await(2, TimeUnit.SECONDS)) + + val invalidationThread = thread { + guard.invalidate() + invalidationFinished.countDown() + } + try { + assertFalse(invalidationFinished.await(150, TimeUnit.MILLISECONDS)) + } finally { + releaseMutation.countDown() + mutationThread.join(2_000) + invalidationThread.join(2_000) + } + + assertTrue(invalidationFinished.count == 0L) + assertFalse(guard.isCurrent(epoch)) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NdrApplicationMessageDecoderTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NdrApplicationMessageDecoderTest.kt new file mode 100644 index 00000000..8dcd9bdd --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NdrApplicationMessageDecoderTest.kt @@ -0,0 +1,331 @@ +package com.bitchat.android.nostr + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class NdrApplicationMessageDecoderTest { + private val sender = "ab".repeat(32) + + @Test + fun decodesOwnerBoundPairwiseRumor() { + val event = pairwiseRumor(sender, "bitchat1:payload", 123) + + val decoded = NdrApplicationMessageDecoder.decode( + decrypted(event) + ) + + assertEquals("bitchat1:payload", decoded?.content) + assertEquals(123_000L, decoded?.timestampMs) + assertNull(decoded?.expiresAtSeconds) + } + + @Test + fun rejectsRumorClaimingAnotherOwner() { + val event = pairwiseRumor("cd".repeat(32), "bitchat1:payload", 123) + + val decoded = NdrApplicationMessageDecoder.decode( + decrypted(event) + ) + + assertNull(decoded) + } + + @Test + fun rejectsRumorWithoutCurrentProtocolMarker() { + val unsigned = NostrEvent( + pubkey = sender, + createdAt = 123, + kind = NostrKind.DIRECT_MESSAGE, + tags = emptyList(), + content = "bitchat1:payload" + ) + val event = unsigned.copy(id = unsigned.computeEventIdHex()) + + val decoded = NdrApplicationMessageDecoder.decode( + decrypted(event) + ) + + assertNull(decoded) + } + + @Test + fun rejectsLegacyDirectEmbeddedPacket() { + val decoded = NdrApplicationMessageDecoder.decode( + NdrDecryptedMessage( + content = "bitchat1:legacy", + senderPubkeyHex = sender, + eventId = "01".repeat(32), + actionId = "action-1" + ) + ) + + assertNull(decoded) + } + + @Test + fun rejectsLegacyPacketWithMalformedAuthenticatedSender() { + val decoded = NdrApplicationMessageDecoder.decode( + NdrDecryptedMessage( + content = "bitchat1:legacy", + senderPubkeyHex = "not-a-pubkey", + eventId = "01".repeat(32), + actionId = "action-1" + ) + ) + + assertNull(decoded) + } + + @Test + fun malformedJsonShapeIsRejectedWithoutEscapingAnException() { + val decoded = NdrApplicationMessageDecoder.decode( + NdrDecryptedMessage( + content = """{"id":"${"01".repeat(32)}","kind":14}""", + senderPubkeyHex = sender, + eventId = "01".repeat(32), + actionId = "action-1" + ) + ) + + assertNull(decoded) + } + + @Test + fun rejectsSignedRumor() { + val event = pairwiseRumor(sender, "bitchat1:payload", 123) + .copy(sig = "01".repeat(64)) + + val decoded = NdrApplicationMessageDecoder.decode( + decrypted(event) + ) + + assertNull(decoded) + } + + @Test + fun rejectsTamperedEmbeddedDeterministicId() { + val event = pairwiseRumor(sender, "bitchat1:payload", 123) + val tampered = event.copy(id = "01".repeat(32)) + + assertNull( + NdrApplicationMessageDecoder.decode( + decrypted(tampered, eventId = tampered.id) + ) + ) + } + + @Test + fun rejectsMissingInvalidOrMismatchedAuthenticatedEventId() { + val event = pairwiseRumor(sender, "bitchat1:payload", 123) + + assertNull( + NdrApplicationMessageDecoder.decode( + decrypted(event, eventId = "") + ) + ) + assertNull( + NdrApplicationMessageDecoder.decode( + decrypted(event, eventId = "not-an-event-id") + ) + ) + assertNull( + NdrApplicationMessageDecoder.decode( + decrypted(event, eventId = "02".repeat(32)) + ) + ) + } + + @Test + fun rejectsDuplicateOrConflictingVersionMarkers() { + val duplicate = pairwiseRumor( + sender, + "bitchat1:payload", + 123, + extraTags = listOf(listOf("ndr-version", "1")) + ) + val conflicting = pairwiseRumor( + sender, + "bitchat1:payload", + 123, + extraTags = listOf(listOf("ndr-version", "2")) + ) + + assertNull( + NdrApplicationMessageDecoder.decode( + decrypted(duplicate) + ) + ) + assertNull( + NdrApplicationMessageDecoder.decode( + decrypted(conflicting) + ) + ) + } + + @Test + fun requiresExactlyOneUnsignedMillisecondTimestampAndUsesItDirectly() { + val valid = pairwiseRumor( + sender, + "bitchat1:payload", + createdAt = 123, + timestampMs = 42 + ) + val missingBase = NostrEvent( + pubkey = sender, + createdAt = 123, + kind = NostrKind.DIRECT_MESSAGE, + tags = listOf( + listOf("ndr-protocol", "pairwise-rumor"), + listOf("ndr-version", "1") + ), + content = "bitchat1:payload" + ) + val missing = missingBase.copy(id = missingBase.computeEventIdHex()) + val malformed = pairwiseRumor( + sender, + "bitchat1:payload", + createdAt = 123, + timestampTagValue = "-1" + ) + val duplicate = pairwiseRumor( + sender, + "bitchat1:payload", + createdAt = 123, + extraTags = listOf(listOf("ms", "124000")) + ) + + assertEquals( + 42L, + NdrApplicationMessageDecoder.decode( + decrypted(valid) + )?.timestampMs + ) + assertNull( + NdrApplicationMessageDecoder.decode( + decrypted(missing) + ) + ) + assertNull( + NdrApplicationMessageDecoder.decode( + decrypted(malformed) + ) + ) + assertNull( + NdrApplicationMessageDecoder.decode( + decrypted(duplicate) + ) + ) + } + + @Test + fun extractsExpirationForLastMomentHostRecheck() { + val event = pairwiseRumor( + sender, + "bitchat1:payload", + 123, + extraTags = listOf(listOf("expiration", "500")) + ) + + val decoded = NdrApplicationMessageDecoder.decode( + decrypted(event, expiresAtSeconds = 500uL) + ) + + assertEquals(500L, decoded?.expiresAtSeconds) + assertFalse(decoded!!.isExpiredAt(499L)) + assertTrue(decoded.isExpiredAt(500L)) + } + + @Test + fun rejectsMalformedOrDuplicateExpiration() { + val malformed = pairwiseRumor( + sender, + "bitchat1:payload", + 123, + extraTags = listOf(listOf("expiration", "tomorrow")) + ) + val duplicate = pairwiseRumor( + sender, + "bitchat1:payload", + 123, + extraTags = listOf( + listOf("expiration", "500"), + listOf("expiration", "501") + ) + ) + + assertNull( + NdrApplicationMessageDecoder.decode( + decrypted(malformed) + ) + ) + assertNull( + NdrApplicationMessageDecoder.decode( + decrypted(duplicate) + ) + ) + } + + @Test + fun rejectsMissingOrMismatchedActionExpiration() { + val expiring = pairwiseRumor( + sender, + "bitchat1:payload", + 123, + extraTags = listOf(listOf("expiration", "500")) + ) + val nonExpiring = pairwiseRumor(sender, "bitchat1:payload", 123) + + assertNull( + NdrApplicationMessageDecoder.decode( + decrypted(expiring) + ) + ) + assertNull( + NdrApplicationMessageDecoder.decode( + decrypted(expiring, expiresAtSeconds = 501uL) + ) + ) + assertNull( + NdrApplicationMessageDecoder.decode( + decrypted(nonExpiring, expiresAtSeconds = 500uL) + ) + ) + } + + private fun decrypted( + event: NostrEvent, + eventId: String = event.id, + expiresAtSeconds: ULong? = null + ): NdrDecryptedMessage = NdrDecryptedMessage( + content = event.toJsonString(), + senderPubkeyHex = sender, + eventId = eventId, + actionId = "action-1", + expiresAtSeconds = expiresAtSeconds + ) + + private fun pairwiseRumor( + pubkey: String, + content: String, + createdAt: Int, + timestampMs: Long = createdAt.toLong() * 1_000L, + timestampTagValue: String = timestampMs.toString(), + extraTags: List> = emptyList() + ): NostrEvent { + val unsigned = NostrEvent( + pubkey = pubkey, + createdAt = createdAt, + kind = NostrKind.DIRECT_MESSAGE, + tags = listOf( + listOf("ndr-protocol", "pairwise-rumor"), + listOf("ndr-version", "1"), + listOf("ms", timestampTagValue) + ) + extraTags, + content = content + ) + return unsigned.copy(id = unsigned.computeEventIdHex()) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NdrBootstrapDeciderTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NdrBootstrapDeciderTest.kt new file mode 100644 index 00000000..22bbb06b --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NdrBootstrapDeciderTest.kt @@ -0,0 +1,59 @@ +package com.bitchat.android.nostr + +import org.junit.Assert.assertEquals +import org.junit.Test + +class NdrBootstrapDeciderTest { + + @Test + fun activeRatchetDoesNothing() { + assertEquals( + NdrBootstrapAction.NONE, + NdrBootstrapDecider.decide( + hasActiveDoubleRatchet = true, + hasEstablishedNoiseSession = true, + nowMs = 30_000, + lastInviteAttemptMs = 0, + lastHandshakeAttemptMs = 0 + ) + ) + } + + @Test + fun missingNoiseSessionStartsHandshakeBeforeInvite() { + assertEquals( + NdrBootstrapAction.START_NOISE_HANDSHAKE, + NdrBootstrapDecider.decide( + hasActiveDoubleRatchet = false, + hasEstablishedNoiseSession = false, + nowMs = 5_000, + lastInviteAttemptMs = 0, + lastHandshakeAttemptMs = 0 + ) + ) + } + + @Test + fun establishedNoiseSessionSendsInviteAndThrottlesRetries() { + assertEquals( + NdrBootstrapAction.SEND_OOB_INVITE, + NdrBootstrapDecider.decide( + hasActiveDoubleRatchet = false, + hasEstablishedNoiseSession = true, + nowMs = 15_000, + lastInviteAttemptMs = 0, + lastHandshakeAttemptMs = 0 + ) + ) + assertEquals( + NdrBootstrapAction.NONE, + NdrBootstrapDecider.decide( + hasActiveDoubleRatchet = false, + hasEstablishedNoiseSession = true, + nowMs = 20_000, + lastInviteAttemptMs = 15_000, + lastHandshakeAttemptMs = 0 + ) + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NdrBootstrapTriggerCoordinatorTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NdrBootstrapTriggerCoordinatorTest.kt new file mode 100644 index 00000000..4b6296ff --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NdrBootstrapTriggerCoordinatorTest.kt @@ -0,0 +1,40 @@ +package com.bitchat.android.nostr + +import org.junit.Assert.assertEquals +import org.junit.Test + +class NdrBootstrapTriggerCoordinatorTest { + @Test + fun `authenticated policy resolution retries the same peer immediately`() { + val requested = mutableListOf() + val coordinator = NdrBootstrapTriggerCoordinator( + connectedPeerIDs = { emptyList() }, + noiseKeyHexForPeer = { null }, + requestBootstrap = requested::add + ) + + coordinator.onAuthenticatedPolicyResolved("peer-a") + + assertEquals(listOf("peer-a"), requested) + } + + @Test + fun `mutual favorite change retries only the live peer with that noise key`() { + val requested = mutableListOf() + val coordinator = NdrBootstrapTriggerCoordinator( + connectedPeerIDs = { listOf("peer-a", "peer-b", "peer-a") }, + noiseKeyHexForPeer = { peerID -> + when (peerID) { + "peer-a" -> "AABBCC" + "peer-b" -> "112233" + else -> null + } + }, + requestBootstrap = requested::add + ) + + coordinator.onFavoriteChanged("aabbcc") + + assertEquals(listOf("peer-a"), requested) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NdrInviteRetryCoordinatorTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NdrInviteRetryCoordinatorTest.kt new file mode 100644 index 00000000..90782cdb --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NdrInviteRetryCoordinatorTest.kt @@ -0,0 +1,150 @@ +package com.bitchat.android.nostr + +import com.bitchat.android.mesh.NdrMeshRoute +import com.bitchat.android.mesh.NdrTransportTarget +import com.bitchat.android.noise.AuthenticatedNoiseSession +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class NdrInviteRetryCoordinatorTest { + @Test + fun `retries rejected admission four times with bounded backoff`() = runTest { + val attemptTimes = mutableListOf() + val admitted = mutableListOf() + val request = request(generation = "generation-1") + val coordinator = NdrInviteRetryCoordinator( + scope = this, + isStillValid = { true }, + send = { _, completion -> + attemptTimes += testScheduler.currentTime + completion(false) + }, + onAdmitted = admitted::add + ) + + coordinator.start(request) + advanceUntilIdle() + coordinator.start(request.copy()) + advanceUntilIdle() + + assertEquals(listOf(0L, 250L, 750L, 1_750L, 3_750L), attemptTimes) + assertTrue(admitted.isEmpty()) + } + + @Test + fun `same token cannot reset retry budget`() = runTest { + var attempts = 0 + val coordinator = NdrInviteRetryCoordinator( + scope = this, + isStillValid = { true }, + send = { _, completion -> + attempts += 1 + completion(false) + }, + onAdmitted = {} + ) + val first = request(generation = "generation-1") + val duplicate = first.copy() + + coordinator.start(first) + runCurrent() + coordinator.start(duplicate) + advanceUntilIdle() + + assertEquals(5, attempts) + } + + @Test + fun `stale generation invite or favorite cancels before delayed retry`() = runTest { + var attempts = 0 + var stillValid = true + val coordinator = NdrInviteRetryCoordinator( + scope = this, + isStillValid = { stillValid }, + send = { _, completion -> + attempts += 1 + completion(false) + }, + onAdmitted = {} + ) + + coordinator.start(request(generation = "generation-1")) + runCurrent() + stillValid = false + advanceUntilIdle() + + assertEquals(1, attempts) + } + + @Test + fun `replacement generation gets a fresh token while old retry stays cancelled`() = runTest { + val attemptedGenerations = mutableListOf() + val coordinator = NdrInviteRetryCoordinator( + scope = this, + isStillValid = { true }, + send = { request, completion -> + attemptedGenerations += + request.token.route.transportTarget.generationToken + completion(false) + }, + onAdmitted = {} + ) + + coordinator.start(request(generation = "generation-1")) + runCurrent() + coordinator.start(request(generation = "generation-2")) + advanceUntilIdle() + + assertEquals(1, attemptedGenerations.count { it == "generation-1" }) + assertEquals(5, attemptedGenerations.count { it == "generation-2" }) + } + + @Test + fun `successful admission stops retrying`() = runTest { + var attempts = 0 + val admitted = mutableListOf() + val coordinator = NdrInviteRetryCoordinator( + scope = this, + isStillValid = { true }, + send = { _, completion -> + attempts += 1 + completion(attempts == 2) + }, + onAdmitted = admitted::add + ) + + coordinator.start(request(generation = "generation-1")) + advanceUntilIdle() + + assertEquals(2, attempts) + assertEquals(1, admitted.size) + } + + private fun request(generation: String): NdrInviteRetryRequest = + NdrInviteRetryRequest( + token = NdrInviteRetryToken( + peerID = "peer-a", + peerPubkeyHex = "ab".repeat(32), + inviteEventId = "cd".repeat(32), + route = NdrMeshRoute( + transportId = "BLE", + peerID = "peer-a", + authenticatedSession = AuthenticatedNoiseSession( + remoteStaticKey = ByteArray(32) { 1 }, + sessionToken = ByteArray(32) { 2 } + ), + transportTarget = NdrTransportTarget( + endpointId = "endpoint-a", + generationToken = generation + ) + ) + ), + eventJson = """{"id":"${"cd".repeat(32)}"}""" + ) +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NdrNostrServiceTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NdrNostrServiceTest.kt new file mode 100644 index 00000000..293e2aea --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NdrNostrServiceTest.kt @@ -0,0 +1,1600 @@ +package com.bitchat.android.nostr + +import com.bitchat.android.model.NdrFeatureGate +import kotlinx.coroutines.Job +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread + +class NdrNostrServiceTest { + private val localPubkey = "22".repeat(32) + private val peerPubkey = "cc".repeat(32) + + @Before + fun enableNdrForTest() { + NostrInboundAccountLifecycle.invalidate() + NdrFeatureGate.setEnabledForTests(true) + } + + @After + fun resetNdrGate() { + NostrInboundAccountLifecycle.invalidate() + NdrFeatureGate.setEnabledForTests(false) + } + + @Test + fun processExitInvalidatesAccountWideReceiveWork() { + val parentJob = Job() + val accountContext = + NostrInboundAccountLifecycle.begin(localPubkey, parentJob) + val service = service(runtimeFactory = FakeNdrRuntimeFactory(FakeNdrPairwiseRuntime())) + + assertTrue(NostrInboundAccountLifecycle.isCurrent(accountContext.epoch)) + assertTrue(accountContext.receiveJob.isActive) + + service.shutdownForProcessExit() + + assertFalse(NostrInboundAccountLifecycle.isCurrent(accountContext.epoch)) + assertTrue(accountContext.receiveJob.isCancelled) + parentJob.cancel() + } + + @Test + fun processExitCannotReopenPairwiseRuntime() { + val factory = FakeNdrRuntimeFactory(FakeNdrPairwiseRuntime()) + val service = service(runtimeFactory = factory) + val identity = testIdentity() + assertTrue(service.configureIfNeeded(identity)) + + service.shutdownForProcessExit() + + assertFalse(service.configureIfNeeded(identity)) + assertFalse(service.retirePeerForMaintenance(identity, peerPubkey)) + assertEquals(1, factory.createdCount) + } + + @Test + fun disabledRolloutGateRefusesToCreateRuntime() { + NdrFeatureGate.setEnabledForTests(false) + val runtime = FakeNdrPairwiseRuntime() + val factory = FakeNdrRuntimeFactory(runtime) + val service = service(runtimeFactory = factory) + + service.configureIfNeeded(testIdentity()) + + assertFalse(service.isConfigured) + assertEquals(0, factory.createdCount) + assertEquals(NdrSendResult.NO_SESSION, service.sendIfPossible("hello", peerPubkey)) + } + + @Test + fun disabledRolloutCanRetirePinnedPeerWithoutStartingTransport() { + NdrFeatureGate.setEnabledForTests(false) + val relay = FakeRelayManager() + val runtime = FakeNdrPairwiseRuntime(mutableSetOf(peerPubkey)).apply { + pendingEvents += NdrPubSubEvent( + kind = "subscribe", + actionId = "must-stay-dormant", + subid = "messages", + filterJson = """{"authors":["$peerPubkey"],"kinds":[1060]}""" + ) + } + val factory = FakeNdrRuntimeFactory(runtime) + val service = service(relay, factory) + + assertTrue(service.retirePeerForMaintenance(testIdentity(), peerPubkey)) + + assertFalse(service.isConfigured) + assertEquals(1, factory.createdCount) + assertEquals(listOf(peerPubkey), runtime.retiredPeers) + assertTrue(relay.subscriptions.isEmpty()) + } + + @Test + fun configureCachesPairwiseInviteAndInstallsOnlyKind1060Subscription() { + val relay = FakeRelayManager() + val runtime = FakeNdrPairwiseRuntime().apply { + currentInvite = inviteEvent(peerPubkey) + pendingEvents += NdrPubSubEvent( + kind = "subscribe", + actionId = "message-sub", + subid = "messages", + filterJson = """{"authors":["$peerPubkey"],"kinds":[1060]}""" + ) + pendingEvents += NdrPubSubEvent( + kind = "subscribe", + actionId = "appkeys-sub", + subid = "appkeys", + filterJson = """{"authors":["$peerPubkey"],"kinds":[37368]}""" + ) + pendingEvents += NdrPubSubEvent( + kind = "subscribe", + actionId = "invite-sub", + subid = "invites", + filterJson = """{"authors":["$peerPubkey"],"kinds":[30078]}""" + ) + pendingEvents += NdrPubSubEvent( + kind = "subscribe", + actionId = "recipient-sub", + subid = "recipient", + filterJson = """{"kinds":[1060],"#p":["$localPubkey"]}""" + ) + } + val factory = FakeNdrRuntimeFactory(runtime) + val service = service(relay, factory) + + service.configureIfNeeded(testIdentity()) + + assertEquals( + inviteEvent(peerPubkey), + service.currentInviteEventJson() + ) + assertEquals(listOf("messages"), relay.subscriptions.map { it.id }) + assertEquals( + setOf("message-sub"), + runtime.ackedActionIds.toSet() + ) + assertEquals( + "/tmp/ndr-test/pairwise-v1/$localPubkey", + factory.lastStoragePath + ) + } + + @Test + fun appKeysRelayTrafficIsRejectedWithoutEnteringRuntimeOrRelay() { + val relay = FakeRelayManager() + val runtime = FakeNdrPairwiseRuntime().apply { + pendingEvents += NdrPubSubEvent( + kind = "publish", + actionId = "appkeys-publish", + sessionId = "appkeys-session", + eventJson = appKeysEvent(peerPubkey) + ) + } + val service = service(relay, FakeNdrRuntimeFactory(runtime)) + service.configureIfNeeded(testIdentity()) + + service.processInboundRelayEvent( + NostrEvent( + id = "01".repeat(32), + pubkey = peerPubkey, + createdAt = 1, + kind = 37368, + tags = listOf(listOf("type", "app_keys_roster_snapshot")), + content = "roster", + sig = "sig" + ) + ) + + assertTrue(runtime.processedEvents.isEmpty()) + assertTrue(relay.sentEvents.isEmpty()) + assertTrue("appkeys-publish" in runtime.ackedActionIds) + } + + @Test + fun relayPublishIsAcknowledgedOnlyAfterHostQueueAcceptsIt() { + val event = messageEvent(peerPubkey) + val runtime = FakeNdrPairwiseRuntime().apply { + pendingEvents += NdrPubSubEvent( + kind = "publish", + actionId = "relay-publish", + sessionId = "relay-session", + eventJson = event.toJsonString() + ) + } + val failingRelay = FakeRelayManager(failSend = true) + val scheduler = FakeRetryScheduler() + val service = service( + failingRelay, + FakeNdrRuntimeFactory(runtime), + retryScheduler = scheduler + ) + + service.configureIfNeeded(testIdentity()) + assertFalse("relay-publish" in runtime.ackedActionIds) + assertEquals(1_000L, scheduler.scheduled.single().delayMs) + + failingRelay.failSend = false + scheduler.runNext() + + assertTrue("relay-publish" in runtime.ackedActionIds) + assertEquals(listOf(event.id), failingRelay.sentEvents.map { it.id }) + } + + @Test + fun recipientTaggedRelayPublishIsRejectedWithoutLeavingHost() { + val runtime = FakeNdrPairwiseRuntime().apply { + pendingEvents += NdrPubSubEvent( + kind = "publish", + actionId = "recipient-publish", + sessionId = "recipient-session", + eventJson = messageEvent(peerPubkey).copy( + tags = listOf(listOf("p", localPubkey)) + ).toJsonString() + ) + } + val relay = FakeRelayManager() + val service = service(relay, FakeNdrRuntimeFactory(runtime)) + + service.configureIfNeeded(testIdentity()) + + assertTrue(relay.sentEvents.isEmpty()) + assertEquals(listOf("recipient-publish"), runtime.ackedActionIds) + } + + @Test + fun outOfBandAdmissionBlocksOnlyTheMatchingSessionPublish() { + val blockedEvent = messageEvent(peerPubkey).copy(id = "31".repeat(32)) + val unrelatedEvent = messageEvent(peerPubkey).copy(id = "32".repeat(32)) + val runtime = FakeNdrPairwiseRuntime().apply { + pendingEvents += NdrPubSubEvent( + kind = "publish", + actionId = "blocked-publish", + sessionId = "session-a", + eventJson = blockedEvent.toJsonString() + ) + pendingEvents += NdrPubSubEvent( + kind = "out_of_band", + actionId = "oob-a", + sessionId = "session-a", + eventJson = giftWrapEvent(), + peerPubkeyHex = peerPubkey + ) + pendingEvents += NdrPubSubEvent( + kind = "publish", + actionId = "unrelated-publish", + sessionId = "session-b", + eventJson = unrelatedEvent.toJsonString() + ) + } + var oobCompletion: ((Boolean) -> Unit)? = null + val relay = FakeRelayManager() + val service = service(relay, FakeNdrRuntimeFactory(runtime)) + service.onOutOfBandPayload = { _, completion -> oobCompletion = completion } + + service.configureIfNeeded(testIdentity()) + + assertEquals(listOf(unrelatedEvent.id), relay.sentEvents.map(NostrEvent::id)) + assertFalse("blocked-publish" in runtime.ackedActionIds) + assertTrue("unrelated-publish" in runtime.ackedActionIds) + + oobCompletion?.invoke(true) + + assertEquals( + listOf(unrelatedEvent.id, blockedEvent.id), + relay.sentEvents.map(NostrEvent::id) + ) + assertTrue("oob-a" in runtime.ackedActionIds) + assertTrue("blocked-publish" in runtime.ackedActionIds) + } + + @Test + fun pendingOrRejectedRelayConfirmationNeverAcknowledgesOrDuplicatesInFlightSend() { + val event = messageEvent(peerPubkey) + val runtime = FakeNdrPairwiseRuntime().apply { + pendingEvents += NdrPubSubEvent( + kind = "publish", + actionId = "relay-publish", + sessionId = "relay-session", + eventJson = event.toJsonString() + ) + } + val relay = FakeRelayManager(confirmationResult = null) + val scheduler = FakeRetryScheduler() + val service = service( + relay, + FakeNdrRuntimeFactory(runtime), + retryScheduler = scheduler + ) + + service.configureIfNeeded(testIdentity()) + service.processInboundRelayEvent(messageEvent(peerPubkey)) + + assertEquals(1, relay.sentEvents.size) + assertFalse("relay-publish" in runtime.ackedActionIds) + + relay.confirmations.removeFirst().completion(false) + service.processInboundRelayEvent(messageEvent(peerPubkey)) + + assertEquals(1, relay.sentEvents.size) + assertEquals(1_000L, scheduler.scheduled.last().delayMs) + scheduler.runNext() + + assertEquals(2, relay.sentEvents.size) + assertFalse("relay-publish" in runtime.ackedActionIds) + + relay.confirmations.removeFirst().completion(false) + assertEquals(2_000L, scheduler.scheduled.last().delayMs) + relay.reconnect() + assertEquals(3, relay.sentEvents.size) + + relay.confirmations.removeFirst().completion(true) + assertTrue("relay-publish" in runtime.ackedActionIds) + } + + @Test + fun teardownCancelsScheduledPublishRetry() { + val runtime = FakeNdrPairwiseRuntime().apply { + pendingEvents += relayPublishAction("relay-publish", peerPubkey) + } + val relay = FakeRelayManager(failSend = true) + val scheduler = FakeRetryScheduler() + val service = service( + relayManager = relay, + runtimeFactory = FakeNdrRuntimeFactory(runtime), + storageResetter = {}, + retryScheduler = scheduler + ) + service.configureIfNeeded(testIdentity()) + val scheduled = scheduler.scheduled.single() + + assertTrue(service.resetForPanic()) + assertTrue(service.completePanicReset()) + relay.failSend = false + scheduler.runNext() + + assertTrue(scheduled.canceled) + assertTrue(relay.sentEvents.isEmpty()) + } + + @Test + fun bufferedDeliveryIsAcknowledgedOnlyAfterConsumerCompletion() { + val runtime = FakeNdrPairwiseRuntime().apply { + pendingEvents += decryptedAction("delivery-1", peerPubkey, "bitchat1:pending") + } + val service = service(runtimeFactory = FakeNdrRuntimeFactory(runtime)) + service.configureIfNeeded(testIdentity()) + + var delivered: NdrDecryptedMessage? = null + var completion: ((NdrDeliveryResult) -> Unit)? = null + service.onDecryptedMessage = { message, callback -> + delivered = message + completion = callback + } + + assertEquals("delivery-1", delivered?.actionId) + assertFalse("delivery-1" in runtime.ackedActionIds) + + completion?.invoke(NdrDeliveryResult.CONSUMED) + + assertTrue("delivery-1" in runtime.ackedActionIds) + } + + @Test + fun retryResultLeavesDeliveryPendingAndAllowsRedelivery() { + val runtime = FakeNdrPairwiseRuntime().apply { + pendingEvents += decryptedAction("delivery-retry", peerPubkey, "bitchat1:pending") + } + val service = service(runtimeFactory = FakeNdrRuntimeFactory(runtime)) + var deliveryCount = 0 + service.onDecryptedMessage = { _, completion -> + deliveryCount += 1 + completion(NdrDeliveryResult.RETRY) + } + service.configureIfNeeded(testIdentity()) + + assertEquals(1, deliveryCount) + assertFalse("delivery-retry" in runtime.ackedActionIds) + + service.processInboundRelayEvent(messageEvent(peerPubkey)) + + assertEquals(2, deliveryCount) + assertFalse("delivery-retry" in runtime.ackedActionIds) + } + + @Test + fun definitiveRejectedDeliveryIsAcknowledged() { + val runtime = FakeNdrPairwiseRuntime().apply { + pendingEvents += decryptedAction("delivery-rejected", peerPubkey, "invalid") + } + val service = service(runtimeFactory = FakeNdrRuntimeFactory(runtime)) + service.onDecryptedMessage = { _, completion -> + completion(NdrDeliveryResult.REJECTED) + } + + service.configureIfNeeded(testIdentity()) + + assertTrue("delivery-rejected" in runtime.ackedActionIds) + } + + @Test + fun missingConsumerLeavesEveryDeliveryDurableWithoutEviction() { + val runtime = FakeNdrPairwiseRuntime().apply { + repeat(129) { index -> + pendingEvents += decryptedAction( + actionId = "delivery-$index", + sender = peerPubkey, + content = "bitchat1:pending-$index" + ) + } + } + val service = service(runtimeFactory = FakeNdrRuntimeFactory(runtime)) + service.configureIfNeeded(testIdentity()) + assertTrue(runtime.ackedActionIds.isEmpty()) + assertEquals(129, runtime.pendingEvents.size) + + val delivered = mutableListOf() + service.onDecryptedMessage = { message, completion -> + delivered += message.content + completion(NdrDeliveryResult.CONSUMED) + } + + assertEquals(129, delivered.size) + assertEquals("bitchat1:pending-0", delivered.first()) + assertEquals("bitchat1:pending-128", delivered.last()) + assertEquals(129, runtime.ackedActionIds.distinct().size) + } + + @Test + fun outOfBandInviteReturnsResponseWithoutRelayPublishOrEarlyAck() { + val response = giftWrapEvent() + val runtime = FakeNdrPairwiseRuntime(mutableSetOf(peerPubkey)).apply { + acceptInviteEvents += outOfBandAction("response-action", response) + acceptInviteResult = NdrAcceptInviteResult(peerPubkey, createdNewSession = true) + } + val relay = FakeRelayManager() + val service = service( + relayManager = relay, + runtimeFactory = FakeNdrRuntimeFactory(runtime), + invitePeerResolver = { peerPubkey } + ) + service.configureIfNeeded(testIdentity()) + + val result = service.processOutOfBandEventJson( + inviteEvent(peerPubkey), + expectedPeerPubkeyHex = peerPubkey + ) + + assertEquals(peerPubkey, result.sessionLookupPubkeyHex) + assertEquals(listOf("response-action"), result.outboundPayloads.map { it.actionId }) + assertEquals(response, result.outboundPayloads.single().eventJson) + assertEquals(peerPubkey, result.outboundPayloads.single().peerPubkeyHex) + assertTrue(relay.sentEvents.isEmpty()) + assertFalse("response-action" in runtime.ackedActionIds) + + assertTrue(service.acknowledgeOutOfBandPayload(result.outboundPayloads.single())) + assertTrue("response-action" in runtime.ackedActionIds) + } + + @Test + fun pendingOutOfBandActionReplaysOnConfigureAndAcksOnlyAfterAsyncAdmission() { + val response = giftWrapEvent() + val runtime = FakeNdrPairwiseRuntime().apply { + pendingEvents += outOfBandAction("pending-oob", response) + } + val service = service(runtimeFactory = FakeNdrRuntimeFactory(runtime)) + var delivered: NdrOutOfBandPayload? = null + var completion: ((Boolean) -> Unit)? = null + service.onOutOfBandPayload = { payload, callback -> + delivered = payload + completion = callback + } + + service.configureIfNeeded(testIdentity()) + + assertEquals("pending-oob", delivered?.actionId) + assertEquals(peerPubkey, delivered?.peerPubkeyHex) + assertFalse("pending-oob" in runtime.ackedActionIds) + + completion?.invoke(true) + + assertTrue("pending-oob" in runtime.ackedActionIds) + } + + @Test + fun failedOutOfBandAdmissionRemainsDurableAndReplaysOnReconnect() { + val relay = FakeRelayManager() + val runtime = FakeNdrPairwiseRuntime().apply { + pendingEvents += outOfBandAction("pending-oob", giftWrapEvent()) + } + val service = service(relay, FakeNdrRuntimeFactory(runtime)) + val completions = mutableListOf<(Boolean) -> Unit>() + service.onOutOfBandPayload = { _, completion -> + completions += completion + } + service.configureIfNeeded(testIdentity()) + + assertEquals(1, completions.size) + completions.single().invoke(false) + assertFalse("pending-oob" in runtime.ackedActionIds) + + relay.reconnect() + + assertEquals(2, completions.size) + completions.last().invoke(true) + assertTrue("pending-oob" in runtime.ackedActionIds) + } + + @Test + fun outOfBandRetriesAreBoundedUntilRouteAvailabilityChanges() { + val scheduler = FakeRetryScheduler() + val runtime = FakeNdrPairwiseRuntime().apply { + pendingEvents += outOfBandAction("pending-oob", giftWrapEvent()) + } + val service = service( + runtimeFactory = FakeNdrRuntimeFactory(runtime), + retryScheduler = scheduler + ) + var attempts = 0 + service.onOutOfBandPayload = { _, completion -> + attempts += 1 + completion(false) + } + + service.configureIfNeeded(testIdentity()) + repeat(5) { scheduler.runNext() } + + assertEquals(6, attempts) + assertTrue(scheduler.scheduled.isEmpty()) + assertFalse("pending-oob" in runtime.ackedActionIds) + + service.onOutOfBandTransportAvailable() + + assertEquals(7, attempts) + assertTrue(scheduler.scheduled.isNotEmpty()) + } + + @Test + fun inviteWhosePeerDoesNotMatchAuthenticatedFavoriteIsRejectedBeforeRuntime() { + val runtime = FakeNdrPairwiseRuntime() + val service = service( + runtimeFactory = FakeNdrRuntimeFactory(runtime), + invitePeerResolver = { "aa".repeat(32) } + ) + service.configureIfNeeded(testIdentity()) + + val result = service.processOutOfBandEventJson( + inviteEvent("aa".repeat(32)), + expectedPeerPubkeyHex = peerPubkey + ) + + assertTrue(result.outboundPayloads.isEmpty()) + assertTrue(runtime.acceptedInvites.isEmpty()) + } + + @Test + fun outOfBandProcessingRequiresAuthenticatedPeer() { + val runtime = FakeNdrPairwiseRuntime() + val service = service( + runtimeFactory = FakeNdrRuntimeFactory(runtime), + invitePeerResolver = { peerPubkey } + ) + service.configureIfNeeded(testIdentity()) + + val result = service.processOutOfBandEventJson(inviteEvent(peerPubkey)) + + assertTrue(result.outboundPayloads.isEmpty()) + assertTrue(runtime.acceptedInvites.isEmpty()) + } + + @Test + fun markerFailureTearsDownAndLatchesBeforeSessionCreatingFfiCall() { + val markers = InMemoryMarkerStore().apply { + markFailure = java.io.IOException("marker storage unavailable") + } + val runtime = FakeNdrPairwiseRuntime() + val factory = FakeNdrRuntimeFactory(runtime) + val service = service( + runtimeFactory = factory, + markerStore = markers, + invitePeerResolver = { peerPubkey } + ) + service.configureIfNeeded(testIdentity()) + + val result = service.processOutOfBandEventJson( + inviteEvent(peerPubkey), + expectedPeerPubkeyHex = peerPubkey + ) + + assertTrue(result.outboundPayloads.isEmpty()) + assertTrue(runtime.acceptedInvites.isEmpty()) + assertTrue(runtime.destroyed) + assertEquals(NdrSendResult.FAILED, service.sendIfPossible("blocked", peerPubkey)) + service.configureIfNeeded(testIdentity()) + assertEquals(1, factory.createdCount) + } + + @Test + fun authenticatedGiftWrapResponseMayUseEphemeralOuterPubkey() { + val runtime = FakeNdrPairwiseRuntime() + val service = service(runtimeFactory = FakeNdrRuntimeFactory(runtime)) + service.configureIfNeeded(testIdentity()) + val response = giftWrapEvent() + + service.processOutOfBandEventJson(response, peerPubkey) + + assertEquals(listOf(response to peerPubkey), runtime.processedOutOfBandResponses) + assertTrue(runtime.processedEvents.isEmpty()) + } + + @Test + fun relayPathAcceptsOnlyKind1060() { + val runtime = FakeNdrPairwiseRuntime() + val service = service(runtimeFactory = FakeNdrRuntimeFactory(runtime)) + service.configureIfNeeded(testIdentity()) + + service.processInboundRelayEvent( + NostrEvent( + id = "02".repeat(32), + pubkey = peerPubkey, + createdAt = 1, + kind = NostrKind.TEXT_NOTE, + tags = emptyList(), + content = "not-ndr", + sig = "sig" + ) + ) + service.processInboundRelayEvent( + messageEvent(peerPubkey).copy( + id = "03".repeat(32), + tags = listOf(listOf("p", localPubkey)) + ) + ) + service.processInboundRelayEvent(messageEvent(peerPubkey)) + + assertEquals(1, runtime.processedEvents.size) + assertEquals(1060, NostrEvent.fromJsonString(runtime.processedEvents.single())?.kind) + } + + @Test + fun latePublishConfirmationFromPreviousAccountCannotAckReplacementRuntime() { + val oldRuntime = FakeNdrPairwiseRuntime().apply { + pendingEvents += relayPublishAction("shared-action", peerPubkey) + } + val newRuntime = FakeNdrPairwiseRuntime().apply { + pendingEvents += relayPublishAction("shared-action", peerPubkey) + } + val relay = FakeRelayManager(confirmationResult = null) + val service = service( + relayManager = relay, + runtimeFactory = SequencedNdrRuntimeFactory(listOf(oldRuntime, newRuntime)) + ) + service.configureIfNeeded(testIdentity()) + + service.configureIfNeeded( + NostrIdentity( + privateKeyHex = "33".repeat(32), + publicKeyHex = "44".repeat(32), + npub = "npub-replacement", + createdAt = 2L + ) + ) + + assertEquals(1, relay.canceledConfirmations.size) + relay.canceledConfirmations.single().completion(true) + assertTrue(newRuntime.ackedActionIds.isEmpty()) + + relay.confirmations.single().completion(true) + assertEquals(listOf("shared-action"), newRuntime.ackedActionIds) + assertTrue(oldRuntime.ackedActionIds.isEmpty()) + } + + @Test + fun latePublishConfirmationAfterPanicCannotAckSameAccountReplacement() { + val oldRuntime = FakeNdrPairwiseRuntime().apply { + pendingEvents += relayPublishAction("shared-action", peerPubkey) + } + val newRuntime = FakeNdrPairwiseRuntime().apply { + pendingEvents += relayPublishAction("shared-action", peerPubkey) + } + val relay = FakeRelayManager(confirmationResult = null) + val service = service( + relayManager = relay, + runtimeFactory = SequencedNdrRuntimeFactory(listOf(oldRuntime, newRuntime)), + storageResetter = {} + ) + service.configureIfNeeded(testIdentity()) + + assertTrue(service.resetForPanic()) + assertTrue(service.completePanicReset()) + service.configureIfNeeded(testIdentity()) + + relay.canceledConfirmations.single().completion(true) + assertTrue(newRuntime.ackedActionIds.isEmpty()) + + relay.confirmations.single().completion(true) + assertEquals(listOf("shared-action"), newRuntime.ackedActionIds) + assertTrue(oldRuntime.ackedActionIds.isEmpty()) + } + + @Test + fun lateDeliveryAndOobCompletionCannotAckReplacementRuntime() { + val oldRuntime = FakeNdrPairwiseRuntime(mutableSetOf(peerPubkey)).apply { + pendingEvents += decryptedAction("shared-delivery", peerPubkey, "bitchat1:pending") + acceptInviteEvents += outOfBandAction("shared-oob", giftWrapEvent()) + acceptInviteResult = NdrAcceptInviteResult(peerPubkey, createdNewSession = true) + } + val newRuntime = FakeNdrPairwiseRuntime() + val service = service( + runtimeFactory = SequencedNdrRuntimeFactory(listOf(oldRuntime, newRuntime)), + invitePeerResolver = { peerPubkey } + ) + var deliveryCompletion: ((NdrDeliveryResult) -> Unit)? = null + service.onDecryptedMessage = { _, completion -> + deliveryCompletion = completion + } + service.configureIfNeeded(testIdentity()) + val oldPayload = service.processOutOfBandEventJson( + inviteEvent(peerPubkey), + peerPubkey + ).outboundPayloads.single() + + service.configureIfNeeded( + NostrIdentity( + privateKeyHex = "33".repeat(32), + publicKeyHex = "44".repeat(32), + npub = "npub-replacement", + createdAt = 2L + ) + ) + deliveryCompletion?.invoke(NdrDeliveryResult.CONSUMED) + + assertFalse(service.acknowledgeOutOfBandPayload(oldPayload)) + assertTrue(newRuntime.ackedActionIds.isEmpty()) + assertTrue(oldRuntime.ackedActionIds.isEmpty()) + } + + @Test + fun panicResetDestroysRuntimeAndClearsPersistentState() { + val runtime = FakeNdrPairwiseRuntime() + var storageReset = false + val service = service( + runtimeFactory = FakeNdrRuntimeFactory(runtime), + storageResetter = { storageReset = true } + ) + service.configureIfNeeded(testIdentity()) + service.onDecryptedMessage = { _, completion -> + completion(NdrDeliveryResult.CONSUMED) + } + + assertTrue(service.resetForPanic()) + assertTrue(service.completePanicReset()) + + assertFalse(service.isConfigured) + assertNull(service.currentInviteEventJson()) + assertNull(service.onDecryptedMessage) + assertTrue(runtime.destroyed) + assertTrue(storageReset) + } + + @Test + fun failedPanicStorageWipeKeepsNdrDisabled() { + val markers = InMemoryMarkerStore() + val runtime = FakeNdrPairwiseRuntime() + val factory = FakeNdrRuntimeFactory(runtime) + val service = service( + runtimeFactory = factory, + storageResetter = { throw java.io.IOException("busy") }, + markerStore = markers + ) + service.configureIfNeeded(testIdentity()) + + assertFalse(service.resetForPanic()) + assertTrue(service.isPanicWipeRequired) + service.configureIfNeeded(testIdentity()) + + assertFalse(service.isConfigured) + assertEquals(1, factory.createdCount) + + val restartedFactory = FakeNdrRuntimeFactory(FakeNdrPairwiseRuntime()) + val restarted = service( + runtimeFactory = restartedFactory, + storageResetter = {}, + markerStore = markers + ) + restarted.configureIfNeeded(testIdentity()) + assertTrue(restarted.isPanicWipeRequired) + assertFalse(restarted.isConfigured) + assertEquals(0, restartedFactory.createdCount) + + assertTrue(restarted.resetForPanic()) + assertTrue(restarted.completePanicReset()) + restarted.configureIfNeeded(testIdentity()) + assertFalse(restarted.isPanicWipeRequired) + assertTrue(restarted.isConfigured) + assertEquals(1, restartedFactory.createdCount) + } + + @Test + fun failedPrimaryPanicMarkerStillBlocksRestartViaQuarantine() { + val markers = InMemoryMarkerStore().apply { + panicMarkFailure = java.io.IOException("marker unavailable") + } + val quarantine = InMemoryPanicStorageQuarantine() + val service = service( + runtimeFactory = FakeNdrRuntimeFactory(FakeNdrPairwiseRuntime()), + storageResetter = {}, + markerStore = markers, + panicStorageQuarantine = quarantine + ) + service.configureIfNeeded(testIdentity()) + + assertTrue(service.resetForPanic()) + assertFalse(markers.isPanicWipeRequired()) + assertTrue(quarantine.pending) + + val restartedFactory = FakeNdrRuntimeFactory(FakeNdrPairwiseRuntime()) + val restarted = service( + runtimeFactory = restartedFactory, + storageResetter = {}, + markerStore = markers, + panicStorageQuarantine = quarantine + ) + restarted.configureIfNeeded(testIdentity()) + + assertTrue(restarted.isPanicWipeRequired) + assertFalse(restarted.isConfigured) + assertEquals(0, restartedFactory.createdCount) + + markers.panicMarkFailure = null + assertTrue(restarted.resetForPanic()) + assertTrue(restarted.completePanicReset()) + assertFalse(quarantine.pending) + assertFalse(restarted.isPanicWipeRequired) + } + + @Test + fun panicResetWaitsForInFlightRuntimeMutation() { + val sendEntered = CountDownLatch(1) + val releaseSend = CountDownLatch(1) + val resetFinished = CountDownLatch(1) + val runtime = FakeNdrPairwiseRuntime(mutableSetOf(peerPubkey)).apply { + sendTextEntered = sendEntered + releaseSendText = releaseSend + } + val service = service( + runtimeFactory = FakeNdrRuntimeFactory(runtime), + storageResetter = {} + ) + service.configureIfNeeded(testIdentity()) + + val sendThread = thread(start = true, name = "ndr-test-send") { + service.sendIfPossible("hello", peerPubkey) + } + assertTrue(sendEntered.await(2, TimeUnit.SECONDS)) + + var resetSucceeded = false + val resetThread = thread(start = true, name = "ndr-test-reset") { + resetSucceeded = service.resetForPanic() + resetFinished.countDown() + } + try { + assertFalse(resetFinished.await(150, TimeUnit.MILLISECONDS)) + } finally { + releaseSend.countDown() + sendThread.join(2_000) + resetThread.join(2_000) + } + + assertTrue(resetSucceeded) + assertTrue(service.completePanicReset()) + assertTrue(runtime.destroyedAfterSendCompleted) + } + + @Test + fun sendRequiresActivePairwiseSessionAndPublishesExactlyOneKind1060() { + val relay = FakeRelayManager() + val runtime = FakeNdrPairwiseRuntime(mutableSetOf(peerPubkey)).apply { + sendTextEvents += NdrPubSubEvent( + kind = "publish", + actionId = "message-action", + sessionId = "message-session", + eventJson = messageEvent(localPubkey).toJsonString() + ) + } + val service = service(relay, FakeNdrRuntimeFactory(runtime)) + service.configureIfNeeded(testIdentity()) + + assertEquals( + NdrSendResult.NO_SESSION, + service.sendIfPossible("hello", "aa".repeat(32)) + ) + assertEquals(NdrSendResult.SENT, service.sendIfPossible("hello", peerPubkey)) + + assertEquals(listOf(peerPubkey), runtime.sendTextCalls) + assertEquals(listOf(1060), relay.sentEvents.map { it.kind }) + assertTrue("message-action" in runtime.ackedActionIds) + } + + @Test + fun outboundAbsoluteExpirationIsForwardedToPairwiseRuntime() { + val expiresAtSeconds = 4_102_444_800uL + val runtime = FakeNdrPairwiseRuntime(mutableSetOf(peerPubkey)) + val service = service(runtimeFactory = FakeNdrRuntimeFactory(runtime)) + service.configureIfNeeded(testIdentity()) + + val result = service.sendIfPossible( + text = "disappearing", + peerPubkeyHex = peerPubkey, + expiresAtSeconds = expiresAtSeconds + ) + + assertEquals(NdrSendResult.SENT, result) + assertEquals(listOf(peerPubkey), runtime.sendTextCalls) + assertEquals(listOf(expiresAtSeconds), runtime.sendTextExpirationCalls) + } + + @Test + fun activePairwiseSessionSendFailureIsNotReportedAsNoSession() { + val runtime = FakeNdrPairwiseRuntime(mutableSetOf(peerPubkey)).apply { + sendTextFailure = java.io.IOException("storage unavailable") + } + val relay = FakeRelayManager() + val service = service(relay, FakeNdrRuntimeFactory(runtime)) + service.configureIfNeeded(testIdentity()) + + val result = service.sendIfPossible("hello", peerPubkey) + + assertEquals(NdrSendResult.FAILED, result) + assertTrue(relay.sentEvents.isEmpty()) + } + + @Test + fun postCommitMarkerFailureStillReportsDurableNdrAdmission() { + val activePeers = mutableSetOf() + val runtime = FakeNdrPairwiseRuntime(activePeers) + val markers = InMemoryMarkerStore() + val service = service( + runtimeFactory = FakeNdrRuntimeFactory(runtime), + markerStore = markers + ) + service.configureIfNeeded(testIdentity()) + activePeers += peerPubkey + markers.markFailure = java.io.IOException("marker storage unavailable") + + val result = service.sendIfPossible("admitted-once", peerPubkey) + + assertEquals(NdrSendResult.SENT, result) + assertEquals(listOf(peerPubkey), runtime.sendTextCalls) + assertTrue(runtime.sendTextCompleted) + assertTrue(runtime.destroyed) + } + + @Test + fun persistedHalfReadyPairwiseSessionNeverFallsBackAfterRestart() { + val markers = InMemoryMarkerStore() + val runtime = FakeNdrPairwiseRuntime().apply { + halfReadySessionPeers += peerPubkey + } + val service = service( + runtimeFactory = FakeNdrRuntimeFactory(runtime), + markerStore = markers + ) + service.configureIfNeeded(testIdentity()) + + assertTrue(service.hasPairwiseSession(peerPubkey)) + assertFalse(service.hasActiveSession(peerPubkey)) + assertEquals( + NdrSendResult.FAILED, + service.sendIfPossible("must-not-downgrade", peerPubkey) + ) + assertTrue(runtime.sendTextCalls.isEmpty()) + assertTrue(markers.contains(localPubkey)) + + val replacementFactory = FakeNdrRuntimeFactory(FakeNdrPairwiseRuntime()) + val restartedWithMissingState = service( + runtimeFactory = replacementFactory, + markerStore = markers, + pairwiseStateExists = { false } + ) + restartedWithMissingState.configureIfNeeded(testIdentity()) + + assertFalse(restartedWithMissingState.isConfigured) + assertEquals(0, replacementFactory.createdCount) + } + + @Test + fun activeSessionLookupFailureFailsClosed() { + val runtime = FakeNdrPairwiseRuntime(mutableSetOf(peerPubkey)) + val service = service(runtimeFactory = FakeNdrRuntimeFactory(runtime)) + service.configureIfNeeded(testIdentity()) + assertTrue(service.hasActiveSession(peerPubkey)) + runtime.activeSessionLookupFailure = java.io.IOException("database unavailable") + + assertEquals( + NdrSendResult.FAILED, + service.sendIfPossible("hello", peerPubkey) + ) + assertTrue(runtime.sendTextCalls.isEmpty()) + } + + @Test + fun configurationFailureFailsClosedUntilIdentityChanges() { + val replacementRuntime = FakeNdrPairwiseRuntime() + val factory = FailingThenSucceedingNdrRuntimeFactory(replacementRuntime) + val service = service(runtimeFactory = factory) + + service.configureIfNeeded(testIdentity()) + service.configureIfNeeded(testIdentity()) + + assertFalse(service.isConfigured) + assertEquals(1, factory.createdCount) + assertEquals( + NdrSendResult.FAILED, + service.sendIfPossible("must-not-downgrade", peerPubkey) + ) + + service.configureIfNeeded( + NostrIdentity( + privateKeyHex = "33".repeat(32), + publicKeyHex = "44".repeat(32), + npub = "npub-replacement", + createdAt = 2L + ) + ) + + assertTrue(service.isConfigured) + assertEquals(2, factory.createdCount) + } + + @Test + fun establishedMarkerWithMissingPairwiseStateFailsClosedBeforeRuntimeOpen() { + val markers = InMemoryMarkerStore().apply { mark(localPubkey) } + val factory = FakeNdrRuntimeFactory(FakeNdrPairwiseRuntime()) + val service = service( + runtimeFactory = factory, + markerStore = markers, + pairwiseStateExists = { false } + ) + + service.configureIfNeeded(testIdentity()) + + assertFalse(service.isConfigured) + assertEquals(0, factory.createdCount) + assertEquals( + NdrSendResult.FAILED, + service.sendIfPossible("must-not-downgrade", peerPubkey) + ) + } + + @Test + fun peerRetirementIsDurableAndFailureAbortsHostRebind() { + val failingPeer = "aa".repeat(32) + val runtime = FakeNdrPairwiseRuntime(mutableSetOf(peerPubkey, failingPeer)) + val service = service(runtimeFactory = FakeNdrRuntimeFactory(runtime)) + service.configureIfNeeded(testIdentity()) + + assertTrue(service.retirePeer(peerPubkey)) + assertEquals(listOf(peerPubkey), runtime.retiredPeers) + assertFalse(service.hasActiveSession(peerPubkey)) + assertTrue(service.retirePeer(peerPubkey)) + + runtime.retirePeerFailure = java.io.IOException("storage unavailable") + assertFalse(service.retirePeer(failingPeer)) + } + + @Test + fun peerRetirementExceptionAfterDurableRemovalIsIdempotentSuccess() { + val runtime = FakeNdrPairwiseRuntime(mutableSetOf(peerPubkey)).apply { + retirePeerFailureAfterRemoval = java.io.IOException("response lost") + } + val service = service(runtimeFactory = FakeNdrRuntimeFactory(runtime)) + service.configureIfNeeded(testIdentity()) + + assertTrue(service.retirePeer(peerPubkey)) + assertFalse(service.hasActiveSession(peerPubkey)) + } + + @Test + fun runtimeInitializationFailureDestroysPartialRuntimeAndFailsClosed() { + val runtime = FakeNdrPairwiseRuntime().apply { + knownPeerPubkeysFailure = java.io.IOException("database unavailable") + } + val factory = FakeNdrRuntimeFactory(runtime) + val service = service(runtimeFactory = factory) + + service.configureIfNeeded(testIdentity()) + service.configureIfNeeded(testIdentity()) + + assertTrue(runtime.destroyed) + assertFalse(service.isConfigured) + assertEquals(1, factory.createdCount) + assertEquals(NdrSendResult.FAILED, service.sendIfPossible("hello", peerPubkey)) + } + + @Test + fun successfulPanicResetClearsConfigurationFailureLatch() { + val replacementRuntime = FakeNdrPairwiseRuntime() + val factory = FailingThenSucceedingNdrRuntimeFactory(replacementRuntime) + val service = service( + runtimeFactory = factory, + storageResetter = {} + ) + service.configureIfNeeded(testIdentity()) + + assertEquals(NdrSendResult.FAILED, service.sendIfPossible("hello", peerPubkey)) + assertTrue(service.resetForPanic()) + assertTrue(service.completePanicReset()) + service.configureIfNeeded(testIdentity()) + + assertTrue(service.isConfigured) + assertEquals(2, factory.createdCount) + } + + @Test + fun legacyFallbackPolicyAllowsOnlyMissingPairwiseSession() { + assertTrue(shouldUseLegacyNostrFallback(NdrSendResult.NO_SESSION)) + assertFalse(shouldUseLegacyNostrFallback(NdrSendResult.FAILED)) + assertFalse(shouldUseLegacyNostrFallback(NdrSendResult.SENT)) + assertFalse( + shouldUseLegacyNostrFallback( + NdrSendResult.NO_SESSION, + ndrRequired = true + ) + ) + assertFalse( + shouldUseLegacyNostrFallback( + NdrSendResult.NO_SESSION, + rebindBlocked = true + ) + ) + assertTrue( + isLegacyNostrAllowedWhenNdrDisabled( + ndrRequired = false, + rebindBlocked = false + ) + ) + assertFalse( + isLegacyNostrAllowedWhenNdrDisabled( + ndrRequired = true, + rebindBlocked = false + ) + ) + assertFalse( + isLegacyNostrAllowedWhenNdrDisabled( + ndrRequired = false, + rebindBlocked = true + ) + ) + } + + @Test + fun ndrAdmissionDispositionRetriesEveryProtectedOrUnreadyState() { + assertEquals( + NdrSendDisposition.ADMITTED, + ndrSendDisposition(NdrSendResult.SENT) + ) + assertEquals( + NdrSendDisposition.LEGACY_FALLBACK, + ndrSendDisposition(NdrSendResult.NO_SESSION) + ) + assertEquals( + NdrSendDisposition.RETRYABLE, + ndrSendDisposition(NdrSendResult.NO_SESSION, ndrRequired = true) + ) + assertEquals( + NdrSendDisposition.RETRYABLE, + ndrSendDisposition(NdrSendResult.NO_SESSION, pairwiseOnly = true) + ) + assertEquals( + NdrSendDisposition.RETRYABLE, + ndrSendDisposition(NdrSendResult.FAILED) + ) + assertEquals( + NdrSendDisposition.RETRYABLE, + ndrSendDisposition( + NdrSendResult.NO_SESSION, + ndrRequired = true, + rebindBlocked = true + ) + ) + } + + private fun service( + relayManager: FakeRelayManager = FakeRelayManager(), + runtimeFactory: NdrPairwiseRuntimeFactory, + storageResetter: () -> Unit = {}, + invitePeerResolver: (String) -> String? = { null }, + retryScheduler: NdrRetryScheduler = FakeRetryScheduler(), + markerStore: NdrEstablishedSessionMarkerStore = InMemoryMarkerStore(), + panicStorageQuarantine: NdrPanicStorageQuarantine = + InMemoryPanicStorageQuarantine(), + pairwiseStateExists: (String) -> Boolean = { false } + ) = NdrNostrService( + relayManager = relayManager, + runtimeFactory = runtimeFactory, + storageDirectoryProvider = { "/tmp/ndr-test" }, + storageResetter = storageResetter, + establishedSessionMarkers = markerStore, + panicStorageQuarantine = panicStorageQuarantine, + pairwiseStateExists = pairwiseStateExists, + invitePeerResolver = invitePeerResolver, + retryScheduler = retryScheduler + ) + + private fun testIdentity() = NostrIdentity( + privateKeyHex = "11".repeat(32), + publicKeyHex = localPubkey, + npub = "npub-test", + createdAt = 1L + ) + + private fun inviteEvent(pubkey: String): String = """ + {"id":"${"10".repeat(32)}","pubkey":"$pubkey","created_at":1,"kind":30078,"tags":[["l","double-ratchet/invites"]],"content":"invite","sig":"sig"} + """.trimIndent() + + private fun giftWrapEvent(): String = """ + {"id":"${"11".repeat(32)}","pubkey":"${"ee".repeat(32)}","created_at":1,"kind":1059,"tags":[["p","$localPubkey"]],"content":"wrapped","sig":"sig"} + """.trimIndent() + + private fun appKeysEvent(pubkey: String): String = """ + {"id":"${"12".repeat(32)}","pubkey":"$pubkey","created_at":1,"kind":37368,"tags":[["type","app_keys_roster_snapshot"]],"content":"roster","sig":"sig"} + """.trimIndent() + + private fun messageEvent(pubkey: String) = NostrEvent( + id = "13".repeat(32), + pubkey = pubkey, + createdAt = 1, + kind = 1060, + tags = emptyList(), + content = "ciphertext", + sig = "sig" + ) + + private fun relayPublishAction(actionId: String, pubkey: String) = NdrPubSubEvent( + kind = "publish", + actionId = actionId, + sessionId = "session-$actionId", + eventJson = messageEvent(pubkey).toJsonString() + ) + + private fun outOfBandAction( + actionId: String, + eventJson: String, + peer: String = peerPubkey + ) = NdrPubSubEvent( + kind = "out_of_band", + actionId = actionId, + sessionId = "session-$actionId", + eventJson = eventJson, + peerPubkeyHex = peer + ) + + private fun decryptedAction( + actionId: String, + sender: String, + content: String + ) = NdrPubSubEvent( + kind = "delivery", + actionId = actionId, + senderPubkeyHex = sender, + content = content, + eventId = actionId.hashCode().toUInt().toString(16).padStart(64, '0') + ) + + private class FakeNdrRuntimeFactory( + private val runtime: FakeNdrPairwiseRuntime + ) : NdrPairwiseRuntimeFactory { + var lastStoragePath: String? = null + var createdCount: Int = 0 + + override fun newWithStoragePath( + ourPubkeyHex: String, + ourIdentityPrivkeyHex: String, + storagePath: String + ): NdrPairwiseRuntime { + lastStoragePath = storagePath + createdCount += 1 + return runtime + } + } + + private class SequencedNdrRuntimeFactory( + runtimes: List + ) : NdrPairwiseRuntimeFactory { + private val remaining = ArrayDeque(runtimes) + + override fun newWithStoragePath( + ourPubkeyHex: String, + ourIdentityPrivkeyHex: String, + storagePath: String + ): NdrPairwiseRuntime = remaining.removeFirst() + } + + private class FailingThenSucceedingNdrRuntimeFactory( + private val runtime: FakeNdrPairwiseRuntime + ) : NdrPairwiseRuntimeFactory { + var createdCount = 0 + + override fun newWithStoragePath( + ourPubkeyHex: String, + ourIdentityPrivkeyHex: String, + storagePath: String + ): NdrPairwiseRuntime { + createdCount += 1 + if (createdCount == 1) throw java.io.IOException("database unavailable") + return runtime + } + } + + private class FakeRelayManager( + var failSend: Boolean = false, + var failSubscribe: Boolean = false, + var confirmationResult: Boolean? = true + ) : NdrRelayManager { + data class Subscription( + val id: String, + val filter: NostrFilter, + val handler: (NostrEvent) -> Boolean + ) + data class Confirmation( + val eventId: String, + val completion: (Boolean) -> Unit + ) + + val subscriptions = mutableListOf() + val unsubscribed = mutableListOf() + val sentEvents = mutableListOf() + val confirmations = ArrayDeque() + val canceledConfirmations = mutableListOf() + private var connectionAvailableHandler: (() -> Unit)? = null + + override fun subscribe(filter: NostrFilter, id: String, handler: (NostrEvent) -> Boolean) { + if (failSubscribe) throw java.io.IOException("subscribe failed") + subscriptions += Subscription(id, filter, handler) + } + + override fun unsubscribe(id: String) { + unsubscribed += id + } + + override fun sendEventConfirmed( + event: NostrEvent, + completion: (accepted: Boolean) -> Unit + ) { + if (failSend) throw java.io.IOException("send failed") + sentEvents += event + val result = confirmationResult + if (result == null) { + confirmations.addLast(Confirmation(event.id, completion)) + } else { + completion(result) + } + } + + override fun cancelConfirmedEvent(eventId: String) { + val retained = ArrayDeque() + while (confirmations.isNotEmpty()) { + val confirmation = confirmations.removeFirst() + if (confirmation.eventId == eventId) { + canceledConfirmations += confirmation + confirmation.completion(false) + } else { + retained.addLast(confirmation) + } + } + confirmations.addAll(retained) + } + + override fun setOnConnectionAvailable(handler: () -> Unit) { + connectionAvailableHandler = handler + } + + fun reconnect() { + connectionAvailableHandler?.invoke() + } + } + + private class FakeRetryScheduler : NdrRetryScheduler { + data class ScheduledTask( + val delayMs: Long, + val task: () -> Unit, + var canceled: Boolean = false + ) + + val scheduled = ArrayDeque() + + override fun schedule(delayMs: Long, task: () -> Unit): NdrRetryCancellation { + val scheduledTask = ScheduledTask(delayMs, task) + scheduled.addLast(scheduledTask) + return NdrRetryCancellation { scheduledTask.canceled = true } + } + + fun runNext() { + while (scheduled.isNotEmpty()) { + val scheduledTask = scheduled.removeFirst() + if (!scheduledTask.canceled) { + scheduledTask.task() + return + } + } + } + } + + private class InMemoryMarkerStore : NdrEstablishedSessionMarkerStore { + private val marked = mutableSetOf() + var markFailure: Throwable? = null + var panicMarkFailure: Throwable? = null + private var panicWipeRequired = false + + override fun contains(accountPubkeyHex: String): Boolean = + accountPubkeyHex.lowercase() in marked + + override fun mark(accountPubkeyHex: String) { + markFailure?.let { throw it } + marked += accountPubkeyHex.lowercase() + } + + override fun clearEstablishedSessions() { + marked.clear() + } + + override fun isPanicWipeRequired(): Boolean = panicWipeRequired + + override fun markPanicWipeRequired() { + panicMarkFailure?.let { throw it } + panicWipeRequired = true + } + + override fun clearPanicWipeRequired() { + panicWipeRequired = false + } + } + + private class InMemoryPanicStorageQuarantine : NdrPanicStorageQuarantine { + var pending = false + var nativeStateWiped = false + + override fun isPending(): Boolean = pending + + override fun begin() { + pending = true + } + + override fun wipeNativeState() { + check(pending) + nativeStateWiped = true + } + + override fun clear() { + check(nativeStateWiped) + pending = false + } + } + + private class FakeNdrPairwiseRuntime( + private val activeSessionPeers: MutableSet = mutableSetOf() + ) : NdrPairwiseRuntime { + val halfReadySessionPeers = mutableSetOf() + val pendingEvents = mutableListOf() + val ackedActionIds = mutableListOf() + val processedEvents = mutableListOf() + val processedOutOfBandResponses = mutableListOf>() + val acceptedInvites = mutableListOf() + val acceptedInviteUrls = mutableListOf() + val acceptInviteEvents = mutableListOf() + val acceptInviteUrlEvents = mutableListOf() + val processEvents = mutableListOf() + val sendTextEvents = mutableListOf() + val sendTextCalls = mutableListOf() + val sendTextExpirationCalls = mutableListOf() + var acceptInviteResult = NdrAcceptInviteResult( + peerPubkeyHex = "aa".repeat(32), + createdNewSession = true + ) + var acceptInviteUrlResult = acceptInviteResult + var currentInvite: String? = null + var destroyed = false + var sendTextEntered: CountDownLatch? = null + var releaseSendText: CountDownLatch? = null + var sendTextFailure: Throwable? = null + var activeSessionLookupFailure: Throwable? = null + var knownPeerPubkeysFailure: Throwable? = null + var retirePeerFailure: Throwable? = null + var retirePeerFailureAfterRemoval: Throwable? = null + val retiredPeers = mutableListOf() + @Volatile + var sendTextCompleted = false + var destroyedAfterSendCompleted = false + + override fun currentInviteEventJson(): String? = currentInvite + + override fun currentInviteUrl(root: String): String? = null + + override fun acceptInviteFromEventJson( + eventJson: String, + expectedPeerPubkeyHex: String + ): NdrAcceptInviteResult { + acceptedInvites += eventJson + pendingEvents += acceptInviteEvents + return acceptInviteResult + } + + override fun acceptInviteFromUrl( + inviteUrl: String, + expectedPeerPubkeyHex: String + ): NdrAcceptInviteResult { + acceptedInviteUrls += inviteUrl + pendingEvents += acceptInviteUrlEvents + return acceptInviteUrlResult + } + + override fun processEvent(eventJson: String) { + processedEvents += eventJson + pendingEvents += processEvents + } + + override fun processOutOfBandResponse( + eventJson: String, + expectedPeerPubkeyHex: String + ) { + processedOutOfBandResponses += eventJson to expectedPeerPubkeyHex + } + + override fun pendingActions(nowSeconds: ULong): List = + pendingEvents.toList() + + override fun ackActions(actionIds: List) { + ackedActionIds += actionIds + pendingEvents.removeAll { it.actionId in actionIds } + } + + override fun sessionInfo(peerPubkeyHex: String): NdrPairwiseSessionInfo? { + activeSessionLookupFailure?.let { throw it } + val peer = peerPubkeyHex.lowercase() + return when { + peer in activeSessionPeers -> + NdrPairwiseSessionInfo( + sendReady = true, + receiveReady = true, + trackedSenderPubkeys = listOf(peer) + ) + peer in halfReadySessionPeers -> + NdrPairwiseSessionInfo( + sendReady = false, + receiveReady = false, + trackedSenderPubkeys = emptyList() + ) + else -> null + } + } + + override fun knownPeerPubkeys(): List { + knownPeerPubkeysFailure?.let { throw it } + return (activeSessionPeers + halfReadySessionPeers).toList() + } + + override fun retirePeer(peerPubkeyHex: String): Boolean { + retirePeerFailure?.let { throw it } + retiredPeers += peerPubkeyHex.lowercase() + val removed = activeSessionPeers.remove(peerPubkeyHex.lowercase()) || + halfReadySessionPeers.remove(peerPubkeyHex.lowercase()) + retirePeerFailureAfterRemoval?.let { throw it } + return removed + } + + override fun sendText( + recipientPubkeyHex: String, + text: String, + expiresAtSeconds: ULong? + ): NdrPairwiseSendResult { + sendTextCalls += recipientPubkeyHex + sendTextExpirationCalls += expiresAtSeconds + sendTextEntered?.countDown() + releaseSendText?.await(2, TimeUnit.SECONDS) + sendTextFailure?.let { throw it } + sendTextCompleted = true + pendingEvents += sendTextEvents + return NdrPairwiseSendResult( + innerEventId = "21".repeat(32), + outerEventId = "22".repeat(32) + ) + } + + override fun getOurPubkeyHex(): String = "22".repeat(32) + + override fun getTotalSessions(): ULong = activeSessionPeers.size.toULong() + + override fun destroy() { + destroyedAfterSendCompleted = sendTextCompleted + destroyed = true + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NdrOutOfBandRoutePolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NdrOutOfBandRoutePolicyTest.kt new file mode 100644 index 00000000..f44263f6 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NdrOutOfBandRoutePolicyTest.kt @@ -0,0 +1,125 @@ +package com.bitchat.android.nostr + +import com.bitchat.android.mesh.NdrMeshRoute +import com.bitchat.android.mesh.NdrTransportTarget +import com.bitchat.android.noise.AuthenticatedNoiseSession +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class NdrOutOfBandRoutePolicyTest { + private val peerPubkey = "ab".repeat(32) + private val noiseKey = ByteArray(32) { 1 } + private val route = NdrMeshRoute( + transportId = "BLE", + peerID = "peer", + authenticatedSession = AuthenticatedNoiseSession( + remoteStaticKey = noiseKey, + sessionToken = ByteArray(32) { 2 } + ), + transportTarget = NdrTransportTarget( + endpointId = "endpoint", + generationToken = "generation" + ) + ) + + @Test + fun acceptsOnlyLiveGenerationWithExactMutualFavoriteBinding() { + assertTrue( + NdrOutOfBandRoutePolicy.isAuthorized( + route = route, + expectedPeerPubkeyHex = peerPubkey, + currentRoute = { _, _ -> route }, + favoriteBinding = { + NdrFavoriteRouteBinding( + isMutual = true, + peerPubkeyHex = peerPubkey + ) + } + ) + ) + } + + @Test + fun rejectsReplacementNoiseGeneration() { + val replacement = route.copy( + authenticatedSession = AuthenticatedNoiseSession( + remoteStaticKey = noiseKey, + sessionToken = ByteArray(32) { 3 } + ) + ) + + assertFalse( + NdrOutOfBandRoutePolicy.isAuthorized( + route = route, + expectedPeerPubkeyHex = peerPubkey, + currentRoute = { _, _ -> replacement }, + favoriteBinding = { + NdrFavoriteRouteBinding(true, peerPubkey) + } + ) + ) + } + + @Test + fun rejectsFavoriteRevocationOrNostrRebinding() { + assertFalse( + NdrOutOfBandRoutePolicy.isAuthorized( + route = route, + expectedPeerPubkeyHex = peerPubkey, + currentRoute = { _, _ -> route }, + favoriteBinding = { + NdrFavoriteRouteBinding(false, peerPubkey) + } + ) + ) + assertFalse( + NdrOutOfBandRoutePolicy.isAuthorized( + route = route, + expectedPeerPubkeyHex = peerPubkey, + currentRoute = { _, _ -> route }, + favoriteBinding = { + NdrFavoriteRouteBinding(true, "cd".repeat(32)) + } + ) + ) + } + + @Test + fun validatesTheExactTransportWhenMultipleRoutesCoexist() { + val wifiRoute = route.copy( + transportId = "WIFI_AWARE", + transportTarget = NdrTransportTarget( + endpointId = "wifi-endpoint", + generationToken = "wifi-generation" + ) + ) + + assertTrue( + NdrOutOfBandRoutePolicy.isAuthorized( + route = wifiRoute, + expectedPeerPubkeyHex = peerPubkey, + currentRoute = { _, transportId -> + when (transportId) { + "BLE" -> route + "WIFI_AWARE" -> wifiRoute + else -> null + } + }, + favoriteBinding = { + NdrFavoriteRouteBinding(true, peerPubkey) + } + ) + ) + assertFalse( + NdrOutOfBandRoutePolicy.isAuthorized( + route = wifiRoute, + expectedPeerPubkeyHex = peerPubkey, + currentRoute = { _, _ -> route }, + favoriteBinding = { + NdrFavoriteRouteBinding(true, peerPubkey) + } + ) + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NdrPanicStartupRecoveryTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NdrPanicStartupRecoveryTest.kt new file mode 100644 index 00000000..6cd26c60 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NdrPanicStartupRecoveryTest.kt @@ -0,0 +1,149 @@ +package com.bitchat.android.nostr + +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class NdrPanicStartupRecoveryTest { + @After + fun restoreNetworkGate() { + NdrPanicStartupRecovery.recoverBeforeNetwork( + markerStore = InMemoryMarkerStore(retryRequired = false), + panicStorageQuarantine = InMemoryQuarantine(pending = false), + clearIdentity = { true }, + clearFavorites = { true } + ) + } + + @Test + fun startupRetryFinishesNativeAndHostWipeBeforeAllowingNetwork() { + val markers = InMemoryMarkerStore(retryRequired = true) + val quarantine = InMemoryQuarantine(pending = false) + var identityCleared = false + var favoritesCleared = false + + val recovered = NdrPanicStartupRecovery.recoverBeforeNetwork( + markerStore = markers, + panicStorageQuarantine = quarantine, + clearIdentity = { + assertTrue(quarantine.nativeStateWiped) + identityCleared = true + true + }, + clearFavorites = { + assertTrue(identityCleared) + favoritesCleared = true + true + } + ) + + assertTrue(recovered) + assertTrue(identityCleared) + assertTrue(favoritesCleared) + assertFalse(markers.retryRequired) + assertFalse(quarantine.pending) + assertTrue(NdrPanicStartupRecovery.isNetworkStartupAllowed()) + } + + @Test + fun failedHostWipeKeepsStartupInertAndRetryMarkerDurable() { + val markers = InMemoryMarkerStore(retryRequired = true) + val quarantine = InMemoryQuarantine(pending = false) + + val recovered = NdrPanicStartupRecovery.recoverBeforeNetwork( + markerStore = markers, + panicStorageQuarantine = quarantine, + clearIdentity = { true }, + clearFavorites = { false } + ) + + assertFalse(recovered) + assertTrue(markers.retryRequired) + assertTrue(quarantine.pending) + assertFalse(NdrPanicStartupRecovery.isNetworkStartupAllowed()) + } + + @Test + fun failedIdentityWipeKeepsStartupInertAndBothRetrySignalsDurable() { + val markers = InMemoryMarkerStore(retryRequired = true) + val quarantine = InMemoryQuarantine(pending = false) + var favoritesClearCalled = false + + val recovered = NdrPanicStartupRecovery.recoverBeforeNetwork( + markerStore = markers, + panicStorageQuarantine = quarantine, + clearIdentity = { false }, + clearFavorites = { + favoritesClearCalled = true + true + } + ) + + assertFalse(recovered) + assertFalse(favoritesClearCalled) + assertTrue(markers.retryRequired) + assertTrue(quarantine.pending) + assertFalse(NdrPanicStartupRecovery.isNetworkStartupAllowed()) + } + + @Test + fun quarantineResidueAloneTriggersRecoveryBeforeNetworkStartup() { + val markers = InMemoryMarkerStore(retryRequired = false) + val quarantine = InMemoryQuarantine(pending = true) + var identityCleared = false + + val recovered = NdrPanicStartupRecovery.recoverBeforeNetwork( + markerStore = markers, + panicStorageQuarantine = quarantine, + clearIdentity = { + identityCleared = true + true + }, + clearFavorites = { true } + ) + + assertTrue(recovered) + assertTrue(identityCleared) + assertTrue(quarantine.nativeStateWiped) + assertFalse(quarantine.pending) + assertTrue(NdrPanicStartupRecovery.isNetworkStartupAllowed()) + } + + private class InMemoryMarkerStore( + var retryRequired: Boolean + ) : NdrEstablishedSessionMarkerStore { + override fun contains(accountPubkeyHex: String): Boolean = false + override fun mark(accountPubkeyHex: String) = Unit + override fun clearEstablishedSessions() = Unit + override fun isPanicWipeRequired(): Boolean = retryRequired + override fun markPanicWipeRequired() { + retryRequired = true + } + override fun clearPanicWipeRequired() { + retryRequired = false + } + } + + private class InMemoryQuarantine( + var pending: Boolean + ) : NdrPanicStorageQuarantine { + var nativeStateWiped = false + + override fun isPending(): Boolean = pending + + override fun begin() { + pending = true + } + + override fun wipeNativeState() { + check(pending) + nativeStateWiped = true + } + + override fun clear() { + check(nativeStateWiped) + pending = false + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NdrSubscriptionAdmissionTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NdrSubscriptionAdmissionTest.kt new file mode 100644 index 00000000..888d7907 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NdrSubscriptionAdmissionTest.kt @@ -0,0 +1,202 @@ +package com.bitchat.android.nostr + +import com.bitchat.android.model.NdrFeatureGate +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class NdrSubscriptionAdmissionTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @After + fun resetFeatureGate() { + NdrFeatureGate.setEnabledForTests(false) + } + + @Test + fun `rejected native subscription remains pending instead of being acknowledged`() { + NdrFeatureGate.setEnabledForTests(true) + val runtime = SubscriptionRuntime() + val relay = RecordingRelayManager() + val storageDirectory = temporaryFolder.newFolder("ndr").absolutePath + val service = NdrNostrService( + relayManager = relay, + runtimeFactory = object : NdrPairwiseRuntimeFactory { + override fun newWithStoragePath( + ourPubkeyHex: String, + ourIdentityPrivkeyHex: String, + storagePath: String + ): NdrPairwiseRuntime = runtime + }, + storageDirectoryProvider = { storageDirectory } + ) + + assertTrue(service.configureIfNeeded(testIdentity())) + assertTrue(relay.subscriptions.isEmpty()) + assertTrue(runtime.ackedActionIds.isEmpty()) + assertEquals( + listOf(SUBSCRIPTION_ACTION_ID), + runtime.pendingActions(0u).map { it.actionId } + ) + } + + @Test + fun `reset-blocked pairwise subscription installs and acknowledges once after reset`() { + NdrFeatureGate.setEnabledForTests(true) + val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) + val relayManager = NostrRelayManager( + scope = scope, + eventDeduplicator = NostrEventDeduplicator(maxCapacity = 8) + ) + val runtime = SubscriptionRuntime( + NdrPubSubEvent( + kind = "subscribe", + actionId = SUBSCRIPTION_ACTION_ID, + subid = "messages", + filterJson = + """{"authors":["${"aa".repeat(32)}"],"kinds":[1060]}""" + ) + ) + val storageDirectory = temporaryFolder.newFolder("reset-ndr").absolutePath + val service = NdrNostrService( + relayManager = BitchatNdrRelayAdapter( + relayManager, + accountRelayUrls = emptyList() + ), + runtimeFactory = object : NdrPairwiseRuntimeFactory { + override fun newWithStoragePath( + ourPubkeyHex: String, + ourIdentityPrivkeyHex: String, + storagePath: String + ): NdrPairwiseRuntime = runtime + }, + storageDirectoryProvider = { storageDirectory } + ) + val identity = testIdentity() + val resetToken = relayManager.beginAccountReset() + + try { + assertTrue(service.configureIfNeeded(identity)) + assertTrue(relayManager.getActiveSubscriptions().isEmpty()) + assertTrue(runtime.ackedActionIds.isEmpty()) + + assertTrue(relayManager.discardForAccountReset(resetToken)) + assertTrue(relayManager.completeAccountReset(resetToken)) + assertTrue(service.configureIfNeeded(identity)) + assertEquals( + setOf("messages"), + relayManager.getActiveSubscriptions().keys + ) + assertEquals(listOf(SUBSCRIPTION_ACTION_ID), runtime.ackedActionIds) + + assertTrue(service.configureIfNeeded(identity)) + assertEquals( + setOf("messages"), + relayManager.getActiveSubscriptions().keys + ) + assertEquals(listOf(SUBSCRIPTION_ACTION_ID), runtime.ackedActionIds) + } finally { + val cleanupReset = relayManager.discardForAccountReset() + relayManager.completeAccountReset(cleanupReset) + scope.cancel() + } + } + + private fun testIdentity(): NostrIdentity = + NostrIdentity.fromPrivateKey("0".repeat(63) + "1") + + private class RecordingRelayManager : NdrRelayManager { + val subscriptions = mutableListOf() + + override fun subscribe( + filter: NostrFilter, + id: String, + handler: (NostrEvent) -> Boolean + ) { + subscriptions += id + } + + override fun unsubscribe(id: String) = Unit + + override fun sendEventConfirmed( + event: NostrEvent, + completion: (accepted: Boolean) -> Unit + ) = completion(true) + + override fun cancelConfirmedEvent(eventId: String) = Unit + + override fun setOnConnectionAvailable(handler: () -> Unit) = Unit + } + + private class SubscriptionRuntime( + private val action: NdrPubSubEvent = NdrPubSubEvent( + kind = "subscribe", + actionId = SUBSCRIPTION_ACTION_ID, + subid = "messages", + filterJson = """ + { + "kinds": [1060], + "authors": ["${"aa".repeat(32)}"], + "#p": ["${"bb".repeat(32)}"] + } + """.trimIndent() + ) + ) : NdrPairwiseRuntime { + val ackedActionIds = mutableListOf() + + override fun currentInviteEventJson(): String? = null + override fun currentInviteUrl(root: String): String? = null + + override fun acceptInviteFromEventJson( + eventJson: String, + expectedPeerPubkeyHex: String + ): NdrAcceptInviteResult = unsupported() + + override fun acceptInviteFromUrl( + inviteUrl: String, + expectedPeerPubkeyHex: String + ): NdrAcceptInviteResult = unsupported() + + override fun processEvent(eventJson: String) = Unit + + override fun processOutOfBandResponse( + eventJson: String, + expectedPeerPubkeyHex: String + ) = Unit + + override fun pendingActions(nowSeconds: ULong): List = + if (SUBSCRIPTION_ACTION_ID in ackedActionIds) emptyList() else listOf(action) + + override fun ackActions(actionIds: List) { + ackedActionIds += actionIds + } + + override fun sessionInfo(peerPubkeyHex: String): NdrPairwiseSessionInfo? = null + override fun knownPeerPubkeys(): List = emptyList() + override fun retirePeer(peerPubkeyHex: String): Boolean = false + + override fun sendText( + recipientPubkeyHex: String, + text: String, + expiresAtSeconds: ULong? + ): NdrPairwiseSendResult = unsupported() + + override fun getOurPubkeyHex(): String = "cc".repeat(32) + override fun getTotalSessions(): ULong = 0u + override fun destroy() = Unit + + private fun unsupported(): T = error("not used") + } + + companion object { + private const val SUBSCRIPTION_ACTION_ID = "subscription-action" + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt index d7fffc2c..f154ec1e 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt @@ -2,6 +2,7 @@ package com.bitchat.android.nostr import android.app.Application import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.model.NdrFeatureGate import com.bitchat.android.services.AppStateStore import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -23,11 +24,15 @@ class NostrBackgroundEventProcessorTest { @Before fun setUp() { AppStateStore.clear() + NdrFeatureGate.setEnabledForTests(false) + NostrInboundAccountLifecycle.invalidate() scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) } @After fun tearDown() { + NostrInboundAccountLifecycle.invalidate() + NdrFeatureGate.setEnabledForTests(false) scope.cancel() AppStateStore.clear() } @@ -36,6 +41,7 @@ class NostrBackgroundEventProcessorTest { fun `cold start processes more events than the removed handoff queue capacity`() = runBlocking { val application = ApplicationProvider.getApplicationContext() val processor = NostrBackgroundEventProcessor(application, scope) + val accountEpoch = processor.configureAccount(NostrIdentity.generate()) repeat(300) { index -> processor.onGeohashMessage( @@ -47,7 +53,8 @@ class NostrBackgroundEventProcessorTest { tags = listOf(listOf("g", "u4pruy")), content = "message-$index" ), - geohash = "u4pruy" + geohash = "u4pruy", + accountEpoch = accountEpoch ) } diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt index 634e053f..ab09ff48 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt @@ -1,6 +1,7 @@ package com.bitchat.android.nostr import android.os.Build +import com.bitchat.android.model.NdrFeatureGate import com.bitchat.android.services.AppStateStore import com.bitchat.android.services.ConversationRepository import com.bitchat.android.services.InMemoryConversationStorageCipher @@ -11,6 +12,7 @@ import com.bitchat.android.ui.MessageManager import com.bitchat.android.ui.NoiseSessionDelegate import com.bitchat.android.ui.PrivateChatManager import com.google.gson.Gson +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -23,6 +25,7 @@ import kotlinx.coroutines.test.setMain import kotlinx.coroutines.withTimeout import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -33,6 +36,9 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean @RunWith(RobolectricTestRunner::class) @Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) @@ -47,6 +53,8 @@ class NostrDirectMessageHandlerTest { fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + NdrFeatureGate.setEnabledForTests(false) + NostrInboundAccountLifecycle.invalidate() AppStateStore.clear() conversationDatabaseName = "nostr-dm-${UUID.randomUUID()}.db" conversationRepository = ConversationRepository( @@ -60,6 +68,8 @@ class NostrDirectMessageHandlerTest { @After fun tearDown() { + NostrInboundAccountLifecycle.invalidate() + NdrFeatureGate.setEnabledForTests(false) AppStateStore.clear() AppStateStore.setConversationRepositoryForTest(null) conversationRepository.closeForTest() @@ -92,7 +102,8 @@ class NostrDirectMessageHandlerTest { scope = scope, repo = GeohashRepository(application, state, dataManager), dataManager = dataManager, - seenStoreProvider = { seenStore } + seenStoreProvider = { seenStore }, + legacyNostrInboundAllowed = { true } ) val sender = NostrIdentity.generate() val recipient = NostrIdentity.generate() @@ -129,9 +140,10 @@ class NostrDirectMessageHandlerTest { giftWrapCreatedAt = now - 86_400 ) - handler.onGiftWrap(first, "", recipient) + val accountEpoch = handler.configureAccount(recipient) + handler.onGiftWrap(first, "", recipient, accountEpoch) waitForMessage(state, firstId) - handler.onGiftWrap(second, "", recipient) + handler.onGiftWrap(second, "", recipient, accountEpoch) waitForMessage(state, secondId) val messages = state.getPrivateChatsValue().values.single() @@ -140,6 +152,153 @@ class NostrDirectMessageHandlerTest { assertEquals(secondRumorTime * 1000L, messages[1].timestamp.time) } + @Test + fun `legacy inbound policy rejects before a valid gift wrap reaches chat state`() { + val application = RuntimeEnvironment.getApplication() + val state = ChatState(scope).apply { setNickname("recipient") } + val dataManager = DataManager(application) + val privateChatManager = PrivateChatManager( + state = state, + messageManager = MessageManager(state), + dataManager = dataManager, + noiseSessionDelegate = mock() + ) + val seenStore = mock() + val policyChecked = CompletableDeferred() + val handler = NostrDirectMessageHandler( + application = application, + state = state, + privateChatManager = privateChatManager, + updateDeliveryStatus = { _, _ -> }, + scope = scope, + repo = GeohashRepository(application, state, dataManager), + dataManager = dataManager, + seenStoreProvider = { seenStore }, + legacyNostrInboundAllowed = { senderPubkey -> + policyChecked.complete(senderPubkey) + false + } + ) + val sender = NostrIdentity.generate() + val recipient = NostrIdentity.generate() + val now = (System.currentTimeMillis() / 1000).toInt() + val giftWrap = privateMessageGiftWrap( + content = requireNotNull( + NostrEmbeddedBitChat.encodePMForNostrNoRecipient( + content = "must-not-arrive", + messageID = "blocked-legacy", + senderPeerID = "0011223344556677" + ) + ), + sender = sender, + recipient = recipient, + rumorCreatedAt = now - 60, + giftWrapCreatedAt = now - 5 + ) + + val accountEpoch = handler.configureAccount(recipient) + handler.onGiftWrap(giftWrap, "", recipient, accountEpoch) + + kotlinx.coroutines.runBlocking { + assertEquals(sender.publicKeyHex, withTimeout(5_000) { policyChecked.await() }) + delay(10) + } + assertEquals(0, state.getPrivateChatsValue().values.flatten().size) + } + + @Test + fun `account invalidation prevents an in-flight legacy gift wrap from restoring chat state`() { + val application = RuntimeEnvironment.getApplication() + val state = ChatState(scope).apply { setNickname("recipient") } + val dataManager = DataManager(application) + val privateChatManager = PrivateChatManager( + state = state, + messageManager = MessageManager(state), + dataManager = dataManager, + noiseSessionDelegate = mock() + ) + val seenStore = mock() + whenever(seenStore.hasDelivered(any())).thenReturn(true) + whenever(seenStore.hasBeenReadLocally(any())).thenReturn(false) + val policyEntered = CountDownLatch(1) + val releasePolicy = CountDownLatch(1) + val blockFirstPolicyCheck = AtomicBoolean(true) + val handler = NostrDirectMessageHandler( + application = application, + state = state, + privateChatManager = privateChatManager, + updateDeliveryStatus = { _, _ -> }, + scope = scope, + repo = GeohashRepository(application, state, dataManager), + dataManager = dataManager, + seenStoreProvider = { seenStore }, + legacyNostrInboundAllowed = { + if (blockFirstPolicyCheck.compareAndSet(true, false)) { + policyEntered.countDown() + releasePolicy.await(5, TimeUnit.SECONDS) + } + true + } + ) + val sender = NostrIdentity.generate() + val recipient = NostrIdentity.generate() + val now = (System.currentTimeMillis() / 1000).toInt() + val staleGiftWrap = privateMessageGiftWrap( + content = requireNotNull( + NostrEmbeddedBitChat.encodePMForNostrNoRecipient( + content = "must-not-return", + messageID = "stale-legacy", + senderPeerID = "0011223344556677" + ) + ), + sender = sender, + recipient = recipient, + rumorCreatedAt = now - 60, + giftWrapCreatedAt = now - 5 + ) + val freshGiftWrap = privateMessageGiftWrap( + content = requireNotNull( + NostrEmbeddedBitChat.encodePMForNostrNoRecipient( + content = "fresh", + messageID = "fresh-legacy", + senderPeerID = "0011223344556677" + ) + ), + sender = sender, + recipient = recipient, + rumorCreatedAt = now - 30, + giftWrapCreatedAt = now - 4 + ) + + val staleEpoch = handler.configureAccount(recipient) + val staleJob = requireNotNull( + handler.onGiftWrap(staleGiftWrap, "", recipient, staleEpoch) + ) + try { + assertTrue(policyEntered.await(5, TimeUnit.SECONDS)) + handler.invalidateAccount() + } finally { + releasePolicy.countDown() + } + kotlinx.coroutines.runBlocking { + withTimeout(5_000) { staleJob.join() } + } + assertEquals(0, state.getPrivateChatsValue().values.flatten().size) + + val freshEpoch = handler.configureAccount(recipient) + val freshJob = requireNotNull( + handler.onGiftWrap(freshGiftWrap, "", recipient, freshEpoch) + ) + kotlinx.coroutines.runBlocking { + withTimeout(5_000) { freshJob.join() } + } + waitForMessage(state, "fresh-legacy") + assertEquals( + listOf("fresh-legacy"), + state.getPrivateChatsValue().values.flatten().map { it.id } + ) + } + private fun waitForMessage(state: ChatState, messageId: String) { kotlinx.coroutines.runBlocking { withTimeout(5_000) { diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt index 8954cbe3..db6e5372 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt @@ -10,20 +10,28 @@ class NostrPendingEventQueueTest { fun `empty relay set is not queued`() { val queue = NostrPendingEventQueue(capacity = 2) - assertNull(queue.enqueue(event("empty"), emptyList(), liveLocationToken = null)) + assertNull( + queue.enqueue( + event("empty"), + emptyList(), + liveLocationToken = null, + accountGeneration = 1L + ) + ) assertEquals(0, queue.size()) } @Test fun `capacity evicts the oldest publish`() { val queue = NostrPendingEventQueue(capacity = 2) - queue.enqueue(event("one"), listOf("relay"), liveLocationToken = null) - queue.enqueue(event("two"), listOf("relay"), liveLocationToken = null) - queue.enqueue(event("three"), listOf("relay"), liveLocationToken = null) + queue.enqueue(event("one"), listOf("relay"), null, accountGeneration = 1L) + queue.enqueue(event("two"), listOf("relay"), null, accountGeneration = 1L) + queue.enqueue(event("three"), listOf("relay"), null, accountGeneration = 1L) assertEquals( listOf("two", "three"), - queue.pendingForRelay("relay").map { it.event.content } + queue.pendingForRelay("relay", accountGeneration = 1L) + .map { it.event.content } ) } @@ -32,10 +40,20 @@ class NostrPendingEventQueueTest { val queue = NostrPendingEventQueue(capacity = 4) val signedEvent = event("same") val firstId = requireNotNull( - queue.enqueue(signedEvent, listOf("relay-a", "relay-b"), liveLocationToken = null) + queue.enqueue( + signedEvent, + listOf("relay-a", "relay-b"), + liveLocationToken = null, + accountGeneration = 1L + ) ) val secondId = requireNotNull( - queue.enqueue(signedEvent, listOf("relay-a"), liveLocationToken = null) + queue.enqueue( + signedEvent, + listOf("relay-a"), + liveLocationToken = null, + accountGeneration = 1L + ) ) assertNotEquals(firstId, secondId) @@ -43,11 +61,13 @@ class NostrPendingEventQueueTest { assertEquals( listOf(secondId), - queue.pendingForRelay("relay-a").map { it.queueId } + queue.pendingForRelay("relay-a", accountGeneration = 1L) + .map { it.queueId } ) assertEquals( listOf(firstId), - queue.pendingForRelay("relay-b").map { it.queueId } + queue.pendingForRelay("relay-b", accountGeneration = 1L) + .map { it.queueId } ) queue.markDelivered(firstId, "relay-b") @@ -57,14 +77,42 @@ class NostrPendingEventQueueTest { @Test fun `privacy purge retains non-live publishes`() { val queue = NostrPendingEventQueue(capacity = 4) - queue.enqueue(event("manual"), listOf("relay"), liveLocationToken = null) - queue.enqueue(event("live"), listOf("relay"), liveLocationToken = 42L) + queue.enqueue(event("manual"), listOf("relay"), null, accountGeneration = 1L) + queue.enqueue(event("live"), listOf("relay"), 42L, accountGeneration = 1L) - queue.removeLiveLocationEvents() + queue.removeLiveLocationEvents(accountGeneration = 1L) assertEquals( listOf("manual"), - queue.pendingForRelay("relay").map { it.event.content } + queue.pendingForRelay("relay", accountGeneration = 1L) + .map { it.event.content } + ) + } + + @Test + fun `old account deliveries are excluded after generation changes`() { + val queue = NostrPendingEventQueue(capacity = 4) + queue.enqueue(event("old"), listOf("relay"), null, accountGeneration = 1L) + queue.enqueue(event("current"), listOf("relay"), null, accountGeneration = 2L) + + val deliveries = queue.pendingForRelay("relay", accountGeneration = 2L) + + assertEquals(listOf("current"), deliveries.map { it.event.content }) + assertEquals(listOf(2L), deliveries.map { it.accountGeneration }) + } + + @Test + fun `stale privacy purge cannot remove replacement account deliveries`() { + val queue = NostrPendingEventQueue(capacity = 4) + queue.enqueue(event("old-live"), listOf("relay"), 11L, accountGeneration = 1L) + queue.enqueue(event("new-live"), listOf("relay"), 22L, accountGeneration = 2L) + + queue.removeLiveLocationEvents(accountGeneration = 1L) + + assertEquals( + listOf("new-live"), + queue.pendingForRelay("relay", accountGeneration = 2L) + .map { it.event.content } ) } diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayConnectionEpochTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayConnectionEpochTest.kt new file mode 100644 index 00000000..83277eea --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayConnectionEpochTest.kt @@ -0,0 +1,141 @@ +package com.bitchat.android.nostr + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.StandardTestDispatcher +import okhttp3.Request +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class NostrRelayConnectionEpochTest { + @Test + fun `disconnect invalidates already queued connect work before reconnect`() { + val dispatcher = StandardTestDispatcher() + val scope = CoroutineScope(dispatcher + SupervisorJob()) + val openedUrls = mutableListOf() + val manager = NostrRelayManager( + scope = scope, + eventDeduplicator = NostrEventDeduplicator(maxCapacity = 8), + webSocketFactory = { request, _ -> + openedUrls += request.url.host + RecordingWebSocket(request) + } + ) + + try { + manager.connect() + manager.disconnect() + replaceRelays(manager, listOf(FRESH_RELAY_URL)) + manager.connect() + + dispatcher.scheduler.runCurrent() + + assertEquals(listOf(FRESH_RELAY_HOST), openedUrls) + } finally { + manager.disconnect() + scope.cancel() + } + } + + @Test + fun `message callback from replaced socket cannot enter a current subscription`() { + val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) + val deduplicator = NostrEventDeduplicator(maxCapacity = 8) + var listener: WebSocketListener? = null + lateinit var originalSocket: RecordingWebSocket + val manager = NostrRelayManager( + scope = scope, + eventDeduplicator = deduplicator, + webSocketFactory = { request, createdListener -> + listener = createdListener + RecordingWebSocket(request).also { originalSocket = it } + } + ) + replaceRelays(manager, listOf(FRESH_RELAY_URL)) + var processed = 0 + val event = NostrEvent( + id = "7a".repeat(32), + pubkey = "7b".repeat(32), + createdAt = 1, + kind = 1060, + tags = emptyList(), + content = "ciphertext", + sig = "signature" + ) + + try { + manager.subscribeAfterSuccessfulProcessing( + filter = NostrFilter(kinds = listOf(1060)), + id = "current-subscription", + targetRelayUrls = listOf(FRESH_RELAY_URL) + ) { + processed += 1 + true + } + manager.connect() + installConnection( + manager, + FRESH_RELAY_URL, + RecordingWebSocket(Request.Builder().url(FRESH_RELAY_URL).build()) + ) + + requireNotNull(listener).onMessage( + originalSocket, + """["EVENT","current-subscription",${event.toJsonString()}]""" + ) + + assertEquals(0, processed) + assertEquals(false, deduplicator.contains(event.id)) + } finally { + manager.disconnect() + scope.cancel() + } + } + + @Suppress("UNCHECKED_CAST") + private fun replaceRelays(manager: NostrRelayManager, urls: List) { + val field = NostrRelayManager::class.java.getDeclaredField("relaysList") + field.isAccessible = true + val relays = field.get(manager) as MutableList + synchronized(relays) { + relays.clear() + relays.addAll(urls.map(NostrRelayManager::Relay)) + } + } + + @Suppress("UNCHECKED_CAST") + private fun installConnection( + manager: NostrRelayManager, + relayUrl: String, + socket: WebSocket + ) { + val field = NostrRelayManager::class.java.getDeclaredField("connections") + field.isAccessible = true + val connections = field.get(manager) as MutableMap + connections[relayUrl] = socket + } + + private class RecordingWebSocket( + private val request: Request + ) : WebSocket { + override fun request(): Request = request + override fun queueSize(): Long = 0L + override fun send(text: String): Boolean = true + override fun send(bytes: ByteString): Boolean = true + override fun close(code: Int, reason: String?): Boolean = true + override fun cancel() = Unit + } + + companion object { + private const val FRESH_RELAY_HOST = "fresh-cycle.example" + private const val FRESH_RELAY_URL = "wss://$FRESH_RELAY_HOST/" + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt index de53a579..ac903a10 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt @@ -40,6 +40,8 @@ class NostrRelayManagerLifecycleSmokeTest { @Test fun `disconnected manager maintains subscription and empty publish invariants locally`() { val manager = NostrRelayManager.shared + val setupReset = manager.discardForAccountReset() + assertTrue(manager.completeAccountReset(setupReset)) manager.disconnect() manager.clearAllSubscriptions() @@ -54,7 +56,7 @@ class NostrRelayManagerLifecycleSmokeTest { assertEquals(1, manager.getActiveSubscriptionCount()) assertTrue(manager.getActiveSubscriptions().containsKey(id)) assertTrue(manager.validateSubscriptionConsistency().isConsistent) - manager.sendEvent(signedEvent(), relayUrls = emptyList()) + assertFalse(manager.sendEvent(signedEvent(), relayUrls = emptyList())) manager.retryConnection("wss://not-configured.example") manager.unsubscribe(id) @@ -66,6 +68,114 @@ class NostrRelayManagerLifecycleSmokeTest { assertFalse(manager.isConnected.value) } + @Test + fun `account reset advances generation and discards queued legacy gift wraps`() { + val manager = NostrRelayManager.shared + val setupReset = manager.discardForAccountReset() + assertTrue(manager.completeAccountReset(setupReset)) + val oldGeneration = manager.accountGenerationForTesting() + val event = signedEvent() + assertTrue(manager.registerPendingGiftWrap(event.id, oldGeneration)) + manager.sendEvent(event, relayUrls = targetRelays) + + assertEquals(1, manager.queuedEventCountForTesting()) + assertEquals(1, manager.pendingGiftWrapCountForTesting()) + + val resetToken = manager.discardForAccountReset() + + assertEquals(oldGeneration + 1, manager.accountGenerationForTesting()) + assertFalse(manager.isAccountGenerationCurrent(oldGeneration)) + assertFalse( + manager.isAccountGenerationCurrent( + manager.accountGenerationForTesting() + ) + ) + assertEquals(0, manager.queuedEventCountForTesting()) + assertEquals(0, manager.pendingGiftWrapCountForTesting()) + assertTrue(manager.completeAccountReset(resetToken)) + } + + @Test + fun `new relay work is rejected during reset and admitted after completion`() { + val manager = NostrRelayManager.shared + val resetToken = manager.discardForAccountReset() + val event = signedEvent() + + assertFalse(manager.sendEvent(event, relayUrls = targetRelays)) + assertEquals(0, manager.queuedEventCountForTesting()) + + assertTrue(manager.completeAccountReset(resetToken)) + assertTrue(manager.sendEvent(event, relayUrls = targetRelays)) + assertEquals(1, manager.queuedEventCountForTesting()) + + val cleanupReset = manager.discardForAccountReset() + assertTrue(manager.completeAccountReset(cleanupReset)) + } + + @Test + fun `stale reset cannot clear or reopen a newer relay reset`() { + val manager = NostrRelayManager.shared + val firstReset = manager.beginAccountReset() + val secondReset = manager.beginAccountReset() + val event = signedEvent() + + assertFalse(manager.discardForAccountReset(firstReset)) + assertFalse(manager.completeAccountReset(firstReset)) + assertFalse(manager.sendEvent(event, relayUrls = targetRelays)) + assertEquals(0, manager.queuedEventCountForTesting()) + + assertTrue(manager.discardForAccountReset(secondReset)) + assertTrue(manager.completeAccountReset(secondReset)) + assertTrue(manager.sendEvent(event, relayUrls = targetRelays)) + + val cleanupReset = manager.discardForAccountReset() + assertTrue(manager.completeAccountReset(cleanupReset)) + } + + @Test + fun `stale geohash wrapper cannot recapture a replacement generation`() { + val manager = NostrRelayManager.shared + val setupReset = manager.discardForAccountReset() + assertTrue(manager.completeAccountReset(setupReset)) + val oldGeneration = manager.captureAccountGeneration() + val replacementReset = manager.discardForAccountReset() + assertTrue(manager.completeAccountReset(replacementReset)) + val event = signedEvent() + + manager.sendEventToGeohash( + event = event, + geohash = "u4pruydq", + expectedAccountGeneration = oldGeneration + ) + manager.subscribe( + filter = NostrFilter(kinds = listOf(NostrKind.TEXT_NOTE)), + id = "stale-wrapper", + handler = {}, + targetRelayUrls = emptyList(), + expectedAccountGeneration = oldGeneration + ) + + assertEquals(0, manager.queuedEventCountForTesting()) + assertFalse(manager.getActiveSubscriptions().containsKey("stale-wrapper")) + + val cleanupReset = manager.discardForAccountReset() + assertTrue(manager.completeAccountReset(cleanupReset)) + } + + @Test + fun `generation captured during reset never becomes replacement work`() { + val manager = NostrRelayManager.shared + val resetToken = manager.beginAccountReset() + val parkedGeneration = manager.captureAccountGeneration() + + assertTrue(manager.discardForAccountReset(resetToken)) + assertTrue(manager.completeAccountReset(resetToken)) + assertFalse(manager.isAccountGenerationCurrent(parkedGeneration)) + + val cleanupReset = manager.discardForAccountReset() + assertTrue(manager.completeAccountReset(cleanupReset)) + } + private fun signedEvent(): NostrEvent { val privateKey = "0".repeat(63) + "1" return NostrEvent( @@ -76,4 +186,6 @@ class NostrRelayManagerLifecycleSmokeTest { content = "local" ).sign(privateKey) } + + private val targetRelays = listOf(NostrRelayManager.defaultRelays().first()) } diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayResetCallbackTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayResetCallbackTest.kt new file mode 100644 index 00000000..1c1c60ca --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayResetCallbackTest.kt @@ -0,0 +1,96 @@ +package com.bitchat.android.nostr + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import okhttp3.Request +import okhttp3.WebSocket +import okio.ByteString +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.concurrent.thread + +@RunWith(RobolectricTestRunner::class) +class NostrRelayResetCallbackTest { + @Test + fun `reset confirmation callback runs outside account locks`() { + val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) + val manager = NostrRelayManager( + scope = scope, + eventDeduplicator = NostrEventDeduplicator(maxCapacity = 8) + ) + installConnection(manager, RELAY_URL, RecordingWebSocket()) + val callbackReachedAccountLock = AtomicBoolean(false) + val event = signedEvent() + manager.sendEventConfirmed(event, listOf(RELAY_URL)) { + val lockReached = CountDownLatch(1) + thread(start = true, name = "relay-reset-callback-lock-probe") { + manager.registerPendingGiftWrap( + id = "callback-probe", + expectedAccountGeneration = manager.captureAccountGeneration() + ) + lockReached.countDown() + } + callbackReachedAccountLock.set(lockReached.await(1, TimeUnit.SECONDS)) + } + + val resetToken = manager.beginAccountReset() + val resetFinished = CountDownLatch(1) + thread(start = true, name = "relay-reset-probe") { + manager.discardForAccountReset(resetToken) + resetFinished.countDown() + } + + try { + assertTrue(resetFinished.await(2, TimeUnit.SECONDS)) + assertTrue(callbackReachedAccountLock.get()) + assertTrue(manager.completeAccountReset(resetToken)) + } finally { + scope.cancel() + } + } + + @Suppress("UNCHECKED_CAST") + private fun installConnection( + manager: NostrRelayManager, + relayUrl: String, + socket: WebSocket + ) { + val field = NostrRelayManager::class.java.getDeclaredField("connections") + field.isAccessible = true + val connections = field.get(manager) as MutableMap + connections[relayUrl] = socket + } + + private fun signedEvent(): NostrEvent { + val privateKey = "0".repeat(63) + "1" + return NostrEvent( + pubkey = NostrCrypto.derivePublicKey(privateKey), + createdAt = 1, + kind = 1060, + tags = emptyList(), + content = "ciphertext" + ).sign(privateKey) + } + + private class RecordingWebSocket : WebSocket { + override fun request(): Request = + Request.Builder().url(RELAY_URL).build() + + override fun queueSize(): Long = 0L + override fun send(text: String): Boolean = true + override fun send(bytes: ByteString): Boolean = true + override fun close(code: Int, reason: String?): Boolean = true + override fun cancel() = Unit + } + + companion object { + private const val RELAY_URL = "wss://relay-reset.example" + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelaySubscriptionRaceTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelaySubscriptionRaceTest.kt new file mode 100644 index 00000000..e84a7bd1 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelaySubscriptionRaceTest.kt @@ -0,0 +1,193 @@ +package com.bitchat.android.nostr + +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import okhttp3.Request +import okhttp3.WebSocket +import okio.ByteString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class NostrRelaySubscriptionRaceTest { + @Test + fun immediateEventDuringReqUsesCommitAwareHandler() { + val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) + val deduplicator = NostrEventDeduplicator(maxCapacity = 8) + val manager = NostrRelayManager(scope, deduplicator) + val subscriptionId = "commit-aware-race" + val event = NostrEvent( + id = "7a".repeat(32), + pubkey = "7b".repeat(32), + createdAt = 1, + kind = 1060, + tags = emptyList(), + content = "ciphertext", + sig = "signature" + ) + var processed = 0 + installConnection( + manager = manager, + relayUrl = RELAY_URL, + webSocket = ImmediateEventWebSocket { + deliverEvent(manager, subscriptionId, event) + } + ) + + manager.subscribeAfterSuccessfulProcessing( + filter = NostrFilter(kinds = listOf(1060)), + id = subscriptionId + ) { + processed += 1 + true + } + + assertEquals(1, processed) + assertTrue(deduplicator.contains(event.id)) + } + + @Test + fun ndrAdapterNeverUsesConnectedLiveLocationOnlyRelay() { + val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) + val manager = NostrRelayManager( + scope, + NostrEventDeduplicator(maxCapacity = 8) + ) + val accountRelayUrl = NostrRelayManager.defaultRelays().first() + val liveOnlyRelayUrl = "wss://live-location-only.example" + val accountSocket = RecordingWebSocket() + val liveOnlySocket = RecordingWebSocket() + installConnection(manager, accountRelayUrl, accountSocket) + installConnection(manager, liveOnlyRelayUrl, liveOnlySocket) + val adapter = BitchatNdrRelayAdapter(manager) + + adapter.subscribe( + filter = NostrFilter(kinds = listOf(1060)), + id = "ndr-account-only" + ) { true } + + val event = NostrEvent( + id = "7c".repeat(32), + pubkey = "7d".repeat(32), + createdAt = 1, + kind = 1060, + tags = emptyList(), + content = "ciphertext", + sig = "signature" + ) + var accepted: Boolean? = null + adapter.sendEventConfirmed(event) { accepted = it } + + assertTrue(accountSocket.messages.any { "\"REQ\"" in it }) + assertTrue(accountSocket.messages.any { "\"EVENT\"" in it }) + assertTrue(liveOnlySocket.messages.isEmpty()) + assertEquals(null, accepted) + + adapter.cancelConfirmedEvent(event.id) + assertFalse(accepted ?: true) + } + + @Test + fun ndrAdapterRejectsSubscriptionDuringAccountReset() { + val manager = NostrRelayManager( + CoroutineScope(Dispatchers.Unconfined + SupervisorJob()), + NostrEventDeduplicator(maxCapacity = 8) + ) + val adapter = BitchatNdrRelayAdapter(manager) + val resetToken = manager.beginAccountReset() + + assertThrows(IllegalStateException::class.java) { + adapter.subscribe( + filter = NostrFilter(kinds = listOf(1060)), + id = "blocked-ndr" + ) { true } + } + + assertTrue(manager.discardForAccountReset(resetToken)) + assertTrue(manager.completeAccountReset(resetToken)) + } + + @Suppress("UNCHECKED_CAST") + private fun installConnection( + manager: NostrRelayManager, + relayUrl: String, + webSocket: WebSocket + ) { + val field = NostrRelayManager::class.java.getDeclaredField("connections") + field.isAccessible = true + val connections = + field.get(manager) as ConcurrentHashMap + connections[relayUrl] = webSocket + } + + private fun deliverEvent( + manager: NostrRelayManager, + subscriptionId: String, + event: NostrEvent + ) { + val method = NostrRelayManager::class.java.getDeclaredMethod( + "handleMessage", + String::class.java, + String::class.java, + Long::class.javaPrimitiveType + ) + method.isAccessible = true + method.invoke( + manager, + """["EVENT","$subscriptionId",${event.toJsonString()}]""", + RELAY_URL, + manager.captureAccountGeneration() + ) + } + + private class ImmediateEventWebSocket( + private val onRequest: () -> Unit + ) : WebSocket { + override fun request(): Request = + Request.Builder().url("https://relay.example").build() + + override fun queueSize(): Long = 0L + + override fun send(text: String): Boolean { + onRequest() + return true + } + + override fun send(bytes: ByteString): Boolean = false + + override fun close(code: Int, reason: String?): Boolean = true + + override fun cancel() = Unit + } + + private class RecordingWebSocket : WebSocket { + val messages = mutableListOf() + + override fun request(): Request = + Request.Builder().url("https://relay.example").build() + + override fun queueSize(): Long = 0L + + override fun send(text: String): Boolean { + messages += text + return true + } + + override fun send(bytes: ByteString): Boolean = false + + override fun close(code: Int, reason: String?): Boolean = true + + override fun cancel() = Unit + } + + companion object { + private const val RELAY_URL = "wss://relay.example" + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrReliabilityPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrReliabilityPolicyTest.kt new file mode 100644 index 00000000..428b195b --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrReliabilityPolicyTest.kt @@ -0,0 +1,59 @@ +package com.bitchat.android.nostr + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class NostrReliabilityPolicyTest { + @Test + fun commitAwareDedupeRetriesAfterFailureAndConsumesOnlySuccess() { + val deduplicator = NostrEventDeduplicator(maxCapacity = 8) + val event = event("01".repeat(32)) + var attempts = 0 + + assertFalse( + deduplicator.processEventAfterSuccess(event) { + attempts += 1 + false + } + ) + assertTrue( + deduplicator.processEventAfterSuccess(event) { + attempts += 1 + true + } + ) + assertFalse( + deduplicator.processEventAfterSuccess(event) { + attempts += 1 + true + } + ) + assertEquals(2, attempts) + } + + @Test + fun nip20DuplicateSuccessIsExactAndCaseSensitive() { + assertTrue(isNip20ConfirmedSuccess(accepted = true, message = null)) + assertTrue( + isNip20ConfirmedSuccess( + accepted = false, + message = "duplicate: already have this event" + ) + ) + assertFalse(isNip20ConfirmedSuccess(false, "Duplicate: already have this event")) + assertFalse(isNip20ConfirmedSuccess(false, "duplicate")) + assertFalse(isNip20ConfirmedSuccess(false, " duplicate: already have this event")) + } + + private fun event(id: String) = NostrEvent( + id = id, + pubkey = "02".repeat(32), + createdAt = 1, + kind = 1060, + tags = emptyList(), + content = "ciphertext", + sig = "signature" + ) +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrTransportAdmissionTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrTransportAdmissionTest.kt new file mode 100644 index 00000000..abdc2d76 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrTransportAdmissionTest.kt @@ -0,0 +1,141 @@ +package com.bitchat.android.nostr + +import android.os.Build +import com.bitchat.android.model.ReadReceipt +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +@OptIn(ExperimentalCoroutinesApi::class) +class NostrTransportAdmissionTest { + @Test + fun `cancelled transport scope completes admission exactly once as retryable`() { + val job = Job().apply { cancel() } + val transport = NostrTransport( + context = RuntimeEnvironment.getApplication(), + transportScope = CoroutineScope(Dispatchers.Unconfined + job) + ) + val admissions = mutableListOf() + + transport.sendPrivateMessage( + content = "hello", + to = "aa".repeat(32), + recipientNickname = "peer", + messageID = "cancelled", + completion = admissions::add + ) + + assertEquals(listOf(NostrSendAdmission.RETRYABLE), admissions) + } + + @Test + fun `already expired payload is terminal before identity or relay work`() { + val transport = NostrTransport( + context = RuntimeEnvironment.getApplication(), + transportScope = CoroutineScope(Dispatchers.Unconfined + Job()) + ) + val admissions = mutableListOf() + + transport.sendPrivateMessage( + content = "expired", + to = "aa".repeat(32), + recipientNickname = "peer", + messageID = "expired", + expiresAtSeconds = 0uL, + completion = admissions::add + ) + + assertEquals(listOf(NostrSendAdmission.TERMINAL_FAILED), admissions) + } + + @Test + fun `invocation before relay reset cannot enqueue when coroutine resumes after reset`() { + val dispatcher = StandardTestDispatcher() + val scope = TestScope(dispatcher) + val relayManager = NostrRelayManager(scope) + val setupReset = relayManager.discardForAccountReset() + relayManager.completeAccountReset(setupReset) + val oldGeneration = relayManager.accountGenerationForTesting() + val transport = NostrTransport( + context = RuntimeEnvironment.getApplication(), + transportScope = scope, + relayManager = relayManager + ) + + transport.sendDeliveryAckGeohash( + messageID = "old-account-message", + toRecipientHex = "22".repeat(32), + fromIdentity = NostrIdentity.fromPrivateKey("11".repeat(32)) + ) + val resetToken = relayManager.discardForAccountReset() + scope.advanceUntilIdle() + + assertEquals(oldGeneration + 1, relayManager.accountGenerationForTesting()) + assertEquals(0, relayManager.queuedEventCountForTesting()) + assertEquals(0, relayManager.pendingGiftWrapCountForTesting()) + relayManager.completeAccountReset(resetToken) + } + + @Test + fun `account reset clears throttled reads and invalidates their delayed work`() { + val dispatcher = StandardTestDispatcher() + val scope = TestScope(dispatcher) + val relayManager = NostrRelayManager(scope) + val setupReset = relayManager.discardForAccountReset() + relayManager.completeAccountReset(setupReset) + val transport = NostrTransport( + context = RuntimeEnvironment.getApplication(), + transportScope = scope, + relayManager = relayManager + ) + + transport.sendReadReceipt(ReadReceipt("first"), "aa".repeat(32)) + transport.sendReadReceipt(ReadReceipt("second"), "aa".repeat(32)) + assertEquals(1, transport.activeReadCountForTesting()) + assertEquals(1, transport.queuedReadCountForTesting()) + + transport.discardForAccountReset() + val resetToken = relayManager.discardForAccountReset() + scope.advanceUntilIdle() + + assertEquals(0, transport.activeReadCountForTesting()) + assertEquals(0, transport.queuedReadCountForTesting()) + assertEquals(0, relayManager.queuedEventCountForTesting()) + relayManager.completeAccountReset(resetToken) + } + + @Test + fun `stale transport reset cannot reopen admission`() { + val transport = NostrTransport( + context = RuntimeEnvironment.getApplication(), + transportScope = CoroutineScope(Dispatchers.Unconfined + Job()) + ) + val firstReset = transport.discardForAccountReset() + val secondReset = transport.discardForAccountReset() + val admissions = mutableListOf() + + assertEquals(false, transport.completeAccountReset(firstReset)) + transport.sendPrivateMessage( + content = "blocked", + to = "aa".repeat(32), + recipientNickname = "peer", + messageID = "stale-reset", + completion = admissions::add + ) + assertEquals(listOf(NostrSendAdmission.RETRYABLE), admissions) + + assertEquals(true, transport.completeAccountReset(secondReset)) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/service/ShutdownGateTest.kt b/app/src/test/kotlin/com/bitchat/android/service/ShutdownGateTest.kt new file mode 100644 index 00000000..c79a17a0 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/service/ShutdownGateTest.kt @@ -0,0 +1,24 @@ +package com.bitchat.android.service + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ShutdownGateTest { + @Test + fun `shutdown is uncommitted initially`() { + val gate = ShutdownGate() + + assertFalse(gate.isCommitted()) + } + + @Test + fun `committed shutdown is irreversible and idempotent`() { + val gate = ShutdownGate() + + assertTrue(gate.commit()) + assertFalse(gate.commit()) + + assertTrue(gate.isCommitted()) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt index 893abe0e..93c5b4f1 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt @@ -248,6 +248,42 @@ class AppStateStoreTest { assertTrue(status is DeliveryStatus.Read) } + @Test + fun `local terminal failure replaces Sending but not admitted or delivered states`() { + val statuses = listOf( + DeliveryStatus.Sending, + DeliveryStatus.Sent, + DeliveryStatus.Delivered("peer-a", Date(2)), + DeliveryStatus.Read("peer-a", Date(3)) + ) + statuses.forEachIndexed { index, status -> + AppStateStore.addPrivateMessage( + "peer-a", + BitchatMessage( + id = "status-$index", + sender = "bob", + content = "hello", + timestamp = Date(1), + isPrivate = true, + deliveryStatus = status + ) + ) + AppStateStore.updatePrivateMessageStatus( + "status-$index", + DeliveryStatus.Failed("local terminal failure") + ) + } + + val resulting = AppStateStore.privateMessages.value + .values + .flatten() + .associate { it.id to it.deliveryStatus } + assertTrue(resulting["status-0"] is DeliveryStatus.Failed) + assertTrue(resulting["status-1"] is DeliveryStatus.Sent) + assertTrue(resulting["status-2"] is DeliveryStatus.Delivered) + assertTrue(resulting["status-3"] is DeliveryStatus.Read) + } + @Test fun `in flight message can become failed without overwriting confirmed delivery`() { val sending = BitchatMessage( diff --git a/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt b/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt index 48cbe2da..624ac75a 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt @@ -5,6 +5,8 @@ import android.os.Build import com.bitchat.android.identity.SecureIdentityStateManager import com.bitchat.android.mesh.MeshService import com.bitchat.android.mesh.PeerInfo +import com.bitchat.android.nostr.NostrSendAdmission +import com.bitchat.android.nostr.NostrTransport import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -35,9 +37,22 @@ class MessageRouterTest { private val noiseKey = ByteArray(32) { 0x0B } private lateinit var mesh: MeshService + private lateinit var nostr: NostrTransport private lateinit var router: MessageRouter + private lateinit var identityManager: SecureIdentityStateManager private var fakeTime = 1_000_000L private val expired = mutableListOf() + private val admitted = mutableListOf() + private val failed = mutableListOf() + private val pendingNostrSends = mutableListOf() + private var nostrAvailable = false + + private data class PendingNostrSend( + val content: String, + val peerID: String, + val messageID: String, + val completion: (NostrSendAdmission) -> Unit + ) @Before fun setup() { @@ -46,10 +61,11 @@ class MessageRouterTest { "message-router-test-${UUID.randomUUID()}", Context.MODE_PRIVATE ) - val identityManager = SecureIdentityStateManager(prefs, testOnly = true) + identityManager = SecureIdentityStateManager(prefs, testOnly = true) ContactDirectory.identityManagerProvider = { identityManager } mesh = mock() + nostr = mock() whenever(mesh.myPeerID).thenReturn(myPeerID) whenever(mesh.getPeerNicknames()).thenReturn(mapOf(peerID to "peer")) @@ -59,10 +75,34 @@ class MessageRouterTest { MessageRouter.resetForTesting() fakeTime = 1_000_000L expired.clear() + admitted.clear() + failed.clear() + pendingNostrSends.clear() + nostrAvailable = false - router = MessageRouter.getInstance(context, mesh) + router = MessageRouter( + context = context, + mesh = mesh, + nostr = nostr, + privateNostrSender = NostrPrivateMessageSender { + content, + target, + _, + messageID, + completion -> + pendingNostrSends += PendingNostrSend( + content = content, + peerID = target, + messageID = messageID, + completion = completion + ) + }, + canSendViaNostrOverride = { nostrAvailable } + ) router.clock = { fakeTime } router.onMessageExpired = { expired.add(it) } + router.onMessageAdmitted = { admitted.add(it) } + router.onMessageFailed = { messageID, _ -> failed.add(messageID) } } @After @@ -174,6 +214,274 @@ class MessageRouterTest { verify(mesh, never()).initiateNoiseHandshake(any()) } + @Test + fun `retryable Nostr refusal remains queued until exactly one durable admission`() { + peerOffline() + nostrAvailable = true + + assertEquals( + MessageRouter.RouteResult.NOSTR_PENDING, + router.sendPrivate("hello", peerID, "peer", "msg-ndr") + ) + assertEquals(listOf("msg-ndr"), pendingNostrSends.map { it.messageID }) + assertTrue(admitted.isEmpty()) + + pendingNostrSends.single().completion(NostrSendAdmission.RETRYABLE) + router.tickOutbox() + assertEquals(listOf("msg-ndr", "msg-ndr"), pendingNostrSends.map { it.messageID }) + assertTrue(admitted.isEmpty()) + + val admittedAttempt = pendingNostrSends.last() + admittedAttempt.completion(NostrSendAdmission.ADMITTED) + admittedAttempt.completion(NostrSendAdmission.ADMITTED) + router.tickOutbox() + + assertEquals(listOf("msg-ndr"), admitted) + assertEquals(2, pendingNostrSends.size) + assertTrue(failed.isEmpty()) + verify(mesh, never()).sendPrivateMessage(any(), any(), any(), anyOrNull()) + } + + @Test + fun `one Nostr message per conversation is in flight and queued order is preserved`() { + peerOffline() + nostrAvailable = true + + router.sendPrivate("first", peerID, "peer", "msg-1") + router.sendPrivate("second", peerID, "peer", "msg-2") + router.tickOutbox() + + assertEquals(listOf("msg-1"), pendingNostrSends.map { it.messageID }) + + pendingNostrSends[0].completion(NostrSendAdmission.ADMITTED) + assertEquals(listOf("msg-1", "msg-2"), pendingNostrSends.map { it.messageID }) + + pendingNostrSends[1].completion(NostrSendAdmission.ADMITTED) + assertEquals(listOf("msg-1", "msg-2"), admitted) + } + + @Test + fun `mesh and duplicate session callbacks cannot race an in-flight Nostr copy`() { + peerOffline() + nostrAvailable = true + router.sendPrivate("hello", peerID, "peer", "msg-race") + + peerReady() + router.onSessionEstablished(peerID) + router.onSessionEstablished(peerID) + + assertEquals(1, pendingNostrSends.size) + verify(mesh, never()).sendPrivateMessage(any(), any(), any(), anyOrNull()) + + pendingNostrSends.single().completion(NostrSendAdmission.RETRYABLE) + router.onSessionEstablished(peerID) + router.onSessionEstablished(peerID) + + verify(mesh, times(1)).sendPrivateMessage("hello", peerID, "peer", "msg-race") + assertEquals(1, pendingNostrSends.size) + } + + @Test + fun `terminal Nostr failure removes the entry and reports failure once`() { + peerOffline() + nostrAvailable = true + router.sendPrivate("invalid", peerID, "peer", "msg-failed") + + val attempt = pendingNostrSends.single() + attempt.completion(NostrSendAdmission.TERMINAL_FAILED) + attempt.completion(NostrSendAdmission.TERMINAL_FAILED) + router.tickOutbox() + + assertEquals(listOf("msg-failed"), failed) + assertTrue(admitted.isEmpty()) + assertEquals(1, pendingNostrSends.size) + } + + @Test + fun `duplicate session established callbacks flush a queued mesh message once`() { + peerOffline() + router.sendPrivate("hello", peerID, "peer", "msg-once") + + peerReady() + router.onSessionEstablished(peerID) + router.onSessionEstablished(peerID) + + verify(mesh, times(1)).sendPrivateMessage("hello", peerID, "peer", "msg-once") + } + + @Test + fun `late result from an earlier Nostr attempt cannot complete its replacement`() { + peerOffline() + nostrAvailable = true + router.sendPrivate("hello", peerID, "peer", "msg-stale") + + val first = pendingNostrSends.single() + first.completion(NostrSendAdmission.RETRYABLE) + router.tickOutbox() + val second = pendingNostrSends.last() + + first.completion(NostrSendAdmission.ADMITTED) + assertTrue(admitted.isEmpty()) + assertEquals(1, router.queuedMessageCount) + assertEquals(1, router.inFlightNostrAttemptCount) + + second.completion(NostrSendAdmission.ADMITTED) + assertEquals(listOf("msg-stale"), admitted) + assertEquals(0, router.queuedMessageCount) + assertEquals(0, router.inFlightNostrAttemptCount) + } + + @Test + fun `alias convergence migrates in-flight ownership without duplicate Nostr send`() { + whenever(mesh.getPeerInfo(peerID)).thenReturn(null) + nostrAvailable = true + router.sendPrivate("hello", peerID, "peer", "msg-alias") + assertEquals(1, pendingNostrSends.size) + + identityManager.cachePeerNoiseKey( + peerID, + ContactIdentityResolver.noiseKeyHex(noiseKey) + ) + router.flushOutboxFor(peerID) + router.tickOutbox() + + assertEquals(1, pendingNostrSends.size) + pendingNostrSends.single().completion(NostrSendAdmission.ADMITTED) + assertEquals(listOf("msg-alias"), admitted) + assertEquals(0, router.queuedMessageCount) + assertEquals(0, router.inFlightNostrAttemptCount) + } + + @Test + fun `synchronous Nostr admission is safe and leaves no in-flight token`() { + peerOffline() + val synchronous = MessageRouter( + context = RuntimeEnvironment.getApplication(), + mesh = mesh, + nostr = nostr, + privateNostrSender = NostrPrivateMessageSender { + _, + _, + _, + _, + completion -> + completion(NostrSendAdmission.ADMITTED) + }, + canSendViaNostrOverride = { true } + ) + val synchronousAdmissions = mutableListOf() + synchronous.onMessageAdmitted = synchronousAdmissions::add + + synchronous.sendPrivate("first", peerID, "peer", "sync-1") + synchronous.sendPrivate("second", peerID, "peer", "sync-2") + + assertEquals(listOf("sync-1", "sync-2"), synchronousAdmissions) + assertEquals(0, synchronous.queuedMessageCount) + assertEquals(0, synchronous.inFlightNostrAttemptCount) + } + + @Test + fun `TTL and cap never evict an in-flight Nostr message`() { + peerOffline() + nostrAvailable = true + router.sendPrivate("first", peerID, "peer", "msg-0") + repeat(100) { index -> + router.sendPrivate("queued-$index", peerID, "peer", "msg-${index + 1}") + } + + assertEquals(listOf("msg-1"), expired) + assertEquals("msg-0", pendingNostrSends.single().messageID) + assertEquals(100, router.queuedMessageCount) + + fakeTime += 86_400_001L + router.tickOutbox() + + assertFalse("msg-0" in expired) + assertEquals(1, router.queuedMessageCount) + assertEquals(1, router.inFlightNostrAttemptCount) + + pendingNostrSends.single().completion(NostrSendAdmission.ADMITTED) + assertEquals(listOf("msg-0"), admitted) + assertEquals(0, router.queuedMessageCount) + } + + @Test + fun `account reset discards plaintext and ignores every late callback`() { + peerOffline() + nostrAvailable = true + router.sendPrivate("old identity", peerID, "peer", "msg-old-account") + val oldAttempt = pendingNostrSends.single() + + router.discardForAccountReset() + + assertEquals(0, router.queuedMessageCount) + assertEquals(0, router.inFlightNostrAttemptCount) + assertEquals( + MessageRouter.RouteResult.DROPPED, + router.sendPrivate( + "must not survive reset", + peerID, + "peer", + "msg-reset-window" + ) + ) + assertEquals(0, router.queuedMessageCount) + oldAttempt.completion(NostrSendAdmission.ADMITTED) + oldAttempt.completion(NostrSendAdmission.RETRYABLE) + oldAttempt.completion(NostrSendAdmission.TERMINAL_FAILED) + + assertTrue(admitted.isEmpty()) + assertEquals(listOf("msg-reset-window"), failed) + router.tickOutbox() + assertEquals(1, pendingNostrSends.size) + } + + @Test + fun `stale router reset cannot reopen a newer reset`() { + peerOffline() + val firstReset = router.discardForAccountReset() + val secondReset = router.discardForAccountReset() + + assertFalse(router.completeAccountReset(firstReset)) + assertEquals( + MessageRouter.RouteResult.DROPPED, + router.sendPrivate( + "blocked", + peerID, + "peer", + "msg-stale-reset" + ) + ) + + assertTrue(router.completeAccountReset(secondReset)) + assertEquals( + MessageRouter.RouteResult.QUEUED, + router.sendPrivate( + "fresh", + peerID, + "peer", + "msg-current-reset" + ) + ) + } + + @Test + fun `normal scheduler stop preserves queued plaintext and in-flight ownership`() { + peerOffline() + nostrAvailable = true + router.sendPrivate("keep across service stop", peerID, "peer", "msg-pause") + val attempt = pendingNostrSends.single() + + router.stopOutboxScheduler() + + assertEquals(1, router.queuedMessageCount) + assertEquals(1, router.inFlightNostrAttemptCount) + attempt.completion(NostrSendAdmission.ADMITTED) + assertEquals(listOf("msg-pause"), admitted) + assertEquals(0, router.queuedMessageCount) + assertEquals(0, router.inFlightNostrAttemptCount) + } + @Test fun `scheduler stops with the mesh service and restarts on rebind`() { MessageRouter.disableSchedulerForTesting = false diff --git a/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt index 51204f30..3f3a7e6e 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt @@ -445,6 +445,30 @@ class MediaSendingManagerMigrationTest { } } + @Test + fun `public transfer failure replaces the local sending seed`() { + manager.sendImageNote(null, null, file.absolutePath) + + val fileEcho = state.getMessagesValue() + .single { it.type == BitchatMessageType.Image } + assertTrue(fileEcho.deliveryStatus is DeliveryStatus.Sending) + + manager.updateTransferProgress("failed-transfer", fileEcho.id) + manager.handleTransferProgressEvent( + com.bitchat.android.mesh.TransferProgressEvent( + transferId = "failed-transfer", + sent = 0, + total = 100, + completed = true, + failed = true + ) + ) + + val failedEcho = state.getMessagesValue() + .single { it.id == fileEcho.id } + assertTrue(failedEcho.deliveryStatus is DeliveryStatus.Failed) + } + @Test fun `cancelled consent cannot later send or echo`() { whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) diff --git a/app/src/test/kotlin/com/bitchat/android/ui/MessageManagerDeliveryStatusTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/MessageManagerDeliveryStatusTest.kt new file mode 100644 index 00000000..4c91a3c8 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/MessageManagerDeliveryStatusTest.kt @@ -0,0 +1,70 @@ +package com.bitchat.android.ui + +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.DeliveryStatus +import com.bitchat.android.services.AppStateStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.Date + +class MessageManagerDeliveryStatusTest { + private lateinit var state: ChatState + private lateinit var manager: MessageManager + + @Before + fun setUp() { + AppStateStore.clear() + state = ChatState(CoroutineScope(Dispatchers.Unconfined + SupervisorJob())) + manager = MessageManager(state) + } + + @After + fun tearDown() { + AppStateStore.clear() + } + + @Test + fun `local failure replaces Sending but never downgrades admitted delivery evidence`() { + val statuses = listOf( + DeliveryStatus.Sending, + DeliveryStatus.Sent, + DeliveryStatus.Delivered("peer", Date(2)), + DeliveryStatus.Read("peer", Date(3)) + ) + state.setPrivateChats( + mapOf( + "peer" to statuses.mapIndexed { index, status -> + BitchatMessage( + id = "message-$index", + sender = "me", + content = "hello", + timestamp = Date(1), + isPrivate = true, + deliveryStatus = status + ) + } + ) + ) + + statuses.indices.forEach { index -> + manager.updateMessageDeliveryStatus( + "message-$index", + DeliveryStatus.Failed("local terminal failure") + ) + } + + val resulting = state.getPrivateChatsValue() + .values + .flatten() + .associate { it.id to it.deliveryStatus } + assertTrue(resulting["message-0"] is DeliveryStatus.Failed) + assertTrue(resulting["message-1"] is DeliveryStatus.Sent) + assertTrue(resulting["message-2"] is DeliveryStatus.Delivered) + assertTrue(resulting["message-3"] is DeliveryStatus.Read) + } +} diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index a62cbff6..d26a6ed9 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -3880,6 +3880,14 @@ + + + + + + + + diff --git a/tools/reproducible-builds/build-in-container.sh b/tools/reproducible-builds/build-in-container.sh index 76bcc15d..46467d7c 100755 --- a/tools/reproducible-builds/build-in-container.sh +++ b/tools/reproducible-builds/build-in-container.sh @@ -43,6 +43,35 @@ trap cleanup EXIT # nested bind mounts, which are not portable across Docker runtimes. git -C "$PROJECT_ROOT" archive --format=tar "$source_commit" | tar -xf - -C "$staging_root" + +# NDR native libraries are deliberately source-built and ignored by Git. Admit only the four +# expected ABI outputs after the tracked Kotlin binding and pinned submodule have been verified. +NDR_BINDING="app/src/main/java/uniffi/ndr_ffi/ndr_ffi.kt" +NDR_SUBMODULE="vendor/nostr-double-ratchet" +NDR_EXPECTED_REVISION="$(tr -d '[:space:]' < "$PROJECT_ROOT/app/src/main/ndr-ffi/SOURCE_REVISION")" +NDR_ACTUAL_REVISION="$(git -C "$PROJECT_ROOT/$NDR_SUBMODULE" rev-parse HEAD)" +if [ "$NDR_ACTUAL_REVISION" != "$NDR_EXPECTED_REVISION" ]; then + echo "error: NDR source revision does not match the pinned revision" >&2 + exit 1 +fi +if [ -n "$(git -C "$PROJECT_ROOT/$NDR_SUBMODULE" status --porcelain --untracked-files=all)" ]; then + echo "error: NDR source has local changes" >&2 + exit 1 +fi +if ! git -C "$PROJECT_ROOT" diff --quiet -- "$NDR_BINDING"; then + echo "error: generated NDR Kotlin binding is not current" >&2 + exit 1 +fi +for abi in arm64-v8a armeabi-v7a x86_64 x86; do + ndr_library="app/src/main/jniLibs/$abi/libndr_ffi.so" + if [ ! -f "$PROJECT_ROOT/$ndr_library" ]; then + echo "error: missing source-built NDR library for $abi" >&2 + exit 1 + fi + mkdir -p "$staging_root/app/src/main/jniLibs/$abi" + cp "$PROJECT_ROOT/$ndr_library" "$staging_root/$ndr_library" +done + cp "$CONTAINER_LOCAL_PROPERTIES" "$staging_root/local.properties" gradle_home="$PROJECT_ROOT/.reproducible-build/$GRADLE_HOME_NAME" diff --git a/tools/reproducible-builds/build-release.sh b/tools/reproducible-builds/build-release.sh index 197dffc9..3d4666b7 100755 --- a/tools/reproducible-builds/build-release.sh +++ b/tools/reproducible-builds/build-release.sh @@ -87,6 +87,7 @@ if ! [[ "$source_commit" =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]]; then exit 1 fi native_manifest_sha256="$(sha256sum "$PROJECT_ROOT/tools/arti-build/SHA256SUMS" | awk '{print $1}')" +ndr_source_revision="$(tr -d '[:space:]' < "$PROJECT_ROOT/app/src/main/ndr-ffi/SOURCE_REVISION")" cat > "$OUTPUT_DIR/BUILDINFO.json" < "$OUTPUT_DIR/BUILDINFO.json" <