mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
Merge 0020d61e0d23313dbf456b36437553429feee88c into 094657efa0aabbb6f71c9050149d1d01aee96400
This commit is contained in:
commit
4ff858bcb8
54
.github/workflows/android-build.yml
vendored
54
.github/workflows/android-build.yml
vendored
@ -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 }}"
|
||||
|
||||
|
||||
18
.github/workflows/release.yml
vendored
18
.github/workflows/release.yml
vendored
@ -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
3
.gitignore
vendored
@ -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
4
.gitmodules
vendored
Normal 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
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
7
app/proguard-rules.pro
vendored
7
app/proguard-rules.pro
vendored
@ -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.
|
||||
|
||||
@ -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}$")
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -15,6 +15,8 @@ import java.util.*
|
||||
data class FavoriteRelationship(
|
||||
val peerNoisePublicKey: ByteArray, // Noise static public key (32 bytes)
|
||||
val peerNostrPublicKey: String?, // npub bech32 string
|
||||
val peerNdrSessionPubkeyHex: String? = null,
|
||||
val ndrRequired: Boolean = false,
|
||||
val peerNickname: String,
|
||||
val isFavorite: Boolean, // We favorited them
|
||||
val theyFavoritedUs: Boolean, // They favorited us
|
||||
@ -31,6 +33,8 @@ data class FavoriteRelationship(
|
||||
|
||||
if (!peerNoisePublicKey.contentEquals(other.peerNoisePublicKey)) return false
|
||||
if (peerNostrPublicKey != other.peerNostrPublicKey) return false
|
||||
if (peerNdrSessionPubkeyHex != other.peerNdrSessionPubkeyHex) return false
|
||||
if (ndrRequired != other.ndrRequired) return false
|
||||
if (peerNickname != other.peerNickname) return false
|
||||
if (isFavorite != other.isFavorite) return false
|
||||
if (theyFavoritedUs != other.theyFavoritedUs) return false
|
||||
@ -41,6 +45,8 @@ data class FavoriteRelationship(
|
||||
override fun hashCode(): Int {
|
||||
var result = peerNoisePublicKey.contentHashCode()
|
||||
result = 31 * result + (peerNostrPublicKey?.hashCode() ?: 0)
|
||||
result = 31 * result + (peerNdrSessionPubkeyHex?.hashCode() ?: 0)
|
||||
result = 31 * result + ndrRequired.hashCode()
|
||||
result = 31 * result + peerNickname.hashCode()
|
||||
result = 31 * result + isFavorite.hashCode()
|
||||
result = 31 * result + theyFavoritedUs.hashCode()
|
||||
@ -76,12 +82,21 @@ interface FavoritesChangeListener {
|
||||
* Manages favorites with Noise↔Nostr mapping
|
||||
* Singleton pattern matching iOS implementation.
|
||||
*/
|
||||
class FavoritesPersistenceService private constructor(private val context: Context) {
|
||||
class FavoritesPersistenceService private constructor(
|
||||
private val stateManager: SecureIdentityStateManager
|
||||
) {
|
||||
internal constructor(
|
||||
stateManager: SecureIdentityStateManager,
|
||||
testOnly: Boolean
|
||||
) : this(stateManager) {
|
||||
require(testOnly) { "Injected favorites storage is test-only" }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FavoritesPersistenceService"
|
||||
private const val FAVORITES_KEY = "favorite_relationships" // noiseHex -> relationship
|
||||
private const val PEERID_INDEX_KEY = "favorite_peerid_index" // peerID(16-hex) -> npub
|
||||
internal const val FAVORITES_KEY = "favorite_relationships"
|
||||
internal const val PEERID_INDEX_KEY = "favorite_peerid_index"
|
||||
internal const val NDR_REBIND_JOURNAL_KEY = "favorite_ndr_rebind_v1"
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: FavoritesPersistenceService? = null
|
||||
@ -93,42 +108,55 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
||||
if (INSTANCE == null) {
|
||||
synchronized(this) {
|
||||
if (INSTANCE == null) {
|
||||
INSTANCE = FavoritesPersistenceService(context.applicationContext)
|
||||
INSTANCE = FavoritesPersistenceService(
|
||||
SecureIdentityStateManager(context.applicationContext)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val stateManager = SecureIdentityStateManager(context)
|
||||
private val gson = Gson()
|
||||
private val favorites = mutableMapOf<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()
|
||||
)
|
||||
}
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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}")
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
1376
app/src/main/java/com/bitchat/android/nostr/NdrNostrService.kt
Normal file
1376
app/src/main/java/com/bitchat/android/nostr/NdrNostrService.kt
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
155
app/src/main/java/com/bitchat/android/nostr/NdrTypes.kt
Normal file
155
app/src/main/java/com/bitchat/android/nostr/NdrTypes.kt
Normal 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
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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 =
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
2663
app/src/main/java/uniffi/ndr_ffi/ndr_ffi.kt
Normal file
2663
app/src/main/java/uniffi/ndr_ffi/ndr_ffi.kt
Normal file
File diff suppressed because it is too large
Load Diff
1
app/src/main/ndr-ffi/SOURCE_REVISION
Normal file
1
app/src/main/ndr-ffi/SOURCE_REVISION
Normal file
@ -0,0 +1 @@
|
||||
0fe8caf2d4e24e2030ffae195597a2764613a659
|
||||
30
app/src/main/ndr-ffi/VENDORED_FROM.md
Normal file
30
app/src/main/ndr-ffi/VENDORED_FROM.md
Normal 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.
|
||||
153
app/src/main/ndr-ffi/build-android.sh
Executable file
153
app/src/main/ndr-ffi/build-android.sh
Executable 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}"
|
||||
2
app/src/main/ndr-ffi/uniffi.toml
Normal file
2
app/src/main/ndr-ffi/uniffi.toml
Normal file
@ -0,0 +1,2 @@
|
||||
[bindings.kotlin]
|
||||
android = true
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)}"}"""
|
||||
)
|
||||
}
|
||||
1600
app/src/test/kotlin/com/bitchat/android/nostr/NdrNostrServiceTest.kt
Normal file
1600
app/src/test/kotlin/com/bitchat/android/nostr/NdrNostrServiceTest.kt
Normal file
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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/"
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
)
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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"/>
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user