Merge 0020d61e0d23313dbf456b36437553429feee88c into 094657efa0aabbb6f71c9050149d1d01aee96400

This commit is contained in:
Martti Malmi 2026-08-07 00:42:03 +00:00 committed by GitHub
commit 4ff858bcb8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
105 changed files with 16162 additions and 979 deletions

View File

@ -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 }}"

View File

@ -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 }}"

3
.gitignore vendored
View File

@ -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

4
.gitmodules vendored Normal file
View File

@ -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

View File

@ -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)
}

View File

@ -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

View File

@ -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.

View File

@ -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}$")
}
}

View File

@ -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.

View File

@ -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)

View File

@ -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 NoiseNostr 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<String, FavoriteRelationship>() // noiseHex -> relationship
private val peerIdIndex = mutableMapOf<String, String>() // peerID (lowercase 16-hex) -> npub
private val listeners = mutableListOf<FavoritesChangeListener>()
private var ndrPeerRetirementGuard: ((oldPeerPubkeyHex: String) -> Boolean)? = null
private val ndrRebindsInProgress = mutableSetOf<String>()
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<FavoriteRelationship> = favorites.values.filter { it.isMutual }
fun getOurFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isFavorite }
fun getAllRelationships(): List<FavoriteRelationship> = favorites.values.toList()
@Synchronized
fun getMutualFavorites(): List<FavoriteRelationship> =
favoritesForReadLocked().filter { it.isMutual }
@Synchronized
fun getOurFavorites(): List<FavoriteRelationship> =
favoritesForReadLocked().filter { it.isFavorite }
@Synchronized
fun getAllRelationships(): List<FavoriteRelationship> = 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<FavoriteRelationship> =
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<String, FavoriteRelationship>
): Boolean = runCatching {
stateManager.commitSecureValuesSynchronously(
values = mapOf(FAVORITES_KEY to favoritesJson(snapshot))
)
}.getOrDefault(false)
private fun commitPeerIdIndexSnapshotLocked(
snapshot: Map<String, String>
): Boolean = runCatching {
stateManager.commitSecureValuesSynchronously(
values = mapOf(PEERID_INDEX_KEY to gson.toJson(snapshot))
)
}.getOrDefault(false)
private fun favoritesJson(
snapshot: Map<String, FavoriteRelationship>
): 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<String, FavoriteRelationshipData> = 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<String> =
listOfNotNull(targetNostrPubkeyHex, targetNdrSessionPubkeyHex).toSet()
fun quarantinedIdentityPubkeys(): Set<String> =
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()
)
}

View File

@ -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.
*/

View File

@ -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<String, String> = emptyMap(),
removals: Set<String> = 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

View File

@ -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

View File

@ -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
*/

View File

@ -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,

View File

@ -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()

View File

@ -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,

View File

@ -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?

View File

@ -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,

View File

@ -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.

View File

@ -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
)
}

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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

View File

@ -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
}
}

View File

@ -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
}
}
}

View File

@ -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<String>()
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}")
}

View File

@ -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

View File

@ -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()
}
}

View File

@ -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
}
}
}

View File

@ -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<String>,
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)
}
}

View File

@ -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")
}

View File

@ -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<Long> = 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<String, ActiveRetry>()
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<String>) {
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)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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)
}
}
}

View File

@ -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<NdrOutOfBandPayload>,
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<String>
) {
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<NdrPubSubEvent>
fun ackActions(actionIds: List<String>)
fun sessionInfo(peerPubkeyHex: String): NdrPairwiseSessionInfo?
fun knownPeerPubkeys(): List<String>
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
}

View File

@ -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()
}
}

View File

@ -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<String, GeohashDmSubscription>()
private val pendingGeohashDmSubscriptions = mutableSetOf<String>()
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<String, GeohashDmSubscription> {
val required = linkedMapOf<String, GeohashDmSubscription>()
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

View File

@ -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)

View File

@ -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<String>()
private val seen = HashSet<String>()
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
}
}

View File

@ -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<String>()
// 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

View File

@ -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
}
}
}

View File

@ -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<String>,
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<String>,
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<Delivery> = synchronized(lock) {
fun pendingForRelay(
relayUrl: String,
accountGeneration: Long
): List<Delivery> = 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
}
}
}

View File

@ -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 =

View File

@ -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<QueuedRead>()
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<NostrEvent>,
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

View File

@ -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()
}
}
}

View File

@ -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
}

View File

@ -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 }
}

View File

@ -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

View File

@ -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(

View File

@ -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<String, ConversationRetry>()
// 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<String, InFlightNostrAttempt>()
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<QueuedMessage>? {
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<QueuedMessage>
): Map.Entry<String, InFlightNostrAttempt>? {
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<QueuedMessage>
) {
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<QueuedMessage>
) {
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

View File

@ -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<String>(MAX_IDS)
private val locallyRead = LinkedHashSet<String>(MAX_IDS)
private val readReceiptsSent = LinkedHashSet<String>(MAX_IDS)
private val ndrProcessed = LinkedHashSet<String>(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<String>) {
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<String> = emptyList(),
val delivered: List<String>? = emptyList(),
// Keep the existing JSON field name for backward-compatible secure-store migration.
@SerializedName("read")
val locallyRead: List<String> = emptyList(),
val locallyRead: List<String>? = emptyList(),
@SerializedName("read_receipts_sent")
val readReceiptsSent: List<String>? = null
val readReceiptsSent: List<String>? = null,
val ndrProcessed: List<String>? = emptyList()
)
}

View File

@ -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<String, Long>()
private val ndrNoiseHandshakeAttemptMs = ConcurrentHashMap<String, Long>()
private val ndrAvailablePeers = ConcurrentHashMap.newKeySet<String>()
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<String>) {
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<String>()
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
}
}

View File

@ -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
)
}

View File

@ -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) {

View File

@ -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) {

View File

@ -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,

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
0fe8caf2d4e24e2030ffae195597a2764613a659

View File

@ -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.

View File

@ -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}"

View File

@ -0,0 +1,2 @@
[bindings.kotlin]
android = true

View File

@ -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<Context>()
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<String>()
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<String>()
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<String>()
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<String, FavoriteRelationship>
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<String>()
override fun putString(
key: String?,
value: String?
): SharedPreferences.Editor {
delegateEditor.putString(key, value)
return this
}
override fun putStringSet(
key: String?,
values: MutableSet<String>?
): 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
}
}
}
}

View File

@ -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()
}
}
}

View File

@ -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<Int>()
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<Int>()
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
)
}

View File

@ -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
}
}
}

View File

@ -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)
}
}
}

View File

@ -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))
}
}

View File

@ -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()
}
}
}

View File

@ -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())
}
}

View File

@ -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))
}
}

View File

@ -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<List<String>> = 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())
}
}

View File

@ -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
)
)
}
}

View File

@ -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<String>()
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<String>()
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)
}
}

View File

@ -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<Long>()
val admitted = mutableListOf<NdrInviteRetryRequest>()
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<Any>()
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<NdrInviteRetryRequest>()
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)}"}"""
)
}

File diff suppressed because it is too large Load Diff

View File

@ -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)
}
)
)
}
}

View File

@ -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
}
}
}

View File

@ -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<String>()
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<String>()
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<NdrPubSubEvent> =
if (SUBSCRIPTION_ACTION_ID in ackedActionIds) emptyList() else listOf(action)
override fun ackActions(actionIds: List<String>) {
ackedActionIds += actionIds
}
override fun sessionInfo(peerPubkeyHex: String): NdrPairwiseSessionInfo? = null
override fun knownPeerPubkeys(): List<String> = 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 <T> unsupported(): T = error("not used")
}
companion object {
private const val SUBSCRIPTION_ACTION_ID = "subscription-action"
}
}

View File

@ -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<Application>()
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
)
}

View File

@ -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<NoiseSessionDelegate>()
)
val seenStore = mock<SeenMessageStore>()
val policyChecked = CompletableDeferred<String>()
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<NoiseSessionDelegate>()
)
val seenStore = mock<SeenMessageStore>()
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) {

View File

@ -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 }
)
}

View File

@ -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<String>()
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<String>) {
val field = NostrRelayManager::class.java.getDeclaredField("relaysList")
field.isAccessible = true
val relays = field.get(manager) as MutableList<NostrRelayManager.Relay>
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<String, WebSocket>
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/"
}
}

View File

@ -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())
}

View File

@ -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<String, WebSocket>
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"
}
}

View File

@ -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<String, WebSocket>
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<String>()
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"
}
}

View File

@ -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"
)
}

View File

@ -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<NostrSendAdmission>()
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<NostrSendAdmission>()
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<NostrSendAdmission>()
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))
}
}

View File

@ -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())
}
}

View File

@ -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(

View File

@ -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<String>()
private val admitted = mutableListOf<String>()
private val failed = mutableListOf<String>()
private val pendingNostrSends = mutableListOf<PendingNostrSend>()
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<String>()
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

View File

@ -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)))

View File

@ -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)
}
}

View File

@ -3880,6 +3880,14 @@
<sha256 value="30f5789efa39ddbf96095aada3fc1260c4561faf2f714686717cb2dc5049475a" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="net.java.dev.jna" name="jna" version="5.13.0">
<artifact name="jna-5.13.0.aar">
<sha256 value="3a03daf5a7212201c3a9b44f734dfc9babb3addcb5cbfa639f9f535e887925c0" origin="Generated by Gradle"/>
</artifact>
<artifact name="jna-5.13.0.pom">
<sha256 value="f515c2578178f45247ecca7a9e1db109531b1c42f2424e253ceeb0f6b8d42374" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="net.java.dev.jna" name="jna" version="5.6.0">
<artifact name="jna-5.6.0.jar">
<sha256 value="5557e235a8aa2f9766d5dc609d67948f2a8832c2d796cea9ef1d6cbe0b3b7eaf" origin="Generated by Gradle"/>

View File

@ -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"

Some files were not shown because too many files have changed in this diff Show More