diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index a5a31ce6..9911c273 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -1,15 +1,17 @@ name: Android CI on: + workflow_dispatch: push: branches: [ "main", "develop" ] pull_request: branches: [ "main", "develop" ] jobs: - test: + verify: + name: Test & Lint runs-on: ubuntu-latest - + steps: - name: Checkout code uses: actions/checkout@v4 @@ -39,7 +41,10 @@ jobs: - name: Run unit tests run: ./gradlew testDebugUnitTest - - name: Upload Test Reports (xml+html) + - name: Run lint + run: ./gradlew lintDebug + + - name: Upload test reports if: always() uses: actions/upload-artifact@v4 with: @@ -48,47 +53,6 @@ jobs: **/build/test-results/ **/build/reports/tests/ - - name: Upload test results - uses: actions/upload-artifact@v4 - if: always() - with: - name: test-results - path: | - **/build/test-results/ - **/build/reports/tests/ - - lint: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' - - - name: Setup Gradle - uses: gradle/gradle-build-action@v3 - - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - - name: Cache Gradle packages - uses: actions/cache@v3 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: Run lint - run: ./gradlew lintDebug - - name: Upload lint results uses: actions/upload-artifact@v4 if: always() @@ -97,9 +61,13 @@ jobs: path: '**/build/reports/lint-results-*.html' build: + name: Build ${{ matrix.variant }} runs-on: ubuntu-latest - needs: [test, lint] - + needs: verify + strategy: + matrix: + variant: [Debug, Release] + steps: - name: Checkout code uses: actions/checkout@v4 @@ -126,20 +94,11 @@ jobs: restore-keys: | ${{ runner.os }}-gradle- - - name: Build debug APK - run: ./gradlew assembleDebug + - name: Build ${{ matrix.variant }} APK + run: ./gradlew assemble${{ matrix.variant }} - - name: Build release APK - run: ./gradlew assembleRelease - - - name: Upload debug APK + - name: Upload ${{ matrix.variant }} APK uses: actions/upload-artifact@v4 with: - name: debug-apk - path: app/build/outputs/apk/debug/*.apk - - - name: Upload release APK - uses: actions/upload-artifact@v4 - with: - name: release-apk - path: app/build/outputs/apk/release/*.apk + name: ${{ matrix.variant }}-apk + path: app/build/outputs/apk/**/*.apk diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b3b8b7b..f1e1841a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: with: distribution: temurin java-version: 17 - + - name: Setup Gradle uses: gradle/gradle-build-action@v2 with: @@ -38,32 +38,32 @@ jobs: - name: Grant execute permission for Gradlew run: chmod +x ./gradlew - - name: Build APK + - name: Build Release APKs (with architecture splits) run: ./gradlew assembleRelease --no-daemon --stacktrace - + - name: List APK files run: | echo "APK files built:" - find app/build/outputs/apk/release -name "*.apk" -type f + find app/build/outputs/apk/release -name "*.apk" -type f -exec ls -lh {} \; - - name: Rename APK + - name: Rename APKs for GitHub Release run: | - mv app/build/outputs/apk/release/app-release-unsigned.apk app/build/outputs/apk/release/bitchat.apk - + cd app/build/outputs/apk/release + [ -f "app-arm64-v8a-release-unsigned.apk" ] && mv app-arm64-v8a-release-unsigned.apk bitchat-android-arm64.apk + [ -f "app-x86_64-release-unsigned.apk" ] && mv app-x86_64-release-unsigned.apk bitchat-android-x86_64.apk + [ -f "app-universal-release-unsigned.apk" ] && mv app-universal-release-unsigned.apk bitchat-android-universal.apk + - name: DEBUG run: | set -x - pwd - ls -all - cd app/build/outputs/ ls -all - tree - - # Optional: Sign APK (requires secrets) - # - name: Sign APK + tree || ls -R + + # Optional: Sign APKs (uncomment and configure secrets when ready) + # - name: Sign APKs # uses: r0adkll/sign-android-release@v1 # with: # releaseDirectory: app/build/outputs/apk/release @@ -72,10 +72,10 @@ jobs: # keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} # keyPassword: ${{ secrets.KEY_PASSWORD }} - - name: Upload APK as artifact + - name: Upload APKs as artifacts uses: actions/upload-artifact@v4 with: - name: bitchat-release-apk-${{ github.ref_name }} + name: bitchat-android-release-${{ github.ref_name }} path: app/build/outputs/apk/release/*.apk retention-days: 30 if-no-files-found: error @@ -85,16 +85,23 @@ jobs: runs-on: ubuntu-latest steps: - - name: Download APK artifact + - name: Download release artifacts uses: actions/download-artifact@v4 with: - name: bitchat-release-apk-${{ github.ref_name }} - path: . + name: bitchat-android-release-${{ github.ref_name }} + path: release - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: - files: bitchat.apk + files: | + release/bitchat-android-arm64.apk + release/bitchat-android-x86_64.apk + release/bitchat-android-universal.apk name: Release ${{ github.ref_name }} + body: | + **bitchat-android-arm64.apk** - ARM64 (most phones) + **bitchat-android-x86_64.apk** - x86_64 (Chromebooks, tablets) + **bitchat-android-universal.apk** - All architectures (fallback) env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 151e1b4f..64ac199e 100644 --- a/.gitignore +++ b/.gitignore @@ -43,7 +43,9 @@ dependency-reduced-pom.xml # Other *.log .cxx/ -*build/ +# Gradle/Android build directories (but not tools/arti-build/) +**/build/ +!tools/arti-build/ out/ gen/ *~ @@ -57,3 +59,7 @@ google-services.json # Keystore files *.jks *.keystore + +# Arti build artifacts (cloned repo and Rust build cache) +tools/arti-build/.arti-source/ +tools/arti-build/target/ diff --git a/README.md b/README.md index 50f0cf54..c9b0091d 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ The app requires the following permissions (automatically requested): - **Bluetooth**: Core BLE functionality - **Location**: Required for BLE scanning on Android +- **Network**: Expand your mesh through public internet relays - **Notifications**: Message alerts and background updates ### Hardware Requirements diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ca87d8dc..a789136e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.bitchat.droid" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = 26 - versionName = "1.5.1" + versionCode = 30 + versionName = "1.6.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -30,6 +30,12 @@ android { } buildTypes { + debug { + ndk { + // Include x86_64 for emulator support during development + abiFilters += listOf("arm64-v8a", "x86_64") + } + } release { isMinifyEnabled = true isShrinkResources = true @@ -39,6 +45,18 @@ android { ) } } + + // APK splits for GitHub releases - creates arm64, x86_64, and universal APKs + // AAB for Play Store handles architecture distribution automatically + splits { + abi { + isEnable = true + reset() + include("arm64-v8a", "x86_64") + isUniversalApk = true // For F-Droid and fallback + } + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 @@ -73,12 +91,22 @@ dependencies { // Lifecycle implementation(libs.bundles.lifecycle) + implementation(libs.androidx.lifecycle.process) // Navigation implementation(libs.androidx.navigation.compose) // Permissions implementation(libs.accompanist.permissions) + + // QR + implementation(libs.zxing.core) + implementation(libs.mlkit.barcode.scanning) + + // CameraX + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.compose) // Cryptography implementation(libs.bundles.cryptography) @@ -95,8 +123,11 @@ dependencies { // WebSocket implementation(libs.okhttp) - // Arti (Tor in Rust) Android bridge - use published AAR with native libs - implementation("info.guardianproject:arti-mobile-ex:1.2.3") + // Arti (Tor in Rust) Android bridge - custom build from latest source + // Built with rustls, 16KB page size support, and onio//un service client + // Native libraries are in src/tor/jniLibs/ (extracted from arti-custom.aar) + // Only included in tor flavor to reduce APK size for standard builds + // Note: AAR is kept in libs/ for reference, but libraries loaded from jniLibs/ // Google Play Services Location implementation(libs.gms.location) diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index cd316d72..4bc0bb5c 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -243,17 +243,6 @@ column="35"/> - - - - + + + + + + + + + + + + + + @@ -38,6 +52,11 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/nostr_relays.csv b/app/src/main/assets/nostr_relays.csv index 834060a2..345b731b 100644 --- a/app/src/main/assets/nostr_relays.csv +++ b/app/src/main/assets/nostr_relays.csv @@ -1,277 +1,290 @@ Relay URL,Latitude,Longitude -nostr-relay.amethyst.name,39.0438,-77.4874 -bcast.seutoba.com.br,43.6532,-79.3832 -premium.primal.net,40.7357,-74.1724 -strfry.openhoofd.nl,51.9229,4.40833 -relay.mostro.network,40.8302,-74.1299 -ribo.us.nostria.app,41.5868,-93.625 -relayone.geektank.ai,18.2148,-63.0574 -nostr.mikoshi.de,50.1109,8.68213 -relay.lifpay.me,1.35208,103.82 -shu02.shugur.net,21.4902,39.2246 -nostr.hifish.org,47.4043,8.57398 -orangepiller.org,60.1699,24.9384 -nostr.myshosholoza.co.za,52.3676,4.90414 -relay.nostrcheck.me,43.6532,-79.3832 -relay.nostrhub.tech,49.0291,8.35696 -nostr.mehdibekhtaoui.com,49.4939,-1.54813 -nostrcheck.me,40.7357,-74.1724 -relay.arx-ccn.com,50.4754,12.3683 -relay2.ngengine.org,43.6532,-79.3832 -nostr.21crypto.ch,47.4988,8.72369 -nostr.girino.org,43.6532,-79.3832 -relay.nostriches.club,43.6532,-79.3832 -relay.angor.io,48.1046,11.6002 -nostr.data.haus,50.4754,12.3683 -nostr.zoracle.org,45.6018,-121.185 -relay2.angor.io,48.1046,11.6002 -librerelay.aaroniumii.com,43.6532,-79.3832 -nostr.tadryanom.me,40.7357,-74.1724 -nostr-relay.cbrx.io,43.6532,-79.3832 -nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 -relay.aloftus.io,34.0881,-118.379 -relay.barine.co,43.6532,-79.3832 -nostr.red5d.dev,43.6532,-79.3832 -nos.lol,50.4754,12.3683 -ithurtswhenip.ee,51.223,6.78245 -strfry.bonsai.com,37.8715,-122.273 -relay01.lnfi.network,39.0997,-94.5786 -nostrcheck.tnsor.network,43.6532,-79.3832 -x.kojira.io,43.6532,-79.3832 -nostr.snowbla.de,60.1699,24.9384 -purplerelay.com,50.1109,8.68213 -wot.sovbit.host,64.1466,-21.9426 -shu04.shugur.net,25.2604,55.2989 -nr.yay.so,46.2126,6.1154 -nostr.smartflowsocial.com,43.6532,-79.3832 -shu05.shugur.net,48.8566,2.35222 -wot.dtonon.com,43.6532,-79.3832 -pyramid.fiatjaf.com,51.5072,-0.127586 -nostr.88mph.life,40.7357,-74.1724 -dev-nostr.bityacht.io,25.0797,121.234 -cyberspace.nostr1.com,40.7128,-74.006 -relay.ngengine.org,40.7357,-74.1724 -nostr.calitabby.net,39.9268,-75.0246 -relay.toastr.net,40.8054,-74.0241 -relay.cosmicbolt.net,37.3986,-121.964 -nostr.davidebtc.me,50.1109,8.68213 -espelho.girino.org,43.6532,-79.3832 -srtrelay.c-stellar.net,40.7357,-74.1724 -relay.nostriot.com,41.5695,-83.9786 -relay.bitcoinveneto.org,64.1466,-21.9426 -bitcoiner.social,39.1585,-94.5728 -purpura.cloud,40.7357,-74.1724 -nostr.blankfors.se,60.1699,24.9384 -relay.nostromo.social,49.4543,11.0746 -ribo.af.nostria.app,-26.2041,28.0473 -relay.olas.app,50.4754,12.3683 -nostr.carroarmato0.be,50.9928,3.26317 -nostr.oxtr.dev,50.4754,12.3683 -relay.upleb.uk,52.2297,21.0122 -nostr.stakey.net,52.3676,4.90414 -nostr2.girino.org,40.7357,-74.1724 -nostr.rikmeijer.nl,50.4754,12.3683 -relay5.bitransfer.org,40.7357,-74.1724 -relay.mattybs.lol,40.7357,-74.1724 -nostr.makibisskey.work,40.7357,-74.1724 -wot.basspistol.org,49.4521,11.0767 -relay.nostr.net,50.4754,12.3683 -a.nos.lol,50.4754,12.3683 -wot.dergigi.com,64.1476,-21.9392 -fanfares.nostr1.com,40.7057,-74.0136 -nostr.faultables.net,43.6532,-79.3832 -relay.dwadziesciajeden.pl,52.2297,21.0122 -adre.su,59.9311,30.3609 -wot.nostr.party,36.1627,-86.7816 -relay03.lnfi.network,39.0997,-94.5786 -relay.orangepill.ovh,49.1689,-0.358841 -gnostr.com,42.6978,23.3246 -relay.wellorder.net,45.5201,-122.99 -relay.21e6.cz,50.1682,14.0546 -relay.tagayasu.xyz,43.6715,-79.38 -relay.chatbett.de,28.0199,-82.5248 -wot.soundhsa.com,33.1384,-95.6011 -prl.plus,38.9072,-77.0369 -nostr.einundzwanzig.space,50.1109,8.68213 -nostream.breadslice.com,1.35208,103.82 -relay.chorus.community,50.1109,8.68213 -nostr.mom,50.4754,12.3683 -relay.lightning.pub,41.8959,-88.2169 -relay.hook.cafe,40.7357,-74.1724 -relay-testnet.k8s.layer3.news,37.3387,-121.885 -relay.fundstr.me,42.3601,-71.0589 -relay.stream.labs.h3.se,59.4016,17.9455 -relay.holzeis.me,43.6532,-79.3832 -articles.layer3.news,37.3387,-121.885 -nproxy.kristapsk.lv,60.1699,24.9384 -nostr.thebiglake.org,32.71,-96.6745 -relay.coinos.io,40.7357,-74.1724 -nostr.camalolo.com,24.1469,120.684 -theoutpost.life,64.1476,-21.9392 -relay.nostraddress.com,40.7357,-74.1724 -nostr.casa21.space,40.7357,-74.1724 -relay.agorist.space,52.3734,4.89406 -relay.nostr.wirednet.jp,34.706,135.493 -nostr.jerrynya.fun,31.2304,121.474 -relay.13room.space,43.6532,-79.3832 -offchain.pub,47.6743,-117.112 -relay.hasenpfeffr.com,39.0438,-77.4874 -nostr-verified.wellorder.net,45.5201,-122.99 -nostrelay.memory-art.xyz,43.6532,-79.3832 -freeben666.fr,43.1204,6.12857 -nostr.bilthon.dev,25.8128,-80.2377 -nostr.spicyz.io,40.7357,-74.1724 -bcast.girino.org,40.7357,-74.1724 -nostr.noones.com,50.1109,8.68213 -relay.electriclifestyle.com,26.2897,-80.1293 +alien.macneilmediagroup.com,43.6532,-79.3832 +v-relay.d02.vrtmrz.net,34.6937,135.502 +relay.thebluepulse.com,49.4521,11.0767 relay.guggero.org,47.3769,8.54169 -relay.siamdev.cc,13.8434,100.363 -relay.varke.eu,52.6921,6.19372 -nostr.now,36.55,139.733 -relay.origin.land,35.6673,139.751 -yabu.me,35.6092,139.73 -nostr-02.yakihonne.com,1.32123,103.695 -nostr.tac.lol,47.4748,-122.273 -r.bitcoinhold.net,40.7357,-74.1724 -nostr.rtvslawenia.com,49.4543,11.0746 -relay.sigit.io,50.4754,12.3683 -nostr-rs-relay-ishosta.phamthanh.me,40.7357,-74.1724 -relay.bitcoinartclock.com,50.4754,12.3683 -relay.snort.social,40.7357,-74.1724 -relay.mwaters.net,50.9871,2.12554 -nostr-03.dorafactory.org,1.35208,103.82 -relay.damus.io,40.7357,-74.1724 -black.nostrcity.club,41.8781,-87.6298 -noxir.kpherox.dev,34.8587,135.509 -relay.javi.space,43.4633,11.8796 -fenrir-s.notoshi.win,43.6532,-79.3832 -relay02.lnfi.network,39.0997,-94.5786 -mhp258zrpiiwn.clorecloud.net,40.7357,-74.1724 -relay.libernet.app,43.6532,-79.3832 -nostr.coincards.com,53.5501,-113.469 -ynostr.yael.at,60.1699,24.9384 -slick.mjex.me,39.048,-77.4817 -nostr.agentcampfire.com,50.8933,6.05805 -nostr-pub.wellorder.net,45.5201,-122.99 -relay.artiostr.ch,43.6532,-79.3832 -relay.davidebtc.me,50.1109,8.68213 -shu01.shugur.net,21.4902,39.2246 -relay.nostr.band,60.1699,24.9384 -relay.evanverma.com,40.8302,-74.1299 -nostr.simplex.icu,50.8198,-1.08798 -wot.nostr.net,43.6532,-79.3832 -relay.nostr.vet,52.6467,4.7395 -r.lostr.net,52.3676,4.90414 -relay.getsafebox.app,43.6532,-79.3832 -relay.moinsen.com,50.4754,12.3683 -nostr.luisschwab.net,43.6532,-79.3832 -strfry.elswa-dev.online,48.8566,2.35222 -alienos.libretechsystems.xyz,55.4724,9.87335 -nostr.overmind.lol,40.7357,-74.1724 -nos.xmark.cc,50.6924,3.20113 -relay.degmods.com,50.4754,12.3683 -relay.seq1.net,40.7128,-74.006 -relay.uid.ovh,43.6532,-79.3832 -santo.iguanatech.net,40.8302,-74.1299 -nostr.spaceshell.xyz,43.6532,-79.3832 -nostr.hekster.org,37.3986,-121.964 -wheat.happytavern.co,43.6532,-79.3832 -nostr-02.czas.top,53.471,9.88208 -satsage.xyz,37.3986,-121.964 -no.str.cr,9.92857,-84.0528 -relay.puresignal.news,40.7357,-74.1724 -nostr-relay.nextblockvending.com,47.2343,-119.853 -keys.nostr1.com,40.7057,-74.0136 -relayone.soundhsa.com,33.1384,-95.6011 -nostr-dev.wellorder.net,45.5201,-122.99 -alien.macneilmediagroup.com,40.7357,-74.1724 -nostr.kalf.org,52.3676,4.90414 -nostrelites.org,41.8781,-87.6298 -temp.iris.to,43.6532,-79.3832 -relay.utxo.farm,35.6916,139.768 -freelay.sovbit.host,64.1476,-21.9392 -relay.nosto.re,51.8933,4.42083 -inbox.azzamo.net,52.2633,21.0283 -nostr.ovia.to,43.6532,-79.3832 -nostr.lostr.space,40.7357,-74.1724 -nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 -relay.nostar.org,40.7357,-74.1724 -relay.mccormick.cx,52.3563,4.95714 -nostr.0x7e.xyz,47.4988,8.72369 -relay.0xchat.com,1.35208,103.82 -relay.thebluepulse.com,40.7357,-74.1724 -relay.letsfo.com,51.098,17.0321 -nostrelay.circum.space,52.3676,4.90414 -nostr-relay.psfoundation.info,39.0438,-77.4874 -strfry.shock.network,41.8959,-88.2169 -nostr-2.21crypto.ch,47.4988,8.72369 -relay1.nostrchat.io,60.1699,24.9384 -nostr.plantroon.com,50.1013,8.62643 -relay.artx.market,43.652,-79.3633 -nostr.chaima.info,51.223,6.78245 -wot.nostr.place,30.2672,-97.7431 -nostr.rblb.it,43.4633,11.8796 -relay.magiccity.live,25.8128,-80.2377 -soloco.nl,40.7357,-74.1724 -dizzyspells.nostr1.com,40.7128,-74.006 -relay.zone667.com,60.1699,24.9384 -nostr-01.uid.ovh,43.6532,-79.3832 -zap.watch,45.5029,-73.5723 -relay.wolfcoil.com,35.6092,139.73 -relay.nostrdice.com,-33.8688,151.209 -relay.goodmorningbitcoin.com,43.6532,-79.3832 -relay.btcforplebs.com,40.7357,-74.1724 -nostr.sathoarder.com,48.5734,7.75211 -nostr.azzamo.net,52.2633,21.0283 -wot.utxo.one,40.7128,-74.006 -relay.bitcoindistrict.org,43.6532,-79.3832 relay.lumina.rocks,49.0291,8.35695 -relay.primal.net,43.6532,-79.3832 -nostr.notribe.net,40.8302,-74.1299 -relay.usefusion.ai,38.7134,-78.1591 -relay-dev.satlantis.io,40.8302,-74.1299 -nostr.vulpem.com,49.4543,11.0746 -orangesync.tech,50.1109,8.68213 -relay.fountain.fm,39.0997,-94.5786 -relay.cypherflow.ai,48.8566,2.35222 -nostr-relay.online,40.7357,-74.1724 -relay.internationalright-wing.org,-22.5022,-48.7114 -relay.nostr.place,32.7767,-96.797 -relay.npubhaus.com,40.7357,-74.1724 -relay.jeffg.fyi,43.6532,-79.3832 -nostr.night7.space,50.4754,12.3683 -nostr.4rs.nl,49.0291,8.35696 -nostr.liberty.fans,36.9104,-89.5875 -ribo.eu.nostria.app,52.3676,4.90414 -nostr.bitcoiner.social,39.1585,-94.5728 -relay.basspistol.org,46.2044,6.14316 -dev-relay.lnfi.network,39.0997,-94.5786 -nostr.openhoofd.nl,51.9229,4.40833 -nostr.huszonegy.world,47.4979,19.0402 -relay.2nix.de,60.1699,24.9384 +relay-freeharmonypeople.space,38.7223,-9.13934 +nostr-relay.online,43.6532,-79.3832 +shu05.shugur.net,48.8566,2.35222 +relay.evanverma.com,40.8302,-74.1299 +relay2.angor.io,48.1046,11.6002 +relay.arx-ccn.com,50.4754,12.3683 +relay.davidebtc.me,51.5072,-0.127586 +temp.iris.to,43.6532,-79.3832 +nostr.superfriends.online,43.6532,-79.3832 vitor.nostr1.com,40.7057,-74.0136 -nostr.zenon.network,43.5009,-70.4428 -relay.etch.social,41.2619,-95.8608 -relay.fr13nd5.com,52.5233,13.3426 -relay.agora.social,50.7383,15.0648 -relay.credenso.cafe,43.3601,-80.3127 -relay.illuminodes.com,47.6061,-122.333 -relay.freeplace.nl,52.3676,4.90414 -relay-rpi.edufeed.org,49.4543,11.0746 -relay.bullishbounty.com,43.6532,-79.3832 -relay04.lnfi.network,39.0997,-94.5786 -nostr-01.yakihonne.com,1.32123,103.695 -nostr-relay.xbytez.io,50.6924,3.20113 -wot.sebastix.social,51.8933,4.42083 -strfry.felixzieger.de,50.1013,8.62643 -nostr.n7ekb.net,47.4941,-122.294 -khatru.nostrver.se,51.8933,4.42083 -relayrs.notoshi.win,40.7357,-74.1724 -relay.wavlake.com,41.2619,-95.8608 +relay.moinsen.com,50.4754,12.3683 +ithurtswhenip.ee,51.223,6.78245 +nostr.tadryanom.me,43.6532,-79.3832 +relay.nostr.wirednet.jp,34.706,135.493 +relay.0xchat.com,1.35208,103.82 +nostrcheck.tnsor.network,43.6532,-79.3832 +relay.ngengine.org,43.6532,-79.3832 +relay.mitchelltribe.com,39.0438,-77.4874 +wotr.relatr.xyz,53.3498,-6.26031 +relay.chorus.community,50.1109,8.68213 +orly.ft.hn,50.4754,12.3683 +nostr.stakey.net,52.3676,4.90414 +relay.malxte.de,52.52,13.405 +nostrelay.circum.space,52.3676,4.90414 +nostrcheck.me,43.6532,-79.3832 wot.sudocarlos.com,51.5072,-0.127586 -nostr.sagaciousd.com,49.2827,-123.121 +relay.routstr.com,43.6532,-79.3832 +relay.siamdev.cc,13.8434,100.363 +wot.sovbit.host,64.1466,-21.9426 +strfry.bonsai.com,37.8715,-122.273 +freeben666.fr,43.7221,7.15296 +okn.czas.top,51.267,6.81738 +relay.nostar.org,43.6532,-79.3832 +relay.jeffg.fyi,43.6532,-79.3832 +nostr-02.uid.ovh,43.6532,-79.3832 +relay.fr13nd5.com,52.5233,13.3426 +srtrelay.c-stellar.net,43.6532,-79.3832 +relay.dwadziesciajeden.pl,52.2297,21.0122 +relay.sigit.io,50.4754,12.3683 +nostr.girino.org,43.6532,-79.3832 +bitsat.molonlabe.holdings,51.4012,-1.3147 +nostream.breadslice.com,1.35208,103.82 +nostr.rtvslawenia.com,49.4543,11.0746 +nostr.openhoofd.nl,51.9229,4.40833 +librerelay.aaroniumii.com,43.6532,-79.3832 +relay.etch.social,41.2619,-95.8608 +strfry.felixzieger.de,50.1013,8.62643 +relay.wavlake.com,41.2619,-95.8608 +relay.fundstr.me,42.3601,-71.0589 +slick.mjex.me,39.048,-77.4817 +relay.upleb.uk,51.9194,19.1451 +nos4smartnkind.tech,40.1872,44.5152 +discovery.eu.nostria.app,52.3676,4.90414 +nostr.tac.lol,47.4748,-122.273 +wot.nostr.net,43.6532,-79.3832 +nostr-dev.wellorder.net,45.5201,-122.99 +fanfares.nostr1.com,40.7128,-74.006 +relay.bitcoindistrict.org,43.6532,-79.3832 +nostr-relay.nextblockvending.com,47.2343,-119.853 +relay-rpi.edufeed.org,49.4521,11.0767 +relay.nsnip.io,60.1699,24.9384 +black.nostrcity.club,48.8575,2.35138 +nostr-02.czas.top,51.2277,6.77346 +schnorr.me,43.6532,-79.3832 +relay.fountain.fm,39.0997,-94.5786 +nostr-verified.wellorder.net,45.5201,-122.99 +relay.divine.video,43.6532,-79.3832 +relay.thibautduchene.fr,43.6532,-79.3832 +nostr.plantroon.com,50.1013,8.62643 +relay.hasenpfeffr.com,39.0438,-77.4874 +relay.chakany.systems,43.6532,-79.3832 +nostr-01.yakihonne.com,1.32123,103.695 +nostr.azzamo.net,52.2633,21.0283 +nostr.bond,50.1109,8.68213 +nostr.coincards.com,53.5501,-113.469 +relay.nostrhub.tech,49.0291,8.35696 +relay.cosmicbolt.net,37.3986,-121.964 +nostr.21crypto.ch,47.5356,8.73209 +relay-dev.satlantis.io,40.8302,-74.1299 +offchain.pub,47.6743,-117.112 +relay.orangepill.ovh,49.1689,-0.358841 +nostr-relay.gateway.in.th,15.2634,100.344 +nostr.now,36.55,139.733 +relay.usefusion.ai,38.7134,-78.1591 +nostr.lkjsxc.com,43.6532,-79.3832 +nproxy.kristapsk.lv,60.1699,24.9384 +relay.nostrverse.net,43.6532,-79.3832 +relay.zone667.com,60.1699,24.9384 +relay.origin.land,35.6673,139.751 +relay.damus.io,43.6532,-79.3832 +relay.bnos.space,1.35208,103.82 +relay.endfiat.money,43.6532,-79.3832 +relay.bullishbounty.com,43.6532,-79.3832 +wot.nostr.party,36.1627,-86.7816 +nostr.huszonegy.world,47.4979,19.0402 +theoutpost.life,64.1476,-21.9392 +nostr.noones.com,50.1109,8.68213 +relay.21e6.cz,50.7383,15.0648 +nostr-relay.xbytez.io,50.6924,3.20113 +wot.shaving.kiwi,43.6532,-79.3832 +relay.trustroots.org,43.6532,-79.3832 +wot.dtonon.com,43.6532,-79.3832 +nostr.camalolo.com,24.1469,120.684 +czas.xyz,48.8566,2.35222 +relayrs.notoshi.win,43.6532,-79.3832 +skeme.vanderwarker.family,40.8218,-74.45 +nostr.red5d.dev,43.6532,-79.3832 +relay.javi.space,43.4633,11.8796 +relay.nuts.cash,34.0362,-118.443 +nostr.commonshub.brussels,49.4543,11.0746 +relay.artx.market,43.652,-79.3633 +satsage.xyz,37.3986,-121.964 +relay.nostx.io,43.6532,-79.3832 +shu02.shugur.net,21.4902,39.2246 +strfry.ymir.cloud,34.0965,-117.585 +relay.nostr-check.me,43.6532,-79.3832 +relay04.lnfi.network,39.0997,-94.5786 +kotukonostr.onrender.com,37.7775,-122.397 +nostr.davidebtc.me,51.5072,-0.127586 +relay2.ngengine.org,43.6532,-79.3832 +ribo.us.nostria.app,41.5868,-93.625 +nostr.czas.top,50.1109,8.68213 +relay.nostrzh.org,43.6532,-79.3832 +relay.angor.io,48.1046,11.6002 +alienos.libretechsystems.xyz,55.4724,9.87335 +vault.iris.to,43.6532,-79.3832 +relay.mccormick.cx,52.3563,4.95714 +nostr.quali.chat,60.1699,24.9384 +wheat.happytavern.co,43.6532,-79.3832 +relay.olas.app,50.4754,12.3683 +nostr-relayrs.gateway.in.th,15.2634,100.344 +relay.snort.social,53.3498,-6.26031 +nostr-2.21crypto.ch,47.5356,8.73209 +pyramid.treegaze.com,43.6532,-79.3832 +relay.smies.me,33.7501,-84.3885 +wot.dergigi.com,64.1476,-21.9392 +nostr-relay.zimage.com,34.0549,-118.243 +nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 +nostr.jerrynya.fun,31.2304,121.474 +purplerelay.com,50.1109,8.68213 +relay.comcomponent.com,34.7062,135.493 +relay.unitypay.cash,41.4513,-81.7021 +nostr.mikoshi.de,50.1109,8.68213 +prl.plus,42.6978,23.3246 +shu04.shugur.net,25.2604,55.2989 +nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 +nostr.zoracle.org,45.6018,-121.185 +nostriches.club,43.6532,-79.3832 +nostr.notribe.net,40.8302,-74.1299 +nostr.vulpem.com,49.4543,11.0746 +bucket.coracle.social,37.7775,-122.397 +wot.yesnostr.net,50.9871,2.12554 +wot.nostr.place,32.7767,-96.797 +nostr.mehdibekhtaoui.com,49.4939,-1.54813 +relay.getsafebox.app,43.6532,-79.3832 +nostr2.girino.org,43.6532,-79.3832 +nostr.blankfors.se,60.1699,24.9384 +premium.primal.net,43.6532,-79.3832 +nostr.rikmeijer.nl,50.4754,12.3683 +relay-fra.zombi.cloudrodion.com,48.8566,2.35222 +adre.su,59.9311,30.3609 +nostr.n7ekb.net,47.4941,-122.294 +nostr.hekster.org,37.3986,-121.964 +relay.jabato.space,52.52,13.405 +fenrir-s.notoshi.win,43.6532,-79.3832 +notemine.io,52.2026,20.9397 +r.bitcoinhold.net,43.6532,-79.3832 +nostr-pub.wellorder.net,45.5201,-122.99 +articles.layer3.news,37.3387,-121.885 +relays.diggoo.com,43.6532,-79.3832 +relayb.uid.ovh,43.6532,-79.3832 +nostr.thebiglake.org,32.71,-96.6745 +chat-relay.zap-work.com,43.6532,-79.3832 +nr.yay.so,46.2126,6.1154 +shu01.shugur.net,21.4902,39.2246 +khatru.nostrver.se,51.1792,5.89444 +relay.openfarmtools.org,60.1699,24.9384 +relay01.lnfi.network,39.0997,-94.5786 +nostrja-kari.heguro.com,43.6532,-79.3832 +nostr.snowbla.de,60.1699,24.9384 +relay.satlantis.io,32.8769,-80.0114 +relay.vrtmrz.net,43.6532,-79.3832 +purpura.cloud,43.6532,-79.3832 +orangepiller.org,60.1699,24.9384 +relay.coinos.io,43.6532,-79.3832 +relay.layer.systems,49.0291,8.35695 +relay.toastr.net,40.8054,-74.0241 +ynostr.yael.at,60.1699,24.9384 +ribo.eu.nostria.app,52.3676,4.90414 +relay.nostrcheck.me,43.6532,-79.3832 +relay.primal.net,43.6532,-79.3832 +relay-testnet.k8s.layer3.news,37.3387,-121.885 +relay.threenine.services,51.5524,-0.29686 +relay.nostr.net,43.6532,-79.3832 +nostr.middling.mydns.jp,35.8099,140.12 +soloco.nl,43.6532,-79.3832 +nostr.4rs.nl,49.0291,8.35696 +no.str.cr,9.92857,-84.0528 +espelho.girino.org,43.6532,-79.3832 +nostr.hifish.org,47.4043,8.57398 +wot.sebastix.social,51.1792,5.89444 +relay03.lnfi.network,39.0997,-94.5786 +relay.nosto.re,51.1792,5.89444 +relay.nostrdice.com,-33.8688,151.209 +nostr.agentcampfire.com,52.3676,4.90414 +nostr.night7.space,50.4754,12.3683 +nostr.nodesmap.com,59.3327,18.0656 +relayone.soundhsa.com,33.1384,-95.6011 +relay.illuminodes.com,47.6061,-122.333 +nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 +nostr.robosats.org,64.1476,-21.9392 +relay.seq1.net,43.6532,-79.3832 +strfry.shock.network,39.0438,-77.4874 +nostr.ovia.to,43.6532,-79.3832 +relay.holzeis.me,43.6532,-79.3832 +bcast.seutoba.com.br,43.6532,-79.3832 +relay.libernet.app,43.6532,-79.3832 +nostr-01.uid.ovh,43.6532,-79.3832 +nostr.luisschwab.net,43.6532,-79.3832 +strfry.elswa-dev.online,50.1109,8.68213 +relay.nostriot.com,41.5695,-83.9786 +nostr.oxtr.dev,50.4754,12.3683 +nostr.mom,50.4754,12.3683 +nostr-03.dorafactory.org,1.35208,103.82 +relay.camelus.app,45.5201,-122.99 +strfry.openhoofd.nl,51.9229,4.40833 +nos.lol,50.4754,12.3683 +nostr.bitcoiner.social,39.1585,-94.5728 relay.islandbitcoin.com,12.8498,77.6545 -trizone.dev,1.35208,103.82 -schnorr.me,40.7357,-74.1724 -relay.vrtmrz.net,40.7357,-74.1724 +relay.tagayasu.xyz,43.6715,-79.38 +dev-nostr.bityacht.io,25.0797,121.234 +relay.nostrhub.fr,48.1045,11.6004 +relay.satmaxt.xyz,43.6532,-79.3832 +yabu.me,35.6092,139.73 +relay.samt.st,40.8302,-74.1299 +relay02.lnfi.network,39.0997,-94.5786 +nostr.88mph.life,51.5072,-0.127586 +dev-relay.lnfi.network,39.0997,-94.5786 +relay.goodmorningbitcoin.com,43.6532,-79.3832 +nostrelay.memory-art.xyz,43.6532,-79.3832 +relay.degmods.com,50.4754,12.3683 +nostr-02.yakihonne.com,1.32123,103.695 +nostr.spicyz.io,43.6532,-79.3832 +relay.minibolt.info,43.6532,-79.3832 +bcast.girino.org,43.6532,-79.3832 +nostr.bilthon.dev,25.8128,-80.2377 +relay.bitcoinartclock.com,50.4754,12.3683 +cyberspace.nostr1.com,40.7128,-74.006 +nostr.data.haus,50.4754,12.3683 +relay.agora.social,50.7383,15.0648 +relay.wavefunc.live,34.0362,-118.443 +nostr.casa21.space,43.6532,-79.3832 +relay.mostro.network,40.8302,-74.1299 relay.ditto.pub,43.6532,-79.3832 +nostr-relay.amethyst.name,39.0438,-77.4874 +relay.credenso.cafe,43.3601,-80.3127 +nostr.calitabby.net,39.9268,-75.0246 +relay.magiccity.live,25.8128,-80.2377 +nostr.chaima.info,51.223,6.78245 +wot.brightbolt.net,47.6735,-116.781 +nostr.sathoarder.com,48.5734,7.75211 +inbox.azzamo.net,52.2633,21.0283 +nostr.na.social,43.6532,-79.3832 +nostr.myshosholoza.co.za,52.3676,4.90414 +nostr.spaceshell.xyz,43.6532,-79.3832 +relay.wellorder.net,45.5201,-122.99 +nostr.0x7e.xyz,47.4988,8.72369 +nostr-relay.cbrx.io,43.6532,-79.3832 +relay.btcforplebs.com,43.6532,-79.3832 +bitcoiner.social,39.1585,-94.5728 +relaynostr.breadslice.com,43.6532,-79.3832 +nostr.overmind.lol,43.6532,-79.3832 +santo.iguanatech.net,40.8302,-74.1299 +relay.lightning.pub,39.0438,-77.4874 +nostr.rblb.it,43.7094,10.6582 +relay.bitcoinveneto.org,64.1466,-21.9426 +relay.wolfcoil.com,35.6092,139.73 +nostr-relay.corb.net,38.8353,-104.822 +relay.nostr.place,32.7767,-96.797 +wot.soundhsa.com,33.1384,-95.6011 +relay.npubhaus.com,43.6532,-79.3832 +nostr-relay.psfoundation.info,39.0438,-77.4874 +x.kojira.io,43.6532,-79.3832 +relay.cypherflow.ai,48.8566,2.35222 diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index df9cd6e5..b120b216 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -3,18 +3,21 @@ package com.bitchat.android import android.app.Application import com.bitchat.android.nostr.RelayDirectory import com.bitchat.android.ui.theme.ThemePreferenceManager -import com.bitchat.android.net.TorManager +import com.bitchat.android.net.ArtiTorManager /** * Main application class for bitchat Android */ class BitchatApplication : Application() { - + override fun onCreate() { super.onCreate() - + // Initialize Tor first so any early network goes over Tor - try { TorManager.init(this) } catch (_: Exception) { } + try { + val torProvider = ArtiTorManager.getInstance() + torProvider.init(this) + } catch (_: Exception){} // Initialize relay directory (loads assets/nostr_relays.csv) RelayDirectory.initialize(this) @@ -38,6 +41,12 @@ class BitchatApplication : Application() { // Initialize debug preference manager (persists debug toggles) try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { } + // Initialize mesh service preferences + try { com.bitchat.android.service.MeshServicePreferences.init(this) } catch (_: Exception) { } + + // Proactively start the foreground service to keep mesh alive + try { com.bitchat.android.service.MeshForegroundService.start(this) } catch (_: Exception) { } + // TorManager already initialized above } } diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 28d67280..f287784d 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -26,6 +26,7 @@ import com.bitchat.android.onboarding.BatteryOptimizationManager import com.bitchat.android.onboarding.BatteryOptimizationPreferenceManager import com.bitchat.android.onboarding.BatteryOptimizationScreen import com.bitchat.android.onboarding.BatteryOptimizationStatus +import com.bitchat.android.onboarding.BackgroundLocationPermissionScreen import com.bitchat.android.onboarding.InitializationErrorScreen import com.bitchat.android.onboarding.InitializingScreen import com.bitchat.android.onboarding.LocationCheckScreen @@ -40,6 +41,7 @@ import com.bitchat.android.ui.ChatViewModel import com.bitchat.android.ui.OrientationAwareActivity import com.bitchat.android.ui.theme.BitchatTheme import com.bitchat.android.nostr.PoWPreferenceManager +import com.bitchat.android.services.VerificationService import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -51,7 +53,7 @@ class MainActivity : OrientationAwareActivity() { private lateinit var locationStatusManager: LocationStatusManager private lateinit var batteryOptimizationManager: BatteryOptimizationManager - // Core mesh service - managed at app level + // Core mesh service - provided by the foreground service holder private lateinit var meshService: BluetoothMeshService private val mainViewModel: MainViewModel by viewModels() private val chatViewModel: ChatViewModel by viewModels { @@ -63,16 +65,55 @@ class MainActivity : OrientationAwareActivity() { } } + private val forceFinishReceiver = object : android.content.BroadcastReceiver() { + override fun onReceive(context: android.content.Context, intent: android.content.Intent) { + if (intent.action == com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH) { + android.util.Log.i("MainActivity", "Received force finish broadcast, closing UI") + finishAffinity() + } + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // Register receiver for force finish signal from shutdown coordinator + val filter = android.content.IntentFilter(com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH) + if (android.os.Build.VERSION.SDK_INT >= 33) { + registerReceiver( + forceFinishReceiver, + filter, + com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH, + null, + android.content.Context.RECEIVER_NOT_EXPORTED + ) + } else { + @Suppress("DEPRECATION") + registerReceiver( + forceFinishReceiver, + filter, + com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH, + null + ) + } + + // Check if this is a quit request from the notification + if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) { + android.util.Log.d("MainActivity", "Quit request received in onCreate, finishing activity") + finish() + return + } + + com.bitchat.android.service.AppShutdownCoordinator.cancelPendingShutdown() + // Enable edge-to-edge display for modern Android look enableEdgeToEdge() // Initialize permission management permissionManager = PermissionManager(this) - // Initialize core mesh service first - meshService = BluetoothMeshService(this) + // Ensure foreground service is running and get mesh instance from holder + try { com.bitchat.android.service.MeshForegroundService.start(applicationContext) } catch (_: Exception) { } + meshService = com.bitchat.android.service.MeshServiceHolder.getOrCreate(applicationContext) bluetoothStatusManager = BluetoothStatusManager( activity = this, context = this, @@ -95,6 +136,9 @@ class MainActivity : OrientationAwareActivity() { activity = this, permissionManager = permissionManager, onOnboardingComplete = ::handleOnboardingComplete, + onBackgroundLocationRequired = { + mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION) + }, onOnboardingFailed = ::handleOnboardingFailed ) @@ -227,6 +271,21 @@ class MainActivity : OrientationAwareActivity() { ) } + OnboardingState.BACKGROUND_LOCATION_EXPLANATION -> { + BackgroundLocationPermissionScreen( + modifier = modifier, + onContinue = { + onboardingCoordinator.requestBackgroundLocation() + }, + onRetry = { + onboardingCoordinator.checkBackgroundLocationAndProceed() + }, + onSkip = { + onboardingCoordinator.skipBackgroundLocation() + } + ) + } + OnboardingState.CHECKING, OnboardingState.INITIALIZING, OnboardingState.COMPLETE -> { // Set up back navigation handling for the chat screen val backCallback = object : OnBackPressedCallback(true) { @@ -340,10 +399,17 @@ class MainActivity : OrientationAwareActivity() { if (permissionManager.isFirstTimeLaunch()) { Log.d("MainActivity", "First time launch, showing permission explanation") mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) - } else if (permissionManager.areAllPermissionsGranted()) { - Log.d("MainActivity", "Existing user with permissions, initializing app") - mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING) - initializeApp() + } else if (permissionManager.areRequiredPermissionsGranted()) { + Log.d("MainActivity", "Existing user with required permissions") + if (permissionManager.needsBackgroundLocationPermission() && + !permissionManager.isBackgroundLocationGranted() && + !com.bitchat.android.onboarding.BackgroundLocationPreferenceManager.isSkipped(this@MainActivity) + ) { + mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION) + } else { + mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING) + initializeApp() + } } else { Log.d("MainActivity", "Existing user missing permissions, showing explanation") mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) @@ -616,6 +682,7 @@ class MainActivity : OrientationAwareActivity() { // Handle any notification intent handleNotificationIntent(intent) + handleVerificationIntent(intent) // Small delay to ensure mesh service is fully initialized delay(500) @@ -630,9 +697,21 @@ class MainActivity : OrientationAwareActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + setIntent(intent) + + // Check if this is a quit request from the notification + if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) { + android.util.Log.d("MainActivity", "Quit request received, finishing activity") + finish() + return + } + + com.bitchat.android.service.AppShutdownCoordinator.cancelPendingShutdown() + // Handle notification intents when app is already running if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { handleNotificationIntent(intent) + handleVerificationIntent(intent) } } @@ -640,9 +719,8 @@ class MainActivity : OrientationAwareActivity() { super.onResume() // Check Bluetooth and Location status on resume and handle accordingly if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { - // Set app foreground state - meshService.connectionManager.setAppBackgroundState(false) - chatViewModel.setAppBackgroundState(false) + // Reattach mesh delegate to new ChatViewModel instance after Activity recreation + try { meshService.delegate = chatViewModel } catch (_: Exception) { } // Check if Bluetooth was disabled while app was backgrounded val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus() @@ -665,13 +743,12 @@ class MainActivity : OrientationAwareActivity() { } } - override fun onPause() { + override fun onPause() { super.onPause() // Only set background state if app is fully initialized if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { - // Set app background state - meshService.connectionManager.setAppBackgroundState(true) - chatViewModel.setAppBackgroundState(true) + // Detach UI delegate so the foreground service can own DM notifications while UI is closed + try { meshService.delegate = null } catch (_: Exception) { } } } @@ -734,10 +811,23 @@ class MainActivity : OrientationAwareActivity() { } } + private fun handleVerificationIntent(intent: Intent) { + val uri = intent.data ?: return + if (uri.scheme != "bitchat" || uri.host != "verify") return + + chatViewModel.showVerificationSheet() + val qr = VerificationService.verifyScannedQR(uri.toString()) + if (qr != null) { + chatViewModel.beginQRVerification(qr) + } + } + override fun onDestroy() { super.onDestroy() + try { unregisterReceiver(forceFinishReceiver) } catch (_: Exception) { } + // Cleanup location status manager try { locationStatusManager.cleanup() @@ -746,14 +836,6 @@ class MainActivity : OrientationAwareActivity() { Log.w("MainActivity", "Error cleaning up location status manager: ${e.message}") } - // Stop mesh services if app was fully initialized - if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { - try { - meshService.stopServices() - Log.d("MainActivity", "Mesh services stopped successfully") - } catch (e: Exception) { - Log.w("MainActivity", "Error stopping mesh services in onDestroy: ${e.message}") - } - } + // Do not stop mesh here; ForegroundService owns lifecycle for background reliability } } diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt new file mode 100644 index 00000000..96f408bc --- /dev/null +++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt @@ -0,0 +1,34 @@ +package com.bitchat.android.core.ui.component.button + +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun CloseButton( + onClick: () -> Unit, + modifier: Modifier = Modifier.Companion +) { + IconButton( + onClick = onClick, + modifier = modifier + .size(32.dp), + colors = IconButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), + containerColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.1f) + ) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close", + modifier = Modifier.Companion.size(18.dp) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt b/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt index 449d705f..c615d62b 100644 --- a/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt +++ b/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt @@ -19,7 +19,7 @@ import java.util.concurrent.ConcurrentHashMap * This is the main interface for all encryption/decryption operations in bitchat. * It now uses the Noise protocol for secure transport encryption with proper session management. */ -class EncryptionService(private val context: Context) { +open class EncryptionService(private val context: Context) { companion object { private const val TAG = "EncryptionService" @@ -27,14 +27,14 @@ class EncryptionService(private val context: Context) { } // Core Noise encryption service - private val noiseService: NoiseEncryptionService = NoiseEncryptionService(context) + private val noiseService: NoiseEncryptionService by lazy { NoiseEncryptionService(context) } // Session tracking for established connections private val establishedSessions = ConcurrentHashMap() // peerID -> fingerprint // Ed25519 signing keys (separate from Noise static keys) - private val ed25519PrivateKey: Ed25519PrivateKeyParameters - private val ed25519PublicKey: Ed25519PublicKeyParameters + private lateinit var ed25519PrivateKey: Ed25519PrivateKeyParameters + private lateinit var ed25519PublicKey: Ed25519PublicKeyParameters // Callbacks for UI state updates var onSessionEstablished: ((String) -> Unit)? = null // peerID @@ -42,6 +42,13 @@ class EncryptionService(private val context: Context) { var onHandshakeRequired: ((String) -> Unit)? = null // peerID init { + initialize() + } + + /** + * Initialization logic moved to method to allow overriding in tests + */ + protected open fun initialize() { // Initialize or load Ed25519 signing keys val keyPair = loadOrCreateEd25519KeyPair() ed25519PrivateKey = keyPair.private as Ed25519PrivateKeyParameters @@ -356,7 +363,7 @@ class EncryptionService(private val context: Context) { /** * Verify Ed25519 signature against data using a public key */ - fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean { + open fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean { return try { val publicKey = Ed25519PublicKeyParameters(publicKeyBytes, 0) val verifier = Ed25519Signer() diff --git a/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt b/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt index b498dd83..d4ccb9bf 100644 --- a/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt +++ b/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt @@ -5,13 +5,14 @@ import android.location.Geocoder import android.location.Location import android.location.LocationManager import android.util.Log -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import com.google.gson.Gson import com.google.gson.reflect.TypeToken import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import java.util.Locale /** @@ -46,11 +47,11 @@ class GeohashBookmarksStore private constructor(private val context: Context) { private val membership = mutableSetOf() - private val _bookmarks = MutableLiveData>(emptyList()) - val bookmarks: LiveData> = _bookmarks + private val _bookmarks = MutableStateFlow>(emptyList()) + val bookmarks: StateFlow> = _bookmarks.asStateFlow() - private val _bookmarkNames = MutableLiveData>(emptyMap()) - val bookmarkNames: LiveData> = _bookmarkNames + private val _bookmarkNames = MutableStateFlow>(emptyMap()) + val bookmarkNames: StateFlow> = _bookmarkNames.asStateFlow() // For throttling / preventing duplicate geocode lookups private val resolving = mutableSetOf() @@ -68,8 +69,8 @@ class GeohashBookmarksStore private constructor(private val context: Context) { val gh = normalize(geohash) if (gh.isEmpty() || membership.contains(gh)) return membership.add(gh) - val updated = listOf(gh) + (_bookmarks.value ?: emptyList()) - _bookmarks.postValue(updated) + val updated = listOf(gh) + (_bookmarks.value) + _bookmarks.value = updated persist(updated) // Resolve friendly name asynchronously resolveNameIfNeeded(gh) @@ -79,12 +80,12 @@ class GeohashBookmarksStore private constructor(private val context: Context) { val gh = normalize(geohash) if (!membership.contains(gh)) return membership.remove(gh) - val updated = (_bookmarks.value ?: emptyList()).filterNot { it == gh } - _bookmarks.postValue(updated) + val updated = (_bookmarks.value).filterNot { it == gh } + _bookmarks.value = updated // Remove stored name to avoid stale cache growth - val names = _bookmarkNames.value?.toMutableMap() ?: mutableMapOf() + val names = _bookmarkNames.value.toMutableMap() if (names.remove(gh) != null) { - _bookmarkNames.postValue(names) + _bookmarkNames.value = names persistNames(names) } persist(updated) @@ -108,7 +109,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) { } } membership.clear(); membership.addAll(seen) - _bookmarks.postValue(ordered) + _bookmarks.value = ordered } } catch (e: Exception) { Log.e(TAG, "Failed to load bookmarks: ${e.message}") @@ -118,7 +119,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) { if (!namesJson.isNullOrEmpty()) { val mapType = object : TypeToken>() {}.type val dict = gson.fromJson>(namesJson, mapType) - _bookmarkNames.postValue(dict) + _bookmarkNames.value = dict } } catch (e: Exception) { Log.e(TAG, "Failed to load bookmark names: ${e.message}") @@ -127,14 +128,14 @@ class GeohashBookmarksStore private constructor(private val context: Context) { private fun persist() { try { - val json = gson.toJson(_bookmarks.value ?: emptyList()) + val json = gson.toJson(_bookmarks.value) prefs.edit().putString(STORE_KEY, json).apply() } catch (_: Exception) {} } private fun persistNames() { try { - val json = gson.toJson(_bookmarkNames.value ?: emptyMap()) + val json = gson.toJson(_bookmarkNames.value) prefs.edit().putString(NAMES_STORE_KEY, json).apply() } catch (_: Exception) {} } @@ -144,8 +145,8 @@ class GeohashBookmarksStore private constructor(private val context: Context) { fun clearAll() { try { membership.clear() - _bookmarks.postValue(emptyList()) - _bookmarkNames.postValue(emptyMap()) + _bookmarks.value = emptyList() + _bookmarkNames.value = emptyMap() prefs.edit() .remove(STORE_KEY) .remove(NAMES_STORE_KEY) @@ -209,9 +210,9 @@ class GeohashBookmarksStore private constructor(private val context: Context) { } if (!name.isNullOrEmpty()) { - val current = _bookmarkNames.value?.toMutableMap() ?: mutableMapOf() + val current = _bookmarkNames.value.toMutableMap() current[gh] = name - _bookmarkNames.postValue(current) + _bookmarkNames.value = current persistNames(current) } } catch (e: Exception) { diff --git a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt index b8da1fee..3e74a8ee 100644 --- a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt +++ b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt @@ -10,12 +10,12 @@ import android.location.LocationManager import android.os.Bundle import android.util.Log import androidx.core.app.ActivityCompat -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.* import java.util.* import com.google.gson.Gson import com.google.gson.JsonSyntaxException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow /** * Manages location permissions, one-shot location retrieval, and computing geohash channels. @@ -53,28 +53,26 @@ class LocationChannelManager private constructor(private val context: Context) { private var dataManager: com.bitchat.android.ui.DataManager? = null // Published state for UI bindings (matching iOS @Published properties) - private val _permissionState = MutableLiveData(PermissionState.NOT_DETERMINED) - val permissionState: LiveData = _permissionState + private val _permissionState = MutableStateFlow(PermissionState.NOT_DETERMINED) + val permissionState: StateFlow = _permissionState - private val _availableChannels = MutableLiveData>(emptyList()) - val availableChannels: LiveData> = _availableChannels + private val _availableChannels = MutableStateFlow>(emptyList()) + val availableChannels: StateFlow> = _availableChannels - private val _selectedChannel = MutableLiveData(ChannelID.Mesh) - val selectedChannel: LiveData = _selectedChannel + private val _selectedChannel = MutableStateFlow(ChannelID.Mesh) + val selectedChannel: StateFlow = _selectedChannel - private val _teleported = MutableLiveData(false) - val teleported: LiveData = _teleported + private val _teleported = MutableStateFlow(false) + val teleported: StateFlow = _teleported - private val _locationNames = MutableLiveData>(emptyMap()) - val locationNames: LiveData> = _locationNames + private val _locationNames = MutableStateFlow>(emptyMap()) + val locationNames: StateFlow> = _locationNames - // Add a new LiveData property to indicate when location is being fetched - private val _isLoadingLocation = MutableLiveData(false) - val isLoadingLocation: LiveData = _isLoadingLocation + private val _isLoadingLocation = MutableStateFlow(false) + val isLoadingLocation: StateFlow = _isLoadingLocation - // Add a new LiveData property to track if location services are enabled by user - private val _locationServicesEnabled = MutableLiveData(false) - val locationServicesEnabled: LiveData = _locationServicesEnabled + private val _locationServicesEnabled = MutableStateFlow(false) + val locationServicesEnabled: StateFlow = _locationServicesEnabled init { updatePermissionState() @@ -102,15 +100,15 @@ class LocationChannelManager private constructor(private val context: Context) { when (getCurrentPermissionStatus()) { PermissionState.NOT_DETERMINED -> { Log.d(TAG, "Permission not determined - user needs to grant in app settings") - _permissionState.postValue(PermissionState.NOT_DETERMINED) + _permissionState.value = PermissionState.NOT_DETERMINED } PermissionState.DENIED, PermissionState.RESTRICTED -> { Log.d(TAG, "Permission denied or restricted") - _permissionState.postValue(PermissionState.DENIED) + _permissionState.value = PermissionState.DENIED } PermissionState.AUTHORIZED -> { Log.d(TAG, "Permission authorized - requesting location") - _permissionState.postValue(PermissionState.AUTHORIZED) + _permissionState.value = PermissionState.AUTHORIZED requestOneShotLocation() } } @@ -126,36 +124,55 @@ class LocationChannelManager private constructor(private val context: Context) { } /** - * Begin periodic one-shot location refreshes while a selector UI is visible + * Begin real-time location updates while a selector UI is visible + * Uses requestLocationUpdates for continuous updates, plus a one-shot to prime state immediately */ fun beginLiveRefresh(interval: Long = 5000L) { - Log.d(TAG, "Beginning live refresh with interval ${interval}ms") - + Log.d(TAG, "Beginning live refresh (continuous updates)") + if (_permissionState.value != PermissionState.AUTHORIZED) { Log.w(TAG, "Cannot start live refresh - permission not authorized") return } - + if (!isLocationServicesEnabled()) { Log.w(TAG, "Cannot start live refresh - location services disabled by user") return } - // Cancel existing timer + // Cancel any existing timer-based refreshers refreshTimer?.cancel() - - // Start new timer with coroutines - refreshTimer = CoroutineScope(Dispatchers.IO).launch { - while (isActive) { - if (isLocationServicesEnabled()) { - requestOneShotLocation() + refreshTimer = null + + // Register for continuous updates from available providers + try { + if (hasLocationPermission()) { + val providers = listOf( + LocationManager.GPS_PROVIDER, + LocationManager.NETWORK_PROVIDER + ) + + providers.forEach { provider -> + if (locationManager.isProviderEnabled(provider)) { + // 2s min time, 5m min distance for responsive yet battery-aware updates + locationManager.requestLocationUpdates( + provider, + interval, + 5f, + continuousLocationListener + ) + Log.d(TAG, "Registered continuous updates for $provider") + } } - delay(interval) + + // Prime state immediately with last known / current location + requestOneShotLocation() } + } catch (e: SecurityException) { + Log.e(TAG, "Missing location permission for continuous updates: ${e.message}") + } catch (e: Exception) { + Log.e(TAG, "Failed to register continuous updates: ${e.message}") } - - // Kick off immediately - requestOneShotLocation() } /** @@ -165,6 +182,12 @@ class LocationChannelManager private constructor(private val context: Context) { Log.d(TAG, "Ending live refresh") refreshTimer?.cancel() refreshTimer = null + // Unregister continuous updates listener + try { + locationManager.removeUpdates(continuousLocationListener) + } catch (_: SecurityException) { + } catch (_: Exception) { + } } /** @@ -180,7 +203,7 @@ class LocationChannelManager private constructor(private val context: Context) { lastLocation?.let { location -> when (channel) { is ChannelID.Mesh -> { - _teleported.postValue(false) + _teleported.value = false } is ChannelID.Location -> { val currentGeohash = Geohash.encode( @@ -189,7 +212,7 @@ class LocationChannelManager private constructor(private val context: Context) { precision = channel.channel.level.precision ) val isTeleportedNow = currentGeohash != channel.channel.geohash - _teleported.postValue(isTeleportedNow) + _teleported.value = isTeleportedNow Log.d(TAG, "Teleported (immediate recompute): $isTeleportedNow (current: $currentGeohash, selected: ${channel.channel.geohash})") } } @@ -201,7 +224,7 @@ class LocationChannelManager private constructor(private val context: Context) { */ fun setTeleported(teleported: Boolean) { Log.d(TAG, "Setting teleported status: $teleported") - _teleported.postValue(teleported) + _teleported.value = teleported } /** @@ -209,7 +232,7 @@ class LocationChannelManager private constructor(private val context: Context) { */ fun enableLocationServices() { Log.d(TAG, "enableLocationServices() called by user") - _locationServicesEnabled.postValue(true) + _locationServicesEnabled.value = true saveLocationServicesState(true) // If we have permission, start location operations @@ -223,15 +246,15 @@ class LocationChannelManager private constructor(private val context: Context) { */ fun disableLocationServices() { Log.d(TAG, "disableLocationServices() called by user") - _locationServicesEnabled.postValue(false) + _locationServicesEnabled.value = false saveLocationServicesState(false) // Stop any ongoing location operations endLiveRefresh() // Clear available channels when location is disabled - _availableChannels.postValue(emptyList()) - _locationNames.postValue(emptyMap()) + _availableChannels.value = emptyList() + _locationNames.value = emptyMap() // If user had a location channel selected, switch back to mesh if (_selectedChannel.value is ChannelID.Location) { @@ -243,7 +266,7 @@ class LocationChannelManager private constructor(private val context: Context) { * Check if location services are enabled by the user */ fun isLocationServicesEnabled(): Boolean { - return _locationServicesEnabled.value ?: false + return _locationServicesEnabled.value } // MARK: - Location Operations @@ -280,13 +303,13 @@ class LocationChannelManager private constructor(private val context: Context) { if (lastKnownLocation != null) { Log.d(TAG, "Using last known location: ${lastKnownLocation.latitude}, ${lastKnownLocation.longitude}") lastLocation = lastKnownLocation - _isLoadingLocation.postValue(false) // Make sure loading state is off + _isLoadingLocation.value = false // Make sure loading state is off computeChannels(lastKnownLocation) reverseGeocodeIfNeeded(lastKnownLocation) } else { Log.d(TAG, "No last known location available") // Set loading state to true so UI can show a spinner - _isLoadingLocation.postValue(true) + _isLoadingLocation.value = true // Request a fresh location only when we don't have a last known location Log.d(TAG, "Requesting fresh location...") @@ -294,21 +317,45 @@ class LocationChannelManager private constructor(private val context: Context) { } } catch (e: SecurityException) { Log.e(TAG, "Security exception requesting location: ${e.message}") - _isLoadingLocation.postValue(false) // Turn off loading state on error + _isLoadingLocation.value = false // Turn off loading state on error updatePermissionState() } } + // Continuous location listener for real-time updates + private val continuousLocationListener = object : LocationListener { + override fun onLocationChanged(location: Location) { + Log.d(TAG, "Real-time location: ${location.latitude}, ${location.longitude} acc=${location.accuracy}m") + lastLocation = location + _isLoadingLocation.value = false + computeChannels(location) + reverseGeocodeIfNeeded(location) + } + + override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) { + // Deprecated but can still be called on older devices + Log.v(TAG, "Provider status changed: $provider -> $status") + } + + override fun onProviderEnabled(provider: String) { + Log.d(TAG, "Provider enabled: $provider") + } + + override fun onProviderDisabled(provider: String) { + Log.d(TAG, "Provider disabled: $provider") + } + } + // One-time location listener to get a fresh location update private val oneShotLocationListener = object : LocationListener { override fun onLocationChanged(location: Location) { - Log.d(TAG, "Fresh location received: ${location.latitude}, ${location.longitude}") + Log.d(TAG, "One-shot location: ${location.latitude}, ${location.longitude}") lastLocation = location computeChannels(location) reverseGeocodeIfNeeded(location) - + // Update loading state to indicate we have a location now - _isLoadingLocation.postValue(false) + _isLoadingLocation.value = false // Remove this listener after getting the update try { @@ -317,18 +364,30 @@ class LocationChannelManager private constructor(private val context: Context) { Log.e(TAG, "Error removing location listener: ${e.message}") } } + + override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) { + // Required for compatibility with older platform versions + } + + override fun onProviderEnabled(provider: String) { + // Required for compatibility with older platform versions + } + + override fun onProviderDisabled(provider: String) { + // Required for compatibility with older platform versions + } } // Request a fresh location update using getCurrentLocation instead of continuous updates private fun requestFreshLocation() { if (!hasLocationPermission()) { - _isLoadingLocation.postValue(false) // Turn off loading state if no permission + _isLoadingLocation.value = false // Turn off loading state if no permission return } try { // Set loading state to true to indicate we're actively trying to get a location - _isLoadingLocation.postValue(true) + _isLoadingLocation.value = true // Try common providers in order of preference val providers = listOf( @@ -358,7 +417,7 @@ class LocationChannelManager private constructor(private val context: Context) { Log.w(TAG, "Received null location from getCurrentLocation") } // Update loading state to indicate we have a location now - _isLoadingLocation.postValue(false) + _isLoadingLocation.value = false } ) } else { @@ -378,14 +437,14 @@ class LocationChannelManager private constructor(private val context: Context) { // If no provider was available, turn off loading state if (!providerFound) { Log.w(TAG, "No location providers available") - _isLoadingLocation.postValue(false) + _isLoadingLocation.value = false } } catch (e: SecurityException) { Log.e(TAG, "Security exception requesting location: ${e.message}") - _isLoadingLocation.postValue(false) // Turn off loading state on error + _isLoadingLocation.value = false // Turn off loading state on error } catch (e: Exception) { Log.e(TAG, "Error requesting location: ${e.message}") - _isLoadingLocation.postValue(false) // Turn off loading state on error + _isLoadingLocation.value = false // Turn off loading state on error } } @@ -408,7 +467,7 @@ class LocationChannelManager private constructor(private val context: Context) { private fun updatePermissionState() { val newState = getCurrentPermissionStatus() Log.d(TAG, "Permission state updated to: $newState") - _permissionState.postValue(newState) + _permissionState.value = newState } private fun hasLocationPermission(): Boolean { @@ -433,13 +492,13 @@ class LocationChannelManager private constructor(private val context: Context) { Log.v(TAG, "Generated ${level.displayName}: $geohash") } - _availableChannels.postValue(result) + _availableChannels.value = result // Recompute teleported status based on current location vs selected channel val selectedChannelValue = _selectedChannel.value when (selectedChannelValue) { is ChannelID.Mesh -> { - _teleported.postValue(false) + _teleported.value = false } is ChannelID.Location -> { val currentGeohash = Geohash.encode( @@ -448,12 +507,9 @@ class LocationChannelManager private constructor(private val context: Context) { precision = selectedChannelValue.channel.level.precision ) val isTeleported = currentGeohash != selectedChannelValue.channel.geohash - _teleported.postValue(isTeleported) + _teleported.value = isTeleported Log.d(TAG, "Teleported status: $isTeleported (current: $currentGeohash, selected: ${selectedChannelValue.channel.geohash})") } - null -> { - _teleported.postValue(false) - } } } @@ -482,7 +538,7 @@ class LocationChannelManager private constructor(private val context: Context) { val names = namesByLevel(address) Log.d(TAG, "Reverse geocoding result: $names") - _locationNames.postValue(names) + _locationNames.value = names } else { Log.w(TAG, "No reverse geocoding results") } @@ -601,26 +657,26 @@ class LocationChannelManager private constructor(private val context: Context) { } if (channel != null) { - _selectedChannel.postValue(channel) + _selectedChannel.value = channel Log.d(TAG, "Restored persisted channel: ${channel.displayName}") } else { Log.d(TAG, "Could not restore persisted channel, defaulting to Mesh") - _selectedChannel.postValue(ChannelID.Mesh) + _selectedChannel.value = ChannelID.Mesh } } else { Log.w(TAG, "Invalid channel data format in persistence") - _selectedChannel.postValue(ChannelID.Mesh) + _selectedChannel.value = ChannelID.Mesh } } else { Log.d(TAG, "No persisted channel found, defaulting to Mesh") - _selectedChannel.postValue(ChannelID.Mesh) + _selectedChannel.value = ChannelID.Mesh } } catch (e: JsonSyntaxException) { Log.e(TAG, "Failed to parse persisted channel data: ${e.message}") - _selectedChannel.postValue(ChannelID.Mesh) + _selectedChannel.value = ChannelID.Mesh } catch (e: Exception) { Log.e(TAG, "Failed to load persisted channel: ${e.message}") - _selectedChannel.postValue(ChannelID.Mesh) + _selectedChannel.value = ChannelID.Mesh } } @@ -629,7 +685,7 @@ class LocationChannelManager private constructor(private val context: Context) { */ fun clearPersistedChannel() { dataManager?.clearLastGeohashChannel() - _selectedChannel.postValue(ChannelID.Mesh) + _selectedChannel.value = ChannelID.Mesh Log.d(TAG, "Cleared persisted channel selection") } @@ -653,11 +709,11 @@ class LocationChannelManager private constructor(private val context: Context) { private fun loadLocationServicesState() { try { val enabled = dataManager?.isLocationServicesEnabled() ?: false - _locationServicesEnabled.postValue(enabled) + _locationServicesEnabled.value = enabled Log.d(TAG, "Loaded location services state: $enabled") } catch (e: Exception) { Log.e(TAG, "Failed to load location services state: ${e.message}") - _locationServicesEnabled.postValue(false) + _locationServicesEnabled.value = false } } @@ -668,16 +724,9 @@ class LocationChannelManager private constructor(private val context: Context) { Log.d(TAG, "Cleaning up LocationChannelManager") endLiveRefresh() - // For older Android versions, remove any remaining location listener to prevent memory leaks - if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.R) { - try { - locationManager.removeUpdates(oneShotLocationListener) - } catch (e: SecurityException) { - Log.e(TAG, "Error removing location listener during cleanup: ${e.message}") - } catch (e: Exception) { - Log.e(TAG, "Error during cleanup: ${e.message}") - } - } + // Remove listeners to prevent memory leaks + try { locationManager.removeUpdates(oneShotLocationListener) } catch (_: Exception) {} + try { locationManager.removeUpdates(continuousLocationListener) } catch (_: Exception) {} // For Android 11+, getCurrentLocation doesn't need explicit cleanup as it's a one-time operation } } diff --git a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt index 2b0b2bdd..012ea3c4 100644 --- a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt +++ b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt @@ -5,7 +5,10 @@ import android.content.SharedPreferences import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import java.security.MessageDigest +import android.util.Base64 import android.util.Log +import com.bitchat.android.util.hexEncodedString +import androidx.core.content.edit /** * Manages persistent identity storage and peer ID rotation - 100% compatible with iOS implementation @@ -24,9 +27,15 @@ class SecureIdentityStateManager(private val context: Context) { private const val KEY_STATIC_PUBLIC_KEY = "static_public_key" private const val KEY_SIGNING_PRIVATE_KEY = "signing_private_key" private const val KEY_SIGNING_PUBLIC_KEY = "signing_public_key" + private const val KEY_VERIFIED_FINGERPRINTS = "verified_fingerprints" + private const val KEY_CACHED_PEER_FINGERPRINTS = "cached_peer_fingerprints" + private const val KEY_CACHED_PEER_NOISE_KEYS = "cached_peer_noise_keys" + private const val KEY_CACHED_NOISE_FINGERPRINTS = "cached_noise_fingerprints" + private const val KEY_CACHED_FINGERPRINT_NICKNAMES = "cached_fingerprint_nicknames" } private val prefs: SharedPreferences + private val lock = Any() init { // Create master key for encryption @@ -168,7 +177,7 @@ class SecureIdentityStateManager(private val context: Context) { fun generateFingerprint(publicKeyData: ByteArray): String { val digest = MessageDigest.getInstance("SHA-256") val hash = digest.digest(publicKeyData) - return hash.joinToString("") { "%02x".format(it) } + return hash.hexEncodedString() } /** @@ -178,6 +187,112 @@ class SecureIdentityStateManager(private val context: Context) { // SHA-256 fingerprint should be 64 hex characters return fingerprint.matches(Regex("^[a-fA-F0-9]{64}$")) } + + // MARK: - Verified Fingerprints + + fun getVerifiedFingerprints(): Set { + return prefs.getStringSet(KEY_VERIFIED_FINGERPRINTS, emptySet())?.toSet() ?: emptySet() + } + + fun isVerifiedFingerprint(fingerprint: String): Boolean { + return getVerifiedFingerprints().contains(fingerprint) + } + + fun setVerifiedFingerprint(fingerprint: String, verified: Boolean) { + if (!isValidFingerprint(fingerprint)) return + synchronized(lock) { + val current = prefs.getStringSet(KEY_VERIFIED_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf() + if (verified) { + current.add(fingerprint) + } else { + current.remove(fingerprint) + } + prefs.edit { putStringSet(KEY_VERIFIED_FINGERPRINTS, current) } + } + } + + fun getCachedPeerFingerprint(peerID: String): String? { + val pid = peerID.lowercase() + // Reading is safe without lock for SharedPreferences, but synchronizing ensures memory visibility + // if we are paranoid, but SharedPreferences is generally thread-safe for reads. + // However, to ensure we don't read a partial update (unlikely with SP), we can leave it. + // The critical part is the write. + val entries = prefs.getStringSet(KEY_CACHED_PEER_FINGERPRINTS, emptySet()) ?: return null + val entry = entries.firstOrNull { it.startsWith("$pid:") } ?: return null + return entry.substringAfter(':').takeIf { isValidFingerprint(it) } + } + + fun cachePeerFingerprint(peerID: String, fingerprint: String) { + if (!isValidFingerprint(fingerprint)) return + val pid = peerID.lowercase() + synchronized(lock) { + val current = prefs.getStringSet(KEY_CACHED_PEER_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf() + current.removeAll { it.startsWith("$pid:") } + current.add("$pid:$fingerprint") + prefs.edit { putStringSet(KEY_CACHED_PEER_FINGERPRINTS, current) } + } + } + + fun getCachedNoiseKey(peerID: String): String? { + val pid = peerID.lowercase() + val entries = prefs.getStringSet(KEY_CACHED_PEER_NOISE_KEYS, emptySet()) ?: return null + val entry = entries.firstOrNull { it.startsWith("$pid=") } ?: return null + return entry.substringAfter('=').takeIf { it.matches(Regex("^[a-fA-F0-9]{64}$")) } + } + + fun cachePeerNoiseKey(peerID: String, noiseKeyHex: String) { + if (!noiseKeyHex.matches(Regex("^[a-fA-F0-9]{64}$"))) return + val pid = peerID.lowercase() + synchronized(lock) { + val current = prefs.getStringSet(KEY_CACHED_PEER_NOISE_KEYS, emptySet())?.toMutableSet() ?: mutableSetOf() + current.removeAll { it.startsWith("$pid=") } + current.add("$pid=${noiseKeyHex.lowercase()}") + prefs.edit { putStringSet(KEY_CACHED_PEER_NOISE_KEYS, current) } + } + } + + fun getCachedNoiseFingerprint(noiseKeyHex: String): String? { + val key = noiseKeyHex.lowercase() + val entries = prefs.getStringSet(KEY_CACHED_NOISE_FINGERPRINTS, emptySet()) ?: return null + val entry = entries.firstOrNull { it.startsWith("$key=") } ?: return null + return entry.substringAfter('=').takeIf { isValidFingerprint(it) } + } + + fun cacheNoiseFingerprint(noiseKeyHex: String, fingerprint: String) { + if (!isValidFingerprint(fingerprint)) return + if (!noiseKeyHex.matches(Regex("^[a-fA-F0-9]{64}$"))) return + val key = noiseKeyHex.lowercase() + synchronized(lock) { + val current = prefs.getStringSet(KEY_CACHED_NOISE_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf() + current.removeAll { it.startsWith("$key=") } + current.add("$key=$fingerprint") + prefs.edit { putStringSet(KEY_CACHED_NOISE_FINGERPRINTS, current) } + } + } + + fun getCachedFingerprintNickname(fingerprint: String): String? { + if (!isValidFingerprint(fingerprint)) return null + val key = fingerprint.lowercase() + val entries = prefs.getStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, emptySet()) ?: return null + val entry = entries.firstOrNull { it.startsWith("$key=") } ?: return null + val encoded = entry.substringAfter('=') + return runCatching { + val bytes = Base64.decode(encoded, Base64.NO_WRAP) + String(bytes, Charsets.UTF_8) + }.getOrNull() + } + + fun cacheFingerprintNickname(fingerprint: String, nickname: String) { + if (!isValidFingerprint(fingerprint)) return + val key = fingerprint.lowercase() + val encoded = Base64.encodeToString(nickname.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) + synchronized(lock) { + val current = prefs.getStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, emptySet())?.toMutableSet() ?: mutableSetOf() + current.removeAll { it.startsWith("$key=") } + current.add("$key=$encoded") + prefs.edit { putStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, current) } + } + } // MARK: - Peer ID Rotation Management (removed) // Android now derives peer ID from the persisted Noise identity fingerprint. diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt index 7098d46c..2f1664e7 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -84,6 +84,10 @@ class BluetoothConnectionManager( // Public property for address-peer mapping val addressPeerMap get() = connectionTracker.addressPeerMap + + // Expose first-announce helpers to higher layers + fun noteAnnounceReceived(address: String) { connectionTracker.noteAnnounceReceived(address) } + fun hasSeenFirstAnnounce(address: String): Boolean = connectionTracker.hasSeenFirstAnnounce(address) init { powerManager.delegate = this @@ -145,6 +149,7 @@ class BluetoothConnectionManager( try { isActive = true + Log.d(TAG, "ConnectionManager activated (permissions and adapter OK)") // set the adapter's name to our 8-character peerID for iOS privacy, TODO: Make this configurable // try { @@ -175,6 +180,7 @@ class BluetoothConnectionManager( this@BluetoothConnectionManager.isActive = false return@launch } + Log.d(TAG, "GATT Server started") } else { Log.i(TAG, "GATT Server disabled by debug settings; not starting") } @@ -185,6 +191,7 @@ class BluetoothConnectionManager( this@BluetoothConnectionManager.isActive = false return@launch } + Log.d(TAG, "GATT Client started") } else { Log.i(TAG, "GATT Client disabled by debug settings; not starting") } @@ -210,6 +217,7 @@ class BluetoothConnectionManager( isActive = false connectionScope.launch { + Log.d(TAG, "Stopping client/server and power components...") // Stop component managers clientManager.stop() serverManager.stop() @@ -226,14 +234,19 @@ class BluetoothConnectionManager( Log.i(TAG, "All Bluetooth services stopped") } } - - /** - * Set app background state for power optimization - */ - fun setAppBackgroundState(inBackground: Boolean) { - powerManager.setAppBackgroundState(inBackground) - } + /** + * Indicates whether this instance can be safely reused for a future start. + * Returns false if its coroutine scope has been cancelled. + */ + fun isReusable(): Boolean { + val active = connectionScope.isActive + if (!active) { + Log.d(TAG, "BluetoothConnectionManager isReusable=false (scope cancelled)") + } + return active + } + /** * Broadcast packet to connected devices with connection limit enforcement * Automatically fragments large packets to fit within BLE MTU limits diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt index 0f0bdd86..7029185f 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt @@ -30,6 +30,8 @@ class BluetoothConnectionTracker( private val connectedDevices = ConcurrentHashMap() private val subscribedDevices = CopyOnWriteArrayList() val addressPeerMap = ConcurrentHashMap() + // Track whether we have seen the first ANNOUNCE on a given device connection + private val firstAnnounceSeen = ConcurrentHashMap() // RSSI tracking from scan results (for devices we discover but may connect as servers) private val scanRSSI = ConcurrentHashMap() @@ -91,6 +93,8 @@ class BluetoothConnectionTracker( Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}") connectedDevices[deviceAddress] = deviceConn pendingConnections.remove(deviceAddress) + // Mark as awaiting first ANNOUNCE on this connection + firstAnnounceSeen[deviceAddress] = false } /** @@ -194,7 +198,8 @@ class BluetoothConnectionTracker( } // Update connection attempt atomically - val attempts = (currentAttempt?.attempts ?: 0) + 1 + // If the previous attempt window expired, reset backoff to 1; otherwise increment + val attempts = if (currentAttempt?.isExpired() == true) 1 else (currentAttempt?.attempts ?: 0) + 1 pendingConnections[deviceAddress] = ConnectionAttempt(attempts) Log.d(TAG, "Tracker: Added pending connection for $deviceAddress (attempts: $attempts)") return true @@ -279,7 +284,7 @@ class BluetoothConnectionTracker( subscribedDevices.removeAll { it.address == deviceAddress } addressPeerMap.remove(deviceAddress) } - pendingConnections.remove(deviceAddress) + firstAnnounceSeen.remove(deviceAddress) Log.d(TAG, "Cleaned up device connection for $deviceAddress") } @@ -313,6 +318,21 @@ class BluetoothConnectionTracker( addressPeerMap.clear() pendingConnections.clear() scanRSSI.clear() + firstAnnounceSeen.clear() + } + + /** + * Mark that we have received the first ANNOUNCE over this device connection. + */ + fun noteAnnounceReceived(deviceAddress: String) { + firstAnnounceSeen[deviceAddress] = true + } + + /** + * Check whether the first ANNOUNCE has been seen for a device connection. + */ + fun hasSeenFirstAnnounce(deviceAddress: String): Boolean { + return firstAnnounceSeen[deviceAddress] == true } /** diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 1d9e0f99..dc73a642 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -7,12 +7,15 @@ import com.bitchat.android.model.BitchatMessage import com.bitchat.android.protocol.MessagePadding import com.bitchat.android.model.RoutedPacket import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.model.NoisePayload +import com.bitchat.android.model.NoisePayloadType import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.SpecialRecipients import com.bitchat.android.model.RequestSyncPacket import com.bitchat.android.sync.GossipSyncManager import com.bitchat.android.util.toHexString +import com.bitchat.android.services.VerificationService import kotlinx.coroutines.* import java.util.* import kotlin.math.sign @@ -52,6 +55,12 @@ class BluetoothMeshService(private val context: Context) { internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access private val packetProcessor = PacketProcessor(myPeerID) private lateinit var gossipSyncManager: GossipSyncManager + // Service-level notification manager for background (no-UI) DMs + private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager( + context.applicationContext, + androidx.core.app.NotificationManagerCompat.from(context.applicationContext), + com.bitchat.android.util.NotificationIntervalManager() + ) // Service state management private var isActive = false @@ -61,8 +70,12 @@ class BluetoothMeshService(private val context: Context) { // Coroutines private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + // Tracks whether this instance has been terminated via stopServices() + private var terminated = false init { + Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID") + VerificationService.configure(encryptionService) setupDelegates() messageHandler.packetProcessor = packetProcessor //startPeriodicDebugLogging() @@ -98,6 +111,7 @@ class BluetoothMeshService(private val context: Context) { return signPacketBeforeBroadcast(packet) } } + Log.d(TAG, "Delegates set up; GossipSyncManager initialized") } /** @@ -105,6 +119,7 @@ class BluetoothMeshService(private val context: Context) { */ private fun startPeriodicDebugLogging() { serviceScope.launch { + Log.d(TAG, "Starting periodic debug logging loop") while (isActive) { try { delay(10000) // 10 seconds @@ -116,6 +131,7 @@ class BluetoothMeshService(private val context: Context) { Log.e(TAG, "Error in periodic debug logging: ${e.message}") } } + Log.d(TAG, "Periodic debug logging loop ended (isActive=$isActive)") } } @@ -124,6 +140,7 @@ class BluetoothMeshService(private val context: Context) { */ private fun sendPeriodicBroadcastAnnounce() { serviceScope.launch { + Log.d(TAG, "Starting periodic announce loop") while (isActive) { try { delay(30000) // 30 seconds @@ -132,6 +149,7 @@ class BluetoothMeshService(private val context: Context) { Log.e(TAG, "Error in periodic broadcast announce: ${e.message}") } } + Log.d(TAG, "Periodic announce loop ended (isActive=$isActive)") } } @@ -139,6 +157,7 @@ class BluetoothMeshService(private val context: Context) { * Setup delegate connections between components */ private fun setupDelegates() { + Log.d(TAG, "Setting up component delegates") // Provide nickname resolver to BLE broadcaster for detailed logs try { connectionManager.setNicknameResolver { pid -> peerManager.getPeerNickname(pid) } @@ -146,6 +165,9 @@ class BluetoothMeshService(private val context: Context) { // PeerManager delegates to main mesh service delegate peerManager.delegate = object : PeerManagerDelegate { override fun onPeerListUpdated(peerIDs: List) { + // Update process-wide state first + try { com.bitchat.android.services.AppStateStore.setPeers(peerIDs) } catch (_: Exception) { } + // Then notify UI delegate if attached delegate?.didUpdatePeerList(peerIDs) } override fun onPeerRemoved(peerID: String) { @@ -165,6 +187,7 @@ class BluetoothMeshService(private val context: Context) { override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) { // Send announcement and cached messages after key exchange serviceScope.launch { + Log.d(TAG, "Key exchange completed with $peerID; sending follow-ups") delay(100) sendAnnouncementToPeer(peerID) @@ -352,7 +375,36 @@ class BluetoothMeshService(private val context: Context) { // Callbacks override fun onMessageReceived(message: BitchatMessage) { + // Always reflect into process-wide store so UI can hydrate after recreation + try { + when { + message.isPrivate -> { + val peer = message.senderPeerID ?: "" + if (peer.isNotEmpty()) com.bitchat.android.services.AppStateStore.addPrivateMessage(peer, message) + } + message.channel != null -> { + com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel!!, message) + } + else -> { + com.bitchat.android.services.AppStateStore.addPublicMessage(message) + } + } + } catch (_: Exception) { } + // And forward to UI delegate if attached delegate?.didReceiveMessage(message) + + // If no UI delegate attached (app closed), show DM notification via service manager + if (delegate == null && message.isPrivate) { + try { + val senderPeerID = message.senderPeerID + if (senderPeerID != null) { + val nick = try { peerManager.getPeerNickname(senderPeerID) } catch (_: Exception) { null } ?: senderPeerID + val preview = com.bitchat.android.ui.NotificationTextUtils.buildPrivateMessagePreview(message) + serviceNotificationManager.setAppBackgroundState(true) + serviceNotificationManager.showPrivateMessageNotification(senderPeerID, nick, preview) + } + } catch (_: Exception) { } + } } override fun onChannelLeave(channel: String, fromPeer: String) { @@ -366,6 +418,14 @@ class BluetoothMeshService(private val context: Context) { override fun onReadReceiptReceived(messageID: String, peerID: String) { delegate?.didReceiveReadReceipt(messageID, peerID) } + + override fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long) { + delegate?.didReceiveVerifyChallenge(peerID, payload, timestampMs) + } + + override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) { + delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs) + } } // PacketProcessor delegates @@ -408,25 +468,17 @@ class BluetoothMeshService(private val context: Context) { val deviceAddress = routed.relayAddress val pid = routed.peerID if (deviceAddress != null && pid != null) { - // Only set mapping if not already mapped - if (!connectionManager.addressPeerMap.containsKey(deviceAddress)) { + // First ANNOUNCE over a device connection defines a direct neighbor. + if (!connectionManager.hasSeenFirstAnnounce(deviceAddress)) { + // Bind or rebind this device address to the announcing peer connectionManager.addressPeerMap[deviceAddress] = pid - Log.d(TAG, "Mapped device $deviceAddress to peer $pid on ANNOUNCE") + connectionManager.noteAnnounceReceived(deviceAddress) + Log.d(TAG, "Mapped device $deviceAddress to peer $pid on FIRST-ANNOUNCE for this connection") - // Mark this peer as directly connected for UI - try { - peerManager.getPeerInfo(pid)?.let { - // Set direct connection flag - // (This will also trigger a peer list update) - peerManager.setDirectConnection(pid, true) - // Also push reactive directness state to UI (best-effort) - try { - // Note: UI observes via didUpdatePeerList, but we can also update ChatState on a timer - } catch (_: Exception) { } - } - } catch (_: Exception) { } + // Mark as directly connected (upgrades from routed if needed) + try { peerManager.setDirectConnection(pid, true) } catch (_: Exception) { } - // Schedule initial sync for this new directly connected peer only + // Initial sync for this newly direct peer try { gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { } } } @@ -489,12 +541,23 @@ class BluetoothMeshService(private val context: Context) { // BluetoothConnectionManager delegates connectionManager.delegate = object : BluetoothConnectionManagerDelegate { override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) { + // Log incoming for debug graphs (do not double-count anywhere else) + try { + val nick = getPeerNicknames()[peerID] + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming( + packetType = packet.type.toString(), + fromPeerID = peerID, + fromNickname = nick, + fromDeviceAddress = device?.address + ) + } catch (_: Exception) { } packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address)) } override fun onDeviceConnected(device: android.bluetooth.BluetoothDevice) { // Send initial announcements after services are ready serviceScope.launch { + Log.d(TAG, "Device connected: ${device.address}; scheduling announce") delay(200) sendBroadcastAnnounce() } @@ -509,6 +572,7 @@ class BluetoothMeshService(private val context: Context) { } override fun onDeviceDisconnected(device: android.bluetooth.BluetoothDevice) { + Log.d(TAG, "Device disconnected: ${device.address}") val addr = device.address // Remove mapping and, if that was the last direct path for the peer, clear direct flag val peer = connectionManager.addressPeerMap[addr] @@ -547,6 +611,11 @@ class BluetoothMeshService(private val context: Context) { Log.w(TAG, "Mesh service already active, ignoring duplicate start request") return } + if (terminated) { + // This instance's scope was cancelled previously; refuse to start to avoid using dead scopes. + Log.e(TAG, "Mesh service instance was terminated; create a new instance instead of restarting") + return + } Log.i(TAG, "Starting Bluetooth mesh service with peer ID: $myPeerID") @@ -558,6 +627,7 @@ class BluetoothMeshService(private val context: Context) { Log.d(TAG, "Started periodic broadcast announcements (every 30 seconds)") // Start periodic syncs gossipSyncManager.start() + Log.d(TAG, "GossipSyncManager started") } else { Log.e(TAG, "Failed to start Bluetooth services") } @@ -579,11 +649,14 @@ class BluetoothMeshService(private val context: Context) { sendLeaveAnnouncement() serviceScope.launch { + Log.d(TAG, "Stopping subcomponents and cancelling scope...") delay(200) // Give leave message time to send // Stop all components gossipSyncManager.stop() + Log.d(TAG, "GossipSyncManager stopped") connectionManager.stopServices() + Log.d(TAG, "BluetoothConnectionManager stop requested") peerManager.shutdown() fragmentManager.shutdown() securityManager.shutdown() @@ -591,9 +664,24 @@ class BluetoothMeshService(private val context: Context) { messageHandler.shutdown() packetProcessor.shutdown() + // Mark this instance as terminated and cancel its scope so it won't be reused + terminated = true serviceScope.cancel() + Log.i(TAG, "BluetoothMeshService terminated and scope cancelled") } } + + /** + * Whether this instance can be safely reused. Returns false after stopServices() or if + * any critical internal scope has been cancelled. + */ + fun isReusable(): Boolean { + val reusable = !terminated && serviceScope.isActive && connectionManager.isReusable() + if (!reusable) { + Log.d(TAG, "isReusable=false (terminated=$terminated, scopeActive=${serviceScope.isActive}, connReusable=${connectionManager.isReusable()})") + } + return reusable + } /** * Send public message @@ -813,7 +901,7 @@ class BluetoothMeshService(private val context: Context) { fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) { serviceScope.launch { Log.d(TAG, "📖 Sending read receipt for message $messageID to $recipientPeerID") - + // Route geohash read receipts via MessageRouter instead of here val geo = runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance() }.getOrNull() val isGeoAlias = try { @@ -824,8 +912,15 @@ class BluetoothMeshService(private val context: Context) { geo.sendReadReceipt(com.bitchat.android.model.ReadReceipt(messageID), recipientPeerID) return@launch } - + try { + // Avoid duplicate read receipts: check persistent store first + val seenStore = try { com.bitchat.android.services.SeenMessageStore.getInstance(context.applicationContext) } catch (_: Exception) { null } + if (seenStore?.hasRead(messageID) == true) { + Log.d(TAG, "Skipping read receipt for $messageID - already marked read") + return@launch + } + // Create read receipt payload using NoisePayloadType exactly like iOS val readReceiptPayload = com.bitchat.android.model.NoisePayload( type = com.bitchat.android.model.NoisePayloadType.READ_RECEIPT, @@ -851,12 +946,59 @@ class BluetoothMeshService(private val context: Context) { val signedPacket = signPacketBeforeBroadcast(packet) connectionManager.broadcastPacket(RoutedPacket(signedPacket)) Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID") - + + // Persist as read after successful send + try { seenStore?.markRead(messageID) } catch (_: Exception) { } + } catch (e: Exception) { Log.e(TAG, "Failed to send read receipt to $recipientPeerID: ${e.message}") } } } + + // MARK: QR Verification over Noise + + fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) { + val tlv = VerificationService.buildVerifyChallenge(noiseKeyHex, nonceA) + val payload = NoisePayload( + type = NoisePayloadType.VERIFY_CHALLENGE, + data = tlv + ) + sendNoisePayloadToPeer(payload, peerID, "verify challenge") + } + + fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) { + val tlv = VerificationService.buildVerifyResponse(noiseKeyHex, nonceA) ?: return + val payload = NoisePayload( + type = NoisePayloadType.VERIFY_RESPONSE, + data = tlv + ) + sendNoisePayloadToPeer(payload, peerID, "verify response") + } + + private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String, label: String) { + serviceScope.launch { + try { + val encrypted = encryptionService.encrypt(payload.encode(), recipientPeerID) + val packet = BitchatPacket( + version = 1u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(recipientPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = encrypted, + signature = null, + ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS + ) + + val signedPacket = signPacketBeforeBroadcast(packet) + connectionManager.broadcastPacket(RoutedPacket(signedPacket)) + Log.d(TAG, "📤 Sent $label to $recipientPeerID (${payload.data.size} bytes)") + } catch (e: Exception) { + Log.e(TAG, "Failed to send $label to $recipientPeerID: ${e.message}") + } + } + } /** * Send broadcast announce with TLV-encoded identity announcement - exactly like iOS @@ -864,7 +1006,7 @@ class BluetoothMeshService(private val context: Context) { fun sendBroadcastAnnounce() { Log.d(TAG, "Sending broadcast announce") serviceScope.launch { - val nickname = delegate?.getNickname() ?: myPeerID + val nickname = try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { myPeerID } // Get the static public key for the announcement val staticKey = encryptionService.getStaticPublicKey() @@ -927,7 +1069,7 @@ class BluetoothMeshService(private val context: Context) { fun sendAnnouncementToPeer(peerID: String) { if (peerManager.hasAnnouncedToPeer(peerID)) return - val nickname = delegate?.getNickname() ?: myPeerID + val nickname = try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { myPeerID } // Get the static public key for the announcement val staticKey = encryptionService.getStaticPublicKey() @@ -1003,12 +1145,11 @@ class BluetoothMeshService(private val context: Context) { * Send leave announcement */ private fun sendLeaveAnnouncement() { - val nickname = delegate?.getNickname() ?: myPeerID val packet = BitchatPacket( type = MessageType.LEAVE.value, ttl = MAX_TTL, senderID = myPeerID, - payload = nickname.toByteArray() + payload = byteArrayOf() ) // Sign the packet before broadcasting @@ -1055,6 +1196,13 @@ class BluetoothMeshService(private val context: Context) { return peerManager.getFingerprintForPeer(peerID) } + /** + * Get current active peer count (for status/notifications) + */ + fun getActivePeerCount(): Int { + return try { peerManager.getActivePeerCount() } catch (_: Exception) { 0 } + } + /** * Get peer info for verification purposes */ @@ -1081,6 +1229,10 @@ class BluetoothMeshService(private val context: Context) { fun getIdentityFingerprint(): String { return encryptionService.getIdentityFingerprint() } + + fun getStaticNoisePublicKey(): ByteArray? { + return encryptionService.getStaticPublicKey() + } /** * Check if encryption icon should be shown for a peer @@ -1253,6 +1405,8 @@ interface BluetoothMeshDelegate { fun didReceiveChannelLeave(channel: String, fromPeer: String) fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) + fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) + fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? fun getNickname(): String? fun isFavorite(peerID: String): Boolean diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt index 78af426f..73103772 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt @@ -73,9 +73,17 @@ class BluetoothPacketBroadcaster( try { val fromNick = incomingPeer?.let { nicknameResolver?.invoke(it) } val toNick = toPeer?.let { nicknameResolver?.invoke(it) } - val isRelay = (incomingAddr != null || incomingPeer != null) - - com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logPacketRelayDetailed( + val manager = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() + // Always log outgoing for the actual transmission target + manager.logOutgoing( + packetType = typeName, + toPeerID = toPeer, + toNickname = toNick, + toDeviceAddress = toDeviceAddress, + previousHopPeerID = incomingPeer + ) + // Keep the verbose relay message for human readability + manager.logPacketRelayDetailed( packetType = typeName, senderPeerID = senderPeerID, senderNickname = senderNick, @@ -86,7 +94,7 @@ class BluetoothPacketBroadcaster( toNickname = toNick, toDeviceAddress = toDeviceAddress, ttl = ttl, - isRelay = isRelay + isRelay = true ) } catch (_: Exception) { // Silently ignore debug logging failures diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPermissionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPermissionManager.kt index 917de66e..45d48ac6 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPermissionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPermissionManager.kt @@ -33,9 +33,9 @@ class BluetoothPermissionManager(private val context: Context) { Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION )) - + return permissions.all { ActivityCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index d358c4b6..63081d74 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -157,6 +157,14 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro // Simplified: Call delegate with messageID and peerID directly delegate?.onReadReceiptReceived(messageID, peerID) } + com.bitchat.android.model.NoisePayloadType.VERIFY_CHALLENGE -> { + Log.d(TAG, "🔐 Verify challenge received from $peerID (${noisePayload.data.size} bytes)") + delegate?.onVerifyChallengeReceived(peerID, noisePayload.data, packet.timestamp.toLong()) + } + com.bitchat.android.model.NoisePayloadType.VERIFY_RESPONSE -> { + Log.d(TAG, "🔐 Verify response received from $peerID (${noisePayload.data.size} bytes)") + delegate?.onVerifyResponseReceived(peerID, noisePayload.data, packet.timestamp.toLong()) + } } } catch (e: Exception) { @@ -618,4 +626,6 @@ interface MessageHandlerDelegate { fun onChannelLeave(channel: String, fromPeer: String) fun onDeliveryAckReceived(messageID: String, peerID: String) fun onReadReceiptReceived(messageID: String, peerID: String) + fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long) + fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) } diff --git a/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt b/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt index 4c279d4a..d4c84c03 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt @@ -299,8 +299,7 @@ class PeerManager { */ fun isPeerActive(peerID: String): Boolean { val info = peers[peerID] ?: return false - val now = System.currentTimeMillis() - return (now - info.lastSeen) <= stalePeerTimeoutMs && info.isConnected + return info.isConnected } /** @@ -328,8 +327,7 @@ class PeerManager { * Get list of active peer IDs */ fun getActivePeerIDs(): List { - val now = System.currentTimeMillis() - return peers.filterValues { (now - it.lastSeen) <= stalePeerTimeoutMs && it.isConnected } + return peers.filterValues { it.isConnected } .keys .toList() .sorted() diff --git a/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt b/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt index 9db444e6..3096828a 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt @@ -7,7 +7,13 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.BatteryManager +import android.os.Handler +import android.os.Looper import android.util.Log +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner import kotlinx.coroutines.* import kotlin.math.max @@ -15,7 +21,7 @@ import kotlin.math.max * Power-aware Bluetooth management for bitchat * Adjusts scanning, advertising, and connection behavior based on battery state */ -class PowerManager(private val context: Context) { +class PowerManager(private val context: Context) : LifecycleEventObserver { companion object { private const val TAG = "PowerManager" @@ -49,7 +55,7 @@ class PowerManager(private val context: Context) { private var currentMode = PowerMode.BALANCED private var isCharging = false private var batteryLevel = 100 - private var isAppInBackground = false + private var isAppInBackground = true private val powerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private var dutyCycleJob: Job? = null @@ -87,6 +93,16 @@ class PowerManager(private val context: Context) { init { registerBatteryReceiver() + + // Register for process lifecycle events on the main thread + Handler(Looper.getMainLooper()).post { + try { + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + } catch (e: Exception) { + Log.e(TAG, "Failed to register lifecycle observer: ${e.message}") + } + } + updatePowerMode() } @@ -99,13 +115,30 @@ class PowerManager(private val context: Context) { Log.i(TAG, "Stopping power management") powerScope.cancel() unregisterBatteryReceiver() + + // Unregister lifecycle observer + Handler(Looper.getMainLooper()).post { + try { + ProcessLifecycleOwner.get().lifecycle.removeObserver(this) + } catch (e: Exception) { + Log.e(TAG, "Failed to remove lifecycle observer: ${e.message}") + } + } } - fun setAppBackgroundState(inBackground: Boolean) { - if (isAppInBackground != inBackground) { - isAppInBackground = inBackground - Log.d(TAG, "App background state changed: $inBackground") - updatePowerMode() + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + when (event) { + Lifecycle.Event.ON_START -> { + Log.d(TAG, "Process lifecycle: ON_START (App coming to foreground)") + isAppInBackground = false + updatePowerMode() + } + Lifecycle.Event.ON_STOP -> { + Log.d(TAG, "Process lifecycle: ON_STOP (App going to background)") + isAppInBackground = true + updatePowerMode() + } + else -> {} } } @@ -133,7 +166,7 @@ class PowerManager(private val context: Context) { .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT) PowerMode.ULTRA_LOW_POWER -> builder - .setScanMode(ScanSettings.SCAN_MODE_OPPORTUNISTIC) + .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER) .setMatchMode(ScanSettings.MATCH_MODE_STICKY) .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT) } @@ -224,26 +257,29 @@ class PowerManager(private val context: Context) { } private fun updatePowerMode() { - val newMode = when { - // Always use performance mode when charging (unless in background too long) + // Determine the base mode from battery/charging state only + val baseMode = when { + // Charging in foreground may use performance isCharging && !isAppInBackground -> PowerMode.PERFORMANCE - - // Critical battery - use ultra low power + + // Critical battery - force ultra low power regardless of foreground/background batteryLevel <= CRITICAL_BATTERY -> PowerMode.ULTRA_LOW_POWER - - // Low battery - use power saver + + // Low battery - prefer power saver batteryLevel <= LOW_BATTERY -> PowerMode.POWER_SAVER - - // Background app with medium battery - use power saver - isAppInBackground && batteryLevel <= MEDIUM_BATTERY -> PowerMode.POWER_SAVER - - // Background app with good battery - use balanced - isAppInBackground -> PowerMode.BALANCED - - // Foreground with good battery - use balanced + + // Otherwise balanced else -> PowerMode.BALANCED } - + + // If app is in background (including when running as a foreground service), + // cap the power mode to at least POWER_SAVER. Preserve ULTRA_LOW_POWER. + val newMode = if (isAppInBackground) { + if (baseMode == PowerMode.ULTRA_LOW_POWER) PowerMode.ULTRA_LOW_POWER else PowerMode.POWER_SAVER + } else { + baseMode + } + if (newMode != currentMode) { val oldMode = currentMode currentMode = newMode diff --git a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt index 476ead73..5beb3384 100644 --- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt @@ -50,35 +50,36 @@ class SecurityManager(private val encryptionService: EncryptionService, private return false } - // Validate packet payload - if (packet.payload.isEmpty()) { - Log.d(TAG, "Dropping packet with empty payload") - return false - } - // Replay attack protection (same 5-minute window as iOS) val currentTime = System.currentTimeMillis() - val packetTime = packet.timestamp.toLong() - val timeDiff = kotlin.math.abs(currentTime - packetTime) - -// if (timeDiff > MESSAGE_TIMEOUT) { -// Log.d(TAG, "Dropping old packet from $peerID, time diff: ${timeDiff/1000}s") -// return false -// } + val messageType = MessageType.fromValue(packet.type) // Duplicate detection val messageID = generateMessageID(packet, peerID) - if (processedMessages.contains(messageID)) { - Log.d(TAG, "Dropping duplicate packet: $messageID") - return false - } + if (processedMessages.contains(messageID)) { + // Check for ANNOUNCE exception: allow if it looks like a direct neighbor (max TTL) + // This ensures we catch the "first announce" on a new connection for binding, + // while still dropping looped/relayed duplicates. + val isFreshAnnounce = messageType == MessageType.ANNOUNCE && + packet.ttl >= com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS + + if (!isFreshAnnounce) { + Log.d(TAG, "Dropping duplicate packet: $messageID") + return false + } + Log.d(TAG, "Allowing duplicate ANNOUNCE from direct neighbor: $messageID") + } + // Add to processed messages processedMessages.add(messageID) messageTimestamps[messageID] = currentTime - // NEW: Signature verification logging (not rejecting yet) - verifyPacketSignatureWithLogging(packet, peerID) + // Enforce mandatory signature verification + if (!verifyPacketSignature(packet, peerID)) { + Log.w(TAG, "Dropping packet from $peerID due to signature verification failure") + return false + } Log.d(TAG, "Packet validation passed for $peerID, messageID: $messageID") return true @@ -231,34 +232,58 @@ class SecurityManager(private val encryptionService: EncryptionService, private } /** - * Verify packet signature using peer's signing public key and log the result + * Verify packet signature using peer's signing public key + * Returns true only if signature is present and valid */ - private fun verifyPacketSignatureWithLogging(packet: BitchatPacket, peerID: String) { + private fun verifyPacketSignature(packet: BitchatPacket, peerID: String): Boolean { try { - // Check if packet has a signature + // only verify ANNOUNCE, MESSAGE, and FILE_TRANSFER + if (MessageType.fromValue(packet.type) !in setOf( + MessageType.ANNOUNCE, + MessageType.MESSAGE, + MessageType.FILE_TRANSFER + )) { + return true + } + // 1. Mandatory Signature Check if (packet.signature == null) { - Log.d(TAG, "📝 Signature check for $peerID: NO_SIGNATURE (packet type ${packet.type})") - return + Log.w(TAG, "❌ Signature check for $peerID: NO_SIGNATURE (packet type ${packet.type})") + return false } - // Try to get peer's signing public key from peer info - val peerInfo = delegate?.getPeerInfo(peerID) - val signingPublicKey = peerInfo?.signingPublicKey + // 2. Get Signing Public Key + var signingPublicKey: ByteArray? = null + + if (MessageType.fromValue(packet.type) == MessageType.ANNOUNCE) { + // Special Case: ANNOUNCE packets carry their own signing key + try { + val announcement = com.bitchat.android.model.IdentityAnnouncement.decode(packet.payload) + signingPublicKey = announcement?.signingPublicKey + } catch (e: Exception) { + Log.w(TAG, "Failed to decode announcement for key extraction: ${e.message}") + } + } else { + // Standard Case: Get key from known peer info + val peerInfo = delegate?.getPeerInfo(peerID) + signingPublicKey = peerInfo?.signingPublicKey + } if (signingPublicKey == null) { - Log.d(TAG, "📝 Signature check for $peerID: NO_SIGNING_KEY (packet type ${packet.type})") - return + // If we don't have a key (and it's not an announce), we can't verify. + // For security, we must reject packets from unknown peers unless it's an announce. + Log.w(TAG, "❌ Signature check for $peerID: NO_SIGNING_KEY_AVAILABLE (packet type ${packet.type})") + return false } - // Get the canonical packet data for signature verification (without signature) + // 3. Get Canonical Data val packetDataForSigning = packet.toBinaryDataForSigning() if (packetDataForSigning == null) { - Log.w(TAG, "📝 Signature check for $peerID: ENCODING_ERROR (packet type ${packet.type})") - return + Log.w(TAG, "❌ Signature check for $peerID: ENCODING_ERROR (packet type ${packet.type})") + return false } - // Verify the signature using the peer's signing public key - val signature = packet.signature!! // We already checked for null above + // 4. Verify Signature + val signature = packet.signature!! val isSignatureValid = encryptionService.verifyEd25519Signature( signature, packetDataForSigning, @@ -266,13 +291,16 @@ class SecurityManager(private val encryptionService: EncryptionService, private ) if (isSignatureValid) { - Log.d(TAG, "📝 Signature check for $peerID: ✅ VALID (packet type ${packet.type})") + // Log.v(TAG, "✅ Signature verified for $peerID (type ${packet.type})") + return true } else { - Log.w(TAG, "📝 Signature check for $peerID: ❌ INVALID (packet type ${packet.type})") + Log.w(TAG, "❌ Signature INVALID for $peerID (type ${packet.type})") + return false } } catch (e: Exception) { - Log.w(TAG, "📝 Signature check for $peerID: ERROR - ${e.message} (packet type ${packet.type})") + Log.e(TAG, "❌ Signature verification error for $peerID: ${e.message}") + return false } } diff --git a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt index 7f691a9c..c46a114f 100644 --- a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt +++ b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt @@ -21,6 +21,8 @@ enum class NoisePayloadType(val value: UByte) { PRIVATE_MESSAGE(0x01u), // Private chat message with TLV encoding READ_RECEIPT(0x02u), // Message was read DELIVERED(0x03u), // Message was delivered + VERIFY_CHALLENGE(0x10u), // Verification challenge + VERIFY_RESPONSE(0x11u), // Verification response FILE_TRANSFER(0x20u); diff --git a/app/src/main/java/com/bitchat/android/net/TorManager.kt b/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt similarity index 52% rename from app/src/main/java/com/bitchat/android/net/TorManager.kt rename to app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt index b7c2230d..8dc0cad8 100644 --- a/app/src/main/java/com/bitchat/android/net/TorManager.kt +++ b/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt @@ -2,6 +2,7 @@ package com.bitchat.android.net import android.app.Application import android.util.Log +import com.bitchat.android.util.AppConstants import info.guardianproject.arti.ArtiLogListener import info.guardianproject.arti.ArtiProxy import kotlinx.coroutines.CoroutineScope @@ -11,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.launch @@ -24,54 +26,97 @@ import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicLong /** - * Manages embedded Tor lifecycle & provides SOCKS proxy address. - * Uses Arti (Tor in Rust) for improved security and reliability. + * Tor provider implementation using custom-built Arti (Tor-in-Rust). + * + * This singleton provides Tor anonymity features using a custom Arti build + * compiled with 16KB page size support for Google Play compliance. + * + * Based on the original TorManager implementation. */ -object TorManager { - private const val TAG = "TorManager" - private const val DEFAULT_SOCKS_PORT = com.bitchat.android.util.AppConstants.Tor.DEFAULT_SOCKS_PORT - private const val RESTART_DELAY_MS = com.bitchat.android.util.AppConstants.Tor.RESTART_DELAY_MS // 2 seconds between stop/start - private const val INACTIVITY_TIMEOUT_MS = com.bitchat.android.util.AppConstants.Tor.INACTIVITY_TIMEOUT_MS // 5 seconds of no activity before restart - private const val MAX_RETRY_ATTEMPTS = com.bitchat.android.util.AppConstants.Tor.MAX_RETRY_ATTEMPTS - private const val STOP_TIMEOUT_MS = com.bitchat.android.util.AppConstants.Tor.STOP_TIMEOUT_MS +class ArtiTorManager private constructor() { + enum class TorState { + OFF, + STARTING, + BOOTSTRAPPING, + RUNNING, + STOPPING, + ERROR + } + + data class TorStatus( + val mode: TorMode = TorMode.OFF, + val running: Boolean = false, + val bootstrapPercent: Int = 0, + val lastLogLine: String = "", + val state: TorState = TorState.OFF + ) + + companion object { + private const val TAG = "ArtiTorManager" + private const val DEFAULT_SOCKS_PORT = AppConstants.Tor.DEFAULT_SOCKS_PORT + private const val RESTART_DELAY_MS = AppConstants.Tor.RESTART_DELAY_MS + private const val INACTIVITY_TIMEOUT_MS = AppConstants.Tor.INACTIVITY_TIMEOUT_MS + private const val MAX_RETRY_ATTEMPTS = AppConstants.Tor.MAX_RETRY_ATTEMPTS + private const val STOP_TIMEOUT_MS = AppConstants.Tor.STOP_TIMEOUT_MS + + @Volatile + private var INSTANCE: ArtiTorManager? = null + + fun getInstance(): ArtiTorManager { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: ArtiTorManager().also { INSTANCE = it } + } + } + } private val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - @Volatile private var initialized = false - @Volatile private var socksAddr: InetSocketAddress? = null - private val artiProxyRef = AtomicReference(null) - @Volatile private var lastMode: TorMode = TorMode.OFF + @Volatile + private var initialized = false + @Volatile + private var socksAddr: InetSocketAddress? = null + @Volatile + private var artiProxy: ArtiProxy? = null + @Volatile + private var lastMode: TorMode = TorMode.OFF private val applyMutex = Mutex() - @Volatile private var desiredMode: TorMode = TorMode.OFF - @Volatile private var currentSocksPort: Int = DEFAULT_SOCKS_PORT - @Volatile private var lastLogTime = AtomicLong(0L) - @Volatile private var retryAttempts = 0 - @Volatile private var bindRetryAttempts = 0 + @Volatile + private var desiredMode: TorMode = TorMode.OFF + @Volatile + private var currentSocksPort: Int = DEFAULT_SOCKS_PORT + @Volatile + private var lastLogTime = AtomicLong(0L) + @Volatile + private var retryAttempts = 0 + @Volatile + private var bindRetryAttempts = 0 private var inactivityJob: Job? = null private var retryJob: Job? = null private var currentApplication: Application? = null private enum class LifecycleState { STOPPED, STARTING, RUNNING, STOPPING } - @Volatile private var lifecycleState: LifecycleState = LifecycleState.STOPPED - enum class TorState { OFF, STARTING, BOOTSTRAPPING, RUNNING, STOPPING, ERROR } + @Volatile + private var lifecycleState: LifecycleState = LifecycleState.STOPPED - data class TorStatus( - val mode: TorMode = TorMode.OFF, - val running: Boolean = false, - val bootstrapPercent: Int = 0, // kept for backwards compatibility with UI; 0 or 100 only - val lastLogLine: String = "", - val state: TorState = TorState.OFF + private val _statusFlow = MutableStateFlow( + TorStatus( + mode = TorMode.OFF, + running = false, + bootstrapPercent = 0, + lastLogLine = "", + state = TorState.OFF + ) ) - private val _status = MutableStateFlow(TorStatus()) - val statusFlow: StateFlow = _status.asStateFlow() + val statusFlow: StateFlow = _statusFlow.asStateFlow() private val stateChangeDeferred = AtomicReference?>(null) fun isProxyEnabled(): Boolean { - val s = _status.value - return s.mode != TorMode.OFF && s.running && s.bootstrapPercent >= 100 && socksAddr != null && s.state == TorState.RUNNING + val s = _statusFlow.value + return s.mode != TorMode.OFF && s.running && s.bootstrapPercent >= 100 && + socksAddr != null && s.state == TorState.RUNNING } fun init(application: Application) { @@ -82,7 +127,21 @@ object TorManager { currentApplication = application TorPreferenceManager.init(application) - // Apply saved mode at startup. If ON, set planned SOCKS immediately to avoid any leak. + val logListener = ArtiLogListener { logLine -> + val text = logLine ?: return@ArtiLogListener + val s = text + Log.i(TAG, "arti: $s") + lastLogTime.set(System.currentTimeMillis()) + _statusFlow.update { it.copy(lastLogLine = s) } + handleArtiLogLine(s) + } + + artiProxy = ArtiProxy.Builder(application) + .setSocksPort(currentSocksPort) + .setDnsPort(currentSocksPort + 1) + .setLogListener(logListener) + .build() + val savedMode = TorPreferenceManager.get(application) if (savedMode == TorMode.ON) { if (currentSocksPort < DEFAULT_SOCKS_PORT) { @@ -90,13 +149,15 @@ object TorManager { } desiredMode = savedMode socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort) - try { OkHttpProvider.reset() } catch (_: Throwable) { } + try { + OkHttpProvider.reset() + } catch (_: Throwable) { + } // Only reset OkHttp during init } appScope.launch { applyMode(application, savedMode) } - // Observe changes appScope.launch { TorPreferenceManager.modeFlow.collect { mode -> applyMode(application, mode) @@ -112,53 +173,63 @@ object TorManager { try { desiredMode = mode lastMode = mode - val s = _status.value - if (mode == s.mode && mode != TorMode.OFF && (lifecycleState == LifecycleState.STARTING || lifecycleState == LifecycleState.RUNNING)) { - Log.i(TAG, "applyMode: already in progress/running mode=$mode, state=$lifecycleState; skip") + val s = _statusFlow.value + if (mode == s.mode && mode != TorMode.OFF && + (lifecycleState == LifecycleState.STARTING || lifecycleState == LifecycleState.RUNNING) + ) { + Log.i( + TAG, + "applyMode: already in progress/running mode=$mode, state=$lifecycleState; skip" + ) return } when (mode) { TorMode.OFF -> { Log.i(TAG, "applyMode: OFF -> stopping tor") lifecycleState = LifecycleState.STOPPING - _status.value = _status.value.copy(mode = TorMode.OFF, running = false, bootstrapPercent = 0, state = TorState.STOPPING) - stopArti() // non-suspending immediate request - // Best-effort wait for STOPPED before we declare OFF + _statusFlow.value = _statusFlow.value.copy( + mode = TorMode.OFF, + running = false, + bootstrapPercent = 0, + state = TorState.STOPPING + ) + stopArti() waitForStateTransition(target = TorState.OFF, timeoutMs = STOP_TIMEOUT_MS) socksAddr = null - _status.value = _status.value.copy(mode = TorMode.OFF, running = false, bootstrapPercent = 0, state = TorState.OFF) + _statusFlow.value = _statusFlow.value.copy( + mode = TorMode.OFF, + running = false, + bootstrapPercent = 0, + state = TorState.OFF + ) currentSocksPort = DEFAULT_SOCKS_PORT bindRetryAttempts = 0 lifecycleState = LifecycleState.STOPPED - // Rebuild clients WITHOUT proxy and reconnect relays - try { - OkHttpProvider.reset() - com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() - } catch (_: Throwable) { } + resetNetworkConnections() } + TorMode.ON -> { Log.i(TAG, "applyMode: ON -> starting arti") - // Reset port to default unless we're already using a higher port if (currentSocksPort < DEFAULT_SOCKS_PORT) { currentSocksPort = DEFAULT_SOCKS_PORT } bindRetryAttempts = 0 lifecycleState = LifecycleState.STARTING - _status.value = _status.value.copy(mode = TorMode.ON, running = false, bootstrapPercent = 0, state = TorState.STARTING) - // Immediately set the planned SOCKS address so all traffic is forced through it, - // even before Tor is fully bootstrapped. This prevents any direct connections. + _statusFlow.value = _statusFlow.value.copy( + mode = TorMode.ON, + running = false, + bootstrapPercent = 0, + state = TorState.STARTING + ) socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort) - try { OkHttpProvider.reset() } catch (_: Throwable) { } - try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { } + resetNetworkConnections() startArti(application, useDelay = false) - // Defer enabling proxy until bootstrap completes appScope.launch { waitUntilBootstrapped() - if (_status.value.running && desiredMode == TorMode.ON) { + if (_statusFlow.value.running && desiredMode == TorMode.ON) { socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort) Log.i(TAG, "Tor ON: proxy set to ${socksAddr}") - OkHttpProvider.reset() - try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { } + resetNetworkConnections() } } } @@ -171,84 +242,95 @@ object TorManager { private suspend fun startArti(application: Application, useDelay: Boolean = false) { try { - // Ensure any previous instance is fully stopped before starting a new one stopArtiAndWait() - Log.i(TAG, "Starting Arti on port $currentSocksPort…") if (useDelay) { delay(RESTART_DELAY_MS) } - val logListener = ArtiLogListener { logLine -> - val text = logLine ?: return@ArtiLogListener - val s = text.toString() - Log.i(TAG, "arti: $s") - lastLogTime.set(System.currentTimeMillis()) - _status.value = _status.value.copy(lastLogLine = s) - handleArtiLogLine(s) + val proxy = artiProxy ?: run { + Log.e(TAG, "ArtiProxy not initialized! This should not happen.") + _statusFlow.update { it.copy(state = TorState.ERROR) } + return } - val proxy = ArtiProxy.Builder(application) - .setSocksPort(currentSocksPort) - .setDnsPort(currentSocksPort + 1) - .setLogListener(logListener) - .build() - - artiProxyRef.set(proxy) proxy.start() lastLogTime.set(System.currentTimeMillis()) - _status.value = _status.value.copy(running = true, bootstrapPercent = 0, state = TorState.STARTING) + _statusFlow.update { + it.copy( + running = true, + bootstrapPercent = 0, + state = TorState.STARTING + ) + } lifecycleState = LifecycleState.RUNNING startInactivityMonitoring() - // Removed onion service startup (BLE-only file transfer in this branch) - } catch (e: Exception) { Log.e(TAG, "Error starting Arti on port $currentSocksPort: ${e.message}") - _status.value = _status.value.copy(state = TorState.ERROR) - - // Check if this is a bind error + _statusFlow.update { it.copy(state = TorState.ERROR) } + val isBindError = isBindError(e) if (isBindError && bindRetryAttempts < MAX_RETRY_ATTEMPTS) { bindRetryAttempts++ currentSocksPort++ - Log.w(TAG, "Port bind failed (attempt $bindRetryAttempts/$MAX_RETRY_ATTEMPTS), retrying with port $currentSocksPort") - // Update planned SOCKS address immediately so all new connections target the new port + Log.w( + TAG, + "Port bind failed (attempt $bindRetryAttempts/$MAX_RETRY_ATTEMPTS), retrying with port $currentSocksPort" + ) socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort) - try { OkHttpProvider.reset() } catch (_: Throwable) { } - try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { } - // Immediate retry with incremented port, no exponential backoff for bind errors + resetNetworkConnections() startArti(application, useDelay = false) } else if (isBindError) { Log.e(TAG, "Max bind retry attempts reached ($MAX_RETRY_ATTEMPTS), giving up") lifecycleState = LifecycleState.STOPPED - _status.value = _status.value.copy(running = false, bootstrapPercent = 0, state = TorState.ERROR) + _statusFlow.update { + it.copy( + running = false, + bootstrapPercent = 0, + state = TorState.ERROR + ) + } } else { - // For non-bind errors, use the existing retry mechanism scheduleRetry(application) } } } - - /** - * Checks if the exception indicates a port binding failure - */ + private fun isBindError(exception: Exception): Boolean { val message = exception.message?.lowercase() ?: "" return message.contains("bind") || - message.contains("address already in use") || - message.contains("port") && message.contains("use") || - message.contains("permission denied") && message.contains("port") || - message.contains("could not bind") + message.contains("address already in use") || + message.contains("port") && message.contains("use") || + message.contains("permission denied") && message.contains("port") || + message.contains("could not bind") + } + + /** + * Reset network connections after Tor state changes. + * Rebuilds OkHttp clients and reconnects Nostr relays. + */ + private fun resetNetworkConnections() { + try { + OkHttpProvider.reset() + } catch (_: Throwable) { + } + try { + com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() + } catch (_: Throwable) { + } } private fun stopArtiInternal() { try { - val proxy = artiProxyRef.getAndSet(null) + val proxy = artiProxy if (proxy != null) { Log.i(TAG, "Stopping Arti…") - try { proxy.stop() } catch (_: Throwable) {} + try { + proxy.stop() + } catch (_: Throwable) { + } } stopInactivityMonitoring() stopRetryMonitoring() @@ -260,15 +342,16 @@ object TorManager { private fun stopArti() { stopArtiInternal() socksAddr = null - _status.value = _status.value.copy(running = false, bootstrapPercent = 0, state = TorState.STOPPING) + _statusFlow.value = _statusFlow.value.copy( + running = false, + bootstrapPercent = 0, + state = TorState.STOPPING + ) } private suspend fun stopArtiAndWait(timeoutMs: Long = STOP_TIMEOUT_MS) { - // Request stop stopArtiInternal() - // Wait for confirmation via logs (Stopped) or timeout waitForStateTransition(target = TorState.OFF, timeoutMs = timeoutMs) - // Small grace period before relaunch to let file locks clear delay(200) } @@ -276,7 +359,7 @@ object TorManager { Log.i(TAG, "Restarting Arti (keeping SOCKS proxy enabled)...") stopArtiAndWait() delay(RESTART_DELAY_MS) - startArti(application, useDelay = false) // Already delayed above + startArti(application, useDelay = false) } private fun startInactivityMonitoring() { @@ -287,13 +370,16 @@ object TorManager { val currentTime = System.currentTimeMillis() val lastActivity = lastLogTime.get() val timeSinceLastActivity = currentTime - lastActivity - + if (timeSinceLastActivity > INACTIVITY_TIMEOUT_MS) { - val currentMode = _status.value.mode + val currentMode = _statusFlow.value.mode if (currentMode == TorMode.ON) { - val bootstrapPercent = _status.value.bootstrapPercent + val bootstrapPercent = _statusFlow.value.bootstrapPercent if (bootstrapPercent < 100) { - Log.w(TAG, "Inactivity detected (${timeSinceLastActivity}ms), restarting Arti") + Log.w( + TAG, + "Inactivity detected (${timeSinceLastActivity}ms), restarting Arti" + ) currentApplication?.let { app -> appScope.launch { restartArti(app) @@ -316,11 +402,11 @@ object TorManager { retryJob?.cancel() if (retryAttempts < MAX_RETRY_ATTEMPTS) { retryAttempts++ - val delayMs = (1000L * (1 shl retryAttempts)).coerceAtMost(30000L) // Exponential backoff, max 30s + val delayMs = (1000L * (1 shl retryAttempts)).coerceAtMost(30000L) Log.w(TAG, "Scheduling Arti retry attempt $retryAttempts in ${delayMs}ms") retryJob = appScope.launch { delay(delayMs) - val currentMode = _status.value.mode + val currentMode = _statusFlow.value.mode if (currentMode == TorMode.ON) { Log.i(TAG, "Retrying Arti start (attempt $retryAttempts)") restartArti(application) @@ -337,50 +423,110 @@ object TorManager { } private suspend fun waitUntilBootstrapped() { - val current = _status.value + val current = _statusFlow.value if (!current.running) return if (current.bootstrapPercent >= 100 && current.state == TorState.RUNNING) return - // Suspend until we observe RUNNING at least once while (true) { - val s = statusFlow.first { (it.bootstrapPercent >= 100 && it.state == TorState.RUNNING) || !it.running || it.state == TorState.ERROR } + val s = statusFlow.first { + (it.bootstrapPercent >= 100 && it.state == TorState.RUNNING) || + !it.running || + it.state == TorState.ERROR + } if (!s.running || s.state == TorState.ERROR) return if (s.bootstrapPercent >= 100 && s.state == TorState.RUNNING) return } } private fun handleArtiLogLine(s: String) { + val currentState = _statusFlow.value.state + val currentLifecycle = lifecycleState + when { s.contains("AMEx: state changed to Initialized", ignoreCase = true) -> { - _status.value = _status.value.copy(state = TorState.STARTING) + if (currentLifecycle != LifecycleState.STARTING && currentLifecycle != LifecycleState.RUNNING) { + Log.w(TAG, "Ignoring stale 'Initialized' log (lifecycle: $currentLifecycle)") + return + } + _statusFlow.update { it.copy(state = TorState.STARTING) } completeWaitersIf(TorState.STARTING) } + s.contains("AMEx: state changed to Starting", ignoreCase = true) -> { - _status.value = _status.value.copy(state = TorState.STARTING) + if (currentLifecycle != LifecycleState.STARTING && currentLifecycle != LifecycleState.RUNNING) { + Log.w(TAG, "Ignoring stale 'Starting' log (lifecycle: $currentLifecycle)") + return + } + _statusFlow.update { it.copy(state = TorState.STARTING) } completeWaitersIf(TorState.STARTING) } - s.contains("Sufficiently bootstrapped; system SOCKS now functional", ignoreCase = true) -> { - _status.value = _status.value.copy(bootstrapPercent = 75, state = TorState.BOOTSTRAPPING) + + s.contains( + "Sufficiently bootstrapped; system SOCKS now functional", + ignoreCase = true + ) -> { + if (currentLifecycle != LifecycleState.RUNNING) { + Log.w(TAG, "Ignoring bootstrap log (lifecycle: $currentLifecycle)") + return + } + _statusFlow.update { + it.copy( + bootstrapPercent = 75, + state = TorState.BOOTSTRAPPING + ) + } retryAttempts = 0 bindRetryAttempts = 0 startInactivityMonitoring() } - //s.contains("AMEx: state changed to Running", ignoreCase = true) -> { + s.contains("We have found that guard [scrubbed] is usable.", ignoreCase = true) -> { - // If we already saw Sufficiently bootstrapped, mark as RUNNING and ready. - val bp = if (_status.value.bootstrapPercent >= 100) 100 else 100 // treat Running as ready - _status.value = _status.value.copy(state = TorState.RUNNING, bootstrapPercent = bp, running = true) + if (currentLifecycle != LifecycleState.RUNNING) { + Log.w(TAG, "Ignoring guard discovery log (lifecycle: $currentLifecycle)") + return + } + _statusFlow.update { + it.copy( + state = TorState.RUNNING, + bootstrapPercent = 100, + running = true + ) + } completeWaitersIf(TorState.RUNNING) } + s.contains("AMEx: state changed to Stopping", ignoreCase = true) -> { - _status.value = _status.value.copy(state = TorState.STOPPING, running = false) + if (currentLifecycle != LifecycleState.STOPPING) { + Log.w(TAG, "Ignoring stale 'Stopping' log (lifecycle: $currentLifecycle)") + return + } + _statusFlow.update { + it.copy( + state = TorState.STOPPING, + running = false + ) + } } + s.contains("AMEx: state changed to Stopped", ignoreCase = true) -> { - _status.value = _status.value.copy(state = TorState.OFF, running = false, bootstrapPercent = 0) + if (currentLifecycle != LifecycleState.STOPPING && currentLifecycle != LifecycleState.STOPPED) { + Log.w( + TAG, + "Ignoring stale 'Stopped' log (lifecycle: $currentLifecycle, preventing state corruption)" + ) + return + } + _statusFlow.update { + it.copy( + state = TorState.OFF, + running = false, + bootstrapPercent = 0 + ) + } completeWaitersIf(TorState.OFF) } + s.contains("Another process has the lock on our state files", ignoreCase = true) -> { - // Signal error; we'll likely need to wait longer before restart - _status.value = _status.value.copy(state = TorState.ERROR) + _statusFlow.update { it.copy(state = TorState.ERROR) } } } } @@ -395,13 +541,11 @@ object TorManager { val def = CompletableDeferred() stateChangeDeferred.getAndSet(def)?.cancel() return withTimeoutOrNull(timeoutMs) { - // Fast-path: if we're already there - val cur = _status.value.state + val cur = _statusFlow.value.state if (cur == target) return@withTimeoutOrNull cur def.await() } } - // Visible for instrumentation tests to validate installation - fun installResourcesForTest(application: Application): Boolean { return true } + fun isTorAvailable(): Boolean = true } diff --git a/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt b/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt index 45cff773..aafc0d10 100644 --- a/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt +++ b/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt @@ -42,8 +42,9 @@ object OkHttpProvider { private fun baseBuilderForCurrentProxy(): OkHttpClient.Builder { val builder = OkHttpClient.Builder() - val socks: InetSocketAddress? = TorManager.currentSocksAddress() - // If a SOCKS address is defined, always use it. TorManager sets this as soon as Tor mode is ON, + val torProvider = ArtiTorManager.getInstance() + val socks: InetSocketAddress? = torProvider.currentSocksAddress() + // If a SOCKS address is defined, always use it. TorProvider sets this as soon as Tor mode is ON, // even before bootstrap, to prevent any direct connections from occurring. if (socks != null) { val proxy = Proxy(Proxy.Type.SOCKS, socks) diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt index c72c8e67..822606c2 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt @@ -2,7 +2,6 @@ package com.bitchat.android.nostr import android.app.Application import android.util.Log -import androidx.lifecycle.LiveData import com.bitchat.android.ui.ChatState import com.bitchat.android.ui.GeoPerson import java.util.Date @@ -112,7 +111,7 @@ class GeohashRepository( val geohash = currentGeohash if (geohash == null) { // Use postValue for thread safety - this can be called from background threads - state.postGeohashPeople(emptyList()) + state.setGeohashPeople(emptyList()) return } val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) @@ -143,7 +142,7 @@ class GeohashRepository( ) }.sortedByDescending { it.lastSeen } // Use postValue for thread safety - this can be called from background threads - state.postGeohashPeople(people) + state.setGeohashPeople(people) } fun updateReactiveParticipantCounts() { @@ -155,7 +154,7 @@ class GeohashRepository( counts[gh] = active } // Use postValue for thread safety - this can be called from background threads - state.postGeohashParticipantCounts(counts) + state.setGeohashParticipantCounts(counts) } fun putNostrKeyMapping(tempKeyOrPeer: String, pubkeyHex: String) { diff --git a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt index 655a9783..dc1a8e85 100644 --- a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt @@ -2,13 +2,14 @@ package com.bitchat.android.nostr import android.util.Log import androidx.annotation.MainThread -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow /** * Manages location notes (kind=1 text notes with geohash tags) - * iOS-compatible implementation with LiveData for Android UI binding + * iOS-compatible implementation with StateFlow for Android UI binding */ @MainThread class LocationNotesManager private constructor() { @@ -63,21 +64,21 @@ class LocationNotesManager private constructor() { NO_RELAYS } - // Published state (LiveData for Android) - private val _notes = MutableLiveData>(emptyList()) - val notes: LiveData> = _notes + // Published state (StateFlow for Android) + private val _notes = MutableStateFlow>(emptyList()) + val notes: StateFlow> = _notes.asStateFlow() - private val _geohash = MutableLiveData(null) - val geohash: LiveData = _geohash + private val _geohash = MutableStateFlow(null) + val geohash: StateFlow = _geohash.asStateFlow() - private val _initialLoadComplete = MutableLiveData(false) - val initialLoadComplete: LiveData = _initialLoadComplete + private val _initialLoadComplete = MutableStateFlow(false) + val initialLoadComplete: StateFlow = _initialLoadComplete.asStateFlow() - private val _state = MutableLiveData(State.IDLE) - val state: LiveData = _state + private val _state = MutableStateFlow(State.IDLE) + val state: StateFlow = _state.asStateFlow() - private val _errorMessage = MutableLiveData(null) - val errorMessage: LiveData = _errorMessage + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage.asStateFlow() // Private state private var subscriptionIDs: MutableMap = mutableMapOf() diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt index 9e34c34c..a4803157 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt @@ -2,9 +2,10 @@ package com.bitchat.android.nostr import android.content.Context import android.util.Log -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow /** * High-level Nostr client that manages identity, connections, and messaging @@ -30,11 +31,11 @@ class NostrClient private constructor(private val context: Context) { private var currentIdentity: NostrIdentity? = null // Client state - private val _isInitialized = MutableLiveData() - val isInitialized: LiveData = _isInitialized + private val _isInitialized = MutableStateFlow(false) + val isInitialized: StateFlow = _isInitialized.asStateFlow() - private val _currentNpub = MutableLiveData() - val currentNpub: LiveData = _currentNpub + private val _currentNpub = MutableStateFlow(null) + val currentNpub: StateFlow = _currentNpub.asStateFlow() // Message processing private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) @@ -53,21 +54,21 @@ class NostrClient private constructor(private val context: Context) { currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context) if (currentIdentity != null) { - _currentNpub.postValue(currentIdentity!!.npub) + _currentNpub.value = currentIdentity!!.npub Log.i(TAG, "✅ Nostr identity loaded: ${currentIdentity!!.getShortNpub()}") // Connect to relays relayManager.connect() - _isInitialized.postValue(true) + _isInitialized.value = true Log.i(TAG, "✅ Nostr client initialized successfully") } else { Log.e(TAG, "❌ Failed to load/create Nostr identity") - _isInitialized.postValue(false) + _isInitialized.value = false } } catch (e: Exception) { Log.e(TAG, "❌ Failed to initialize Nostr client: ${e.message}") - _isInitialized.postValue(false) + _isInitialized.value = false } } } @@ -78,7 +79,7 @@ class NostrClient private constructor(private val context: Context) { fun shutdown() { Log.d(TAG, "Shutting down Nostr client") relayManager.disconnect() - _isInitialized.postValue(false) + _isInitialized.value = false } /** @@ -227,12 +228,12 @@ class NostrClient private constructor(private val context: Context) { /** * Get relay connection status */ - val relayConnectionStatus: LiveData = relayManager.isConnected + val relayConnectionStatus: StateFlow = relayManager.isConnected /** * Get relay information */ - val relayInfo: LiveData> = relayManager.relays + val relayInfo: StateFlow> = relayManager.relays // MARK: - Private Methods diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt index 3dcb60d6..18c12e4d 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -2,8 +2,12 @@ package com.bitchat.android.nostr import android.app.Application import android.util.Log +import com.bitchat.android.model.BitchatFilePacket import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus +import com.bitchat.android.model.NoisePayload +import com.bitchat.android.model.NoisePayloadType +import com.bitchat.android.model.PrivateMessagePacket import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.services.SeenMessageStore import com.bitchat.android.ui.ChatState @@ -71,7 +75,7 @@ class NostrDirectMessageHandler( if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch - val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return@launch + val noisePayload = NoisePayload.decode(packet.payload) ?: return@launch val messageTimestamp = Date(giftWrap.createdAt * 1000L) val convKey = "nostr_${senderPubkey.take(16)}" repo.putNostrKeyMapping(convKey, senderPubkey) @@ -104,7 +108,7 @@ class NostrDirectMessageHandler( } private suspend fun processNoisePayload( - payload: com.bitchat.android.model.NoisePayload, + payload: NoisePayload, convKey: String, senderNickname: String, timestamp: Date, @@ -112,8 +116,8 @@ class NostrDirectMessageHandler( recipientIdentity: NostrIdentity ) { when (payload.type) { - com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE -> { - val pm = com.bitchat.android.model.PrivateMessagePacket.decode(payload.data) ?: return + NoisePayloadType.PRIVATE_MESSAGE -> { + val pm = PrivateMessagePacket.decode(payload.data) ?: return val existingMessages = state.getPrivateChatsValue()[convKey] ?: emptyList() if (existingMessages.any { it.id == pm.messageID }) return @@ -148,21 +152,21 @@ class NostrDirectMessageHandler( seenStore.markRead(pm.messageID) } } - com.bitchat.android.model.NoisePayloadType.DELIVERED -> { + NoisePayloadType.DELIVERED -> { val messageId = String(payload.data, Charsets.UTF_8) withContext(Dispatchers.Main) { meshDelegateHandler.didReceiveDeliveryAck(messageId, convKey) } } - com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> { + NoisePayloadType.READ_RECEIPT -> { val messageId = String(payload.data, Charsets.UTF_8) withContext(Dispatchers.Main) { meshDelegateHandler.didReceiveReadReceipt(messageId, convKey) } } - com.bitchat.android.model.NoisePayloadType.FILE_TRANSFER -> { + NoisePayloadType.FILE_TRANSFER -> { // Properly handle encrypted file transfer - val file = com.bitchat.android.model.BitchatFilePacket.decode(payload.data) + 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) @@ -185,6 +189,8 @@ class NostrDirectMessageHandler( Log.w(TAG, "⚠️ Failed to decode Nostr file transfer from $convKey") } } + NoisePayloadType.VERIFY_CHALLENGE, + NoisePayloadType.VERIFY_RESPONSE -> Unit // Ignore verification payloads in Nostr direct messages } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt index eb5806bb..d44e6e0b 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -1,9 +1,10 @@ package com.bitchat.android.nostr import android.util.Log -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import com.google.gson.Gson +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import com.google.gson.JsonArray import com.google.gson.JsonParser import kotlinx.coroutines.* @@ -72,11 +73,11 @@ class NostrRelayManager private constructor() { ) // Published state - private val _relays = MutableLiveData>() - val relays: LiveData> = _relays + private val _relays = MutableStateFlow>(emptyList()) + val relays: StateFlow> = _relays.asStateFlow() - private val _isConnected = MutableLiveData() - val isConnected: LiveData = _isConnected + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() // Internal state private val relaysList = mutableListOf() @@ -226,14 +227,14 @@ class NostrRelayManager private constructor() { "wss://nostr21.com" ) relaysList.addAll(defaultRelayUrls.map { Relay(it) }) - _relays.postValue(relaysList.toList()) + _relays.value = relaysList.toList() updateConnectionStatus() Log.d(TAG, "✅ NostrRelayManager initialized with ${relaysList.size} default relays") } catch (e: Exception) { Log.e(TAG, "Failed to initialize NostrRelayManager: ${e.message}", e) // Initialize with empty list as fallback - _relays.postValue(emptyList()) - _isConnected.postValue(false) + _relays.value = emptyList() + _isConnected.value = false } } @@ -797,12 +798,12 @@ class NostrRelayManager private constructor() { } private fun updateRelaysList() { - _relays.postValue(relaysList.toList()) + _relays.value = relaysList.toList() } private fun updateConnectionStatus() { val connected = relaysList.any { it.isConnected } - _isConnected.postValue(connected) + _isConnected.value = connected } private fun generateSubscriptionId(): String { diff --git a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt new file mode 100644 index 00000000..1346cb23 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt @@ -0,0 +1,251 @@ +package com.bitchat.android.onboarding + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Security +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.res.stringResource +import com.bitchat.android.R + +/** + * Explanation screen shown before requesting background location permission. + */ +@Composable +fun BackgroundLocationPermissionScreen( + modifier: Modifier, + onContinue: () -> Unit, + onRetry: () -> Unit, + onSkip: () -> Unit +) { + val colorScheme = MaterialTheme.colorScheme + val scrollState = rememberScrollState() + + Box(modifier = modifier) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp) + .padding(bottom = 88.dp) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.height(24.dp)) + + HeaderSection(colorScheme) + + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surfaceVariant.copy(alpha = 0.25f), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Filled.LocationOn, + contentDescription = stringResource(R.string.cd_location_services), + tint = colorScheme.primary, + modifier = Modifier + .padding(top = 2.dp) + .size(20.dp) + ) + Column { + Text( + text = stringResource(R.string.background_location_required_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onBackground + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.background_location_explanation), + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onBackground.copy(alpha = 0.8f) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.background_location_settings_tip), + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace + ), + color = colorScheme.onBackground.copy(alpha = 0.8f) + ) + } + } + } + } + + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surfaceVariant.copy(alpha = 0.25f), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Filled.Security, + contentDescription = stringResource(R.string.cd_privacy_protected), + tint = colorScheme.primary, + modifier = Modifier + .padding(top = 2.dp) + .size(20.dp) + ) + Column { + Text( + text = stringResource(R.string.background_location_needs_for), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onBackground + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.background_location_needs_bullets), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = colorScheme.onBackground.copy(alpha = 0.8f) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.background_location_privacy_note), + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium + ), + color = colorScheme.onBackground + ) + } + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } + + Surface( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth(), + color = colorScheme.surface, + shadowElevation = 8.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button( + onClick = onContinue, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = colorScheme.primary + ) + ) { + Text( + text = stringResource(R.string.grant_background_location), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold + ), + modifier = Modifier.padding(vertical = 4.dp) + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = onRetry, + modifier = Modifier.weight(1f) + ) { + Text( + text = stringResource(R.string.check_again), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace + ) + ) + } + + TextButton( + onClick = onSkip, + modifier = Modifier.weight(1f) + ) { + Text( + text = stringResource(R.string.battery_optimization_skip), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace + ) + ) + } + } + } + } + } +} + +@Composable +private fun HeaderSection(colorScheme: ColorScheme) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.headlineLarge.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = 32.sp + ), + color = colorScheme.onBackground + ) + + Text( + text = stringResource(R.string.background_location_required_subtitle), + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = colorScheme.onBackground.copy(alpha = 0.7f) + ) + } +} diff --git a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPreferenceManager.kt b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPreferenceManager.kt new file mode 100644 index 00000000..0a73e346 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPreferenceManager.kt @@ -0,0 +1,21 @@ +package com.bitchat.android.onboarding + +import android.content.Context + +/** + * Preference manager for background location skip choice. + */ +object BackgroundLocationPreferenceManager { + private const val PREFS_NAME = "bitchat_settings" + private const val KEY_BACKGROUND_LOCATION_SKIP = "background_location_skipped" + + fun setSkipped(context: Context, skipped: Boolean) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit().putBoolean(KEY_BACKGROUND_LOCATION_SKIP, skipped).apply() + } + + fun isSkipped(context: Context): Boolean { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getBoolean(KEY_BACKGROUND_LOCATION_SKIP, false) + } +} diff --git a/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt b/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt index 871cc892..701e9d19 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/OnboardingCoordinator.kt @@ -19,6 +19,7 @@ class OnboardingCoordinator( private val activity: ComponentActivity, private val permissionManager: PermissionManager, private val onOnboardingComplete: () -> Unit, + private val onBackgroundLocationRequired: () -> Unit, private val onOnboardingFailed: (String) -> Unit ) { @@ -27,9 +28,11 @@ class OnboardingCoordinator( } private var permissionLauncher: ActivityResultLauncher>? = null + private var backgroundLocationLauncher: ActivityResultLauncher? = null init { setupPermissionLauncher() + setupBackgroundLocationLauncher() } /** @@ -43,6 +46,14 @@ class OnboardingCoordinator( } } + private fun setupBackgroundLocationLauncher() { + backgroundLocationLauncher = activity.registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + handleBackgroundLocationResult(granted) + } + } + /** * Start the onboarding process */ @@ -50,9 +61,14 @@ class OnboardingCoordinator( Log.d(TAG, "Starting onboarding process") permissionManager.logPermissionStatus() - if (permissionManager.areAllPermissionsGranted()) { - Log.d(TAG, "All permissions already granted, completing onboarding") - completeOnboarding() + if (permissionManager.areRequiredPermissionsGranted()) { + if (shouldRequestBackgroundLocation()) { + Log.d(TAG, "Foreground permissions granted; background location recommended") + onBackgroundLocationRequired() + } else { + Log.d(TAG, "Required permissions already granted, completing onboarding") + completeOnboarding() + } } else { Log.d(TAG, "Missing permissions, need to start explanation flow") // The explanation screen will be shown by the calling activity @@ -76,7 +92,11 @@ class OnboardingCoordinator( val missingPermissions = (missingRequired + optionalToRequest).distinct() if (missingPermissions.isEmpty()) { - completeOnboarding() + if (shouldRequestBackgroundLocation()) { + onBackgroundLocationRequired() + } else { + completeOnboarding() + } return } @@ -98,13 +118,19 @@ class OnboardingCoordinator( val criticalGranted = criticalPermissions.all { permissions[it] == true } when { - allGranted -> { - Log.d(TAG, "All permissions granted successfully") - completeOnboarding() - } criticalGranted -> { - Log.d(TAG, "Critical permissions granted, can proceed with limited functionality") - showPartialPermissionWarning(permissions) + if (shouldRequestBackgroundLocation()) { + Log.d(TAG, "Foreground permissions granted; requesting background location next") + onBackgroundLocationRequired() + return + } + if (allGranted) { + Log.d(TAG, "All permissions granted successfully") + completeOnboarding() + } else { + Log.d(TAG, "Critical permissions granted, can proceed with limited functionality") + showPartialPermissionWarning(permissions) + } } else -> { Log.d(TAG, "Critical permissions denied") @@ -113,15 +139,50 @@ class OnboardingCoordinator( } } + fun requestBackgroundLocation() { + val permission = permissionManager.getBackgroundLocationPermission() + if (permission == null) { + completeOnboarding() + return + } + Log.d(TAG, "Requesting background location permission") + backgroundLocationLauncher?.launch(permission) + } + + private fun handleBackgroundLocationResult(granted: Boolean) { + if (granted) { + Log.d(TAG, "Background location permission granted") + } else { + Log.w(TAG, "Background location permission denied; continuing without it") + } + completeOnboarding() + } + + fun skipBackgroundLocation() { + Log.d(TAG, "User skipped background location permission") + BackgroundLocationPreferenceManager.setSkipped(activity, true) + completeOnboarding() + } + + fun checkBackgroundLocationAndProceed() { + if (!shouldRequestBackgroundLocation()) { + completeOnboarding() + } + } + + private fun shouldRequestBackgroundLocation(): Boolean { + return permissionManager.needsBackgroundLocationPermission() && + !permissionManager.isBackgroundLocationGranted() && + !BackgroundLocationPreferenceManager.isSkipped(activity) + } + /** * Get the list of critical permissions that are absolutely required */ private fun getCriticalPermissions(): List { // For bitchat, Bluetooth and location permissions are critical - // Notifications are nice-to-have but not critical - return permissionManager.getRequiredPermissions().filter { permission -> - !permission.contains("POST_NOTIFICATIONS") - } + // Notifications are nice-to-have but not critical and are not included in getRequiredPermissions() + return permissionManager.getRequiredPermissions() } /** @@ -208,6 +269,7 @@ class OnboardingCoordinator( private fun getPermissionDisplayName(permission: String): String { return when { permission.contains("BLUETOOTH") -> "Bluetooth/Nearby Devices" + permission.contains("BACKGROUND") -> "Background Location" permission.contains("LOCATION") -> "Location (for Bluetooth scanning)" permission.contains("NOTIFICATION") -> "Notifications" else -> permission.substringAfterLast(".") diff --git a/app/src/main/java/com/bitchat/android/onboarding/OnboardingState.kt b/app/src/main/java/com/bitchat/android/onboarding/OnboardingState.kt index f06ddd08..7fa07817 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/OnboardingState.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/OnboardingState.kt @@ -6,8 +6,9 @@ enum class OnboardingState { LOCATION_CHECK, BATTERY_OPTIMIZATION_CHECK, PERMISSION_EXPLANATION, + BACKGROUND_LOCATION_EXPLANATION, PERMISSION_REQUESTING, INITIALIZING, COMPLETE, ERROR -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt index 2b84aefa..e982d2e1 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt @@ -12,12 +12,10 @@ import androidx.compose.material.icons.filled.Power import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -209,29 +207,6 @@ private fun PermissionCategoryCard( color = colorScheme.onBackground.copy(alpha = 0.8f) ) - if (category.type == PermissionType.PRECISE_LOCATION) { - // Extra emphasis for location permission - Spacer(modifier = Modifier.height(4.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Icon( - imageVector = Icons.Filled.Warning, - contentDescription = stringResource(R.string.cd_warning), - tint = Color(0xFFFF9800), - modifier = Modifier.size(16.dp) - ) - Text( - text = stringResource(R.string.location_tracking_warning), - style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium, - color = Color(0xFFFF9800) - ) - ) - } - } } } } @@ -240,6 +215,7 @@ private fun getPermissionIcon(permissionType: PermissionType): ImageVector { return when (permissionType) { PermissionType.NEARBY_DEVICES -> Icons.Filled.Bluetooth PermissionType.PRECISE_LOCATION -> Icons.Filled.LocationOn + PermissionType.BACKGROUND_LOCATION -> Icons.Filled.LocationOn PermissionType.MICROPHONE -> Icons.Filled.Mic PermissionType.NOTIFICATIONS -> Icons.Filled.Notifications PermissionType.BATTERY_OPTIMIZATION -> Icons.Filled.Power diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt index ff0a160f..c32f855b 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt @@ -7,6 +7,7 @@ import android.os.Build import android.os.PowerManager import android.util.Log import androidx.core.content.ContextCompat +import com.bitchat.android.R /** * Centralized permission management for bitchat app @@ -40,7 +41,8 @@ class PermissionManager(private val context: Context) { } /** - * Get all permissions required by the app + * Get required permissions that can be requested together. + * Background location is handled separately to ensure correct request order. * Note: Notification permission is optional and not included here, * so the app works without notification access. */ @@ -72,6 +74,27 @@ class PermissionManager(private val context: Context) { return permissions } + /** + * Background location permission is required on Android 10+ for background BLE scanning. + * Must be requested after foreground location permissions are granted. + */ + fun needsBackgroundLocationPermission(): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + } + + fun getBackgroundLocationPermission(): String? { + return if (needsBackgroundLocationPermission()) { + Manifest.permission.ACCESS_BACKGROUND_LOCATION + } else { + null + } + } + + fun isBackgroundLocationGranted(): Boolean { + val permission = getBackgroundLocationPermission() ?: return true + return isPermissionGranted(permission) + } + /** * Get optional permissions that improve the experience but aren't required. * Currently includes POST_NOTIFICATIONS on Android 13+. @@ -93,9 +116,13 @@ class PermissionManager(private val context: Context) { } /** - * Check if all required permissions are granted + * Check if all required permissions are granted (background location is optional). */ fun areAllPermissionsGranted(): Boolean { + return areRequiredPermissionsGranted() + } + + fun areRequiredPermissionsGranted(): Boolean { return getRequiredPermissions().all { isPermissionGranted(it) } } @@ -131,6 +158,11 @@ class PermissionManager(private val context: Context) { return getRequiredPermissions().filter { !isPermissionGranted(it) } } + fun getMissingBackgroundLocationPermission(): List { + val permission = getBackgroundLocationPermission() ?: return emptyList() + return if (isPermissionGranted(permission)) emptyList() else listOf(permission) + } + /** * Get categorized permission information for display */ @@ -177,6 +209,19 @@ class PermissionManager(private val context: Context) { ) ) + if (needsBackgroundLocationPermission()) { + val backgroundPermission = listOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION) + categories.add( + PermissionCategory( + type = PermissionType.BACKGROUND_LOCATION, + description = context.getString(R.string.perm_background_location_desc), + permissions = backgroundPermission, + isGranted = backgroundPermission.all { isPermissionGranted(it) }, + systemDescription = context.getString(R.string.perm_background_location_system) + ) + ) + } + // Notifications category (if applicable) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { categories.add( @@ -216,7 +261,7 @@ class PermissionManager(private val context: Context) { appendLine("Permission Diagnostics:") appendLine("Android SDK: ${Build.VERSION.SDK_INT}") appendLine("First time launch: ${isFirstTimeLaunch()}") - appendLine("All permissions granted: ${areAllPermissionsGranted()}") + appendLine("Required permissions granted: ${areAllPermissionsGranted()}") appendLine() getCategorizedPermissions().forEach { category -> @@ -228,7 +273,7 @@ class PermissionManager(private val context: Context) { appendLine() } - val missing = getMissingPermissions() + val missing = getMissingPermissions() + getMissingBackgroundLocationPermission() if (missing.isNotEmpty()) { appendLine("Missing permissions:") missing.forEach { permission -> @@ -260,6 +305,7 @@ data class PermissionCategory( enum class PermissionType(val nameValue: String) { NEARBY_DEVICES("Nearby Devices"), PRECISE_LOCATION("Precise Location"), + BACKGROUND_LOCATION("Background Location"), MICROPHONE("Microphone"), NOTIFICATIONS("Notifications"), BATTERY_OPTIMIZATION("Battery Optimization"), diff --git a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt new file mode 100644 index 00000000..1c3abaf9 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt @@ -0,0 +1,95 @@ +package com.bitchat.android.service + +import android.app.Application +import android.os.Process +import androidx.core.app.NotificationManagerCompat +import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.net.ArtiTorManager +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 + +/** + * Coordinates a full application shutdown: + * - Stop mesh cleanly + * - Stop Tor without changing persistent setting + * - Clear in-memory AppState + * - Stop foreground service/notification + * - Kill the process after completion or after a 5s timeout + */ +object AppShutdownCoordinator { + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val shutdownToken = AtomicLong(0L) + @Volatile + private var shutdownJob: Job? = null + + fun cancelPendingShutdown() { + shutdownToken.incrementAndGet() + shutdownJob?.cancel() + shutdownJob = null + } + + fun requestFullShutdownAndKill( + app: Application, + mesh: BluetoothMeshService?, + notificationManager: NotificationManagerCompat, + 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) { } + + // Stop mesh (best-effort) + try { mesh?.stopServices() } 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) { } + } + + // 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) { } + 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 + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/service/BootCompletedReceiver.kt b/app/src/main/java/com/bitchat/android/service/BootCompletedReceiver.kt new file mode 100644 index 00000000..03a9dcd8 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/BootCompletedReceiver.kt @@ -0,0 +1,16 @@ +package com.bitchat.android.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +class BootCompletedReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + // Ensure preferences are initialized on cold boot before reading values + try { MeshServicePreferences.init(context.applicationContext) } catch (_: Exception) { } + + if (MeshServicePreferences.isAutoStartEnabled(true)) { + MeshForegroundService.start(context.applicationContext) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt new file mode 100644 index 00000000..bf1f236f --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt @@ -0,0 +1,373 @@ +package com.bitchat.android.service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.bitchat.android.MainActivity +import com.bitchat.android.R +import com.bitchat.android.mesh.BluetoothMeshService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +class MeshForegroundService : Service() { + + companion object { + private const val CHANNEL_ID = "bitchat_mesh_service" + private const val NOTIFICATION_ID = 10001 + + const val ACTION_START = "com.bitchat.android.service.START" + const val ACTION_STOP = "com.bitchat.android.service.STOP" + const val ACTION_QUIT = "com.bitchat.android.service.QUIT" + const val ACTION_UPDATE_NOTIFICATION = "com.bitchat.android.service.UPDATE_NOTIFICATION" + const val ACTION_NOTIFICATION_PERMISSION_GRANTED = "com.bitchat.android.action.NOTIFICATION_PERMISSION_GRANTED" + + fun start(context: Context) { + val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_START } + + // On API >= 26, avoid background-service start restrictions by using startForegroundService + // only when we can actually post a notification (Android 13+ requires runtime notif permission) + val bgEnabled = MeshServicePreferences.isBackgroundEnabled(true) + val hasNotifPerm = hasNotificationPermissionStatic(context) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (bgEnabled && hasNotifPerm) { + context.startForegroundService(intent) + } else { + // Do not attempt to start a background service from headless context without notif permission + // or when background is disabled, to avoid BackgroundServiceStartNotAllowedException. + android.util.Log.i( + "MeshForegroundService", + "Not starting service on API>=26 (bgEnabled=$bgEnabled, hasNotifPerm=$hasNotifPerm)" + ) + } + } else { + if (bgEnabled) { + context.startService(intent) + } else { + android.util.Log.i("MeshForegroundService", "Background disabled; not starting service (pre-O)") + } + } + } + + /** + * Helper to be invoked right after POST_NOTIFICATIONS is granted to try + * promoting/starting the foreground service immediately without polling. + */ + fun onNotificationPermissionGranted(context: Context) { + // If background is enabled and permission now granted, start/promo service + val hasNotifPerm = hasNotificationPermissionStatic(context) + if (!MeshServicePreferences.isBackgroundEnabled(true) || !hasNotifPerm) return + + val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_UPDATE_NOTIFICATION } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Safe now that we can show a notification + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + + fun stop(context: Context) { + val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_STOP } + context.startService(intent) + } + + private fun shouldStartAsForeground(context: Context): Boolean { + return MeshServicePreferences.isBackgroundEnabled(true) && + hasBluetoothPermissionsStatic(context) && + hasNotificationPermissionStatic(context) + } + + private fun hasBluetoothPermissionsStatic(ctx: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.BLUETOOTH_ADVERTISE) == android.content.pm.PackageManager.PERMISSION_GRANTED && + androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.BLUETOOTH_CONNECT) == android.content.pm.PackageManager.PERMISSION_GRANTED && + androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.BLUETOOTH_SCAN) == android.content.pm.PackageManager.PERMISSION_GRANTED + } else { + val fine = androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.ACCESS_FINE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED + val coarse = androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.ACCESS_COARSE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED + fine || coarse + } + } + + private fun hasNotificationPermissionStatic(ctx: Context): Boolean { + return if (Build.VERSION.SDK_INT >= 33) { + androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED + } else true + } + } + + private lateinit var notificationManager: NotificationManagerCompat + private var updateJob: Job? = null + private var meshService: BluetoothMeshService? = null + private val serviceJob = Job() + private val scope = CoroutineScope(Dispatchers.Default + serviceJob) + private var isInForeground: Boolean = false + private var isShuttingDown: Boolean = false + + override fun onCreate() { + super.onCreate() + notificationManager = NotificationManagerCompat.from(this) + createChannel() + + // Adopt or create the mesh service + val existing = MeshServiceHolder.meshService + meshService = existing ?: MeshServiceHolder.getOrCreate(applicationContext) + if (existing != null) { + android.util.Log.d("MeshForegroundService", "Adopted existing BluetoothMeshService from holder") + } else { + android.util.Log.i("MeshForegroundService", "Created/adopted new BluetoothMeshService via holder") + } + MeshServiceHolder.attach(meshService!!) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (isShuttingDown && intent?.action == ACTION_START) { + AppShutdownCoordinator.cancelPendingShutdown() + isShuttingDown = false + } + if (isShuttingDown && intent?.action != ACTION_QUIT) { + return START_NOT_STICKY + } + when (intent?.action) { + ACTION_STOP -> { + // Stop FGS and mesh cleanly + try { meshService?.stopServices() } catch (_: Exception) { } + try { MeshServiceHolder.clear() } catch (_: Exception) { } + try { stopForeground(true) } catch (_: Exception) { } + notificationManager.cancel(NOTIFICATION_ID) + isInForeground = false + stopSelf() + return START_NOT_STICKY + } + ACTION_QUIT -> { + isShuttingDown = true + updateJob?.cancel() + updateJob = null + try { stopForeground(true) } catch (_: Exception) { } + notificationManager.cancel(NOTIFICATION_ID) + isInForeground = false + // Fully stop all background activity, stop Tor (without changing setting), then kill the app + AppShutdownCoordinator.requestFullShutdownAndKill( + app = application, + mesh = meshService, + notificationManager = notificationManager, + stopForeground = { + try { stopForeground(true) } catch (_: Exception) { } + isInForeground = false + }, + stopService = { stopSelf() } + ) + return START_NOT_STICKY + } + ACTION_UPDATE_NOTIFICATION -> { + // If we became eligible and are not in foreground yet, promote once + if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) { + val n = buildNotification(meshService?.getActivePeerCount() ?: 0) + startForegroundCompat(n) + isInForeground = true + } else { + updateNotification(force = true) + } + } + else -> { /* ACTION_START or null */ } + } + + // Ensure mesh is running (only after permissions are granted) + ensureMeshStarted() + + // Promote exactly once when eligible, otherwise stay background (or stop) + if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) { + val notification = buildNotification(meshService?.getActivePeerCount() ?: 0) + startForegroundCompat(notification) + isInForeground = true + } + + // Periodically refresh the notification with live network size + if (updateJob == null) { + updateJob = scope.launch { + while (isActive) { + // Retry enabling mesh/foreground once permissions become available + ensureMeshStarted() + val eligible = MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() + if (eligible) { + // Only update the notification; do not re-call startForeground() + updateNotification(force = false) + } else { + // If disabled or perms missing, ensure we are not in foreground and clear notif + if (isInForeground) { + try { stopForeground(false) } catch (_: Exception) { } + isInForeground = false + } + notificationManager.cancel(NOTIFICATION_ID) + } + delay(5000) + } + } + } + + return START_STICKY + } + + private fun ensureMeshStarted() { + if (isShuttingDown) return + if (!hasBluetoothPermissions()) return + try { + android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started") + meshService?.startServices() + } catch (e: Exception) { + android.util.Log.e("MeshForegroundService", "Failed to start mesh service: ${e.message}") + } + } + + private fun updateNotification(force: Boolean) { + if (isShuttingDown) { + notificationManager.cancel(NOTIFICATION_ID) + return + } + val count = meshService?.getActivePeerCount() ?: 0 + val notification = buildNotification(count) + if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) { + notificationManager.notify(NOTIFICATION_ID, notification) + } else if (force) { + // If disabled and forced, make sure to remove any prior foreground state + try { stopForeground(false) } catch (_: Exception) { } + notificationManager.cancel(NOTIFICATION_ID) + isInForeground = false + } + } + + private fun hasAllRequiredPermissions(): Boolean { + // For starting FGS with connectedDevice|dataSync, we need: + // - Foreground service permissions (declared in manifest) + // - One of the device-related permissions (we request BL perms at runtime) + // - On Android 13+, POST_NOTIFICATIONS to actually show notification + return hasBluetoothPermissions() && hasNotificationPermission() + } + + private fun hasBluetoothPermissions(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_ADVERTISE) == android.content.pm.PackageManager.PERMISSION_GRANTED && + androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_CONNECT) == android.content.pm.PackageManager.PERMISSION_GRANTED && + androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_SCAN) == android.content.pm.PackageManager.PERMISSION_GRANTED + } else { + // Prior to S, scanning requires location permissions + val fine = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED + val coarse = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_COARSE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED + fine || coarse + } + } + + private fun hasNotificationPermission(): Boolean { + return if (Build.VERSION.SDK_INT >= 33) { + androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED + } else true + } + + private fun buildNotification(activePeers: Int): Notification { + val openIntent = Intent(this, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + this, 0, openIntent, + PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0) + ) + + // Action: Quit Bitchat + val quitIntent = Intent(this, MeshForegroundService::class.java).apply { action = ACTION_QUIT } + val quitPendingIntent = PendingIntent.getService( + this, 1, quitIntent, + PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0) + ) + + val title = getString(R.string.app_name) + val content = getString(R.string.mesh_service_notification_content, activePeers) + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(title) + .setContentText(content) + .setSmallIcon(R.mipmap.ic_launcher) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setContentIntent(pendingIntent) + // Add an action button that appears when notification is expanded + .addAction( + android.R.drawable.ic_menu_close_clear_cancel, + getString(R.string.notification_action_quit_bitchat), + quitPendingIntent + ) + .build() + } + + private fun createChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.mesh_service_channel_name), + NotificationManager.IMPORTANCE_LOW + ).apply { + description = getString(R.string.mesh_service_channel_desc) + setShowBadge(false) + } + (getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) + .createNotificationChannel(channel) + } + } + + private fun hasLocationPermission(): Boolean { + val fine = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED + val coarse = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_COARSE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED + return fine || coarse + } + + private fun startForegroundCompat(notification: Notification) { + if (Build.VERSION.SDK_INT >= 34) { + val type = if (hasLocationPermission()) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION + } else { + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + } + try { + startForeground(NOTIFICATION_ID, notification, type) + } catch (e: SecurityException) { + // Fallback for cases where "While In Use" permission exists but background start is restricted + if (type and ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION != 0) { + android.util.Log.w("MeshForegroundService", "Failed to start with LOCATION type, falling back to CONNECTED_DEVICE: ${e.message}") + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE) + } else { + throw e + } + } + } else { + startForeground(NOTIFICATION_ID, notification) + } + } + + override fun onDestroy() { + 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) { } + isInForeground = false + } + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null +} diff --git a/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt new file mode 100644 index 00000000..d271ab29 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt @@ -0,0 +1,60 @@ +package com.bitchat.android.service + +import android.content.Context +import com.bitchat.android.mesh.BluetoothMeshService + +/** + * Process-wide holder to share a single BluetoothMeshService instance + * between the foreground service and UI (MainActivity/ViewModels). + */ +object MeshServiceHolder { + private const val TAG = "MeshServiceHolder" + @Volatile + var meshService: BluetoothMeshService? = null + private set + + @Synchronized + fun getOrCreate(context: Context): BluetoothMeshService { + val existing = meshService + if (existing != null) { + // If the existing instance is healthy, reuse it; otherwise, replace it. + return try { + if (existing.isReusable()) { + android.util.Log.d(TAG, "Reusing existing BluetoothMeshService instance") + existing + } else { + android.util.Log.w(TAG, "Existing BluetoothMeshService not reusable; replacing with a fresh instance") + // Best-effort stop before replacing + try { existing.stopServices() } catch (e: Exception) { + android.util.Log.w(TAG, "Error while stopping non-reusable instance: ${e.message}") + } + val created = BluetoothMeshService(context.applicationContext) + android.util.Log.i(TAG, "Created new BluetoothMeshService (replacement)") + meshService = created + created + } + } catch (e: Exception) { + android.util.Log.e(TAG, "Error checking service reusability; creating new instance: ${e.message}") + val created = BluetoothMeshService(context.applicationContext) + meshService = created + created + } + } + val created = BluetoothMeshService(context.applicationContext) + android.util.Log.i(TAG, "Created new BluetoothMeshService (no existing instance)") + meshService = created + return created + } + + @Synchronized + fun attach(service: BluetoothMeshService) { + android.util.Log.d(TAG, "Attaching BluetoothMeshService to holder") + meshService = service + } + + @Synchronized + fun clear() { + android.util.Log.d(TAG, "Clearing BluetoothMeshService from holder") + meshService = null + } +} diff --git a/app/src/main/java/com/bitchat/android/service/MeshServicePreferences.kt b/app/src/main/java/com/bitchat/android/service/MeshServicePreferences.kt new file mode 100644 index 00000000..47335ee3 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/MeshServicePreferences.kt @@ -0,0 +1,32 @@ +package com.bitchat.android.service + +import android.content.Context +import android.content.SharedPreferences + +object MeshServicePreferences { + private const val PREFS_NAME = "bitchat_mesh_service_prefs" + private const val KEY_AUTO_START = "auto_start_on_boot" + private const val KEY_BACKGROUND_ENABLED = "background_enabled" + + private lateinit var prefs: SharedPreferences + + fun init(context: Context) { + prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } + + fun isAutoStartEnabled(default: Boolean = true): Boolean { + return prefs.getBoolean(KEY_AUTO_START, default) + } + + fun setAutoStartEnabled(enabled: Boolean) { + prefs.edit().putBoolean(KEY_AUTO_START, enabled).apply() + } + + fun isBackgroundEnabled(default: Boolean = true): Boolean { + return prefs.getBoolean(KEY_BACKGROUND_ENABLED, default) + } + + fun setBackgroundEnabled(enabled: Boolean) { + prefs.edit().putBoolean(KEY_BACKGROUND_ENABLED, enabled).apply() + } +} diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt new file mode 100644 index 00000000..07f146bd --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -0,0 +1,109 @@ +package com.bitchat.android.services + +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.DeliveryStatus +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Process-wide in-memory state store that survives Activity recreation. + * The foreground Mesh service updates this store; UI subscribes/hydrates from it. + */ +object AppStateStore { + // Global de-dup set by message id to avoid duplicate keys in Compose lists + private val seenMessageIds = mutableSetOf() + // Connected peer IDs (mesh ephemeral IDs) + private val _peers = MutableStateFlow>(emptyList()) + val peers: StateFlow> = _peers.asStateFlow() + + // Public mesh timeline messages (non-channel) + private val _publicMessages = MutableStateFlow>(emptyList()) + val publicMessages: StateFlow> = _publicMessages.asStateFlow() + + // Private messages by peerID + private val _privateMessages = MutableStateFlow>>(emptyMap()) + val privateMessages: StateFlow>> = _privateMessages.asStateFlow() + + // Channel messages by channel name + private val _channelMessages = MutableStateFlow>>(emptyMap()) + val channelMessages: StateFlow>> = _channelMessages.asStateFlow() + + fun setPeers(ids: List) { + _peers.value = ids + } + + fun addPublicMessage(msg: BitchatMessage) { + synchronized(this) { + if (seenMessageIds.contains(msg.id)) return + seenMessageIds.add(msg.id) + _publicMessages.value = _publicMessages.value + msg + } + } + + fun addPrivateMessage(peerID: String, msg: BitchatMessage) { + synchronized(this) { + if (seenMessageIds.contains(msg.id)) return + seenMessageIds.add(msg.id) + val map = _privateMessages.value.toMutableMap() + val list = (map[peerID] ?: emptyList()) + msg + map[peerID] = list + _privateMessages.value = map + } + } + + private fun statusPriority(status: DeliveryStatus?): Int = when (status) { + null -> 0 + is DeliveryStatus.Sending -> 1 + is DeliveryStatus.Sent -> 2 + is DeliveryStatus.PartiallyDelivered -> 3 + is DeliveryStatus.Delivered -> 4 + is DeliveryStatus.Read -> 5 + is DeliveryStatus.Failed -> 0 + } + + fun updatePrivateMessageStatus(messageID: String, status: DeliveryStatus) { + synchronized(this) { + val map = _privateMessages.value.toMutableMap() + var changed = false + map.keys.toList().forEach { peer -> + val list = map[peer]?.toMutableList() ?: mutableListOf() + val idx = list.indexOfFirst { it.id == messageID } + if (idx >= 0) { + val current = list[idx].deliveryStatus + // Do not downgrade (e.g., Read -> Delivered) + if (statusPriority(status) >= statusPriority(current)) { + list[idx] = list[idx].copy(deliveryStatus = status) + map[peer] = list + changed = true + } + } + } + if (changed) { + _privateMessages.value = map + } + } + } + + fun addChannelMessage(channel: String, msg: BitchatMessage) { + synchronized(this) { + if (seenMessageIds.contains(msg.id)) return + seenMessageIds.add(msg.id) + val map = _channelMessages.value.toMutableMap() + val list = (map[channel] ?: emptyList()) + msg + map[channel] = list + _channelMessages.value = map + } + } + + // Clear all in-memory state (used for full app shutdown) + fun clear() { + synchronized(this) { + seenMessageIds.clear() + _peers.value = emptyList() + _publicMessages.value = emptyList() + _privateMessages.value = emptyMap() + _channelMessages.value = emptyMap() + } + } +} diff --git a/app/src/main/java/com/bitchat/android/services/NicknameProvider.kt b/app/src/main/java/com/bitchat/android/services/NicknameProvider.kt new file mode 100644 index 00000000..c7cc601f --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/NicknameProvider.kt @@ -0,0 +1,21 @@ +package com.bitchat.android.services + +import android.content.Context +import com.bitchat.android.ui.DataManager + +/** + * Provides current user's nickname for announcements and leave messages. + * If no nickname saved, falls back to the provided peerID. + */ +object NicknameProvider { + fun getNickname(context: Context, myPeerID: String): String { + return try { + val dm = DataManager(context.applicationContext) + val nick = dm.loadNickname() + if (nick.isNullOrBlank()) myPeerID else nick + } catch (_: Exception) { + myPeerID + } + } +} + diff --git a/app/src/main/java/com/bitchat/android/services/VerificationService.kt b/app/src/main/java/com/bitchat/android/services/VerificationService.kt new file mode 100644 index 00000000..06dcd6f3 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/VerificationService.kt @@ -0,0 +1,294 @@ +package com.bitchat.android.services + +import android.net.Uri +import android.util.Base64 +import com.bitchat.android.crypto.EncryptionService +import com.bitchat.android.util.AppConstants +import com.bitchat.android.util.dataFromHexString +import com.bitchat.android.util.hexEncodedString +import java.io.ByteArrayOutputStream +import java.security.SecureRandom +import androidx.core.net.toUri +import java.lang.ref.WeakReference + +/** + * QR verification helpers: schema, signing, and basic challenge/response helpers. + */ +object VerificationService { + private const val CONTEXT = "bitchat-verify-v1" + private const val RESPONSE_CONTEXT = "bitchat-verify-resp-v1" + + private var encryptionServiceRef: WeakReference? = null + + fun configure(encryptionService: EncryptionService) { + this.encryptionServiceRef = WeakReference(encryptionService) + } + + data class VerificationQR( + val v: Int, + val noiseKeyHex: String, + val signKeyHex: String, + val npub: String?, + val nickname: String, + val ts: Long, + val nonceB64: String, + val sigHex: String + ) { + fun canonicalBytes(): ByteArray { + val out = ByteArrayOutputStream() + + fun appendField(value: String) { + val data = value.toByteArray(Charsets.UTF_8) + val len = minOf(data.size, 255) + out.write(len) + out.write(data, 0, len) + } + + appendField(CONTEXT) + appendField(v.toString()) + appendField(noiseKeyHex.lowercase()) + appendField(signKeyHex.lowercase()) + appendField(npub ?: "") + appendField(nickname) + appendField(ts.toString()) + appendField(nonceB64) + return out.toByteArray() + } + + fun toUrlString(): String { + val builder = Uri.Builder() + .scheme("bitchat") + .authority("verify") + .appendQueryParameter("v", v.toString()) + .appendQueryParameter("noise", noiseKeyHex) + .appendQueryParameter("sign", signKeyHex) + .appendQueryParameter("nick", nickname) + .appendQueryParameter("ts", ts.toString()) + .appendQueryParameter("nonce", nonceB64) + .appendQueryParameter("sig", sigHex) + if (npub != null) { + builder.appendQueryParameter("npub", npub) + } + return builder.build().toString() + } + + companion object { + fun fromUrlString(urlString: String): VerificationQR? { + val uri = runCatching { urlString.toUri() }.getOrNull() ?: return null + if (uri.scheme != "bitchat" || uri.host != "verify") return null + + val vStr = uri.getQueryParameter("v") ?: return null + val v = vStr.toIntOrNull() ?: return null + val noise = uri.getQueryParameter("noise") ?: return null + val sign = uri.getQueryParameter("sign") ?: return null + val nick = uri.getQueryParameter("nick") ?: return null + val tsStr = uri.getQueryParameter("ts") ?: return null + val ts = tsStr.toLongOrNull() ?: return null + val nonce = uri.getQueryParameter("nonce") ?: return null + val sig = uri.getQueryParameter("sig") ?: return null + val npub = uri.getQueryParameter("npub") + + return VerificationQR( + v = v, + noiseKeyHex = noise, + signKeyHex = sign, + npub = npub, + nickname = nick, + ts = ts, + nonceB64 = nonce, + sigHex = sig + ) + } + } + } + + fun buildMyQRString(nickname: String, npub: String?): String? { + val service = encryptionServiceRef?.get() ?: return null + val cache = Cache.last + if (cache != null && cache.nickname == nickname && cache.npub == npub) { + if (System.currentTimeMillis() - cache.builtAtMs < 60_000L) { + return cache.value + } + } + + val noiseKey = service.getStaticPublicKey()?.hexEncodedString() ?: return null + val signKey = service.getSigningPublicKey()?.hexEncodedString() ?: return null + val ts = System.currentTimeMillis() / 1000L + val nonce = ByteArray(16) + SecureRandom().nextBytes(nonce) + val nonceB64 = Base64.encodeToString( + nonce, + Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING + ) + + val payload = VerificationQR( + v = 1, + noiseKeyHex = noiseKey, + signKeyHex = signKey, + npub = npub, + nickname = nickname, + ts = ts, + nonceB64 = nonceB64, + sigHex = "" + ) + + val signature = service.signData(payload.canonicalBytes()) ?: return null + val signed = payload.copy(sigHex = signature.hexEncodedString()) + val out = signed.toUrlString() + Cache.last = CacheEntry(nickname, npub, System.currentTimeMillis(), out) + return out + } + + fun verifyScannedQR( + urlString: String, + maxAgeSeconds: Long = AppConstants.Verification.QR_MAX_AGE_SECONDS + ): VerificationQR? { + val service = encryptionServiceRef?.get() ?: return null + val qr = VerificationQR.fromUrlString(urlString) ?: return null + val now = System.currentTimeMillis() / 1000L + if (now - qr.ts > maxAgeSeconds) return null + + val sig = qr.sigHex.dataFromHexString() ?: return null + val signKey = qr.signKeyHex.dataFromHexString() ?: return null + val ok = service.verifyEd25519Signature(sig, qr.canonicalBytes(), signKey) + return if (ok) qr else null + } + + fun buildVerifyChallenge(noiseKeyHex: String, nonceA: ByteArray): ByteArray { + val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8) + val out = ByteArrayOutputStream() + out.write(0x01) + out.write(minOf(noiseData.size, 255)) + out.write(noiseData, 0, minOf(noiseData.size, 255)) + out.write(0x02) + out.write(minOf(nonceA.size, 255)) + out.write(nonceA, 0, minOf(nonceA.size, 255)) + return out.toByteArray() + } + + fun buildVerifyResponse(noiseKeyHex: String, nonceA: ByteArray): ByteArray? { + val service = encryptionServiceRef?.get() ?: return null + val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8) + val msg = ByteArrayOutputStream() + msg.write(RESPONSE_CONTEXT.toByteArray(Charsets.UTF_8)) + msg.write(minOf(noiseData.size, 255)) + msg.write(noiseData, 0, minOf(noiseData.size, 255)) + msg.write(nonceA) + val sig = service.signData(msg.toByteArray()) ?: return null + + val out = ByteArrayOutputStream() + out.write(0x01) + out.write(minOf(noiseData.size, 255)) + out.write(noiseData, 0, minOf(noiseData.size, 255)) + out.write(0x02) + out.write(minOf(nonceA.size, 255)) + out.write(nonceA, 0, minOf(nonceA.size, 255)) + out.write(0x03) + out.write(minOf(sig.size, 255)) + out.write(sig, 0, minOf(sig.size, 255)) + return out.toByteArray() + } + + fun parseVerifyChallenge(data: ByteArray): Pair? { + var idx = 0 + + fun take(n: Int): ByteArray? { + if (idx + n > data.size) return null + val out = data.copyOfRange(idx, idx + n) + idx += n + return out + } + + val t1 = take(1) ?: return null + if (t1[0].toInt() != 0x01) return null + val l1 = take(1)?.get(0)?.toInt() ?: return null + val noiseBytes = take(l1) ?: return null + val noise = noiseBytes.toString(Charsets.UTF_8) + + val t2 = take(1) ?: return null + if (t2[0].toInt() != 0x02) return null + val l2 = take(1)?.get(0)?.toInt() ?: return null + val nonce = take(l2) ?: return null + + return noise to nonce + } + + data class VerifyResponse(val noiseKeyHex: String, val nonceA: ByteArray, val signature: ByteArray) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as VerifyResponse + + if (noiseKeyHex != other.noiseKeyHex) return false + if (!nonceA.contentEquals(other.nonceA)) return false + if (!signature.contentEquals(other.signature)) return false + + return true + } + + override fun hashCode(): Int { + var result = noiseKeyHex.hashCode() + result = 31 * result + nonceA.contentHashCode() + result = 31 * result + signature.contentHashCode() + return result + } + } + + fun parseVerifyResponse(data: ByteArray): VerifyResponse? { + var idx = 0 + + fun take(n: Int): ByteArray? { + if (idx + n > data.size) return null + val out = data.copyOfRange(idx, idx + n) + idx += n + return out + } + + val t1 = take(1) ?: return null + if (t1[0].toInt() != 0x01) return null + val l1 = take(1)?.get(0)?.toInt() ?: return null + val noiseBytes = take(l1) ?: return null + val noise = noiseBytes.toString(Charsets.UTF_8) + + val t2 = take(1) ?: return null + if (t2[0].toInt() != 0x02) return null + val l2 = take(1)?.get(0)?.toInt() ?: return null + val nonce = take(l2) ?: return null + + val t3 = take(1) ?: return null + if (t3[0].toInt() != 0x03) return null + val l3 = take(1)?.get(0)?.toInt() ?: return null + val sig = take(l3) ?: return null + + return VerifyResponse(noise, nonce, sig) + } + + fun verifyResponseSignature( + noiseKeyHex: String, + nonceA: ByteArray, + signature: ByteArray, + signerPublicKeyHex: String + ): Boolean { + val service = encryptionServiceRef?.get() ?: return false + val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8) + val msg = ByteArrayOutputStream() + msg.write(RESPONSE_CONTEXT.toByteArray(Charsets.UTF_8)) + msg.write(minOf(noiseData.size, 255)) + msg.write(noiseData, 0, minOf(noiseData.size, 255)) + msg.write(nonceA) + val signerKey = signerPublicKeyHex.dataFromHexString() ?: return false + return service.verifyEd25519Signature(signature, msg.toByteArray(), signerKey) + } + + private data class CacheEntry( + val nickname: String, + val npub: String?, + val builtAtMs: Long, + val value: String + ) + + private object Cache { + var last: CacheEntry? = null + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 091cf630..d931c027 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -9,13 +9,18 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Public -import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.NetworkCheck +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment +import kotlinx.coroutines.launch import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector @@ -28,12 +33,171 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bitchat.android.nostr.NostrProofOfWork import com.bitchat.android.nostr.PoWPreferenceManager -import com.bitchat.android.ui.debug.DebugSettingsSheet import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.R +import com.bitchat.android.core.ui.component.button.CloseButton +import com.bitchat.android.net.TorMode +import com.bitchat.android.net.TorPreferenceManager +import com.bitchat.android.net.ArtiTorManager + /** - * About Sheet for bitchat app information - * Matches the design language of LocationChannelsSheet + * Feature row for displaying app capabilities + */ +@Composable +private fun FeatureRow( + icon: ImageVector, + title: String, + subtitle: String +) { + val colorScheme = MaterialTheme.colorScheme + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier + .padding(top = 2.dp) + .size(22.dp) + ) + Spacer(modifier = Modifier.width(14.dp)) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface.copy(alpha = 0.6f), + lineHeight = 18.sp + ) + } + } +} + +/** + * Theme selection chip with Apple-like styling + */ +@Composable +private fun ThemeChip( + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f + + Surface( + modifier = modifier, + onClick = onClick, + shape = RoundedCornerShape(10.dp), + color = if (selected) { + if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) + } else { + colorScheme.surfaceVariant.copy(alpha = 0.5f) + } + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = if (selected) Color.White else colorScheme.onSurface.copy(alpha = 0.8f) + ) + } + } +} + +/** + * Unified settings toggle row with icon, title, subtitle, and switch + * Apple-like design with proper spacing + */ +@Composable +private fun SettingsToggleRow( + icon: ImageVector, + title: String, + subtitle: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + enabled: Boolean = true, + statusIndicator: (@Composable () -> Unit)? = null +) { + val colorScheme = MaterialTheme.colorScheme + val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = if (enabled) colorScheme.primary else colorScheme.onSurface.copy(alpha = 0.3f), + modifier = Modifier.size(22.dp) + ) + + Spacer(modifier = Modifier.width(14.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = if (enabled) colorScheme.onSurface else colorScheme.onSurface.copy(alpha = 0.4f) + ) + statusIndicator?.invoke() + } + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface.copy(alpha = if (enabled) 0.6f else 0.3f), + lineHeight = 16.sp + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Switch( + checked = checked, + onCheckedChange = { if (enabled) onCheckedChange(it) }, + enabled = enabled, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), + uncheckedThumbColor = Color.White, + uncheckedTrackColor = colorScheme.surfaceVariant + ) + ) + } +} + +/** + * Apple-like About/Settings Sheet with high-quality design + * Professional UX optimized for checkout scenarios */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -53,8 +217,6 @@ fun AboutSheet( "1.0.0" // fallback version } } - - // Bottom sheet state val sheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true @@ -67,11 +229,10 @@ fun AboutSheet( } } val topBarAlpha by animateFloatAsState( - targetValue = if (isScrolled) 0.95f else 0f, + targetValue = if (isScrolled) 0.98f else 0f, label = "topBarAlpha" ) - // Color scheme matching LocationChannelsSheet val colorScheme = MaterialTheme.colorScheme val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f @@ -80,402 +241,368 @@ fun AboutSheet( modifier = modifier.statusBarsPadding(), onDismissRequest = onDismiss, sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.background, + containerColor = colorScheme.background, dragHandle = null ) { Box(modifier = Modifier.fillMaxWidth()) { LazyColumn( state = lazyListState, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(top = 80.dp, bottom = 20.dp) + contentPadding = PaddingValues(top = 80.dp, bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) ) { - // Header Section + // Header Section - App Identity item(key = "header") { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(bottom = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.Bottom - ) { - Text( - text = stringResource(R.string.app_name), - style = TextStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = 32.sp - ), - color = MaterialTheme.colorScheme.onBackground - ) - - Text( - text = stringResource(R.string.version_prefix, versionName?:""), - fontSize = 11.sp, + Text( + text = stringResource(R.string.app_name), + style = TextStyle( fontFamily = FontFamily.Monospace, - color = colorScheme.onBackground.copy(alpha = 0.5f), - style = MaterialTheme.typography.bodySmall.copy( - baselineShift = BaselineShift(0.1f) - ) - ) - } - + fontWeight = FontWeight.Bold, + fontSize = 28.sp, + letterSpacing = 1.sp + ), + color = colorScheme.onBackground + ) + Text( + text = stringResource(R.string.version_prefix, versionName ?: ""), + fontSize = 13.sp, + fontFamily = FontFamily.Monospace, + color = colorScheme.onBackground.copy(alpha = 0.5f) + ) Text( text = stringResource(R.string.about_tagline), - fontSize = 12.sp, + fontSize = 13.sp, fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f) + color = colorScheme.onBackground.copy(alpha = 0.6f), + modifier = Modifier.padding(top = 4.dp) ) } } - // Features section - item(key = "feature_offline") { - Row( - verticalAlignment = Alignment.Top, - modifier = Modifier - .padding(horizontal = 24.dp) - .padding(vertical = 8.dp) - ) { - Icon( - imageVector = Icons.Filled.Bluetooth, - contentDescription = stringResource(R.string.cd_offline_mesh_chat), - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier - .padding(top = 2.dp) - .size(20.dp) + // Features Section - Grouped Card + item(key = "features") { + Column(modifier = Modifier.padding(horizontal = 20.dp)) { + Text( + text = stringResource(R.string.about_appearance).uppercase(), + style = MaterialTheme.typography.labelSmall, + color = colorScheme.onBackground.copy(alpha = 0.5f), + letterSpacing = 0.5.sp, + modifier = Modifier.padding(start = 16.dp, bottom = 8.dp) ) - Spacer(modifier = Modifier.width(16.dp)) - Column { - Text( - text = stringResource(R.string.about_offline_mesh_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onBackground - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.about_offline_mesh_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f) - ) - } - } - } - item(key = "feature_geohash") { - Row( - verticalAlignment = Alignment.Top, - modifier = Modifier - .padding(horizontal = 24.dp) - .padding(vertical = 8.dp) - ) { - Icon( - imageVector = Icons.Default.Public, - contentDescription = stringResource(R.string.cd_online_geohash_channels), - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier - .padding(top = 2.dp) - .size(20.dp) - ) - Spacer(modifier = Modifier.width(16.dp)) - Column { - Text( - text = stringResource(R.string.about_online_geohash_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onBackground - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.about_online_geohash_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f) - ) - } - } - } - item(key = "feature_encryption") { - Row( - verticalAlignment = Alignment.Top, - modifier = Modifier - .padding(horizontal = 24.dp) - .padding(vertical = 8.dp) - ) { - Icon( - imageVector = Icons.Default.Lock, - contentDescription = stringResource(R.string.cd_end_to_end_encryption), - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier - .padding(top = 2.dp) - .size(20.dp) - ) - Spacer(modifier = Modifier.width(16.dp)) - Column { - Text( - text = stringResource(R.string.about_e2e_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onBackground - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.about_e2e_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f) - ) + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surface, + shape = RoundedCornerShape(16.dp) + ) { + Column { + FeatureRow( + icon = Icons.Filled.Bluetooth, + title = stringResource(R.string.about_offline_mesh_title), + subtitle = stringResource(R.string.about_offline_mesh_desc) + ) + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = colorScheme.outline.copy(alpha = 0.12f) + ) + FeatureRow( + icon = Icons.Default.Public, + title = stringResource(R.string.about_online_geohash_title), + subtitle = stringResource(R.string.about_online_geohash_desc) + ) + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = colorScheme.outline.copy(alpha = 0.12f) + ) + FeatureRow( + icon = Icons.Default.Lock, + title = stringResource(R.string.about_e2e_title), + subtitle = stringResource(R.string.about_e2e_desc) + ) + } } } } // Appearance Section - item(key = "appearance_section") { - Text( - text = stringResource(R.string.about_appearance), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), - modifier = Modifier - .padding(horizontal = 24.dp) - .padding(top = 24.dp, bottom = 8.dp) - ) - val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState() - Row( - modifier = Modifier.padding(horizontal = 24.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - FilterChip( - selected = themePref.isSystem, - onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) }, - label = { Text(stringResource(R.string.about_system), fontFamily = FontFamily.Monospace) } - ) - FilterChip( - selected = themePref.isLight, - onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Light) }, - label = { Text(stringResource(R.string.about_light), fontFamily = FontFamily.Monospace) } - ) - FilterChip( - selected = themePref.isDark, - onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Dark) }, - label = { Text(stringResource(R.string.about_dark), fontFamily = FontFamily.Monospace) } + item(key = "appearance") { + Column(modifier = Modifier.padding(horizontal = 20.dp)) { + Text( + text = "THEME", + style = MaterialTheme.typography.labelSmall, + color = colorScheme.onBackground.copy(alpha = 0.5f), + letterSpacing = 0.5.sp, + modifier = Modifier.padding(start = 16.dp, bottom = 8.dp) ) + val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState() + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surface, + shape = RoundedCornerShape(16.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + ThemeChip( + label = stringResource(R.string.about_system), + selected = themePref.isSystem, + onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) }, + modifier = Modifier.weight(1f) + ) + ThemeChip( + label = stringResource(R.string.about_light), + selected = themePref.isLight, + onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Light) }, + modifier = Modifier.weight(1f) + ) + ThemeChip( + label = stringResource(R.string.about_dark), + selected = themePref.isDark, + onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Dark) }, + modifier = Modifier.weight(1f) + ) + } + } } } - // Proof of Work Section - item(key = "pow_section") { - Text( - text = stringResource(R.string.about_pow), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), - modifier = Modifier - .padding(horizontal = 24.dp) - .padding(top = 24.dp, bottom = 8.dp) - ) - LaunchedEffect(Unit) { - PoWPreferenceManager.init(context) - } + // Settings Section - Unified Card with Toggles + item(key = "settings") { + LaunchedEffect(Unit) { PoWPreferenceManager.init(context) } val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState() + var backgroundEnabled by remember { mutableStateOf(com.bitchat.android.service.MeshServicePreferences.isBackgroundEnabled(true)) } + val torMode = remember { mutableStateOf(TorPreferenceManager.get(context)) } + val torProvider = remember { ArtiTorManager.getInstance() } + val torStatus by torProvider.statusFlow.collectAsState() + val torAvailable = remember { torProvider.isTorAvailable() } - Column( - modifier = Modifier.padding(horizontal = 24.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically + Column(modifier = Modifier.padding(horizontal = 20.dp)) { + Text( + text = "SETTINGS", + style = MaterialTheme.typography.labelSmall, + color = colorScheme.onBackground.copy(alpha = 0.5f), + letterSpacing = 0.5.sp, + modifier = Modifier.padding(start = 16.dp, bottom = 8.dp) + ) + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surface, + shape = RoundedCornerShape(16.dp) ) { - FilterChip( - selected = !powEnabled, - onClick = { PoWPreferenceManager.setPowEnabled(false) }, - label = { Text(stringResource(R.string.about_pow_off), fontFamily = FontFamily.Monospace) } - ) - FilterChip( - selected = powEnabled, - onClick = { PoWPreferenceManager.setPowEnabled(true) }, - label = { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(stringResource(R.string.about_pow_on), fontFamily = FontFamily.Monospace) - // Show current difficulty - if (powEnabled) { - Surface( - color = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), - shape = RoundedCornerShape(50) - ) { Box(Modifier.size(8.dp)) } + Column { + // Background Mode Toggle + SettingsToggleRow( + icon = Icons.Filled.Bluetooth, + title = stringResource(R.string.about_background_title), + subtitle = stringResource(R.string.about_background_desc), + checked = backgroundEnabled, + onCheckedChange = { enabled -> + backgroundEnabled = enabled + com.bitchat.android.service.MeshServicePreferences.setBackgroundEnabled(enabled) + if (!enabled) { + com.bitchat.android.service.MeshForegroundService.stop(context) + } else { + com.bitchat.android.service.MeshForegroundService.start(context) } } - } - ) - } - - Text( - text = stringResource(R.string.about_pow_tip), - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.6f) - ) - - // Show difficulty slider when enabled - if (powEnabled) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = stringResource(R.string.about_pow_difficulty, powDifficulty, NostrProofOfWork.estimateMiningTime(powDifficulty)), - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, ) - - Slider( - value = powDifficulty.toFloat(), - onValueChange = { PoWPreferenceManager.setPowDifficulty(it.toInt()) }, - valueRange = 0f..32f, - steps = 33, - colors = SliderDefaults.colors( - thumbColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), - activeTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) - ) + + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = colorScheme.outline.copy(alpha = 0.12f) + ) + + // Proof of Work Toggle + SettingsToggleRow( + icon = Icons.Filled.Speed, + title = stringResource(R.string.about_pow), + subtitle = stringResource(R.string.about_pow_tip), + checked = powEnabled, + onCheckedChange = { PoWPreferenceManager.setPowEnabled(it) } + ) + + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = colorScheme.outline.copy(alpha = 0.12f) + ) + + // Tor Toggle + SettingsToggleRow( + icon = Icons.Filled.Security, + title = "Tor Network", + subtitle = stringResource(R.string.about_tor_route), + checked = torMode.value == TorMode.ON, + onCheckedChange = { enabled -> + if (torAvailable) { + torMode.value = if (enabled) TorMode.ON else TorMode.OFF + TorPreferenceManager.set(context, torMode.value) + } + }, + enabled = torAvailable, + statusIndicator = if (torMode.value == TorMode.ON) { + { + val statusColor = when { + torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) + torStatus.running -> Color(0xFFFF9500) + else -> Color(0xFFFF3B30) + } + Surface( + color = statusColor, + shape = CircleShape, + modifier = Modifier.size(8.dp) + ) {} + } + } else null ) - - // Show difficulty description - Surface( - modifier = Modifier.fillMaxWidth(), - color = colorScheme.surfaceVariant.copy(alpha = 0.25f), - shape = RoundedCornerShape(8.dp) - ) { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = stringResource(R.string.about_pow_difficulty_attempts, powDifficulty, NostrProofOfWork.estimateWork(powDifficulty)), - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.7f) - ) - Text( - text = when { - powDifficulty == 0 -> stringResource(R.string.about_pow_desc_none) - powDifficulty <= 8 -> stringResource(R.string.about_pow_desc_very_low) - powDifficulty <= 12 -> stringResource(R.string.about_pow_desc_low) - powDifficulty <= 16 -> stringResource(R.string.about_pow_desc_medium) - powDifficulty <= 20 -> stringResource(R.string.about_pow_desc_high) - powDifficulty <= 24 -> stringResource(R.string.about_pow_desc_very_high) - else -> stringResource(R.string.about_pow_desc_extreme) - }, - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.6f) - ) - } - } } } + + // Tor unavailable hint + if (!torAvailable) { + Text( + text = stringResource(R.string.tor_not_available_in_this_build), + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = colorScheme.onBackground.copy(alpha = 0.5f), + modifier = Modifier.padding(start = 16.dp, top = 8.dp) + ) + } } } - // Network (Tor) section - item(key = "network_section") { - val torMode = remember { mutableStateOf(com.bitchat.android.net.TorPreferenceManager.get(context)) } - val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState() - Text( - text = stringResource(R.string.about_network), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), - modifier = Modifier - .padding(horizontal = 24.dp) - .padding(top = 24.dp, bottom = 8.dp) - ) - Column(modifier = Modifier.padding(horizontal = 24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - FilterChip( - selected = torMode.value == com.bitchat.android.net.TorMode.OFF, - onClick = { - torMode.value = com.bitchat.android.net.TorMode.OFF - com.bitchat.android.net.TorPreferenceManager.set(context, torMode.value) - }, - label = { Text("tor off", fontFamily = FontFamily.Monospace) } - ) - FilterChip( - selected = torMode.value == com.bitchat.android.net.TorMode.ON, - onClick = { - torMode.value = com.bitchat.android.net.TorMode.ON - com.bitchat.android.net.TorPreferenceManager.set(context, torMode.value) - }, - label = { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text("tor on", fontFamily = FontFamily.Monospace) - val statusColor = when { - torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500) - torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) - else -> Color.Red - } - Surface(color = statusColor, shape = CircleShape) { - Box(Modifier.size(8.dp)) - } - } - } - ) - } - Text( - text = stringResource(R.string.about_tor_route), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ) - if (torMode.value == com.bitchat.android.net.TorMode.ON) { - val statusText = if (torStatus.running) "Running" else "Stopped" - // Debug status (temporary) + // PoW Difficulty Slider (when enabled) + item(key = "pow_slider") { + val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() + val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState() + + if (powEnabled) { + Column(modifier = Modifier.padding(horizontal = 20.dp)) { Surface( modifier = Modifier.fillMaxWidth(), - color = colorScheme.surfaceVariant.copy(alpha = 0.25f), - shape = RoundedCornerShape(8.dp) + color = colorScheme.surface, + shape = RoundedCornerShape(16.dp) ) { Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - Text( - text = stringResource(R.string.about_tor_status, statusText, torStatus.bootstrapPercent), - style = MaterialTheme.typography.bodySmall, - color = colorScheme.onSurface.copy(alpha = 0.75f) - ) - val lastLog = torStatus.lastLogLine - if (lastLog.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { Text( - text = stringResource(R.string.about_last, lastLog.take(160)), - style = MaterialTheme.typography.labelSmall, + text = "Difficulty", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + Text( + text = "$powDifficulty bits • ${NostrProofOfWork.estimateMiningTime(powDifficulty)}", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, color = colorScheme.onSurface.copy(alpha = 0.6f) ) } + + Slider( + value = powDifficulty.toFloat(), + onValueChange = { PoWPreferenceManager.setPowDifficulty(it.toInt()) }, + valueRange = 0f..32f, + steps = 31, + colors = SliderDefaults.colors( + thumbColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), + activeTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) + ) + ) + + Text( + text = when { + powDifficulty == 0 -> stringResource(R.string.about_pow_desc_none) + powDifficulty <= 8 -> stringResource(R.string.about_pow_desc_very_low) + powDifficulty <= 12 -> stringResource(R.string.about_pow_desc_low) + powDifficulty <= 16 -> stringResource(R.string.about_pow_desc_medium) + powDifficulty <= 20 -> stringResource(R.string.about_pow_desc_high) + powDifficulty <= 24 -> stringResource(R.string.about_pow_desc_very_high) + else -> stringResource(R.string.about_pow_desc_extreme) + }, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = colorScheme.onSurface.copy(alpha = 0.5f) + ) + } + } + } + } + } + + // Tor Status (when enabled) + item(key = "tor_status") { + val torMode = remember { mutableStateOf(TorPreferenceManager.get(context)) } + val torProvider = remember { ArtiTorManager.getInstance() } + val torStatus by torProvider.statusFlow.collectAsState() + + if (torMode.value == TorMode.ON) { + Column(modifier = Modifier.padding(horizontal = 20.dp)) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surface, + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val statusColor = when { + torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) + torStatus.running -> Color(0xFFFF9500) + else -> Color(0xFFFF3B30) + } + Surface(color = statusColor, shape = CircleShape, modifier = Modifier.size(10.dp)) {} + Text( + text = if (torStatus.running) "Connected (${torStatus.bootstrapPercent}%)" else "Disconnected", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + } + if (torStatus.lastLogLine.isNotEmpty()) { + Text( + text = torStatus.lastLogLine.take(120), + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = colorScheme.onSurface.copy(alpha = 0.5f), + maxLines = 2 + ) + } } } } } } - // Emergency Warning Section - item(key = "warning_section") { - val colorScheme = MaterialTheme.colorScheme - val errorColor = colorScheme.error - + // Emergency Warning + item(key = "warning") { Surface( modifier = Modifier - .padding(horizontal = 24.dp, vertical = 24.dp) + .padding(horizontal = 20.dp) .fillMaxWidth(), - color = errorColor.copy(alpha = 0.1f), - shape = RoundedCornerShape(12.dp) + color = colorScheme.error.copy(alpha = 0.1f), + shape = RoundedCornerShape(16.dp) ) { Row( modifier = Modifier.padding(16.dp), @@ -484,61 +611,53 @@ fun AboutSheet( ) { Icon( imageVector = Icons.Filled.Warning, - contentDescription = stringResource(R.string.cd_warning), - tint = errorColor, - modifier = Modifier.size(16.dp) + contentDescription = null, + tint = colorScheme.error, + modifier = Modifier.size(20.dp) ) Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( text = stringResource(R.string.about_emergency_title), - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - color = errorColor + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = colorScheme.error ) Text( text = stringResource(R.string.about_emergency_tip), - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.8f) + fontSize = 13.sp, + color = colorScheme.onSurface.copy(alpha = 0.7f) ) } } } } - // Footer Section + // Footer item(key = "footer") { Column( modifier = Modifier - .padding(horizontal = 24.dp) - .fillMaxWidth(), + .fillMaxWidth() + .padding(horizontal = 20.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(12.dp) ) { if (onShowDebug != null) { - TextButton( - onClick = onShowDebug, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ) - ) { + TextButton(onClick = onShowDebug) { Text( text = stringResource(R.string.about_debug_settings), - fontSize = 11.sp, - fontFamily = FontFamily.Monospace + fontSize = 13.sp, + fontFamily = FontFamily.Monospace, + color = colorScheme.primary ) } } Text( text = stringResource(R.string.about_footer), - fontSize = 11.sp, + fontSize = 12.sp, fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + color = colorScheme.onSurface.copy(alpha = 0.4f) ) - - // Add extra space at bottom for gesture area - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(20.dp)) } } } @@ -551,24 +670,39 @@ fun AboutSheet( .height(64.dp) .background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha)) ) { - TextButton( + CloseButton( onClick = onDismiss, - modifier = Modifier + modifier = modifier .align(Alignment.CenterEnd) - .padding(horizontal = 16.dp) - ) { - Text( - text = stringResource(R.string.close_plain), - style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.onBackground - ) - } + .padding(horizontal = 16.dp), + ) } } } } } +@Composable +fun CloseButton( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + IconButton( + onClick = onClick, + modifier = modifier + .size(32.dp), + colors = IconButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), + containerColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.1f) + ) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close", + modifier = Modifier.size(18.dp) + ) + } +} /** * Password prompt dialog for password-protected channels * Kept as dialog since it requires user input diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 4b1b22ca..6d3a9f3a 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -14,7 +14,6 @@ import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -28,9 +27,9 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bitchat.android.core.ui.utils.singleOrTripleClickable -import com.bitchat.android.geohash.LocationChannelManager.PermissionState import androidx.compose.foundation.Canvas import androidx.compose.ui.geometry.Offset +import androidx.lifecycle.compose.collectAsStateWithLifecycle /** * Header components for ChatScreen @@ -59,7 +58,8 @@ fun isFavoriteReactive( fun TorStatusDot( modifier: Modifier = Modifier ) { - val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState() + val torProvider = remember { com.bitchat.android.net.ArtiTorManager.getInstance() } + val torStatus by torProvider.statusFlow.collectAsState() if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) { val dotColor = when { @@ -248,10 +248,10 @@ fun ChatHeaderContent( when { selectedPrivatePeer != null -> { // Private chat header - Fully reactive state tracking - val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet()) - val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap()) - val peerSessionStates by viewModel.peerSessionStates.observeAsState(emptyMap()) - val peerNicknames by viewModel.peerNicknames.observeAsState(emptyMap()) + val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle() + val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle() + val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle() + val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle() // Reactive favorite computation - no more static lookups! val isFavorite = isFavoriteReactive( @@ -264,8 +264,8 @@ fun ChatHeaderContent( Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, isFav=$isFavorite, sessionState=$sessionState") // Pass geohash context and people for NIP-17 chat title formatting - val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() - val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList()) + val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() + val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() PrivateChatHeader( peerID = selectedPrivatePeer, @@ -318,6 +318,10 @@ private fun PrivateChatHeader( ) { val colorScheme = MaterialTheme.colorScheme val isNostrDM = peerID.startsWith("nostr_") || peerID.startsWith("nostr:") + val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() + val isVerified = remember(peerID, verifiedFingerprints) { + viewModel.isPeerVerified(peerID, verifiedFingerprints) + } // Determine mutual favorite state for this peer (supports mesh ephemeral 16-hex via favorites lookup) val isMutualFavorite = remember(peerID, peerNicknames) { try { @@ -413,17 +417,33 @@ private fun PrivateChatHeader( // Show a globe when chatting via Nostr alias, or when mesh session not established but mutual favorite exists val showGlobe = isNostrDM || (sessionState != "established" && isMutualFavorite) + val securityModifier = if (!isNostrDM) { + Modifier.clickable { viewModel.showSecurityVerificationSheet() } + } else { + Modifier + } + if (showGlobe) { Icon( imageVector = Icons.Outlined.Public, contentDescription = stringResource(R.string.cd_nostr_reachable), - modifier = Modifier.size(14.dp), + modifier = Modifier.size(14.dp).then(securityModifier), tint = Color(0xFF9B59B6) // Purple like iOS ) } else { NoiseSessionIcon( sessionState = sessionState, - modifier = Modifier.size(14.dp) + modifier = Modifier.size(14.dp).then(securityModifier) + ) + } + + if (isVerified) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.Filled.Verified, + contentDescription = stringResource(R.string.verify_title), + modifier = Modifier.size(14.dp).then(securityModifier), + tint = Color(0xFF32D74B) ) } @@ -523,18 +543,18 @@ private fun MainHeader( viewModel: ChatViewModel ) { val colorScheme = MaterialTheme.colorScheme - val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList()) - val joinedChannels by viewModel.joinedChannels.observeAsState(emptySet()) - val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap()) - val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet()) - val isConnected by viewModel.isConnected.observeAsState(false) - val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() - val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList()) + val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle() + val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle() + val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle() + val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() + val isConnected by viewModel.isConnected.collectAsStateWithLifecycle() + val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() + val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() // Bookmarks store for current geohash toggle (iOS parity) val context = androidx.compose.ui.platform.LocalContext.current val bookmarksStore = remember { com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(context) } - val bookmarks by bookmarksStore.bookmarks.observeAsState(emptyList()) + val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle() Row( modifier = Modifier.fillMaxWidth(), @@ -653,8 +673,8 @@ private fun LocationChannelsButton( val colorScheme = MaterialTheme.colorScheme // Get current channel selection from location manager - val selectedChannel by viewModel.selectedLocationChannel.observeAsState() - val teleported by viewModel.isTeleported.observeAsState(false) + val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() + val teleported by viewModel.isTeleported.collectAsStateWithLifecycle() val (badgeText, badgeColor) = when (selectedChannel) { is com.bitchat.android.geohash.ChannelID.Mesh -> { diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 6ec8bef7..bf6a73b0 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.Alignment @@ -25,6 +24,7 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.Dp import androidx.compose.ui.zIndex +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.model.BitchatMessage import com.bitchat.android.ui.media.FullScreenImageViewer @@ -41,22 +41,24 @@ import com.bitchat.android.ui.media.FullScreenImageViewer @Composable fun ChatScreen(viewModel: ChatViewModel) { val colorScheme = MaterialTheme.colorScheme - val messages by viewModel.messages.observeAsState(emptyList()) - val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList()) - val nickname by viewModel.nickname.observeAsState("") - val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState() - val currentChannel by viewModel.currentChannel.observeAsState() - val joinedChannels by viewModel.joinedChannels.observeAsState(emptySet()) - val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap()) - val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet()) - val privateChats by viewModel.privateChats.observeAsState(emptyMap()) - val channelMessages by viewModel.channelMessages.observeAsState(emptyMap()) - val showSidebar by viewModel.showSidebar.observeAsState(false) - val showCommandSuggestions by viewModel.showCommandSuggestions.observeAsState(false) - val commandSuggestions by viewModel.commandSuggestions.observeAsState(emptyList()) - val showMentionSuggestions by viewModel.showMentionSuggestions.observeAsState(false) - val mentionSuggestions by viewModel.mentionSuggestions.observeAsState(emptyList()) - val showAppInfo by viewModel.showAppInfo.observeAsState(false) + val messages by viewModel.messages.collectAsStateWithLifecycle() + val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle() + val nickname by viewModel.nickname.collectAsStateWithLifecycle() + val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle() + val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle() + val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle() + val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle() + val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() + val privateChats by viewModel.privateChats.collectAsStateWithLifecycle() + val channelMessages by viewModel.channelMessages.collectAsStateWithLifecycle() + val showSidebar by viewModel.showSidebar.collectAsStateWithLifecycle() + val showCommandSuggestions by viewModel.showCommandSuggestions.collectAsStateWithLifecycle() + val commandSuggestions by viewModel.commandSuggestions.collectAsStateWithLifecycle() + val showMentionSuggestions by viewModel.showMentionSuggestions.collectAsStateWithLifecycle() + val mentionSuggestions by viewModel.mentionSuggestions.collectAsStateWithLifecycle() + val showAppInfo by viewModel.showAppInfo.collectAsStateWithLifecycle() + val showVerificationSheet by viewModel.showVerificationSheet.collectAsStateWithLifecycle() + val showSecurityVerificationSheet by viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle() var messageText by remember { mutableStateOf(TextFieldValue("")) } var showPasswordPrompt by remember { mutableStateOf(false) } @@ -78,11 +80,11 @@ fun ChatScreen(viewModel: ChatViewModel) { showPasswordDialog = showPasswordPrompt } - val isConnected by viewModel.isConnected.observeAsState(false) - val passwordPromptChannel by viewModel.passwordPromptChannel.observeAsState(null) + val isConnected by viewModel.isConnected.collectAsStateWithLifecycle() + val passwordPromptChannel by viewModel.passwordPromptChannel.collectAsStateWithLifecycle() // Get location channel info for timeline switching - val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() + val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() // Determine what messages to show based on current context (unified timelines) val displayMessages = when { @@ -327,6 +329,10 @@ fun ChatScreen(viewModel: ChatViewModel) { SidebarOverlay( viewModel = viewModel, onDismiss = { viewModel.hideSidebar() }, + onShowVerification = { + viewModel.showVerificationSheet(fromSidebar = true) + viewModel.hideSidebar() + }, modifier = Modifier.fillMaxSize() ) } @@ -373,7 +379,11 @@ fun ChatScreen(viewModel: ChatViewModel) { }, selectedUserForSheet = selectedUserForSheet, selectedMessageForSheet = selectedMessageForSheet, - viewModel = viewModel + viewModel = viewModel, + showVerificationSheet = showVerificationSheet, + onVerificationSheetDismiss = viewModel::hideVerificationSheet, + showSecurityVerificationSheet = showSecurityVerificationSheet, + onSecurityVerificationSheetDismiss = viewModel::hideSecurityVerificationSheet ) } @@ -513,7 +523,11 @@ private fun ChatDialogs( onUserSheetDismiss: () -> Unit, selectedUserForSheet: String, selectedMessageForSheet: BitchatMessage?, - viewModel: ChatViewModel + viewModel: ChatViewModel, + showVerificationSheet: Boolean, + onVerificationSheetDismiss: () -> Unit, + showSecurityVerificationSheet: Boolean, + onSecurityVerificationSheetDismiss: () -> Unit ) { // Password dialog PasswordPromptDialog( @@ -567,4 +581,20 @@ private fun ChatDialogs( viewModel = viewModel ) } + + if (showVerificationSheet) { + VerificationSheet( + isPresented = showVerificationSheet, + onDismiss = onVerificationSheetDismiss, + viewModel = viewModel + ) + } + + if (showSecurityVerificationSheet) { + SecurityVerificationSheet( + isPresented = showSecurityVerificationSheet, + onDismiss = onSecurityVerificationSheetDismiss, + viewModel = viewModel + ) + } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatState.kt b/app/src/main/java/com/bitchat/android/ui/ChatState.kt index e17c1494..c4ae3db0 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt @@ -1,10 +1,17 @@ package com.bitchat.android.ui import android.util.Log -import androidx.lifecycle.LiveData -import androidx.lifecycle.MediatorLiveData -import androidx.lifecycle.MutableLiveData import com.bitchat.android.model.BitchatMessage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn /** * Centralized state definitions and data classes for the chat system @@ -21,171 +28,168 @@ data class CommandSuggestion( /** * Contains all the observable state for the chat system */ -class ChatState { +class ChatState( + scope: CoroutineScope +) { // Core messages and peer state - private val _messages = MutableLiveData>(emptyList()) - val messages: LiveData> = _messages + private val _messages = MutableStateFlow>(emptyList()) + val messages: StateFlow> = _messages.asStateFlow() - private val _connectedPeers = MutableLiveData>(emptyList()) - val connectedPeers: LiveData> = _connectedPeers + private val _connectedPeers = MutableStateFlow>(emptyList()) + val connectedPeers: StateFlow> = _connectedPeers.asStateFlow() - private val _nickname = MutableLiveData() - val nickname: LiveData = _nickname + private val _nickname = MutableStateFlow("") + val nickname: StateFlow = _nickname.asStateFlow() - private val _isConnected = MutableLiveData(false) - val isConnected: LiveData = _isConnected + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() // Private chats - private val _privateChats = MutableLiveData>>(emptyMap()) - val privateChats: LiveData>> = _privateChats + private val _privateChats = MutableStateFlow>>(emptyMap()) + val privateChats: StateFlow>> = _privateChats.asStateFlow() - private val _selectedPrivateChatPeer = MutableLiveData(null) - val selectedPrivateChatPeer: LiveData = _selectedPrivateChatPeer + private val _selectedPrivateChatPeer = MutableStateFlow(null) + val selectedPrivateChatPeer: StateFlow = _selectedPrivateChatPeer.asStateFlow() - private val _unreadPrivateMessages = MutableLiveData>(emptySet()) - val unreadPrivateMessages: LiveData> = _unreadPrivateMessages + private val _unreadPrivateMessages = MutableStateFlow>(emptySet()) + val unreadPrivateMessages: StateFlow> = _unreadPrivateMessages.asStateFlow() // Channels - private val _joinedChannels = MutableLiveData>(emptySet()) - val joinedChannels: LiveData> = _joinedChannels + private val _joinedChannels = MutableStateFlow>(emptySet()) + val joinedChannels: StateFlow> = _joinedChannels.asStateFlow() - private val _currentChannel = MutableLiveData(null) - val currentChannel: LiveData = _currentChannel + private val _currentChannel = MutableStateFlow(null) + val currentChannel: StateFlow = _currentChannel.asStateFlow() - private val _channelMessages = MutableLiveData>>(emptyMap()) - val channelMessages: LiveData>> = _channelMessages + private val _channelMessages = MutableStateFlow>>(emptyMap()) + val channelMessages: StateFlow>> = _channelMessages.asStateFlow() - private val _unreadChannelMessages = MutableLiveData>(emptyMap()) - val unreadChannelMessages: LiveData> = _unreadChannelMessages + private val _unreadChannelMessages = MutableStateFlow>(emptyMap()) + val unreadChannelMessages: StateFlow> = _unreadChannelMessages.asStateFlow() - private val _passwordProtectedChannels = MutableLiveData>(emptySet()) - val passwordProtectedChannels: LiveData> = _passwordProtectedChannels + private val _passwordProtectedChannels = MutableStateFlow>(emptySet()) + val passwordProtectedChannels: StateFlow> = _passwordProtectedChannels.asStateFlow() - private val _showPasswordPrompt = MutableLiveData(false) - val showPasswordPrompt: LiveData = _showPasswordPrompt + private val _showPasswordPrompt = MutableStateFlow(false) + val showPasswordPrompt: StateFlow = _showPasswordPrompt.asStateFlow() - private val _passwordPromptChannel = MutableLiveData(null) - val passwordPromptChannel: LiveData = _passwordPromptChannel + private val _passwordPromptChannel = MutableStateFlow(null) + val passwordPromptChannel: StateFlow = _passwordPromptChannel.asStateFlow() // Sidebar state - private val _showSidebar = MutableLiveData(false) - val showSidebar: LiveData = _showSidebar + private val _showSidebar = MutableStateFlow(false) + val showSidebar: StateFlow = _showSidebar.asStateFlow() // Command autocomplete - private val _showCommandSuggestions = MutableLiveData(false) - val showCommandSuggestions: LiveData = _showCommandSuggestions + private val _showCommandSuggestions = MutableStateFlow(false) + val showCommandSuggestions: StateFlow = _showCommandSuggestions.asStateFlow() - private val _commandSuggestions = MutableLiveData>(emptyList()) - val commandSuggestions: LiveData> = _commandSuggestions + private val _commandSuggestions = MutableStateFlow>(emptyList()) + val commandSuggestions: StateFlow> = _commandSuggestions.asStateFlow() // Mention autocomplete - private val _showMentionSuggestions = MutableLiveData(false) - val showMentionSuggestions: LiveData = _showMentionSuggestions + private val _showMentionSuggestions = MutableStateFlow(false) + val showMentionSuggestions: StateFlow = _showMentionSuggestions.asStateFlow() - private val _mentionSuggestions = MutableLiveData>(emptyList()) - val mentionSuggestions: LiveData> = _mentionSuggestions + private val _mentionSuggestions = MutableStateFlow>(emptyList()) + val mentionSuggestions: StateFlow> = _mentionSuggestions.asStateFlow() // Favorites - private val _favoritePeers = MutableLiveData>(emptySet()) - val favoritePeers: LiveData> = _favoritePeers + private val _favoritePeers = MutableStateFlow>(emptySet()) + val favoritePeers: StateFlow> = _favoritePeers.asStateFlow() // Noise session states for peers (for reactive UI updates) - private val _peerSessionStates = MutableLiveData>(emptyMap()) - val peerSessionStates: LiveData> = _peerSessionStates + private val _peerSessionStates = MutableStateFlow>(emptyMap()) + val peerSessionStates: StateFlow> = _peerSessionStates.asStateFlow() // Peer fingerprint state for reactive favorites (for reactive UI updates) - private val _peerFingerprints = MutableLiveData>(emptyMap()) - val peerFingerprints: LiveData> = _peerFingerprints + private val _peerFingerprints = MutableStateFlow>(emptyMap()) + val peerFingerprints: StateFlow> = _peerFingerprints.asStateFlow() - private val _peerNicknames = MutableLiveData>(emptyMap()) - val peerNicknames: LiveData> = _peerNicknames + private val _peerNicknames = MutableStateFlow>(emptyMap()) + val peerNicknames: StateFlow> = _peerNicknames.asStateFlow() - private val _peerRSSI = MutableLiveData>(emptyMap()) - val peerRSSI: LiveData> = _peerRSSI + private val _peerRSSI = MutableStateFlow>(emptyMap()) + val peerRSSI: StateFlow> = _peerRSSI.asStateFlow() // Direct connection status per peer (for live UI updates) - private val _peerDirect = MutableLiveData>(emptyMap()) - val peerDirect: LiveData> = _peerDirect + private val _peerDirect = MutableStateFlow>(emptyMap()) + val peerDirect: StateFlow> = _peerDirect.asStateFlow() // peerIDToPublicKeyFingerprint REMOVED - fingerprints now handled centrally in PeerManager // Navigation state - private val _showAppInfo = MutableLiveData(false) - val showAppInfo: LiveData = _showAppInfo + private val _showAppInfo = MutableStateFlow(false) + val showAppInfo: StateFlow = _showAppInfo.asStateFlow() + + private val _showVerificationSheet = MutableStateFlow(false) + val showVerificationSheet: StateFlow = _showVerificationSheet.asStateFlow() + + private val _showSecurityVerificationSheet = MutableStateFlow(false) + val showSecurityVerificationSheet: StateFlow = _showSecurityVerificationSheet.asStateFlow() // Location channels state (for Nostr geohash features) - private val _selectedLocationChannel = MutableLiveData(com.bitchat.android.geohash.ChannelID.Mesh) - val selectedLocationChannel: LiveData = _selectedLocationChannel + private val _selectedLocationChannel = MutableStateFlow(com.bitchat.android.geohash.ChannelID.Mesh) + val selectedLocationChannel: StateFlow = _selectedLocationChannel.asStateFlow() - private val _isTeleported = MutableLiveData(false) - val isTeleported: LiveData = _isTeleported + private val _isTeleported = MutableStateFlow(false) + val isTeleported: StateFlow = _isTeleported.asStateFlow() // Geohash people state (iOS-compatible) - private val _geohashPeople = MutableLiveData>(emptyList()) - val geohashPeople: LiveData> = _geohashPeople - // For background thread updates by repositories/handlers in their own scopes - val geohashPeopleMutable: MutableLiveData> get() = _geohashPeople + private val _geohashPeople = MutableStateFlow>(emptyList()) + val geohashPeople: StateFlow> = _geohashPeople.asStateFlow() - - private val _teleportedGeo = MutableLiveData>(emptySet()) - val teleportedGeo: LiveData> = _teleportedGeo + private val _teleportedGeo = MutableStateFlow>(emptySet()) + val teleportedGeo: StateFlow> = _teleportedGeo.asStateFlow() // Geohash participant counts reactive state (for real-time location channel counts) - private val _geohashParticipantCounts = MutableLiveData>(emptyMap()) - val geohashParticipantCounts: LiveData> = _geohashParticipantCounts + private val _geohashParticipantCounts = MutableStateFlow>(emptyMap()) + val geohashParticipantCounts: StateFlow> = _geohashParticipantCounts.asStateFlow() - // Unread state computed properties - val hasUnreadChannels: MediatorLiveData = MediatorLiveData() - val hasUnreadPrivateMessages: MediatorLiveData = MediatorLiveData() + + val hasUnreadChannels: StateFlow = _unreadChannelMessages + .map { unreadMap -> unreadMap.values.any { it > 0 } } + .stateIn( + scope = scope, + started = WhileSubscribed(5_000), + initialValue = false + ) - init { - // Initialize unread state mediators - hasUnreadChannels.addSource(_unreadChannelMessages) { unreadMap -> - hasUnreadChannels.value = unreadMap.values.any { it > 0 } - } - - hasUnreadPrivateMessages.addSource(_unreadPrivateMessages) { unreadSet -> - hasUnreadPrivateMessages.value = unreadSet.isNotEmpty() - } - } + val hasUnreadPrivateMessages: StateFlow = _unreadPrivateMessages + .map { unreadSet -> unreadSet.isNotEmpty() } + .stateIn( + scope = scope, + started = WhileSubscribed(5_000), + initialValue = false + ) // Getters for internal state access - fun getMessagesValue() = _messages.value ?: emptyList() - fun getConnectedPeersValue() = _connectedPeers.value ?: emptyList() + fun getMessagesValue() = _messages.value + fun getConnectedPeersValue() = _connectedPeers.value fun getNicknameValue() = _nickname.value - fun getPrivateChatsValue() = _privateChats.value ?: emptyMap() + fun getPrivateChatsValue() = _privateChats.value fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value - fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value ?: emptySet() - fun getJoinedChannelsValue() = _joinedChannels.value ?: emptySet() - // Thread-safe posting helpers for background updates - fun postGeohashPeople(people: List) { - _geohashPeople.postValue(people) - } - - fun postGeohashParticipantCounts(counts: Map) { - _geohashParticipantCounts.postValue(counts) - } - - + fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value + fun getJoinedChannelsValue() = _joinedChannels.value fun getCurrentChannelValue() = _currentChannel.value - fun getChannelMessagesValue() = _channelMessages.value ?: emptyMap() - fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value ?: emptyMap() - fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value ?: emptySet() - fun getShowPasswordPromptValue() = _showPasswordPrompt.value ?: false + fun getChannelMessagesValue() = _channelMessages.value + fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value + fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value + fun getShowPasswordPromptValue() = _showPasswordPrompt.value fun getPasswordPromptChannelValue() = _passwordPromptChannel.value - fun getShowSidebarValue() = _showSidebar.value ?: false - fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value ?: false - fun getCommandSuggestionsValue() = _commandSuggestions.value ?: emptyList() - fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value ?: false - fun getMentionSuggestionsValue() = _mentionSuggestions.value ?: emptyList() - fun getFavoritePeersValue() = _favoritePeers.value ?: emptySet() - fun getPeerSessionStatesValue() = _peerSessionStates.value ?: emptyMap() - fun getPeerFingerprintsValue() = _peerFingerprints.value ?: emptyMap() - fun getShowAppInfoValue() = _showAppInfo.value ?: false - fun getGeohashPeopleValue() = _geohashPeople.value ?: emptyList() - fun getTeleportedGeoValue() = _teleportedGeo.value ?: emptySet() - fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value ?: emptyMap() + fun getShowSidebarValue() = _showSidebar.value + fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value + fun getCommandSuggestionsValue() = _commandSuggestions.value + fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value + fun getMentionSuggestionsValue() = _mentionSuggestions.value + fun getFavoritePeersValue() = _favoritePeers.value + fun getPeerSessionStatesValue() = _peerSessionStates.value + fun getPeerFingerprintsValue() = _peerFingerprints.value + fun getShowAppInfoValue() = _showAppInfo.value + fun getGeohashPeopleValue() = _geohashPeople.value + fun getTeleportedGeoValue() = _teleportedGeo.value + fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value // Setters for state updates fun setMessages(messages: List) { @@ -197,7 +201,7 @@ class ChatState { } fun postTeleportedGeo(teleported: Set) { - _teleportedGeo.postValue(teleported) + _teleportedGeo.value = teleported } fun setNickname(nickname: String) { @@ -269,7 +273,7 @@ class ChatState { } fun setFavoritePeers(favorites: Set) { - val currentValue = _favoritePeers.value ?: emptySet() + val currentValue = _favoritePeers.value Log.d("ChatState", "setFavoritePeers called with ${favorites.size} favorites: $favorites") Log.d("ChatState", "Current value: $currentValue") Log.d("ChatState", "Values equal: ${currentValue == favorites}") @@ -278,8 +282,7 @@ class ChatState { // Always set the value - even if equal, this ensures observers are triggered _favoritePeers.value = favorites - Log.d("ChatState", "LiveData value after set: ${_favoritePeers.value}") - Log.d("ChatState", "LiveData has active observers: ${_favoritePeers.hasActiveObservers()}") + Log.d("ChatState", "StateFlow value after set: ${_favoritePeers.value}") } fun setPeerSessionStates(states: Map) { @@ -305,6 +308,14 @@ class ChatState { fun setShowAppInfo(show: Boolean) { _showAppInfo.value = show } + + fun setShowVerificationSheet(show: Boolean) { + _showVerificationSheet.value = show + } + + fun setShowSecurityVerificationSheet(show: Boolean) { + _showSecurityVerificationSheet.value = show + } fun setSelectedLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) { _selectedLocationChannel.value = channel diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 74ab15c6..75101897 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -4,12 +4,16 @@ import android.app.Application import android.util.Log import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.LiveData import androidx.lifecycle.viewModelScope +import com.bitchat.android.favorites.FavoritesPersistenceService +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import com.bitchat.android.mesh.BluetoothMeshDelegate import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType +import com.bitchat.android.nostr.NostrIdentityBridge import com.bitchat.android.protocol.BitchatPacket @@ -19,6 +23,13 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.Date import kotlin.random.Random +import com.bitchat.android.services.VerificationService +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.noise.NoiseSession +import com.bitchat.android.nostr.GeohashAliasRegistry +import com.bitchat.android.util.dataFromHexString +import com.bitchat.android.util.hexEncodedString +import java.security.MessageDigest /** * Refactored ChatViewModel - Main coordinator for bitchat functionality @@ -46,8 +57,24 @@ class ChatViewModel( mediaSendingManager.sendImageNote(toPeerIDOrNull, channelOrNull, filePath) } + fun getCurrentNpub(): String? { + return try { + NostrIdentityBridge + .getCurrentNostrIdentity(getApplication()) + ?.npub + } catch (_: Exception) { + null + } + } + + fun buildMyQRString(nickname: String, npub: String?): String { + return VerificationService.buildMyQRString(nickname, npub) ?: "" + } + // MARK: - State management - private val state = ChatState() + private val state = ChatState( + scope = viewModelScope, + ) // Transfer progress tracking private val transferMessageMap = mutableMapOf() @@ -55,6 +82,7 @@ class ChatViewModel( // Specialized managers private val dataManager = DataManager(application.applicationContext) + private val identityManager by lazy { SecureIdentityStateManager(getApplication()) } private val messageManager = MessageManager(state) private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope) @@ -73,6 +101,17 @@ class ChatViewModel( NotificationIntervalManager() ) + private val verificationHandler = VerificationHandler( + context = application.applicationContext, + scope = viewModelScope, + meshService = meshService, + identityManager = identityManager, + state = state, + notificationManager = notificationManager, + messageManager = messageManager + ) + val verifiedFingerprints = verificationHandler.verifiedFingerprints + // Media file sending manager private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager, meshService) @@ -103,44 +142,81 @@ class ChatViewModel( - // Expose state through LiveData (maintaining the same interface) - val messages: LiveData> = state.messages - val connectedPeers: LiveData> = state.connectedPeers - val nickname: LiveData = state.nickname - val isConnected: LiveData = state.isConnected - val privateChats: LiveData>> = state.privateChats - val selectedPrivateChatPeer: LiveData = state.selectedPrivateChatPeer - val unreadPrivateMessages: LiveData> = state.unreadPrivateMessages - val joinedChannels: LiveData> = state.joinedChannels - val currentChannel: LiveData = state.currentChannel - val channelMessages: LiveData>> = state.channelMessages - val unreadChannelMessages: LiveData> = state.unreadChannelMessages - val passwordProtectedChannels: LiveData> = state.passwordProtectedChannels - val showPasswordPrompt: LiveData = state.showPasswordPrompt - val passwordPromptChannel: LiveData = state.passwordPromptChannel - val showSidebar: LiveData = state.showSidebar + + val messages: StateFlow> = state.messages + val connectedPeers: StateFlow> = state.connectedPeers + val nickname: StateFlow = state.nickname + val isConnected: StateFlow = state.isConnected + val privateChats: StateFlow>> = state.privateChats + val selectedPrivateChatPeer: StateFlow = state.selectedPrivateChatPeer + val unreadPrivateMessages: StateFlow> = state.unreadPrivateMessages + val joinedChannels: StateFlow> = state.joinedChannels + val currentChannel: StateFlow = state.currentChannel + val channelMessages: StateFlow>> = state.channelMessages + val unreadChannelMessages: StateFlow> = state.unreadChannelMessages + val passwordProtectedChannels: StateFlow> = state.passwordProtectedChannels + val showPasswordPrompt: StateFlow = state.showPasswordPrompt + val passwordPromptChannel: StateFlow = state.passwordPromptChannel + val showSidebar: StateFlow = state.showSidebar val hasUnreadChannels = state.hasUnreadChannels val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages - val showCommandSuggestions: LiveData = state.showCommandSuggestions - val commandSuggestions: LiveData> = state.commandSuggestions - val showMentionSuggestions: LiveData = state.showMentionSuggestions - val mentionSuggestions: LiveData> = state.mentionSuggestions - val favoritePeers: LiveData> = state.favoritePeers - val peerSessionStates: LiveData> = state.peerSessionStates - val peerFingerprints: LiveData> = state.peerFingerprints - val peerNicknames: LiveData> = state.peerNicknames - val peerRSSI: LiveData> = state.peerRSSI - val peerDirect: LiveData> = state.peerDirect - val showAppInfo: LiveData = state.showAppInfo - val selectedLocationChannel: LiveData = state.selectedLocationChannel - val isTeleported: LiveData = state.isTeleported - val geohashPeople: LiveData> = state.geohashPeople - val teleportedGeo: LiveData> = state.teleportedGeo - val geohashParticipantCounts: LiveData> = state.geohashParticipantCounts + val showCommandSuggestions: StateFlow = state.showCommandSuggestions + val commandSuggestions: StateFlow> = state.commandSuggestions + val showMentionSuggestions: StateFlow = state.showMentionSuggestions + val mentionSuggestions: StateFlow> = state.mentionSuggestions + val favoritePeers: StateFlow> = state.favoritePeers + val peerSessionStates: StateFlow> = state.peerSessionStates + val peerFingerprints: StateFlow> = state.peerFingerprints + val peerNicknames: StateFlow> = state.peerNicknames + val peerRSSI: StateFlow> = state.peerRSSI + val peerDirect: StateFlow> = state.peerDirect + val showAppInfo: StateFlow = state.showAppInfo + val showVerificationSheet: StateFlow = state.showVerificationSheet + val showSecurityVerificationSheet: StateFlow = state.showSecurityVerificationSheet + val selectedLocationChannel: StateFlow = state.selectedLocationChannel + val isTeleported: StateFlow = state.isTeleported + val geohashPeople: StateFlow> = state.geohashPeople + val teleportedGeo: StateFlow> = state.teleportedGeo + val geohashParticipantCounts: StateFlow> = state.geohashParticipantCounts init { // Note: Mesh service delegate is now set by MainActivity loadAndInitialize() + // Hydrate UI state from process-wide AppStateStore to survive Activity recreation + viewModelScope.launch { + try { com.bitchat.android.services.AppStateStore.peers.collect { peers -> + state.setConnectedPeers(peers) + state.setIsConnected(peers.isNotEmpty()) + } } catch (_: Exception) { } + } + viewModelScope.launch { + try { com.bitchat.android.services.AppStateStore.publicMessages.collect { msgs -> + // Source of truth is AppStateStore; replace to avoid duplicate keys in LazyColumn + state.setMessages(msgs) + } } catch (_: Exception) { } + } + viewModelScope.launch { + try { com.bitchat.android.services.AppStateStore.privateMessages.collect { byPeer -> + // Replace with store snapshot + state.setPrivateChats(byPeer) + // Recompute unread set using SeenMessageStore for robustness across Activity recreation + try { + val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()) + val myNick = state.getNicknameValue() ?: meshService.myPeerID + val unread = mutableSetOf() + byPeer.forEach { (peer, list) -> + if (list.any { msg -> msg.sender != myNick && !seen.hasRead(msg.id) }) unread.add(peer) + } + state.setUnreadPrivateMessages(unread) + } catch (_: Exception) { } + } } catch (_: Exception) { } + } + viewModelScope.launch { + try { com.bitchat.android.services.AppStateStore.channelMessages.collect { byChannel -> + // Replace with store snapshot + state.setChannelMessages(byChannel) + } } catch (_: Exception) { } + } // Subscribe to BLE transfer progress and reflect in message deliveryStatus viewModelScope.launch { com.bitchat.android.mesh.TransferProgressManager.events.collect { evt -> @@ -210,6 +286,9 @@ class ChatViewModel( // Initialize favorites persistence service com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication()) + // Load verified fingerprints from secure storage + verificationHandler.loadVerifiedFingerprints() + // Ensure NostrTransport knows our mesh peer ID for embedded packets try { @@ -565,7 +644,7 @@ class ChatViewModel( private fun logCurrentFavoriteState() { Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===") - Log.i("ChatViewModel", "LiveData favorite peers: ${favoritePeers.value}") + Log.i("ChatViewModel", "StateFlow favorite peers: ${favoritePeers.value}") Log.i("ChatViewModel", "DataManager favorite peers: ${dataManager.favoritePeers}") Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}") Log.i("ChatViewModel", "==============================") @@ -609,6 +688,18 @@ class ChatViewModel( // Update fingerprint mappings from centralized manager val fingerprints = privateChatManager.getAllPeerFingerprints() state.setPeerFingerprints(fingerprints) + fingerprints.forEach { (peerID, fingerprint) -> + identityManager.cachePeerFingerprint(peerID, fingerprint) + val info = try { meshService.getPeerInfo(peerID) } catch (_: Exception) { null } + val noiseKeyHex = info?.noisePublicKey?.hexEncodedString() + if (noiseKeyHex != null) { + identityManager.cachePeerNoiseKey(peerID, noiseKeyHex) + identityManager.cacheNoiseFingerprint(noiseKeyHex, fingerprint) + } + info?.nickname?.takeIf { it.isNotBlank() }?.let { nickname -> + identityManager.cacheFingerprintNickname(fingerprint, nickname) + } + } val nicknames = meshService.getPeerNicknames() state.setPeerNicknames(nicknames) @@ -623,6 +714,34 @@ class ChatViewModel( } state.setPeerDirect(directMap) } catch (_: Exception) { } + + // Flush any pending QR verification once a Noise session is established + currentPeers.forEach { peerID -> + if (meshService.getSessionState(peerID) is NoiseSession.NoiseSessionState.Established) { + verificationHandler.sendPendingVerificationIfNeeded(peerID) + } + } + } + + // MARK: - QR Verification + + fun isPeerVerified(peerID: String, verifiedFingerprints: Set): Boolean { + if (peerID.startsWith("nostr_") || peerID.startsWith("nostr:")) return false + val fingerprint = verificationHandler.getPeerFingerprintForDisplay(peerID) + return fingerprint != null && verifiedFingerprints.contains(fingerprint) + } + + fun isNoisePublicKeyVerified(noisePublicKey: ByteArray, verifiedFingerprints: Set): Boolean { + val fingerprint = verificationHandler.fingerprintFromNoiseBytes(noisePublicKey) + return verifiedFingerprints.contains(fingerprint) + } + + fun unverifyFingerprint(peerID: String) { + verificationHandler.unverifyFingerprint(peerID) + } + + fun beginQRVerification(qr: VerificationService.VerificationQR): Boolean { + return verificationHandler.beginQRVerification(qr) } // MARK: - Debug and Troubleshooting @@ -631,41 +750,71 @@ class ChatViewModel( return meshService.getDebugStatus() } - // Note: Mesh service restart is now handled by MainActivity - // This function is no longer needed - - fun setAppBackgroundState(inBackground: Boolean) { - // Forward to notification manager for notification logic - notificationManager.setAppBackgroundState(inBackground) - } - fun setCurrentPrivateChatPeer(peerID: String?) { - // Update notification manager with current private chat peer notificationManager.setCurrentPrivateChatPeer(peerID) } fun setCurrentGeohash(geohash: String?) { - // Update notification manager with current geohash for notification logic notificationManager.setCurrentGeohash(geohash) } fun clearNotificationsForSender(peerID: String) { - // Clear notifications when user opens a chat notificationManager.clearNotificationsForSender(peerID) } fun clearNotificationsForGeohash(geohash: String) { - // Clear notifications when user opens a geohash chat notificationManager.clearNotificationsForGeohash(geohash) } - /** - * Clear mesh mention notifications when user opens mesh chat - */ fun clearMeshMentionNotifications() { notificationManager.clearMeshMentionNotifications() } + private var reopenSidebarAfterVerification = false + + fun showVerificationSheet(fromSidebar: Boolean = false) { + if (fromSidebar) { + reopenSidebarAfterVerification = true + } + state.setShowVerificationSheet(true) + } + + fun hideVerificationSheet() { + state.setShowVerificationSheet(false) + if (reopenSidebarAfterVerification) { + reopenSidebarAfterVerification = false + state.setShowSidebar(true) + } + } + + fun showSecurityVerificationSheet() { + state.setShowSecurityVerificationSheet(true) + } + + fun hideSecurityVerificationSheet() { + state.setShowSecurityVerificationSheet(false) + } + + fun getPeerFingerprintForDisplay(peerID: String): String? { + return verificationHandler.getPeerFingerprintForDisplay(peerID) + } + + fun getMyFingerprint(): String { + return verificationHandler.getMyFingerprint() + } + + fun resolvePeerDisplayNameForFingerprint(peerID: String): String { + return verificationHandler.resolvePeerDisplayNameForFingerprint(peerID) + } + + fun verifyFingerprintValue(fingerprint: String) { + verificationHandler.verifyFingerprintValue(fingerprint) + } + + fun unverifyFingerprintValue(fingerprint: String) { + verificationHandler.unverifyFingerprintValue(fingerprint) + } + // MARK: - Command Autocomplete (delegated) fun updateCommandSuggestions(input: String) { @@ -707,6 +856,14 @@ class ChatViewModel( override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) { meshDelegateHandler.didReceiveReadReceipt(messageID, recipientPeerID) } + + override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) { + verificationHandler.didReceiveVerifyChallenge(peerID, payload) + } + + override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) { + verificationHandler.didReceiveVerifyResponse(peerID, payload) + } override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { return meshDelegateHandler.decryptChannelMessage(encryptedContent, channel) @@ -790,7 +947,7 @@ class ChatViewModel( // Clear secure identity state (if used) try { - val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication()) + val identityManager = SecureIdentityStateManager(getApplication()) identityManager.clearIdentityData() // Also clear secure values used by FavoritesPersistenceService (favorites + peerID index) try { @@ -803,7 +960,7 @@ class ChatViewModel( // Clear FavoritesPersistenceService persistent relationships try { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.clearAllFavorites() + FavoritesPersistenceService.shared.clearAllFavorites() Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships") } catch (_: Exception) { } @@ -932,5 +1089,6 @@ class ChatViewModel( */ fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color { return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark) - } +} + } diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index 1b0837a2..0bdc85ee 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -9,7 +9,6 @@ import androidx.compose.material.icons.outlined.Explore import androidx.compose.material.icons.outlined.LocationOn import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -21,6 +20,7 @@ import com.bitchat.android.ui.theme.BASE_FONT_SIZE import java.util.* import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.R /** @@ -46,11 +46,11 @@ fun GeohashPeopleList( val colorScheme = MaterialTheme.colorScheme // Observe geohash people from ChatViewModel - val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList()) - val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() - val isTeleported by viewModel.isTeleported.observeAsState(false) - val nickname by viewModel.nickname.observeAsState("") - val unreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet()) + val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() + val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() + val isTeleported by viewModel.isTeleported.collectAsStateWithLifecycle() + val nickname by viewModel.nickname.collectAsStateWithLifecycle() + val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() Column { // Header matching iOS style diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index 060bb84e..52e00edf 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -3,7 +3,6 @@ package com.bitchat.android.ui import android.app.Application import android.util.Log import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.LiveData import androidx.lifecycle.viewModelScope import com.bitchat.android.nostr.GeohashMessageHandler import com.bitchat.android.nostr.GeohashRepository @@ -15,6 +14,7 @@ import com.bitchat.android.nostr.NostrSubscriptionManager import com.bitchat.android.nostr.PoWPreferenceManager import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import java.util.Date @@ -55,9 +55,9 @@ class GeohashViewModel( private var geoTimer: Job? = null private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null - val geohashPeople: LiveData> = state.geohashPeople - val geohashParticipantCounts: LiveData> = state.geohashParticipantCounts - val selectedLocationChannel: LiveData = state.selectedLocationChannel + val geohashPeople: StateFlow> = state.geohashPeople + val geohashParticipantCounts: StateFlow> = state.geohashParticipantCounts + val selectedLocationChannel: StateFlow = state.selectedLocationChannel fun initialize() { subscriptionManager.connect() @@ -73,12 +73,16 @@ class GeohashViewModel( } try { locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()) - locationChannelManager?.selectedChannel?.observeForever { channel -> - state.setSelectedLocationChannel(channel) - switchLocationChannel(channel) + viewModelScope.launch { + locationChannelManager?.selectedChannel?.collect { channel -> + state.setSelectedLocationChannel(channel) + switchLocationChannel(channel) + } } - locationChannelManager?.teleported?.observeForever { teleported -> - state.setIsTeleported(teleported) + viewModelScope.launch { + locationChannelManager?.teleported?.collect { teleported -> + state.setIsTeleported(teleported) + } } } catch (e: Exception) { Log.e(TAG, "Failed to initialize location channel state: ${e.message}") @@ -120,7 +124,7 @@ class GeohashViewModel( } try { val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication()) - val teleported = state.isTeleported.value ?: false + val teleported = state.isTeleported.value val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported) val relayManager = NostrRelayManager.getInstance(getApplication()) relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5) @@ -231,7 +235,7 @@ class GeohashViewModel( try { val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication()) repo.updateParticipant(channel.channel.geohash, identity.publicKeyHex, Date()) - val teleported = state.isTeleported.value ?: false + val teleported = state.isTeleported.value if (teleported) repo.markTeleported(identity.publicKeyHex) } catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 251c8af9..6f47d68f 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -18,7 +18,6 @@ import androidx.compose.material.icons.filled.PinDrop import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged @@ -38,7 +37,9 @@ import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.geohash.GeohashBookmarksStore import com.bitchat.android.ui.theme.BASE_FONT_SIZE import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.R +import com.bitchat.android.core.ui.component.button.CloseButton /** * Location Channels Sheet for selecting geohash-based location channels @@ -57,18 +58,18 @@ fun LocationChannelsSheet( val bookmarksStore = remember { GeohashBookmarksStore.getInstance(context) } // Observe location manager state - val permissionState by locationManager.permissionState.observeAsState() - val availableChannels by locationManager.availableChannels.observeAsState(emptyList()) - val selectedChannel by locationManager.selectedChannel.observeAsState() - val locationNames by locationManager.locationNames.observeAsState(emptyMap()) - val locationServicesEnabled by locationManager.locationServicesEnabled.observeAsState(false) + val permissionState by locationManager.permissionState.collectAsStateWithLifecycle() + val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle() + val selectedChannel by locationManager.selectedChannel.collectAsStateWithLifecycle() + val locationNames by locationManager.locationNames.collectAsStateWithLifecycle() + val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle() // Observe bookmarks state - val bookmarks by bookmarksStore.bookmarks.observeAsState(emptyList()) - val bookmarkNames by bookmarksStore.bookmarkNames.observeAsState(emptyMap()) + val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle() + val bookmarkNames by bookmarksStore.bookmarkNames.collectAsStateWithLifecycle() // Observe reactive participant counts - val geohashParticipantCounts by viewModel.geohashParticipantCounts.observeAsState(emptyMap()) + val geohashParticipantCounts by viewModel.geohashParticipantCounts.collectAsStateWithLifecycle() // UI state var customGeohash by remember { mutableStateOf("") } @@ -551,18 +552,12 @@ fun LocationChannelsSheet( .height(56.dp) .background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha)) ) { - TextButton( + CloseButton( onClick = onDismiss, - modifier = Modifier + modifier = modifier .align(Alignment.CenterEnd) - .padding(horizontal = 16.dp) - ) { - Text( - text = stringResource(R.string.close_plain), - style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.onBackground - ) - } + .padding(horizontal = 16.dp), + ) } } } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index 09084a92..601d4c03 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -7,8 +7,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -35,10 +35,10 @@ fun LocationNotesButton( val context = LocalContext.current // Get channel and permission state - val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() + val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() val locationManager = remember { LocationChannelManager.getInstance(context) } - val permissionState by locationManager.permissionState.observeAsState() - val locationServicesEnabled by locationManager.locationServicesEnabled.observeAsState(false) + val permissionState by locationManager.permissionState.collectAsStateWithLifecycle() + val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle(false) // Check both permission AND location services enabled val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED @@ -46,7 +46,7 @@ fun LocationNotesButton( // Get notes count from LocationNotesManager val notesManager = remember { LocationNotesManager.getInstance() } - val notes by notesManager.notes.observeAsState(emptyList()) + val notes by notesManager.notes.collectAsStateWithLifecycle() val notesCount = notes.size // Only show in mesh mode when location is authorized (iOS pattern) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt index 23b9d952..461796d3 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt @@ -12,7 +12,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -25,6 +24,7 @@ import com.bitchat.android.R import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.geohash.GeohashChannelLevel import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.nostr.LocationNotesManager @@ -57,16 +57,16 @@ fun LocationNotesSheet( val locationManager = remember { LocationChannelManager.getInstance(context) } // State - val notes by notesManager.notes.observeAsState(emptyList()) - val state by notesManager.state.observeAsState(LocationNotesManager.State.IDLE) - val errorMessage by notesManager.errorMessage.observeAsState() - val initialLoadComplete by notesManager.initialLoadComplete.observeAsState(false) + val notes by notesManager.notes.collectAsStateWithLifecycle() + val state by notesManager.state.collectAsStateWithLifecycle(LocationNotesManager.State.IDLE) + val errorMessage by notesManager.errorMessage.collectAsStateWithLifecycle() + val initialLoadComplete by notesManager.initialLoadComplete.collectAsStateWithLifecycle(false) // SIMPLIFIED: Get count directly from notes list (no separate counter needed) val count = notes.size // Get location name (building or block) - matches iOS locationNames lookup - val locationNames by locationManager.locationNames.observeAsState(emptyMap()) + val locationNames by locationManager.locationNames.collectAsStateWithLifecycle() val displayLocationName = locationNames[GeohashChannelLevel.BUILDING]?.takeIf { it.isNotEmpty() } ?: locationNames[GeohashChannelLevel.BLOCK]?.takeIf { it.isNotEmpty() } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt index 9d4da4c9..d17b6d7c 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt @@ -4,12 +4,12 @@ import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.geohash.GeohashChannelLevel import com.bitchat.android.geohash.LocationChannelManager @@ -26,15 +26,15 @@ fun LocationNotesSheetPresenter( ) { val context = LocalContext.current val locationManager = remember { LocationChannelManager.getInstance(context) } - val availableChannels by locationManager.availableChannels.observeAsState(emptyList()) - val nickname by viewModel.nickname.observeAsState("") + val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle() + val nickname by viewModel.nickname.collectAsStateWithLifecycle() // iOS pattern: notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash val buildingGeohash = availableChannels.firstOrNull { it.level == GeohashChannelLevel.BUILDING }?.geohash if (buildingGeohash != null) { // Get location name from locationManager - val locationNames by locationManager.locationNames.observeAsState(emptyMap()) + val locationNames by locationManager.locationNames.collectAsStateWithLifecycle() val locationName = locationNames[GeohashChannelLevel.BUILDING] ?: locationNames[GeohashChannelLevel.BLOCK] diff --git a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt index 1ea59444..9c7d776f 100644 --- a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt +++ b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily +import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -26,7 +27,7 @@ private enum class CharacterAnimationState { */ @Composable fun shouldAnimateMessage(messageId: String): Boolean { - val miningMessages by PoWMiningTracker.miningMessages.collectAsState() + val miningMessages by PoWMiningTracker.miningMessages.collectAsStateWithLifecycle() return miningMessages.contains(messageId) } diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt index d6a9e746..d27719c7 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -46,10 +46,10 @@ class MeshDelegateHandler( if (message.isPrivate) { // Private message privateChatManager.handleIncomingPrivateMessage(message) - - // Reactive read receipts: Send immediately if user is currently viewing this chat + + // Reactive read receipts: if chat is focused, send immediately for this message message.senderPeerID?.let { senderPeerID -> - sendReadReceiptIfFocused(senderPeerID) + sendReadReceiptIfFocused(message) } // Show notification with enhanced information - now includes senderPeerID @@ -64,15 +64,26 @@ class MeshDelegateHandler( ) } } else if (message.channel != null) { - // Channel message + // Channel message: AppStateStore is the source of truth for list; only manage unread if (state.getJoinedChannelsValue().contains(message.channel)) { - channelManager.addChannelMessage(message.channel, message, message.senderPeerID) + val channel = message.channel + val viewingClassic = state.getCurrentChannelValue() == channel + val viewingGeohash = try { + if (channel.startsWith("geo:")) { + val geo = channel.removePrefix("geo:") + val selected = state.selectedLocationChannel.value + selected is com.bitchat.android.geohash.ChannelID.Location && selected.channel.geohash.equals(geo, ignoreCase = true) + } else false + } catch (_: Exception) { false } + if (!viewingClassic && !viewingGeohash) { + val currentUnread = state.getUnreadChannelMessagesValue().toMutableMap() + currentUnread[channel] = (currentUnread[channel] ?: 0) + 1 + state.setUnreadChannelMessages(currentUnread) + } } } else { - // Public mesh message - always store to preserve message history - messageManager.addMessage(message) - - // Check for mentions in mesh chat + // Public mesh message: AppStateStore is the source of truth; avoid double-adding to UI state + // Still run mention detection/notifications checkAndTriggerMeshMentionNotification(message) } @@ -205,6 +216,14 @@ class MeshDelegateHandler( messageManager.updateMessageDeliveryStatus(messageID, DeliveryStatus.Read(recipientPeerID, Date())) } } + + override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) { + // Handled by ChatViewModel for verification flow + } + + override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) { + // Handled by ChatViewModel for verification flow + } override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { return channelManager.decryptChannelMessage(encryptedContent, channel) @@ -263,21 +282,31 @@ class MeshDelegateHandler( * Uses same logic as notification system - send read receipt if user is currently * viewing the private chat with this sender AND app is in foreground. */ - private fun sendReadReceiptIfFocused(senderPeerID: String) { + private fun sendReadReceiptIfFocused(message: BitchatMessage) { // Get notification manager's focus state (mirror the notification logic) val isAppInBackground = notificationManager.getAppBackgroundState() val currentPrivateChatPeer = notificationManager.getCurrentPrivateChatPeer() // Send read receipt if user is currently focused on this specific chat - val shouldSendReadReceipt = !isAppInBackground && currentPrivateChatPeer == senderPeerID + val senderPeerID = message.senderPeerID + val shouldSendReadReceipt = !isAppInBackground && senderPeerID != null && currentPrivateChatPeer == senderPeerID - if (shouldSendReadReceipt) { - android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID") - privateChatManager.sendReadReceiptsForPeer(senderPeerID, getMeshService()) - } else { - android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)") + if (shouldSendReadReceipt) { + android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID (message=${message.id})") + val nickname = state.getNicknameValue() ?: "unknown" + // Send directly for this message to avoid relying on unread queues + getMeshService().sendReadReceipt(message.id, senderPeerID!!, nickname) + // Ensure unread badge is cleared for this peer immediately + try { + val current = state.getUnreadPrivateMessagesValue().toMutableSet() + if (current.remove(senderPeerID)) { + state.setUnreadPrivateMessages(current) + } + } catch (_: Exception) { } + } else { + android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)") + } } - } // registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager diff --git a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt index f35689e7..40b7eb98 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt @@ -22,6 +22,8 @@ class MessageManager(private val state: ChatState) { val currentMessages = state.getMessagesValue().toMutableList() currentMessages.add(message) state.setMessages(currentMessages) + // Reflect into process-wide store so snapshot replacements don't drop local outgoing messages + try { com.bitchat.android.services.AppStateStore.addPublicMessage(message) } catch (_: Exception) { } } // Log a system message into the main chat (visible to user) @@ -52,6 +54,8 @@ class MessageManager(private val state: ChatState) { channelMessageList.add(message) currentChannelMessages[channel] = channelMessageList state.setChannelMessages(currentChannelMessages) + // Reflect into process-wide store + try { com.bitchat.android.services.AppStateStore.addChannelMessage(channel, message) } catch (_: Exception) { } // Update unread count if not currently viewing this channel // Consider both classic channels (state.currentChannel) and geohash location channel selection @@ -105,6 +109,8 @@ class MessageManager(private val state: ChatState) { chatMessages.add(message) currentPrivateChats[peerID] = chatMessages state.setPrivateChats(currentPrivateChats) + // Reflect into process-wide store + try { com.bitchat.android.services.AppStateStore.addPrivateMessage(peerID, message) } catch (_: Exception) { } // Mark as unread if not currently viewing this chat if (state.getSelectedPrivateChatPeerValue() != peerID && message.sender != state.getNicknameValue()) { @@ -124,6 +130,8 @@ class MessageManager(private val state: ChatState) { chatMessages.add(message) currentPrivateChats[peerID] = chatMessages state.setPrivateChats(currentPrivateChats) + // Reflect into process-wide store + try { com.bitchat.android.services.AppStateStore.addPrivateMessage(peerID, message) } catch (_: Exception) { } } fun clearPrivateMessages(peerID: String) { @@ -206,6 +214,21 @@ class MessageManager(private val state: ChatState) { // MARK: - Delivery Status Updates + private fun statusPriority(status: DeliveryStatus?): Int = when (status) { + null -> 0 + is DeliveryStatus.Sending -> 1 + is DeliveryStatus.Sent -> 2 + is DeliveryStatus.PartiallyDelivered -> 3 + is DeliveryStatus.Delivered -> 4 + is DeliveryStatus.Read -> 5 + is DeliveryStatus.Failed -> 0 // treat as lowest for UI check marks ordering + } + + private fun chooseStatus(old: DeliveryStatus?, new: DeliveryStatus): DeliveryStatus? { + // 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) { // Update in private chats val updatedPrivateChats = state.getPrivateChatsValue().toMutableMap() @@ -215,22 +238,32 @@ class MessageManager(private val state: ChatState) { val updatedMessages = messages.toMutableList() val messageIndex = updatedMessages.indexOfFirst { it.id == messageID } if (messageIndex >= 0) { - updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = status) - updatedPrivateChats[peerID] = updatedMessages - updated = true + val current = updatedMessages[messageIndex].deliveryStatus + val finalStatus = chooseStatus(current, status) + if (finalStatus !== current) { + updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = finalStatus) + updatedPrivateChats[peerID] = updatedMessages + updated = true + } } } if (updated) { state.setPrivateChats(updatedPrivateChats) + // Keep process-wide store in sync to prevent snapshot overwrites resetting status + try { com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(messageID, status) } catch (_: Exception) { } } // Update in main messages val updatedMessages = state.getMessagesValue().toMutableList() val messageIndex = updatedMessages.indexOfFirst { it.id == messageID } if (messageIndex >= 0) { - updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = status) - state.setMessages(updatedMessages) + val current = updatedMessages[messageIndex].deliveryStatus + val finalStatus = chooseStatus(current, status) + if (finalStatus !== current) { + updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = finalStatus) + state.setMessages(updatedMessages) + } } // Update in channel messages @@ -239,8 +272,12 @@ class MessageManager(private val state: ChatState) { val channelMessagesList = messages.toMutableList() val channelMessageIndex = channelMessagesList.indexOfFirst { it.id == messageID } if (channelMessageIndex >= 0) { - channelMessagesList[channelMessageIndex] = channelMessagesList[channelMessageIndex].copy(deliveryStatus = status) - updatedChannelMessages[channel] = channelMessagesList + val current = channelMessagesList[channelMessageIndex].deliveryStatus + val finalStatus = chooseStatus(current, status) + if (finalStatus !== current) { + channelMessagesList[channelMessageIndex] = channelMessagesList[channelMessageIndex].copy(deliveryStatus = finalStatus) + updatedChannelMessages[channel] = channelMessagesList + } } } state.setChannelMessages(updatedChannelMessages) diff --git a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt index 622800ca..9eb72b48 100644 --- a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt @@ -280,6 +280,37 @@ class NotificationManager( Log.d(TAG, "Displayed notification for $contentTitle with ID $notificationId") } + fun showVerificationNotification(title: String, body: String, peerID: String? = null) { + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + if (peerID != null) { + putExtra(EXTRA_OPEN_PRIVATE_CHAT, true) + putExtra(EXTRA_PEER_ID, peerID) + putExtra(EXTRA_SENDER_NICKNAME, body) + } + } + + val pendingIntent = PendingIntent.getActivity( + context, + (System.currentTimeMillis() and 0x7FFFFFFF).toInt(), + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setContentText(body) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .setShowWhen(true) + .setWhen(System.currentTimeMillis()) + + notificationManager.notify((System.currentTimeMillis() and 0x7FFFFFFF).toInt(), builder.build()) + } + private fun showNotificationForActivePeers(peersSize: Int) { // Create intent to open the app val intent = Intent(context, MainActivity::class.java).apply { diff --git a/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt b/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt index b0d55128..883b2b45 100644 --- a/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt +++ b/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bitchat.android.nostr.NostrProofOfWork import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.R import com.bitchat.android.nostr.PoWPreferenceManager @@ -27,9 +28,9 @@ fun PoWStatusIndicator( modifier: Modifier = Modifier, style: PoWIndicatorStyle = PoWIndicatorStyle.COMPACT ) { - val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() - val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState() - val isMining by PoWPreferenceManager.isMining.collectAsState() + val powEnabled by PoWPreferenceManager.powEnabled.collectAsStateWithLifecycle() + val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsStateWithLifecycle() + val isMining by PoWPreferenceManager.isMining.collectAsStateWithLifecycle() val colorScheme = MaterialTheme.colorScheme val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index e8a3a461..098a83d7 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -292,28 +292,30 @@ class PrivateChatManager( } fun handleIncomingPrivateMessage(message: BitchatMessage, suppressUnread: Boolean) { - message.senderPeerID?.let { senderPeerID -> + val senderPeerID = message.senderPeerID + if (senderPeerID != null) { + // Mesh-origin private message: AppStateStore updates the list; avoid double-add here. if (!isPeerBlocked(senderPeerID)) { - // Add to private messages - if (suppressUnread) { - messageManager.addPrivateMessageNoUnread(senderPeerID, message) - } else { - messageManager.addPrivateMessage(senderPeerID, message) - } - - // Track as unread for read receipt purposes - var unreadCount = 0 - if (!suppressUnread) { + // Ensure chat exists + messageManager.initializePrivateChat(senderPeerID) + // Track as unread for read receipt purposes if not focused + if (!suppressUnread && state.getSelectedPrivateChatPeerValue() != senderPeerID) { val unreadList = unreadReceivedMessages.getOrPut(senderPeerID) { mutableListOf() } unreadList.add(message) - unreadCount = unreadList.size + Log.d(TAG, "Queued unread from $senderPeerID (count=${unreadList.size})") + val currentUnread = state.getUnreadPrivateMessagesValue().toMutableSet() + currentUnread.add(senderPeerID) + state.setUnreadPrivateMessages(currentUnread) } - - Log.d( - TAG, - "Added received message ${message.id} from $senderPeerID to unread list (${unreadCount} unread)" - ) } + return + } + // Non-mesh path (e.g., Nostr): add to UI state using existing logic + val inferredPeer = state.getSelectedPrivateChatPeerValue() ?: return + if (suppressUnread) { + messageManager.addPrivateMessageNoUnread(inferredPeer, message) + } else { + messageManager.addPrivateMessage(inferredPeer, message) } } @@ -322,27 +324,33 @@ class PrivateChatManager( * Called when the user focuses on a private chat */ fun sendReadReceiptsForPeer(peerID: String, meshService: BluetoothMeshService) { - val unreadList = unreadReceivedMessages[peerID] - if (unreadList.isNullOrEmpty()) { - Log.d(TAG, "No unread messages to send read receipts for peer $peerID") - return + // Collect candidate messages: all incoming messages from this peer in the conversation + val chats = try { state.getPrivateChatsValue() } catch (_: Exception) { emptyMap>() } + val messages = chats[peerID].orEmpty() + + if (messages.isEmpty()) { + Log.d(TAG, "No messages found for peer $peerID to send read receipts") } - Log.d(TAG, "Sending read receipts for ${unreadList.size} unread messages from $peerID") - - // Send read receipt for each unread message - now using direct method call - unreadList.forEach { message -> - try { - val myNickname = state.getNicknameValue() ?: "unknown" - meshService.sendReadReceipt(message.id, peerID, myNickname) - Log.d(TAG, "Sent read receipt for message ${message.id} to $peerID") - } catch (e: Exception) { - Log.w(TAG, "Failed to send read receipt for message ${message.id}: ${e.message}") + val myNickname = state.getNicknameValue() ?: "unknown" + var sentCount = 0 + messages.forEach { msg -> + // Only for incoming messages from this peer + if (msg.senderPeerID == peerID) { + try { + meshService.sendReadReceipt(msg.id, peerID, myNickname) + sentCount += 1 + } catch (e: Exception) { + Log.w(TAG, "Failed to send read receipt for message ${msg.id}: ${e.message}") + } } } - // Clear the unread list since we've sent read receipts + // Clear any locally tracked unread queue for this peer unreadReceivedMessages.remove(peerID) + // Also clear UI unread marker for this peer now that chat is focused/read + try { messageManager.clearPrivateUnreadMessages(peerID) } catch (_: Exception) { } + Log.d(TAG, "Sent $sentCount read receipts for peer $peerID (from conversation messages)") } fun cleanupDisconnectedPeer(peerID: String) { diff --git a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt new file mode 100644 index 00000000..59fe6085 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt @@ -0,0 +1,443 @@ +package com.bitchat.android.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Verified +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.outlined.NoEncryption +import androidx.compose.material.icons.outlined.Sync +import androidx.compose.material.icons.outlined.Warning as OutlinedWarning +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.R + +private data class SecurityStatusInfo( + val text: String, + val icon: ImageVector, + val tint: Color +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SecurityVerificationSheet( + isPresented: Boolean, + onDismiss: () -> Unit, + viewModel: ChatViewModel, + modifier: Modifier = Modifier +) { + if (!isPresented) return + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val peerID by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle() + val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() + val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle() + + val isDark = isSystemInDarkTheme() + val accent = if (isDark) Color.Green else Color(0xFF008000) + val boxColor = if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.06f) + val peerHexRegex = remember { Regex("^[0-9a-fA-F]{16}$") } + + ModalBottomSheet( + modifier = modifier.statusBarsPadding(), + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.background, + dragHandle = null + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + SecurityVerificationHeader( + accent = accent, + onClose = onDismiss + ) + + if (peerID == null) { + Text( + text = stringResource(R.string.fingerprint_no_peer), + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } else { + val selectedPeerID = peerID!! + val displayName = viewModel.resolvePeerDisplayNameForFingerprint(selectedPeerID) + val fingerprint = viewModel.getPeerFingerprintForDisplay(selectedPeerID) + val isVerified = fingerprint != null && verifiedFingerprints.contains(fingerprint) + val sessionState = peerSessionStates[selectedPeerID] + val statusInfo = buildStatusInfo( + isVerified = isVerified, + sessionState = sessionState, + accent = accent + ) + + SecurityStatusCard( + displayName = displayName, + accent = accent, + boxColor = boxColor, + statusInfo = statusInfo + ) + + FingerprintBlock( + title = stringResource(R.string.fingerprint_their), + fingerprint = fingerprint, + boxColor = boxColor, + accent = accent + ) + + FingerprintBlock( + title = stringResource(R.string.fingerprint_yours), + fingerprint = viewModel.getMyFingerprint(), + boxColor = boxColor, + accent = accent + ) + + SecurityVerificationActions( + isVerified = isVerified, + fingerprint = fingerprint, + displayName = displayName, + accent = accent, + canStartHandshake = fingerprint == null && selectedPeerID.matches(peerHexRegex), + onStartHandshake = { viewModel.meshService.initiateNoiseHandshake(selectedPeerID) }, + onVerify = { fp -> viewModel.verifyFingerprintValue(fp) }, + onUnverify = { fp -> viewModel.unverifyFingerprintValue(fp) } + ) + } + } + } +} + +@Composable +private fun SecurityVerificationHeader( + accent: Color, + onClose: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.security_verification_title), + style = MaterialTheme.typography.titleSmall.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold + ), + color = accent + ) + Spacer(modifier = Modifier.weight(1f)) + CloseButton(onClick = onClose) + } +} + +@Composable +private fun buildStatusInfo( + isVerified: Boolean, + sessionState: String?, + accent: Color +): SecurityStatusInfo { + val text = when { + isVerified -> stringResource(R.string.fingerprint_status_verified) + sessionState == "established" -> stringResource(R.string.fingerprint_status_encrypted) + sessionState == "handshaking" -> stringResource(R.string.fingerprint_status_handshaking) + sessionState == "failed" -> stringResource(R.string.fingerprint_status_failed) + else -> stringResource(R.string.fingerprint_status_uninitialized) + } + val icon = when { + isVerified -> Icons.Filled.Verified + sessionState == "handshaking" -> Icons.Outlined.Sync + sessionState == "failed" -> Icons.Outlined.OutlinedWarning + sessionState == "established" -> Icons.Filled.Lock + else -> Icons.Outlined.NoEncryption + } + val tint = when { + isVerified -> Color(0xFF32D74B) + sessionState == "failed" -> Color(0xFFFF3B30) + sessionState == "handshaking" -> Color(0xFFFF9500) + sessionState == "established" -> Color(0xFF32D74B) + else -> accent.copy(alpha = 0.6f) + } + return SecurityStatusInfo(text, icon, tint) +} + +@Composable +private fun SecurityStatusCard( + displayName: String, + accent: Color, + boxColor: Color, + statusInfo: SecurityStatusInfo +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(boxColor, shape = MaterialTheme.shapes.medium) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = statusInfo.icon, + contentDescription = null, + tint = statusInfo.tint + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = displayName, + style = MaterialTheme.typography.titleMedium.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold + ), + color = accent + ) + Text( + text = statusInfo.text, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace + ), + color = accent.copy(alpha = 0.8f) + ) + } + } +} + +@Composable +private fun SecurityVerificationActions( + isVerified: Boolean, + fingerprint: String?, + displayName: String, + accent: Color, + canStartHandshake: Boolean, + onStartHandshake: () -> Unit, + onVerify: (String) -> Unit, + onUnverify: (String) -> Unit +) { + if (canStartHandshake) { + Button( + onClick = onStartHandshake, + colors = ButtonDefaults.buttonColors( + containerColor = accent, + contentColor = Color.White + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.fingerprint_start_handshake), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + } + } + + if (isVerified) { + VerificationStatusRow( + icon = Icons.Filled.Verified, + iconTint = Color(0xFF32D74B), + text = stringResource(R.string.fingerprint_verified_label), + textTint = Color(0xFF32D74B) + ) + Text( + text = stringResource(R.string.fingerprint_verified_message), + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace + ), + color = accent.copy(alpha = 0.7f), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + Button( + onClick = { fingerprint?.let(onUnverify) }, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFFF3B30), + contentColor = Color.White + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.verify_remove), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + } + } else { + VerificationStatusRow( + icon = Icons.Filled.Warning, + iconTint = Color(0xFFFF9500), + text = stringResource(R.string.fingerprint_not_verified_label), + textTint = Color(0xFFFF9500) + ) + Text( + text = stringResource(R.string.fingerprint_not_verified_message_fmt, displayName), + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace + ), + color = accent.copy(alpha = 0.7f), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + if (fingerprint != null) { + Button( + onClick = { onVerify(fingerprint) }, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF34C759), + contentColor = Color.White + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.fingerprint_mark_verified), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + } + } + } +} + +@Composable +private fun VerificationStatusRow( + icon: ImageVector, + iconTint: Color, + text: String, + textTint: Color +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconTint + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold + ), + color = textTint + ) + } +} + +@Composable +private fun FingerprintBlock( + title: String, + fingerprint: String?, + boxColor: Color, + accent: Color +) { + val clipboardManager = LocalClipboardManager.current + var showMenu by remember(fingerprint) { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.labelSmall.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold + ), + color = accent.copy(alpha = 0.8f) + ) + if (fingerprint != null) { + Column { + Text( + text = formatFingerprint(fingerprint), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + fontSize = 14.sp + ), + color = accent, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + interactionSource = interactionSource, + indication = null, + onClick = {}, + onLongClick = { showMenu = true } + ) + .background(boxColor, shape = MaterialTheme.shapes.small) + .padding(16.dp), + ) + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + DropdownMenuItem( + text = { Text(text = stringResource(R.string.fingerprint_copy)) }, + onClick = { + clipboardManager.setText(AnnotatedString(fingerprint)) + showMenu = false + } + ) + } + } + } else { + Text( + text = stringResource(R.string.fingerprint_pending), + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + color = Color(0xFFFF9500), + modifier = Modifier.padding(16.dp) + ) + } + } +} + +private fun formatFingerprint(fingerprint: String): String { + val upper = fingerprint.uppercase() + val sb = StringBuilder() + upper.forEachIndexed { index, c -> + if (index > 0 && index % 4 == 0) { + if (index % 16 == 0) sb.append('\n') else sb.append(' ') + } + sb.append(c) + } + return sb.toString() +} diff --git a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt index a8c59ef7..9b8328d6 100644 --- a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt @@ -12,7 +12,6 @@ import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -22,7 +21,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import com.bitchat.android.util.hexEncodedString /** @@ -34,19 +35,20 @@ import com.bitchat.android.ui.theme.BASE_FONT_SIZE fun SidebarOverlay( viewModel: ChatViewModel, onDismiss: () -> Unit, + onShowVerification: () -> Unit, modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme val interactionSource = remember { MutableInteractionSource() } - val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList()) - val joinedChannels by viewModel.joinedChannels.observeAsState(emptyList()) - val currentChannel by viewModel.currentChannel.observeAsState() - val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState() - val nickname by viewModel.nickname.observeAsState("") - val unreadChannelMessages by viewModel.unreadChannelMessages.observeAsState(emptyMap()) - val peerNicknames by viewModel.peerNicknames.observeAsState(emptyMap()) - val peerRSSI by viewModel.peerRSSI.observeAsState(emptyMap()) + val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle() + val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle() + val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle() + val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle() + val nickname by viewModel.nickname.collectAsStateWithLifecycle() + val unreadChannelMessages by viewModel.unreadChannelMessages.collectAsStateWithLifecycle() + val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle() + val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle() Box( modifier = modifier @@ -110,7 +112,7 @@ fun SidebarOverlay( // People section - switch between mesh and geohash lists (iOS-compatible) item { - val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() + val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsState() when (selectedLocationChannel) { is com.bitchat.android.geohash.ChannelID.Location -> { @@ -131,6 +133,7 @@ fun SidebarOverlay( colorScheme = colorScheme, selectedPrivatePeer = selectedPrivatePeer, viewModel = viewModel, + onShowVerification = onShowVerification, onPrivateChatStart = { peerID -> viewModel.startPrivateChat(peerID) onDismiss() @@ -257,8 +260,11 @@ fun PeopleSection( colorScheme: ColorScheme, selectedPrivatePeer: String?, viewModel: ChatViewModel, + onShowVerification: () -> Unit, onPrivateChatStart: (String) -> Unit ) { + val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() + Column(modifier = modifier) { Row( modifier = Modifier @@ -279,6 +285,17 @@ fun PeopleSection( color = colorScheme.onSurface.copy(alpha = 0.6f), fontWeight = FontWeight.Bold ) + Spacer(modifier = Modifier.weight(1f)) + if (selectedLocationChannel !is com.bitchat.android.geohash.ChannelID.Location) { + IconButton(onClick = onShowVerification, modifier = Modifier.size(24.dp)) { + Icon( + imageVector = Icons.Outlined.QrCode, + contentDescription = stringResource(R.string.verify_title), + tint = colorScheme.onSurface.copy(alpha = 0.8f), + modifier = Modifier.size(16.dp) + ) + } + } } if (connectedPeers.isEmpty()) { @@ -291,10 +308,11 @@ fun PeopleSection( } // Observe reactive state for favorites and fingerprints - val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet()) - val privateChats by viewModel.privateChats.observeAsState(emptyMap()) - val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet()) - val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap()) + val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() + val privateChats by viewModel.privateChats.collectAsStateWithLifecycle() + val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle() + val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle() + val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() // Reactive favorite computation for all peers val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) { @@ -308,10 +326,16 @@ fun PeopleSection( // Build mapping of connected peerID -> noise key hex to unify with offline favorites val noiseHexByPeerID: Map = connectedPeers.associateWith { pid -> try { - viewModel.meshService.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) } + viewModel.meshService.getPeerInfo(pid)?.noisePublicKey?.hexEncodedString() } catch (_: Exception) { null } }.filterValues { it != null }.mapValues { it.value!! } + val peerVerifiedStates = remember(verifiedFingerprints, peerFingerprints, connectedPeers) { + connectedPeers.associateWith { peerID -> + viewModel.isPeerVerified(peerID, verifiedFingerprints) + } + } + Log.d("SidebarComponents", "Recomposing with ${favoritePeers.size} favorites, peer states: $peerFavoriteStates") // Smart sorting: unread DMs first, then by most recent DM, then favorites, then alphabetical @@ -342,7 +366,7 @@ fun PeopleSection( // Offline favorites (exclude ones mapped to connected) val offlineFavorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() offlineFavorites.forEach { fav -> - val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) } + val favPeerID = fav.peerNoisePublicKey.hexEncodedString() val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) } if (!isMappedToConnected) { val dn = peerNicknames[favPeerID] ?: fav.peerNickname @@ -368,6 +392,7 @@ fun PeopleSection( sortedPeers.forEach { peerID -> val isFavorite = peerFavoriteStates[peerID] ?: false + val isVerified = peerVerifiedStates[peerID] ?: false // fingerprint and favorite relationship resolution not needed here; UI will show Nostr globe for appended offline favorites below val noiseHex = noiseHexByPeerID[peerID] @@ -384,7 +409,7 @@ fun PeopleSection( val (bName, _) = com.bitchat.android.ui.splitSuffix(displayName) val showHash = (baseNameCounts[bName] ?: 0) > 1 - val directMap by viewModel.peerDirect.observeAsState(emptyMap()) + val directMap by viewModel.peerDirect.collectAsStateWithLifecycle() val isDirectLive = directMap[peerID] ?: try { viewModel.meshService.getPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false } PeerItem( peerID = peerID, @@ -392,6 +417,7 @@ fun PeopleSection( isDirect = isDirectLive, isSelected = peerID == selectedPrivatePeer, isFavorite = isFavorite, + isVerified = isVerified, hasUnreadDM = combinedHasUnread, colorScheme = colorScheme, viewModel = viewModel, @@ -408,7 +434,7 @@ fun PeopleSection( // Append offline favorites we actively favorite (and not currently connected) offlineFavorites.forEach { fav -> - val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) } + val favPeerID = fav.peerNoisePublicKey.hexEncodedString() // If any connected peer maps to this noise key, skip showing the offline entry val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) } if (isMappedToConnected) return@forEach @@ -419,7 +445,7 @@ fun PeopleSection( if (npubOrHex != null) { val hex = if (npubOrHex.startsWith("npub")) { val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npubOrHex) - if (hrp == "npub") data.joinToString("") { "%02x".format(it) } else null + if (hrp == "npub") data.hexEncodedString() else null } else { npubOrHex.lowercase() } @@ -435,6 +461,7 @@ fun PeopleSection( val dn = peerNicknames[favPeerID] ?: fav.peerNickname val (bName, _) = com.bitchat.android.ui.splitSuffix(dn) val showHash = (baseNameCounts[bName] ?: 0) > 1 + val isVerified = viewModel.isNoisePublicKeyVerified(fav.peerNoisePublicKey, verifiedFingerprints) // Compute unreadCount from either noise conversation or Nostr conversation val unreadCount = ( @@ -449,6 +476,7 @@ fun PeopleSection( isDirect = false, isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer, isFavorite = true, + isVerified = isVerified, hasUnreadDM = hasUnread, colorScheme = colorScheme, viewModel = viewModel, @@ -516,6 +544,7 @@ private fun PeerItem( isDirect: Boolean, isSelected: Boolean, isFavorite: Boolean, + isVerified: Boolean, hasUnreadDM: Boolean, colorScheme: ColorScheme, viewModel: ChatViewModel, @@ -616,6 +645,16 @@ private fun PeerItem( color = baseColor.copy(alpha = 0.6f) ) } + + if (isVerified) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.Filled.Verified, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = Color(0xFF32D74B) + ) + } } // Favorite star with proper filled/outlined states diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt b/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt new file mode 100644 index 00000000..32620281 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt @@ -0,0 +1,368 @@ +package com.bitchat.android.ui + +import android.content.Context +import com.bitchat.android.R +import com.bitchat.android.favorites.FavoritesPersistenceService +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.noise.NoiseSession +import com.bitchat.android.nostr.GeohashAliasRegistry +import com.bitchat.android.services.VerificationService +import com.bitchat.android.util.dataFromHexString +import com.bitchat.android.util.hexEncodedString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.security.MessageDigest +import java.util.Date +import java.util.concurrent.ConcurrentHashMap + +/** + * Handles QR verification logic and state, extracted from ChatViewModel. + */ +class VerificationHandler( + private val context: Context, + private val scope: CoroutineScope, + private val meshService: BluetoothMeshService, + private val identityManager: SecureIdentityStateManager, + private val state: ChatState, + private val notificationManager: NotificationManager, + private val messageManager: MessageManager +) { + + private val _verifiedFingerprints = MutableStateFlow>(emptySet()) + val verifiedFingerprints: StateFlow> = _verifiedFingerprints.asStateFlow() + + private val pendingQRVerifications = ConcurrentHashMap() + private val lastVerifyNonceByPeer = ConcurrentHashMap() + private val lastInboundVerifyChallengeAt = ConcurrentHashMap() + private val lastMutualToastAt = ConcurrentHashMap() + + fun loadVerifiedFingerprints() { + _verifiedFingerprints.value = identityManager.getVerifiedFingerprints() + } + + fun isPeerVerified(peerID: String): Boolean { + if (peerID.startsWith("nostr_") || peerID.startsWith("nostr:")) return false + val fingerprint = getPeerFingerprintForDisplay(peerID) + return fingerprint != null && _verifiedFingerprints.value.contains(fingerprint) + } + + fun isNoisePublicKeyVerified(noisePublicKey: ByteArray): Boolean { + val fingerprint = fingerprintFromNoiseBytes(noisePublicKey) + return _verifiedFingerprints.value.contains(fingerprint) + } + + fun unverifyFingerprint(peerID: String) { + val fingerprint = meshService.getPeerFingerprint(peerID) ?: return + identityManager.setVerifiedFingerprint(fingerprint, false) + val current = _verifiedFingerprints.value.toMutableSet() + current.remove(fingerprint) + _verifiedFingerprints.value = current + } + + fun beginQRVerification(qr: VerificationService.VerificationQR): Boolean { + val targetNoise = qr.noiseKeyHex.lowercase() + val peerID = state.getConnectedPeersValue().firstOrNull { pid -> + val noiseKeyHex = meshService.getPeerInfo(pid)?.noisePublicKey?.hexEncodedString()?.lowercase() + noiseKeyHex == targetNoise + } ?: return false + + if (pendingQRVerifications.containsKey(peerID)) return true + val nonce = ByteArray(16) + java.security.SecureRandom().nextBytes(nonce) + val pending = PendingVerification(qr.noiseKeyHex, qr.signKeyHex, nonce, System.currentTimeMillis(), false) + pendingQRVerifications[peerID] = pending + + if (meshService.getSessionState(peerID) is NoiseSession.NoiseSessionState.Established) { + meshService.sendVerifyChallenge(peerID, qr.noiseKeyHex, nonce) + pendingQRVerifications[peerID] = pending.copy(sent = true) + } else { + meshService.initiateNoiseHandshake(peerID) + } + fingerprintFromNoiseHex(qr.noiseKeyHex)?.let { fp -> + identityManager.cacheFingerprintNickname(fp, qr.nickname) + identityManager.cacheNoiseFingerprint(qr.noiseKeyHex, fp) + identityManager.cachePeerNoiseKey(peerID, qr.noiseKeyHex) + } + return true + } + + fun sendPendingVerificationIfNeeded(peerID: String) { + val pending = pendingQRVerifications[peerID] ?: return + if (pending.sent) return + meshService.sendVerifyChallenge(peerID, pending.noiseKeyHex, pending.nonceA) + pendingQRVerifications[peerID] = pending.copy(sent = true) + } + + fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray) { + scope.launch { + val parsed = VerificationService.parseVerifyChallenge(payload) ?: return@launch + val myNoiseHex = meshService.getStaticNoisePublicKey()?.hexEncodedString()?.lowercase() ?: return@launch + if (parsed.first.lowercase() != myNoiseHex) return@launch + + val lastNonce = lastVerifyNonceByPeer[peerID] + if (lastNonce != null && lastNonce.contentEquals(parsed.second)) return@launch + lastVerifyNonceByPeer[peerID] = parsed.second + + val fp = meshService.getPeerFingerprint(peerID) + if (fp != null) { + lastInboundVerifyChallengeAt[fp] = System.currentTimeMillis() + if (_verifiedFingerprints.value.contains(fp)) { + val lastToast = lastMutualToastAt[fp] ?: 0L + if (System.currentTimeMillis() - lastToast > 60_000L) { + lastMutualToastAt[fp] = System.currentTimeMillis() + val name = resolvePeerDisplayName(peerID) + val body = context.getString(R.string.verify_mutual_match_body, name) + addVerificationSystemMessage(peerID, context.getString(R.string.verify_mutual_system_message, name)) + sendVerificationNotification(context.getString(R.string.verify_mutual_match_title), body, peerID) + } + } + } + + meshService.sendVerifyResponse(peerID, parsed.first, parsed.second) + } + } + + fun didReceiveVerifyResponse(peerID: String, payload: ByteArray) { + scope.launch { + val resp = VerificationService.parseVerifyResponse(payload) ?: return@launch + val pending = pendingQRVerifications[peerID] ?: return@launch + if (!resp.noiseKeyHex.equals(pending.noiseKeyHex, ignoreCase = true)) return@launch + if (!resp.nonceA.contentEquals(pending.nonceA)) return@launch + + val ok = VerificationService.verifyResponseSignature( + noiseKeyHex = resp.noiseKeyHex, + nonceA = resp.nonceA, + signature = resp.signature, + signerPublicKeyHex = pending.signKeyHex + ) + if (!ok) return@launch + + pendingQRVerifications.remove(peerID) + val fp = meshService.getPeerFingerprint(peerID) ?: return@launch + identityManager.setVerifiedFingerprint(fp, true) + val current = _verifiedFingerprints.value.toMutableSet() + current.add(fp) + _verifiedFingerprints.value = current + + val name = resolvePeerDisplayName(peerID) + identityManager.cacheFingerprintNickname(fp, name) + val noiseKeyHex = try { + meshService.getPeerInfo(peerID)?.noisePublicKey?.hexEncodedString() + } catch (_: Exception) { + null + } + if (noiseKeyHex != null) { + identityManager.cachePeerNoiseKey(peerID, noiseKeyHex) + identityManager.cacheNoiseFingerprint(noiseKeyHex, fp) + } + addVerificationSystemMessage(peerID, context.getString(R.string.verify_success_system_message, name)) + sendVerificationNotification(context.getString(R.string.verify_success_title), context.getString(R.string.verify_success_body, name), peerID) + + val lastChallenge = lastInboundVerifyChallengeAt[fp] ?: 0L + if (System.currentTimeMillis() - lastChallenge < 600_000L) { + val lastToast = lastMutualToastAt[fp] ?: 0L + if (System.currentTimeMillis() - lastToast > 60_000L) { + lastMutualToastAt[fp] = System.currentTimeMillis() + val body = context.getString(R.string.verify_mutual_match_body, name) + addVerificationSystemMessage(peerID, context.getString(R.string.verify_mutual_system_message, name)) + sendVerificationNotification(context.getString(R.string.verify_mutual_match_title), body, peerID) + } + } + } + } + + fun getPeerFingerprintForDisplay(peerID: String): String? { + val fromMap = state.getPeerFingerprintsValue()[peerID] + if (fromMap != null) return fromMap + val hexRegex = Regex("^[0-9a-fA-F]+$") + return try { + when { + peerID.length == 64 && peerID.matches(hexRegex) -> { + identityManager.getCachedNoiseFingerprint(peerID)?.let { return it } + fingerprintFromNoiseHex(peerID)?.also { identityManager.cacheNoiseFingerprint(peerID, it) } + } + peerID.length == 16 && peerID.matches(hexRegex) -> { + val meshFp = meshService.getPeerFingerprint(peerID) + if (meshFp != null) return meshFp + identityManager.getCachedPeerFingerprint(peerID)?.let { return it } + identityManager.getCachedNoiseKey(peerID)?.let { noiseHex -> + identityManager.getCachedNoiseFingerprint(noiseHex)?.let { return it } + return fingerprintFromNoiseHex(noiseHex)?.also { identityManager.cacheNoiseFingerprint(noiseHex, it) } + } + val favorite = try { + FavoritesPersistenceService.shared.getFavoriteStatus(peerID) + } catch (_: Exception) { + null + } + favorite?.peerNoisePublicKey?.let { fingerprintFromNoiseBytes(it) } + } + peerID.startsWith("nostr_") -> { + val pubHex = GeohashAliasRegistry.get(peerID) + val noiseKey = pubHex?.let { + FavoritesPersistenceService.shared.findNoiseKey(it) + } + noiseKey?.let { + val noiseHex = it.hexEncodedString() + identityManager.getCachedNoiseFingerprint(noiseHex) ?: fingerprintFromNoiseBytes(it) + } + } + peerID.startsWith("nostr:") -> { + val prefix = peerID.removePrefix("nostr:").lowercase() + val pubHex = GeohashAliasRegistry + .snapshot() + .values + .firstOrNull { it.lowercase().startsWith(prefix) } + val noiseKey = pubHex?.let { + FavoritesPersistenceService.shared.findNoiseKey(it) + } + noiseKey?.let { + val noiseHex = it.hexEncodedString() + identityManager.getCachedNoiseFingerprint(noiseHex) ?: fingerprintFromNoiseBytes(it) + } + } + else -> { + val meshFp = meshService.getPeerFingerprint(peerID) + if (meshFp != null) return meshFp + identityManager.getCachedPeerFingerprint(peerID)?.let { return it } + identityManager.getCachedNoiseKey(peerID)?.let { noiseHex -> + identityManager.getCachedNoiseFingerprint(noiseHex)?.let { return it } + return fingerprintFromNoiseHex(noiseHex)?.also { identityManager.cacheNoiseFingerprint(noiseHex, it) } + } + val favorite = try { + FavoritesPersistenceService.shared.getFavoriteStatus(peerID) + } catch (_: Exception) { + null + } + favorite?.peerNoisePublicKey?.let { fingerprintFromNoiseBytes(it) } + } + } + } catch (_: Exception) { + null + } + } + + fun resolvePeerDisplayNameForFingerprint(peerID: String): String { + val nicknameMap = state.peerNicknames.value + nicknameMap[peerID]?.let { return it } + try { + meshService.getPeerInfo(peerID)?.nickname?.let { return it } + } catch (_: Exception) { } + + val fingerprint = getPeerFingerprintForDisplay(peerID) + fingerprint?.let { fp -> + identityManager.getCachedFingerprintNickname(fp)?.let { cached -> + if (cached.isNotBlank()) return cached + } + } + + val hexRegex = Regex("^[0-9a-fA-F]+$") + if (peerID.length == 64 && peerID.matches(hexRegex)) { + val noiseKeyBytes = try { + peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } catch (_: Exception) { null } + val favorite = noiseKeyBytes?.let { + FavoritesPersistenceService.shared.getFavoriteStatus(it) + } + favorite?.peerNickname?.takeIf { it.isNotBlank() }?.let { return it } + } + + if (peerID.length == 16 && peerID.matches(hexRegex)) { + val favorite = try { + FavoritesPersistenceService.shared.getFavoriteStatus(peerID) + } catch (_: Exception) { + null + } + favorite?.peerNickname?.takeIf { it.isNotBlank() }?.let { return it } + } + + return peerID.take(8) + } + + fun getMyFingerprint(): String { + return meshService.getIdentityFingerprint() + } + + fun verifyFingerprintValue(fingerprint: String) { + if (fingerprint.isBlank()) return + identityManager.setVerifiedFingerprint(fingerprint, true) + val current = _verifiedFingerprints.value.toMutableSet() + current.add(fingerprint) + _verifiedFingerprints.value = current + } + + fun unverifyFingerprintValue(fingerprint: String) { + if (fingerprint.isBlank()) return + identityManager.setVerifiedFingerprint(fingerprint, false) + val current = _verifiedFingerprints.value.toMutableSet() + current.remove(fingerprint) + _verifiedFingerprints.value = current + } + + private fun addVerificationSystemMessage(peerID: String, text: String) { + val msg = BitchatMessage( + sender = "system", + content = text, + timestamp = Date(), + isRelay = false, + isPrivate = true, + senderPeerID = peerID + ) + messageManager.addPrivateMessageNoUnread(peerID, msg) + } + + private fun resolvePeerDisplayName(peerID: String): String { + val nick = try { meshService.getPeerInfo(peerID)?.nickname } catch (_: Exception) { null } + return nick ?: peerID.take(8) + } + + private fun sendVerificationNotification(title: String, body: String, peerID: String) { + notificationManager.showVerificationNotification(title, body, peerID) + } + + private fun fingerprintFromNoiseHex(noiseHex: String): String? { + val bytes = noiseHex.dataFromHexString() ?: return null + return fingerprintFromNoiseBytes(bytes) + } + + fun fingerprintFromNoiseBytes(bytes: ByteArray): String { + val hash = MessageDigest.getInstance("SHA-256").digest(bytes) + return hash.hexEncodedString() + } + + private data class PendingVerification( + val noiseKeyHex: String, + val signKeyHex: String, + val nonceA: ByteArray, + val startedAtMs: Long, + val sent: Boolean + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PendingVerification + + if (startedAtMs != other.startedAtMs) return false + if (sent != other.sent) return false + if (noiseKeyHex != other.noiseKeyHex) return false + if (signKeyHex != other.signKeyHex) return false + if (!nonceA.contentEquals(other.nonceA)) return false + + return true + } + + override fun hashCode(): Int { + var result = startedAtMs.hashCode() + result = 31 * result + sent.hashCode() + result = 31 * result + noiseKeyHex.hashCode() + result = 31 * result + signKeyHex.hashCode() + result = 31 * result + nonceA.contentHashCode() + return result + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt new file mode 100644 index 00000000..896a66c1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt @@ -0,0 +1,496 @@ +package com.bitchat.android.ui + +import android.graphics.Bitmap +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.camera.compose.CameraXViewfinder +import androidx.camera.core.CameraSelector +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.core.SurfaceRequest +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.viewfinder.core.ImplementationMode +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.QrCode +import androidx.compose.material.icons.outlined.QrCodeScanner +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import androidx.core.graphics.createBitmap +import androidx.core.graphics.set +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.R +import com.bitchat.android.services.VerificationService +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import kotlinx.coroutines.flow.MutableStateFlow +import com.google.zxing.BarcodeFormat +import com.google.zxing.common.BitMatrix +import com.google.zxing.qrcode.QRCodeWriter +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VerificationSheet( + isPresented: Boolean, + onDismiss: () -> Unit, + viewModel: ChatViewModel, + modifier: Modifier = Modifier +) { + if (!isPresented) return + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val isDark = isSystemInDarkTheme() + val accent = if (isDark) Color.Green else Color(0xFF008000) + val boxColor = if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.06f) + + var showingScanner by remember { mutableStateOf(false) } + val nickname by viewModel.nickname.collectAsStateWithLifecycle() + val npub = remember { + viewModel.getCurrentNpub() + } + + + val qrString = remember(nickname, npub) { + viewModel.buildMyQRString(nickname, npub) + } + + ModalBottomSheet( + modifier = modifier.statusBarsPadding(), + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.background, + dragHandle = null + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + VerificationHeader( + accent = accent, + onClose = onDismiss + ) + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (showingScanner) { + QRScannerPanel( + accent = accent, + boxColor = boxColor, + onScan = { code -> + val qr = VerificationService.verifyScannedQR(code) + if (qr != null && viewModel.beginQRVerification(qr)) { + showingScanner = false + } + } + ) + } else { + MyQrPanel( + qrString = qrString, + accent = accent, + boxColor = boxColor, + ) + } + } + } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + ToggleVerificationModeButton( + showingScanner = showingScanner, + onToggle = { showingScanner = !showingScanner } + ) + + val peerID by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle() + val fingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() + if (peerID != null) { + val fingerprint = viewModel.meshService.getPeerFingerprint(peerID!!) + if (fingerprint != null && fingerprints.contains(fingerprint)) { + Button( + onClick = { viewModel.unverifyFingerprint(peerID!!) }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f), + contentColor = MaterialTheme.colorScheme.onSurface + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.verify_remove), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + } + } + } + } + } + } +} + +@Composable +private fun VerificationHeader( + accent: Color, + onClose: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.verify_title).uppercase(), + fontSize = 14.sp, + fontFamily = FontFamily.Monospace, + color = accent + ) + CloseButton(onClick = onClose) + } +} + +@Composable +private fun MyQrPanel( + qrString: String, + accent: Color, + boxColor: Color, +) { + Text( + text = stringResource(R.string.verify_my_qr_title), + fontSize = 16.sp, + fontFamily = FontFamily.Monospace, + color = accent, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + + QRCodeCard(qrString = qrString, boxColor = boxColor) +} + +@Composable +private fun ToggleVerificationModeButton( + showingScanner: Boolean, + onToggle: () -> Unit +) { + Button( + onClick = onToggle, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f), + contentColor = MaterialTheme.colorScheme.onSurface + ), + modifier = Modifier.fillMaxWidth() + ) { + if (showingScanner) { + Icon( + imageVector = Icons.Outlined.QrCode, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = stringResource(R.string.verify_show_my_qr), + fontFamily = FontFamily.Monospace, + fontSize = 13.sp + ) + } else { + Icon( + imageVector = Icons.Outlined.QrCodeScanner, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = stringResource(R.string.verify_scan_someone), + fontFamily = FontFamily.Monospace, + fontSize = 13.sp + ) + } + } +} + +@Composable +private fun QRCodeCard(qrString: String, boxColor: Color) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(boxColor, RoundedCornerShape(12.dp)) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (qrString.isNotBlank()) { + QRCodeImage(data = qrString, size = 220.dp) + } else { + Box( + modifier = Modifier + .size(220.dp) + .background(Color.Transparent, RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.verify_qr_unavailable), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + } + + SelectionContainer { + Text( + text = qrString, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurface + ) + } + } +} + +@Composable +private fun QRCodeImage(data: String, size: Dp) { + val sizePx = with(LocalDensity.current) { size.toPx().toInt() } + val bitmap = remember(data, sizePx) { generateQrBitmap(data, sizePx) } + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier.size(size) + ) + } +} + +private fun generateQrBitmap(data: String, sizePx: Int): Bitmap? { + if (data.isBlank() || sizePx <= 0) return null + return try { + val matrix = QRCodeWriter().encode(data, BarcodeFormat.QR_CODE, sizePx, sizePx) + bitmapFromMatrix(matrix) + } catch (_: Exception) { + null + } +} + +private fun bitmapFromMatrix(matrix: BitMatrix): Bitmap { + val width = matrix.width + val height = matrix.height + val bitmap = createBitmap(width, height) + for (x in 0 until width) { + for (y in 0 until height) { + bitmap[x, y] = + if (matrix[x, y]) android.graphics.Color.BLACK else android.graphics.Color.WHITE + } + } + return bitmap +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +private fun QRScannerPanel( + onScan: (String) -> Unit, + accent: Color, + boxColor: Color, + modifier: Modifier = Modifier +) { + val permissionState = rememberPermissionState(android.Manifest.permission.CAMERA) + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var lastValid by remember { mutableStateOf(null) } + val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) } + val cameraExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() } + val surfaceRequests = remember { MutableStateFlow(null) } + val surfaceRequest by surfaceRequests.collectAsState(initial = null) + val mainHandler = remember { Handler(Looper.getMainLooper()) } + + val onCodeState = rememberUpdatedState(onScan) + val analyzer = remember { + QRCodeAnalyzer { text -> + mainHandler.post { + if (text == lastValid) return@post + lastValid = text + onCodeState.value(text) + } + } + } + + DisposableEffect(permissionState.status.isGranted) { + var cameraProvider: ProcessCameraProvider? = null + if (permissionState.status.isGranted) { + val executor = ContextCompat.getMainExecutor(context) + cameraProviderFuture.addListener( + { + val provider = cameraProviderFuture.get() + cameraProvider = provider + val preview = Preview.Builder() + .build() + .also { it.setSurfaceProvider { request -> surfaceRequests.value = request } } + val analysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { it.setAnalyzer(cameraExecutor, analyzer) } + + runCatching { + provider.unbindAll() + provider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + analysis + ) + }.onFailure { + Log.w("VerificationSheet", "Failed to bind camera: ${it.message}") + } + }, + executor + ) + } + + onDispose { + surfaceRequests.value = null + runCatching { cameraProvider?.unbindAll() } + } + } + + DisposableEffect(Unit) { + onDispose { cameraExecutor.shutdown() } + } + + Column( + modifier = modifier + .fillMaxWidth() + .background(boxColor, RoundedCornerShape(12.dp)) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.verify_scan_prompt_friend), + fontSize = 16.sp, + fontFamily = FontFamily.Monospace, + color = accent, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + + if (permissionState.status.isGranted) { + surfaceRequest?.let { request -> + CameraXViewfinder( + surfaceRequest = request, + implementationMode = ImplementationMode.EMBEDDED, + modifier = Modifier + .size(220.dp) + .clipToBounds() + ) + } + } else { + Text( + text = stringResource(R.string.verify_camera_permission), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + Button( + onClick = { permissionState.launchPermissionRequest() }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f), + contentColor = MaterialTheme.colorScheme.onSurface + ) + ) { + Text( + text = stringResource(R.string.verify_request_camera), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + } + } + } +} + +private class QRCodeAnalyzer( + private val onCode: (String) -> Unit +) : ImageAnalysis.Analyzer { + private val scanner = BarcodeScanning.getClient( + BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build() + ) + + @ExperimentalGetImage + override fun analyze(imageProxy: ImageProxy) { + val mediaImage = imageProxy.image ?: run { + imageProxy.close() + return + } + val input = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) + scanner.process(input) + .addOnSuccessListener { barcodes -> + val text = barcodes.firstOrNull()?.rawValue + if (!text.isNullOrBlank()) onCode(text) + } + .addOnCompleteListener { imageProxy.close() } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt index e2f9d966..04ad48a2 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt @@ -20,6 +20,7 @@ object DebugPreferenceManager { // GCS keys (no migration/back-compat) private const val KEY_GCS_MAX_BYTES = "gcs_max_filter_bytes" private const val KEY_GCS_FPR = "gcs_filter_fpr_percent" + // Removed: persistent notification toggle is now governed by MeshServicePreferences.isBackgroundEnabled private lateinit var prefs: SharedPreferences @@ -100,4 +101,6 @@ object DebugPreferenceManager { fun setGcsFprPercent(value: Double) { if (ready()) prefs.edit().putLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(value)).apply() } + + // No longer storing persistent notification in debug prefs. } diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt index 6e37286f..77f6ce12 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt @@ -36,6 +36,11 @@ class DebugSettingsManager private constructor() { private val _packetRelayEnabled = MutableStateFlow(true) val packetRelayEnabled: StateFlow = _packetRelayEnabled.asStateFlow() + // Visibility of the debug sheet; gates heavy work + private val _debugSheetVisible = MutableStateFlow(false) + val debugSheetVisible: StateFlow = _debugSheetVisible.asStateFlow() + fun setDebugSheetVisible(visible: Boolean) { _debugSheetVisible.value = visible } + // Connection limit overrides (debug) private val _maxConnectionsOverall = MutableStateFlow(8) val maxConnectionsOverall: StateFlow = _maxConnectionsOverall.asStateFlow() @@ -75,12 +80,63 @@ class DebugSettingsManager private constructor() { // Timestamps to compute rolling window stats private val relayTimestamps = ConcurrentLinkedQueue() + // Per-device and per-peer rolling timestamps for stacked graphs + private val perDeviceRelayTimestamps = mutableMapOf>() + private val perPeerRelayTimestamps = mutableMapOf>() + + // Additional buckets to split incoming vs outgoing + private val incomingTimestamps = ConcurrentLinkedQueue() + private val outgoingTimestamps = ConcurrentLinkedQueue() + private val perDeviceIncoming = mutableMapOf>() + private val perDeviceOutgoing = mutableMapOf>() + private val perPeerIncoming = mutableMapOf>() + private val perPeerOutgoing = mutableMapOf>() + + // Expose current per-second rates (updated when logging/pruning occurs) + private val _perDeviceLastSecond: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perDeviceLastSecond: StateFlow> = _perDeviceLastSecond.asStateFlow() + private val _perPeerLastSecond: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perPeerLastSecond: StateFlow> = _perPeerLastSecond.asStateFlow() + // New flows used by UI for incoming/outgoing stacked plots + private val _perDeviceIncomingLastSecond: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perDeviceIncomingLastSecond: StateFlow> = _perDeviceIncomingLastSecond.asStateFlow() + private val _perDeviceOutgoingLastSecond: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perDeviceOutgoingLastSecond: StateFlow> = _perDeviceOutgoingLastSecond.asStateFlow() + private val _perPeerIncomingLastSecond: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perPeerIncomingLastSecond: StateFlow> = _perPeerIncomingLastSecond.asStateFlow() + private val _perPeerOutgoingLastSecond: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perPeerOutgoingLastSecond: StateFlow> = _perPeerOutgoingLastSecond.asStateFlow() + + // Per-minute counts per key + private val _perDeviceIncomingLastMinute: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perDeviceIncomingLastMinute: StateFlow> = _perDeviceIncomingLastMinute.asStateFlow() + private val _perDeviceOutgoingLastMinute: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perDeviceOutgoingLastMinute: StateFlow> = _perDeviceOutgoingLastMinute.asStateFlow() + private val _perPeerIncomingLastMinute: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perPeerIncomingLastMinute: StateFlow> = _perPeerIncomingLastMinute.asStateFlow() + private val _perPeerOutgoingLastMinute: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perPeerOutgoingLastMinute: StateFlow> = _perPeerOutgoingLastMinute.asStateFlow() + + // Totals per key (since app start) + private val deviceIncomingTotalsMap = mutableMapOf() + private val deviceOutgoingTotalsMap = mutableMapOf() + private val peerIncomingTotalsMap = mutableMapOf() + private val peerOutgoingTotalsMap = mutableMapOf() + private val _perDeviceIncomingTotalsFlow: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perDeviceIncomingTotal: StateFlow> = _perDeviceIncomingTotalsFlow.asStateFlow() + private val _perDeviceOutgoingTotalsFlow: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perDeviceOutgoingTotal: StateFlow> = _perDeviceOutgoingTotalsFlow.asStateFlow() + private val _perPeerIncomingTotalsFlow: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perPeerIncomingTotal: StateFlow> = _perPeerIncomingTotalsFlow.asStateFlow() + private val _perPeerOutgoingTotalsFlow: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perPeerOutgoingTotal: StateFlow> = _perPeerOutgoingTotalsFlow.asStateFlow() // Internal data storage for managing debug data private val debugMessageQueue = ConcurrentLinkedQueue() private val scanResultsQueue = ConcurrentLinkedQueue() private fun updateRelayStatsFromTimestamps() { + if (!_debugSheetVisible.value) return val now = System.currentTimeMillis() // prune older than 15m while (true) { @@ -89,18 +145,84 @@ class DebugSettingsManager private constructor() { relayTimestamps.poll() } else break } + // prune per-device and per-peer and compute 1s/60s rates + fun pruneAndCount1s(map: MutableMap>): Map { + val result = mutableMapOf() + val iterator = map.entries.iterator() + while (iterator.hasNext()) { + val (key, q) = iterator.next() + // prune this queue + while (true) { + val ts = q.peek() ?: break + if (now - ts > 15 * 60 * 1000L) { + q.poll() + } else break + } + // count last 1s only + val count1s = q.count { now - it <= 1_000L } + if (q.isEmpty()) { + // cleanup empty queues to prevent unbounded growth + iterator.remove() + } + if (count1s > 0) result[key] = count1s + } + return result + } + fun pruneAndCount60s(map: MutableMap>): Map { + val result = mutableMapOf() + map.forEach { (key, q) -> + val count60 = q.count { now - it <= 60_000L } + if (count60 > 0) result[key] = count60 + } + return result + } + + val perDevice1s = pruneAndCount1s(perDeviceRelayTimestamps) + val perPeer1s = pruneAndCount1s(perPeerRelayTimestamps) + + _perDeviceLastSecond.value = perDevice1s + _perPeerLastSecond.value = perPeer1s + // Also compute incoming/outgoing per-key rates + _perDeviceIncomingLastSecond.value = pruneAndCount1s(perDeviceIncoming) + _perDeviceOutgoingLastSecond.value = pruneAndCount1s(perDeviceOutgoing) + _perPeerIncomingLastSecond.value = pruneAndCount1s(perPeerIncoming) + _perPeerOutgoingLastSecond.value = pruneAndCount1s(perPeerOutgoing) + _perDeviceIncomingLastMinute.value = pruneAndCount60s(perDeviceIncoming) + _perDeviceOutgoingLastMinute.value = pruneAndCount60s(perDeviceOutgoing) + _perPeerIncomingLastMinute.value = pruneAndCount60s(perPeerIncoming) + _perPeerOutgoingLastMinute.value = pruneAndCount60s(perPeerOutgoing) val last1s = relayTimestamps.count { now - it <= 1_000L } val last10s = relayTimestamps.count { now - it <= 10_000L } val last1m = relayTimestamps.count { now - it <= 60_000L } val last15m = relayTimestamps.size - val total = _relayStats.value.totalRelaysCount + 1 + // And incoming/outgoing per-second counters + val last1sIncoming = incomingTimestamps.count { now - it <= 1_000L } + val last1sOutgoing = outgoingTimestamps.count { now - it <= 1_000L } + val last10sIncoming = incomingTimestamps.count { now - it <= 10_000L } + val last10sOutgoing = outgoingTimestamps.count { now - it <= 10_000L } + val last1mIncoming = incomingTimestamps.count { now - it <= 60_000L } + val last1mOutgoing = outgoingTimestamps.count { now - it <= 60_000L } + val last15mIncoming = incomingTimestamps.size + val last15mOutgoing = outgoingTimestamps.size + val totalIncoming = _relayStats.value.totalIncomingCount + val totalOutgoing = _relayStats.value.totalOutgoingCount _relayStats.value = PacketRelayStats( - totalRelaysCount = total, + totalRelaysCount = totalIncoming + totalOutgoing, lastSecondRelays = last1s, last10SecondRelays = last10s, lastMinuteRelays = last1m, last15MinuteRelays = last15m, - lastResetTime = _relayStats.value.lastResetTime + lastResetTime = _relayStats.value.lastResetTime, + lastSecondIncoming = last1sIncoming, + lastSecondOutgoing = last1sOutgoing, + last10SecondIncoming = last10sIncoming, + last10SecondOutgoing = last10sOutgoing, + lastMinuteIncoming = last1mIncoming, + lastMinuteOutgoing = last1mOutgoing, + last15MinuteIncoming = last15mIncoming, + last15MinuteOutgoing = last15mOutgoing, + totalIncomingCount = totalIncoming, + totalOutgoingCount = totalOutgoing ) } @@ -336,11 +458,61 @@ class DebugSettingsManager private constructor() { } } - // Update rolling statistics only for relays - if (isRelay) { - relayTimestamps.offer(System.currentTimeMillis()) - updateRelayStatsFromTimestamps() + // Do not update counters here; this path is for readable logs only. + } + + // Explicit incoming/outgoing logging to avoid double counting + fun logIncoming(packetType: String, fromPeerID: String?, fromNickname: String?, fromDeviceAddress: String?) { + if (verboseLoggingEnabled.value) { + val who = fromNickname ?: fromPeerID ?: "unknown" + addDebugMessage(DebugMessage.PacketEvent("📥 Incoming $packetType from $who (${fromPeerID ?: "?"}, ${fromDeviceAddress ?: "?"})")) } + val now = System.currentTimeMillis() + val visible = _debugSheetVisible.value + if (visible) incomingTimestamps.offer(now) + fromDeviceAddress?.let { + perDeviceIncoming.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now) + deviceIncomingTotalsMap[it] = (deviceIncomingTotalsMap[it] ?: 0L) + 1L + _perDeviceIncomingTotalsFlow.value = deviceIncomingTotalsMap.toMap() + } + fromPeerID?.let { + perPeerIncoming.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now) + peerIncomingTotalsMap[it] = (peerIncomingTotalsMap[it] ?: 0L) + 1L + _perPeerIncomingTotalsFlow.value = peerIncomingTotalsMap.toMap() + } + // bump totals + val cur = _relayStats.value + _relayStats.value = cur.copy( + totalIncomingCount = cur.totalIncomingCount + 1, + totalRelaysCount = cur.totalRelaysCount + 1 + ) + if (visible) updateRelayStatsFromTimestamps() + } + + fun logOutgoing(packetType: String, toPeerID: String?, toNickname: String?, toDeviceAddress: String?, previousHopPeerID: String? = null) { + if (verboseLoggingEnabled.value) { + val who = toNickname ?: toPeerID ?: "unknown" + addDebugMessage(DebugMessage.PacketEvent("📤 Outgoing $packetType to $who (${toPeerID ?: "?"}, ${toDeviceAddress ?: "?"})")) + } + val now = System.currentTimeMillis() + val visible = _debugSheetVisible.value + if (visible) outgoingTimestamps.offer(now) + toDeviceAddress?.let { + perDeviceOutgoing.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now) + deviceOutgoingTotalsMap[it] = (deviceOutgoingTotalsMap[it] ?: 0L) + 1L + _perDeviceOutgoingTotalsFlow.value = deviceOutgoingTotalsMap.toMap() + } + (toPeerID ?: previousHopPeerID)?.let { + perPeerOutgoing.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now) + peerOutgoingTotalsMap[it] = (peerOutgoingTotalsMap[it] ?: 0L) + 1L + _perPeerOutgoingTotalsFlow.value = peerOutgoingTotalsMap.toMap() + } + val cur = _relayStats.value + _relayStats.value = cur.copy( + totalOutgoingCount = cur.totalOutgoingCount + 1, + totalRelaysCount = cur.totalRelaysCount + 1 + ) + if (visible) updateRelayStatsFromTimestamps() } // MARK: - Clear Data @@ -407,5 +579,15 @@ data class PacketRelayStats( val last10SecondRelays: Int = 0, val lastMinuteRelays: Int = 0, val last15MinuteRelays: Int = 0, - val lastResetTime: Date = Date() + val lastResetTime: Date = Date(), + val lastSecondIncoming: Int = 0, + val lastSecondOutgoing: Int = 0, + val last10SecondIncoming: Int = 0, + val last10SecondOutgoing: Int = 0, + val lastMinuteIncoming: Int = 0, + val lastMinuteOutgoing: Int = 0, + val last15MinuteIncoming: Int = 0, + val last15MinuteOutgoing: Int = 0, + val totalIncomingCount: Long = 0, + val totalOutgoingCount: Long = 0 ) diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt index 208111fa..25be4a21 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt @@ -3,9 +3,11 @@ package com.bitchat.android.ui.debug import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.filled.BugReport @@ -15,6 +17,7 @@ import androidx.compose.material.icons.filled.PowerSettingsNew import androidx.compose.material.icons.filled.SettingsEthernet import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -31,6 +34,9 @@ import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.res.stringResource import com.bitchat.android.R +import androidx.compose.ui.platform.LocalContext +import com.bitchat.android.service.MeshServicePreferences +import com.bitchat.android.service.MeshForegroundService @Composable fun MeshTopologySection() { @@ -113,7 +119,9 @@ fun MeshTopologySection() { } } -@OptIn(ExperimentalMaterial3Api::class) +private enum class GraphMode { OVERALL, PER_DEVICE, PER_PEER } + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun DebugSettingsSheet( isPresented: Boolean, @@ -138,6 +146,8 @@ fun DebugSettingsSheet( val seenCapacity by manager.seenPacketCapacity.collectAsState() val gcsMaxBytes by manager.gcsMaxBytes.collectAsState() val gcsFpr by manager.gcsFprPercent.collectAsState() + val context = LocalContext.current + // Persistent notification is now controlled solely by MeshServicePreferences.isBackgroundEnabled // Push live connected devices from mesh service whenever sheet is visible LaunchedEffect(isPresented) { @@ -174,6 +184,11 @@ fun DebugSettingsSheet( onDismissRequest = onDismiss, sheetState = sheetState ) { + // Mark debug sheet visible/invisible to gate heavy work + LaunchedEffect(Unit) { DebugSettingsManager.getInstance().setDebugSheetVisible(true) } + DisposableEffect(Unit) { + onDispose { DebugSettingsManager.getInstance().setDebugSheetVisible(false) } + } LazyColumn( modifier = Modifier .fillMaxWidth() @@ -292,135 +307,260 @@ fun DebugSettingsSheet( item { Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + // Persistent notification is controlled by About sheet (MeshServicePreferences.isBackgroundEnabled) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, tint = Color(0xFFFF9500)) Text(stringResource(R.string.debug_packet_relay), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) Spacer(Modifier.weight(1f)) Switch(checked = packetRelayEnabled, onCheckedChange = { manager.setPacketRelayEnabled(it) }) } - Text(stringResource(R.string.debug_since_start_fmt, relayStats.totalRelaysCount), fontFamily = FontFamily.Monospace, fontSize = 11.sp) - Text(stringResource(R.string.debug_relays_window_fmt, relayStats.last10SecondRelays, relayStats.lastMinuteRelays, relayStats.last15MinuteRelays), fontFamily = FontFamily.Monospace, fontSize = 11.sp) - // Realtime graph: per-second relays, full-width canvas, bottom-up bars, fast decay - var series by remember { mutableStateOf(List(60) { 0f }) } - LaunchedEffect(isPresented) { + // Removed aggregate labels; we will show per-direction compact labels below titles + // Toggle: overall vs per-connection vs per-peer + var graphMode by rememberSaveable { mutableStateOf(GraphMode.OVERALL) } + val perDeviceIncoming by manager.perDeviceIncomingLastSecond.collectAsState() + val perPeerIncoming by manager.perPeerIncomingLastSecond.collectAsState() + val perDeviceOutgoing by manager.perDeviceOutgoingLastSecond.collectAsState() + val perPeerOutgoing by manager.perPeerOutgoingLastSecond.collectAsState() + val perDeviceIncoming1m by manager.perDeviceIncomingLastMinute.collectAsState() + val perDeviceOutgoing1m by manager.perDeviceOutgoingLastMinute.collectAsState() + val perPeerIncoming1m by manager.perPeerIncomingLastMinute.collectAsState() + val perPeerOutgoing1m by manager.perPeerOutgoingLastMinute.collectAsState() + val perDeviceIncomingTotal by manager.perDeviceIncomingTotal.collectAsState() + val perDeviceOutgoingTotal by manager.perDeviceOutgoingTotal.collectAsState() + val perPeerIncomingTotal by manager.perPeerIncomingTotal.collectAsState() + val perPeerOutgoingTotal by manager.perPeerOutgoingTotal.collectAsState() + val nicknameMap = remember { mutableStateOf>(emptyMap()) } + val devicePeerMap = remember { mutableStateOf>(emptyMap()) } + LaunchedEffect(Unit) { + try { nicknameMap.value = meshService.getPeerNicknames() } catch (_: Exception) { } + // Try to fetch device->peer map periodically for legend resolution while (isPresented) { - val s = relayStats.lastSecondRelays.toFloat() - val last = series.lastOrNull() ?: 0f - // Faster decay and smoothing - val v = last * 0.5f + s * 0.5f - series = (series + v).takeLast(60) - kotlinx.coroutines.delay(400) + try { devicePeerMap.value = meshService.getDeviceAddressToPeerMapping() } catch (_: Exception) { } + kotlinx.coroutines.delay(1000) } } - val maxValRaw = series.maxOrNull() ?: 0f - val maxVal = if (maxValRaw > 0f) maxValRaw else 0f - val leftGutter = 40.dp - Box(Modifier.fillMaxWidth().height(56.dp)) { - // Graph canvas - androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) { - val axisPx = leftGutter.toPx() // reserved left gutter for labels - val barCount = series.size - val availW = (size.width - axisPx).coerceAtLeast(1f) - val w = availW / barCount - val h = size.height - // Baseline at bottom (y = 0) - drawLine( - color = Color(0x33888888), - start = androidx.compose.ui.geometry.Offset(axisPx, h - 1f), - end = androidx.compose.ui.geometry.Offset(size.width, h - 1f), - strokeWidth = 1f + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + // Mode selector + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = graphMode == GraphMode.OVERALL, + onClick = { graphMode = GraphMode.OVERALL }, + label = { Text("Overall") } ) - // Bars from bottom-up; skip zeros entirely - series.forEachIndexed { i, value -> - if (value > 0f && maxVal > 0f) { - val ratio = (value / maxVal).coerceIn(0f, 1f) - val barHeight = (h * ratio).coerceAtLeast(0f) - if (barHeight > 0.5f) { - drawRect( - color = Color(0xFF00C851), - topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = h - barHeight), - size = androidx.compose.ui.geometry.Size(w, barHeight) - ) - } - } + FilterChip( + selected = graphMode == GraphMode.PER_DEVICE, + onClick = { graphMode = GraphMode.PER_DEVICE }, + label = { Text("Per Device") }, + leadingIcon = { Icon(Icons.Filled.Devices, contentDescription = null) } + ) + FilterChip( + selected = graphMode == GraphMode.PER_PEER, + onClick = { graphMode = GraphMode.PER_PEER }, + label = { Text("Per Peer") }, + leadingIcon = { Icon(Icons.Filled.SettingsEthernet, contentDescription = null) } + ) + } + + // Time series state + var overallSeriesIncoming by rememberSaveable { mutableStateOf(List(60) { 0f }) } + var overallSeriesOutgoing by rememberSaveable { mutableStateOf(List(60) { 0f }) } + var stackedKeysIncoming by rememberSaveable { mutableStateOf(listOf()) } + var stackedKeysOutgoing by rememberSaveable { mutableStateOf(listOf()) } + var stackedSeriesIncoming by rememberSaveable { mutableStateOf>>(emptyMap()) } + var stackedSeriesOutgoing by rememberSaveable { mutableStateOf>>(emptyMap()) } + var highlightedKey by rememberSaveable { mutableStateOf(null) } + + // Color palette for stacked legend + val palette = remember { + listOf( + Color(0xFF00C851), Color(0xFF007AFF), Color(0xFFFF9500), Color(0xFFFF3B30), + Color(0xFF5AC8FA), Color(0xFFAF52DE), Color(0xFFFF2D55), Color(0xFF34C759), + Color(0xFFFFCC00), Color(0xFF5856D6) + ) + } + val colorForKey = remember { mutableStateMapOf() } + fun stableColorFor(key: String): Color { + // Deterministic fallback color based on key hash using HSV palette + val h = (key.hashCode().toUInt().toInt() and 0x7FFFFFFF) % 360 + return Color.hsv(h.toFloat(), 0.65f, 0.95f) + } + // Ensure colors are assigned for current keys before drawing + fun ensureColors(keys: List) { + keys.forEachIndexed { idx, k -> + colorForKey.putIfAbsent(k, palette.getOrNull(idx) ?: stableColorFor(k)) } } - // Y-axis ticks (min/max) in the left margin - Text("0", fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.BottomStart).padding(start = 4.dp, bottom = 2.dp)) - Text("${maxVal.toInt()}", fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.TopStart).padding(start = 4.dp, top = 2.dp)) - // Y-axis unit label (vertical) - Text("p/s", fontFamily = FontFamily.Monospace, fontSize = 9.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp).rotate(-90f)) - } - } - } -} -@Composable -fun MeshTopologySection() { - val colorScheme = MaterialTheme.colorScheme - val graphService = remember { MeshGraphService.getInstance() } - val snapshot by graphService.graphState.collectAsState() - - Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF8E8E93)) - Text("mesh topology", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) - } - val nodes = snapshot.nodes - val edges = snapshot.edges - val empty = nodes.isEmpty() - if (empty) { - Text("no gossip yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) - } else { - androidx.compose.foundation.Canvas(Modifier.fillMaxWidth().height(220.dp).background(colorScheme.surface.copy(alpha = 0.4f))) { - val w = size.width - val h = size.height - val cx = w / 2f - val cy = h / 2f - val radius = (minOf(w, h) * 0.36f) - val n = nodes.size - if (n == 1) { - // Single node centered - drawCircle(color = Color(0xFF00C851), radius = 12f, center = androidx.compose.ui.geometry.Offset(cx, cy)) - } else { - // Circular layout - val positions = nodes.mapIndexed { i, node -> - val angle = (2 * Math.PI * i.toDouble()) / n - val x = cx + (radius * Math.cos(angle)).toFloat() - val y = cy + (radius * Math.sin(angle)).toFloat() - node.peerID to androidx.compose.ui.geometry.Offset(x, y) - }.toMap() - - // Draw edges - edges.forEach { e -> - val p1 = positions[e.a] - val p2 = positions[e.b] - if (p1 != null && p2 != null) { - drawLine(color = Color(0xFF4A90E2), start = p1, end = p2, strokeWidth = 2f) + LaunchedEffect(isPresented, graphMode) { + while (isPresented) { + when (graphMode) { + GraphMode.OVERALL -> { + val sIn = relayStats.lastSecondIncoming.toFloat() + val sOut = relayStats.lastSecondOutgoing.toFloat() + overallSeriesIncoming = (overallSeriesIncoming + sIn).takeLast(60) + overallSeriesOutgoing = (overallSeriesOutgoing + sOut).takeLast(60) + } + GraphMode.PER_DEVICE -> { + val snapshotIn = perDeviceIncoming + val snapshotOut = perDeviceOutgoing + fun advance(base: Map>, snap: Map): Map> { + val next = mutableMapOf>() + val union = (base.keys + snap.keys).toSet() + union.forEach { k -> + val prev = base[k] ?: List(60) { 0f } + val s = (snap[k] ?: 0).toFloat() + next[k] = (prev + s).takeLast(60) + } + return next + } + // Advance and prune fully-stale series (all-zero in visible window) + stackedSeriesIncoming = advance(stackedSeriesIncoming, snapshotIn).filterValues { series -> series.any { it != 0f } } + stackedSeriesOutgoing = advance(stackedSeriesOutgoing, snapshotOut).filterValues { series -> series.any { it != 0f } } + stackedKeysIncoming = stackedSeriesIncoming.keys.sorted() + stackedKeysOutgoing = stackedSeriesOutgoing.keys.sorted() + } + GraphMode.PER_PEER -> { + val snapshotIn = perPeerIncoming + val snapshotOut = perPeerOutgoing + fun advance(base: Map>, snap: Map): Map> { + val next = mutableMapOf>() + val union = (base.keys + snap.keys).toSet() + union.forEach { k -> + val prev = base[k] ?: List(60) { 0f } + val s = (snap[k] ?: 0).toFloat() + next[k] = (prev + s).takeLast(60) + } + return next + } + stackedSeriesIncoming = advance(stackedSeriesIncoming, snapshotIn).filterValues { series -> series.any { it != 0f } } + stackedSeriesOutgoing = advance(stackedSeriesOutgoing, snapshotOut).filterValues { series -> series.any { it != 0f } } + stackedKeysIncoming = stackedSeriesIncoming.keys.sorted() + stackedKeysOutgoing = stackedSeriesOutgoing.keys.sorted() + } + } + kotlinx.coroutines.delay(1000) + } } - } + + // Helper functions moved to top-level composable below to avoid scope issues - // Draw nodes - nodes.forEach { node -> - val pos = positions[node.peerID] ?: androidx.compose.ui.geometry.Offset(cx, cy) - drawCircle(color = Color(0xFF00C851), radius = 10f, center = pos) + // Render two blocks: Incoming and Outgoing + Text("Incoming", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text( + "${relayStats.lastSecondIncoming}/s • ${relayStats.lastMinuteIncoming}/m • ${relayStats.last15MinuteIncoming}/15m • total ${relayStats.totalIncomingCount}", + fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) + ) + DrawGraphBlock( + title = "Incoming", + stackedKeys = stackedKeysIncoming, + stackedSeries = stackedSeriesIncoming, + overallSeries = if (graphMode == GraphMode.OVERALL) overallSeriesIncoming else null, + graphMode = graphMode, + highlightedKey = highlightedKey, + onToggleHighlight = { key -> highlightedKey = if (highlightedKey == key) null else key }, + ensureColors = { keys -> ensureColors(keys) }, + colorForKey = { k -> colorForKey[k] ?: stableColorFor(k) }, + legendTitleFor = { key -> + when (graphMode) { + GraphMode.PER_PEER -> { + val nick = nicknameMap.value[key] + val prefix = key.take(6) + if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix + } + GraphMode.PER_DEVICE -> { + val device = key + val pid = connectedDevices.firstOrNull { it.deviceAddress == device }?.peerID + ?: devicePeerMap.value[device] + if (pid != null) { + val nick = nicknameMap.value[pid] + val prefix = pid.take(6) + "$device (${if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix})" + } else device + } + else -> key + } + }, + legendMetricsFor = { key -> + when (graphMode) { + GraphMode.PER_PEER -> { + val s = perPeerIncoming[key] ?: 0 + val m = perPeerIncoming1m[key] ?: 0 + val t = (perPeerIncomingTotal[key] ?: 0L) + "${s}/s • ${m}/m • total ${t}" + } + GraphMode.PER_DEVICE -> { + val s = perDeviceIncoming[key] ?: 0 + val m = perDeviceIncoming1m[key] ?: 0 + val t = (perDeviceIncomingTotal[key] ?: 0L) + "${s}/s • ${m}/m • total ${t}" + } + else -> "" + } + } + ) + if (graphMode != GraphMode.OVERALL && stackedKeysIncoming.isNotEmpty()) { /* legend printed inside DrawGraphBlock */ } + + Spacer(Modifier.height(8.dp)) + Text("Outgoing", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text( + "${relayStats.lastSecondOutgoing}/s • ${relayStats.lastMinuteOutgoing}/m • ${relayStats.last15MinuteOutgoing}/15m • total ${relayStats.totalOutgoingCount}", + fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) + ) + DrawGraphBlock( + title = "Outgoing", + stackedKeys = stackedKeysOutgoing, + stackedSeries = stackedSeriesOutgoing, + overallSeries = if (graphMode == GraphMode.OVERALL) overallSeriesOutgoing else null, + graphMode = graphMode, + highlightedKey = highlightedKey, + onToggleHighlight = { key -> highlightedKey = if (highlightedKey == key) null else key }, + ensureColors = { keys -> ensureColors(keys) }, + colorForKey = { k -> colorForKey[k] ?: stableColorFor(k) }, + legendTitleFor = { key -> + when (graphMode) { + GraphMode.PER_PEER -> { + val nick = nicknameMap.value[key] + val prefix = key.take(6) + if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix + } + GraphMode.PER_DEVICE -> { + val device = key + val pid = connectedDevices.firstOrNull { it.deviceAddress == device }?.peerID + ?: devicePeerMap.value[device] + if (pid != null) { + val nick = nicknameMap.value[pid] + val prefix = pid.take(6) + "$device (${if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix})" + } else device + } + else -> key + } + }, + legendMetricsFor = { key -> + when (graphMode) { + GraphMode.PER_PEER -> { + val s = perPeerOutgoing[key] ?: 0 + val m = perPeerOutgoing1m[key] ?: 0 + val t = (perPeerOutgoingTotal[key] ?: 0L) + "${s}/s • ${m}/m • total ${t}" + } + GraphMode.PER_DEVICE -> { + val s = perDeviceOutgoing[key] ?: 0 + val m = perDeviceOutgoing1m[key] ?: 0 + val t = (perDeviceOutgoingTotal[key] ?: 0L) + "${s}/s • ${m}/m • total ${t}" + } + else -> "" + } + } + ) + if (graphMode != GraphMode.OVERALL && stackedKeysOutgoing.isNotEmpty()) { /* legend printed inside DrawGraphBlock */ } } } } - // Label list for clarity under the canvas - LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 140.dp)) { - items(nodes) { node -> - val label = "${node.peerID.take(8)} • ${node.nickname ?: "unknown"}" - Text(label, fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.85f)) - } - } } - } - } -} - - // Connected devices item { @@ -443,9 +583,6 @@ fun MeshTopologySection() { } } - - - // Connected devices item { Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { @@ -532,3 +669,146 @@ fun MeshTopologySection() { } } } + +@Composable +private fun DrawGraphBlock( + title: String, + stackedKeys: List, + stackedSeries: Map>, + overallSeries: List?, + graphMode: GraphMode, + highlightedKey: String?, + onToggleHighlight: (String) -> Unit, + ensureColors: (List) -> Unit, + colorForKey: (String) -> Color, + legendTitleFor: (String) -> String, + legendMetricsFor: (String) -> String +) { + val colorScheme = MaterialTheme.colorScheme + val leftGutter = 40.dp + Box(Modifier.fillMaxWidth().height(56.dp)) { + androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) { + val axisPx = leftGutter.toPx() + val barCount = 60 + val availW = (size.width - axisPx).coerceAtLeast(1f) + val w = availW / barCount + val h = size.height + drawLine( + color = Color(0x33888888), + start = androidx.compose.ui.geometry.Offset(axisPx, h - 1f), + end = androidx.compose.ui.geometry.Offset(size.width, h - 1f), + strokeWidth = 1f + ) + + when (graphMode) { + GraphMode.OVERALL -> { + val maxValRaw = (overallSeries?.maxOrNull() ?: 0f) + val maxVal = if (maxValRaw > 0f) maxValRaw else 0f + (overallSeries ?: emptyList()).forEachIndexed { i, value -> + if (value > 0f && maxVal > 0f) { + val ratio = (value / maxVal).coerceIn(0f, 1f) + val barHeight = (h * ratio).coerceAtLeast(0f) + if (barHeight > 0.5f) { + drawRect( + color = Color(0xFF00C851), + topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = h - barHeight), + size = androidx.compose.ui.geometry.Size(w, barHeight) + ) + } + } + } + } + else -> { + val indices = 0 until 60 + val totals = indices.map { idx -> + stackedSeries.values.sumOf { it.getOrNull(idx)?.toDouble() ?: 0.0 }.toFloat() + } + val maxTotal = (totals.maxOrNull() ?: 0f) + val drawKeysBars = if (stackedKeys.isNotEmpty()) stackedKeys else stackedSeries.keys.sorted() + indices.forEach { i -> + var yTop = h + if (maxTotal > 0f) { + ensureColors(drawKeysBars) + drawKeysBars.forEach { k -> + val v = stackedSeries[k]?.getOrNull(i) ?: 0f + if (v > 0f) { + val ratio = (v / maxTotal).coerceIn(0f, 1f) + val segH = (h * ratio) + if (segH > 0.5f) { + val top = (yTop - segH) + val baseColor = colorForKey(k) + val c = if (highlightedKey == null || highlightedKey == k) baseColor else baseColor.copy(alpha = 0.35f) + drawRect( + color = c, + topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = top), + size = androidx.compose.ui.geometry.Size(w, segH) + ) + yTop = top + } + } + } + } + } + } + } + } + + Row(Modifier.fillMaxSize()) { + Box(Modifier.width(leftGutter).fillMaxHeight()) { + Text( + "p/s", + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + color = colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp).rotate(-90f) + ) + val topLabel = when (graphMode) { + GraphMode.OVERALL -> (overallSeries?.maxOrNull() ?: 0f).toInt().toString() + else -> { + val totals = (0 until 60).map { idx -> stackedSeries.values.sumOf { it.getOrNull(idx)?.toDouble() ?: 0.0 }.toFloat() } + (totals.maxOrNull() ?: 0f).toInt().toString() + } + } + Text( + topLabel, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + color = colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.align(Alignment.TopEnd).padding(end = 4.dp) + ) + Text( + "0", + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + color = colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.align(Alignment.BottomEnd).padding(end = 4.dp) + ) + } + Spacer(Modifier.weight(1f)) + } + } + + val drawKeys = if (stackedKeys.isNotEmpty()) stackedKeys else stackedSeries.keys.sorted() + if (graphMode != GraphMode.OVERALL && drawKeys.isNotEmpty()) { + Column(Modifier.fillMaxWidth()) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + drawKeys.forEach { key -> + val baseColor = colorForKey(key) + val dimmed = highlightedKey != null && highlightedKey != key + val swatchColor = if (dimmed) baseColor.copy(alpha = 0.35f) else baseColor + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.clickable { onToggleHighlight(key) } + ) { + Box(Modifier.size(10.dp).background(swatchColor, RoundedCornerShape(2.dp))) + Column { + Text(legendTitleFor(key), fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.6f else 0.95f)) + Text(legendMetricsFor(key), fontFamily = FontFamily.Monospace, fontSize = 9.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.45f else 0.75f)) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt index a332fca2..904bc067 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -58,6 +58,10 @@ object AppConstants { const val HIGH_NONCE_WARNING_THRESHOLD: Long = 1_000_000_000L } + object Verification { + const val QR_MAX_AGE_SECONDS: Long = 300L // 5 minutes + } + object Protocol { const val COMPRESSION_THRESHOLD_BYTES: Int = 100 } @@ -76,12 +80,12 @@ object AppConstants { const val SCAN_ON_DURATION_NORMAL_MS: Long = 8_000L const val SCAN_OFF_DURATION_NORMAL_MS: Long = 2_000L const val SCAN_ON_DURATION_POWER_SAVE_MS: Long = 2_000L - const val SCAN_OFF_DURATION_POWER_SAVE_MS: Long = 8_000L + const val SCAN_OFF_DURATION_POWER_SAVE_MS: Long = 28_000L const val SCAN_ON_DURATION_ULTRA_LOW_MS: Long = 1_000L - const val SCAN_OFF_DURATION_ULTRA_LOW_MS: Long = 10_000L + const val SCAN_OFF_DURATION_ULTRA_LOW_MS: Long = 29_000L const val MAX_CONNECTIONS_NORMAL: Int = 8 - const val MAX_CONNECTIONS_POWER_SAVE: Int = 4 - const val MAX_CONNECTIONS_ULTRA_LOW: Int = 2 + const val MAX_CONNECTIONS_POWER_SAVE: Int = 8 + const val MAX_CONNECTIONS_ULTRA_LOW: Int = 4 } object Nostr { @@ -115,6 +119,8 @@ object AppConstants { const val MESSAGE_DEDUP_TIMEOUT_MS: Long = 30_000L const val SYSTEM_EVENT_DEDUP_TIMEOUT_MS: Long = 5_000L const val ACTIVE_PEERS_NOTIFICATION_INTERVAL_MS: Long = 300_000L + const val ACTION_FORCE_FINISH: String = "com.bitchat.android.ACTION_FORCE_FINISH" + const val PERMISSION_FORCE_FINISH: String = "com.bitchat.android.permission.FORCE_FINISH" } object Media { diff --git a/app/src/main/java/info/guardianproject/arti/ArtiException.kt b/app/src/main/java/info/guardianproject/arti/ArtiException.kt new file mode 100644 index 00000000..bee87ff3 --- /dev/null +++ b/app/src/main/java/info/guardianproject/arti/ArtiException.kt @@ -0,0 +1,9 @@ +package info.guardianproject.arti + +/** + * Exception thrown by Arti operations. + * + * This exception is thrown when the native Arti library encounters + * an error during initialization, startup, or other operations. + */ +class ArtiException(message: String, cause: Throwable? = null) : Exception(message, cause) diff --git a/app/src/main/java/info/guardianproject/arti/ArtiLogListener.kt b/app/src/main/java/info/guardianproject/arti/ArtiLogListener.kt new file mode 100644 index 00000000..d21d3989 --- /dev/null +++ b/app/src/main/java/info/guardianproject/arti/ArtiLogListener.kt @@ -0,0 +1,17 @@ +package info.guardianproject.arti + +/** + * Listener interface for Arti log messages. + * + * This interface is called from the native Arti implementation whenever + * a log line is produced. It allows the application to monitor Tor's + * bootstrap progress and connection status. + */ +fun interface ArtiLogListener { + /** + * Called when Arti produces a log line. + * + * @param logLine The log message from Arti, or null if no message + */ + fun onLogLine(logLine: String?) +} diff --git a/app/src/main/java/info/guardianproject/arti/ArtiProxy.kt b/app/src/main/java/info/guardianproject/arti/ArtiProxy.kt new file mode 100644 index 00000000..a81350ad --- /dev/null +++ b/app/src/main/java/info/guardianproject/arti/ArtiProxy.kt @@ -0,0 +1,183 @@ +package info.guardianproject.arti + +import android.app.Application +import android.util.Log +import org.torproject.arti.ArtiNative +import java.io.File + +/** + * Arti Tor proxy implementation compatible with Guardian Project API. + * + * This class provides a wrapper around the custom-built Arti native library, + * maintaining API compatibility with Guardian Project's arti-mobile-ex library. + * + * Usage: + * ``` + * val proxy = ArtiProxy.Builder(application) + * .setSocksPort(9050) + * .setDnsPort(9051) + * .setLogListener { logLine -> Log.i("Arti", logLine ?: "") } + * .build() + * + * proxy.start() + * // ... use proxy ... + * proxy.stop() + * ``` + */ +class ArtiProxy private constructor( + private val application: Application, + private val socksPort: Int, + private val dnsPort: Int, + private val logListener: ArtiLogListener? +) { + companion object { + private const val TAG = "ArtiProxy" + } + + @Volatile + private var isRunning = false + + /** + * Start the Arti Tor proxy. + * + * This method: + * 1. Registers log callback + * 2. Initializes Arti runtime with data directory + * 3. Starts SOCKS proxy on configured port + * + * @throws ArtiException if initialization or startup fails + */ + fun start() { + if (isRunning) { + Log.w(TAG, "Arti already running") + return + } + + try { + logListener?.let { listener -> + Log.d(TAG, "Registering log callback") + ArtiNative.setLogCallback(listener) + } + + val dataDir = getDataDirectory() + Log.i(TAG, "Initializing Arti with data directory: $dataDir") + + val initResult = ArtiNative.initialize(dataDir.absolutePath) + if (initResult != 0) { + throw ArtiException("Failed to initialize Arti: error code $initResult") + } + + Log.i(TAG, "Starting SOCKS proxy on port $socksPort (DNS port: $dnsPort)") + val startResult = ArtiNative.startSocksProxy(socksPort) + when (startResult) { + 0 -> { + isRunning = true + Log.i(TAG, "Arti started successfully") + } + -1 -> throw ArtiException("Arti client not initialized") + -2 -> throw ArtiException("Tokio runtime not initialized") + -3 -> throw ArtiException("Failed to bind SOCKS proxy to port $socksPort (port already in use)") + else -> throw ArtiException("Failed to start SOCKS proxy: error code $startResult") + } + + } catch (e: Exception) { + Log.e(TAG, "Failed to start Arti", e) + if (e is ArtiException) { + throw e + } else { + throw ArtiException("Failed to start Arti: ${e.message}", e) + } + } + } + + /** + * Stop the Arti Tor proxy. + * + * This method gracefully shuts down the Tor client and SOCKS proxy. + */ + fun stop() { + if (!isRunning) { + Log.w(TAG, "Arti not running") + return + } + + try { + Log.i(TAG, "Stopping Arti...") + val stopResult = ArtiNative.stop() + if (stopResult != 0) { + Log.w(TAG, "Stop returned error code: $stopResult") + } + + isRunning = false + Log.i(TAG, "Arti stopped successfully") + + } catch (e: Exception) { + Log.e(TAG, "Error stopping Arti", e) + } + } + + /** + * Get or create Arti data directory. + * + * Directory structure: + * - {app_data}/arti/cache - Tor directory cache + * - {app_data}/arti/state - Tor persistent state + */ + private fun getDataDirectory(): File { + val artiDir = File(application.filesDir, "arti") + if (!artiDir.exists()) { + artiDir.mkdirs() + } + + File(artiDir, "cache").apply { if (!exists()) mkdirs() } + File(artiDir, "state").apply { if (!exists()) mkdirs() } + + return artiDir + } + + /** + * Builder for ArtiProxy instances. + * + * Provides fluent API for configuring proxy settings before creation. + */ + class Builder(private val application: Application) { + private var socksPort: Int = 9050 + private var dnsPort: Int = 9051 + private var logListener: ArtiLogListener? = null + + /** + * Set SOCKS proxy port. + * @param port Port number (default: 9050) + * @return this Builder for chaining + */ + fun setSocksPort(port: Int) = apply { + this.socksPort = port + } + + /** + * Set DNS port. + * @param port Port number (default: 9051) + * @return this Builder for chaining + */ + fun setDnsPort(port: Int) = apply { + this.dnsPort = port + } + + /** + * Set log listener for Arti log messages. + * @param listener Callback for log lines + * @return this Builder for chaining + */ + fun setLogListener(listener: ArtiLogListener) = apply { + this.logListener = listener + } + + /** + * Build and return the configured ArtiProxy instance. + * @return Configured ArtiProxy (not yet started) + */ + fun build(): ArtiProxy { + return ArtiProxy(application, socksPort, dnsPort, logListener) + } + } +} diff --git a/app/src/main/java/org/torproject/arti/ArtiNative.kt b/app/src/main/java/org/torproject/arti/ArtiNative.kt new file mode 100644 index 00000000..13eca6db --- /dev/null +++ b/app/src/main/java/org/torproject/arti/ArtiNative.kt @@ -0,0 +1,54 @@ +package org.torproject.arti + +import info.guardianproject.arti.ArtiLogListener + +/** + * JNI wrapper for custom-built Arti (Tor implementation in Rust) + * + * This class provides native bindings to libarti_android.so compiled from + * the latest Arti source with rustls (no OpenSSL dependency). + * + * Features: + * - Latest Arti v1.7.0 code + * - 16KB page size support (Google Play Nov 2025 ready) + * - Onion service client support + * - Pure Rust TLS (rustls) + */ +object ArtiNative { + + init { + System.loadLibrary("arti_android") + } + + /** + * Get Arti version string + * @return Version string from native library + */ + external fun getVersion(): String + + /** + * Set log callback for Arti logs + * @param callback Callback object with onLogLine(String?) method + */ + external fun setLogCallback(callback: ArtiLogListener) + + /** + * Initialize Arti runtime + * @param dataDir Directory for Arti state/cache + * @return 0 on success, error code otherwise + */ + external fun initialize(dataDir: String): Int + + /** + * Start SOCKS proxy on specified port + * @param port Port number for SOCKS proxy (e.g., 9050) + * @return 0 on success, error code otherwise + */ + external fun startSocksProxy(port: Int): Int + + /** + * Stop Arti and cleanup + * @return 0 on success, error code otherwise + */ + external fun stop(): Int +} \ No newline at end of file diff --git a/app/src/main/jniLibs/arm64-v8a/libarti_android.so b/app/src/main/jniLibs/arm64-v8a/libarti_android.so new file mode 100755 index 00000000..ba4f614b Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libarti_android.so differ diff --git a/app/src/main/jniLibs/x86_64/libarti_android.so b/app/src/main/jniLibs/x86_64/libarti_android.so new file mode 100755 index 00000000..9a1e27b6 Binary files /dev/null and b/app/src/main/jniLibs/x86_64/libarti_android.so differ diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 94414ce5..7ddae251 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -362,4 +362,41 @@ كن أول من يضيف ملاحظة لهذا المكان. إغلاق أضف ملاحظة لهذا المكان + الشبكة تعمل — %1$d أقران + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index a9f82658..24f94630 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -349,4 +349,41 @@ %d জন + মেশ চলছে — %1$d পিয়ার + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 786c9dcb..c02bbb37 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -70,7 +70,7 @@ Aus Favoriten entfernen Lesezeichen hinzufügen - + zurück Kanal: %1$s verlassen @@ -170,7 +170,7 @@ Online‑Geohash‑Kanäle Ende‑zu‑Ende‑Verschlüsselung - + Bild %1$d von %2$d Bild nicht verfügbar Bild in "Downloads" gespeichert @@ -363,4 +363,41 @@ sei der Erste, der hier eine Notiz hinterlässt. schließen füge eine Notiz zu diesem Ort hinzu + Mesh läuft — %1$d Peers + + verifizieren + scannen zur verifizierung + anderen qr scannen + anderen qr scannen + mein qr zeigen + verifizierung entfernen + qr nicht verfügbar + kamera-berechtigung nötig + kamera aktivieren + verifizierungs-url einfügen + validieren + verifizierung angefordert + sicherheitsüberprüfung + ihr fingerabdruck + dein fingerabdruck + handshake ausstehend + privatchat öffnen + verschlüsselt & verifiziert + verschlüsselt + handshake + handshake fehlgeschlagen + unverschlüsselt + verifiziert + du hast diese person verifiziert. + nicht verifiziert + vergleiche fingerabdrücke mit %1$s. + als verifiziert markieren + handshake starten + kopieren + Gegenseitige Verifizierung + Du und %1$s habt euch verifiziert + gegenseitige verifizierung mit %1$s + Verifiziert + Du hast %1$s verifiziert + verifiziert %1$s diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index acf3cb92..eae48a0e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -362,4 +362,41 @@ sé el primero en añadir una para este sitio. cerrar agrega una nota para este lugar + Mesh en ejecución — %1$d pares + + verificar + escanear para verificarme + escanear qr de otro + escanear qr de otro + mostrar mi qr + eliminar verificación + qr no disponible + se necesita permiso de cámara + activar cámara + pegar url de verificación + validar + verificación solicitada + verificación de seguridad + su huella digital + tu huella digital + intercambio pendiente + abrir chat privado para ver + cifrado y verificado + cifrado + negociando + falló negociación + no cifrado + verificado + has verificado esta identidad. + no verificado + compara huellas con %1$s por canal seguro. + marcar verificado + iniciar handshake + copiar + Verificación mutua + Tú y %1$s se verificaron + verificación mutua con %1$s + Verificado + Verificaste a %1$s + verificado %1$s diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 2896a97d..1473c5fd 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -349,4 +349,41 @@ %d نفر + مش در حال اجرا — %1$d همتا + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index bb150d93..0a7dc2e3 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -362,4 +362,41 @@ %d tao + Tumatakbo ang Mesh — %1$d na peer + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index b1936bce..01f480d2 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -70,7 +70,7 @@ Retirer des favoris Ajouter un signet - + retour canal : %1$s quitter @@ -165,7 +165,7 @@ Canaux Geohash en ligne Chiffrement de bout en bout - + Image %1$d sur %2$d Image indisponible Image enregistrée dans "Téléchargements" @@ -376,4 +376,41 @@ soyez le premier à en ajouter pour cet endroit. fermer ajoutez une note pour cet endroit + Mesh actif — %1$d pairs + + vérifier + scanner pour me vérifier + scanner un autre qr + scanner un autre qr + afficher mon qr + supprimer la vérification + qr indisponible + permission caméra requise + activer caméra + coller url de vérification + valider + vérification demandée + vérification de sécurité + leur empreinte + votre empreinte + négociation en cours + ouvrir un chat privé + chiffré & vérifié + chiffré + négociation + échec négociation + non chiffré + vérifié + vous avez vérifié cette personne. + non vérifié + comparez avec %1$s via canal sûr. + marquer vérifié + lancer handshake + copier + Vérification mutuelle + Vous et %1$s êtes vérifiés + vérification mutuelle avec %1$s + Vérifié + Vous avez vérifié %1$s + vérifié %1$s diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index a15f87af..deb22711 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -14,5 +14,42 @@ היה הראשון להוסיף הערה למקום זה. סגור הוסף הערה למקום זה + רשת Mesh פועלת — %1$d עמיתים + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 20981383..acaa54b6 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -70,7 +70,7 @@ पसंदीदा से हटाएँ बुकमार्क जोड़ें - + वापस चैनल: %1$s छोड़ें @@ -362,4 +362,41 @@ इस स्थान के लिए पहला नोट जोड़ें। बंद करें इस स्थान के लिए एक नोट जोड़ें + मेश चल रहा है — %1$d पीयर्स + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 00f8e7cf..18c85a5b 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -70,7 +70,7 @@ Hapus dari favorit Tambah bookmark - + kembali Channel: %1$s keluar @@ -170,7 +170,7 @@ Channel Geohash Online Enkripsi end-to-end - + Gambar %1$d dari %2$d Gambar tidak tersedia Gambar disimpan ke \"Downloads\" @@ -362,4 +362,41 @@ jadilah yang pertama menambahkan catatan untuk tempat ini. tutup tambahkan catatan untuk tempat ini + Mesh berjalan — %1$d peer + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index d086ba3e..07fb5a76 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -396,4 +396,41 @@ sii il primo ad aggiungerne una per questo posto. chiudi aggiungi una nota per questo luogo + Mesh in esecuzione — %1$d peer + + verifica + scansiona per verificarmi + scansiona qr altrui + scansiona qr altrui + mostra mio qr + rimuovi verifica + qr non disponibile + permesso fotocamera necessario + abilita fotocamera + incolla url verifica + convalida + verifica richiesta + verifica sicurezza + loro impronta + tua impronta + handshake in corso + apri chat privata + criptato & verificato + criptato + handshake + handshake fallito + non criptato + verificato + hai verificato questa persona. + non verificato + confronta con %1$s via canale sicuro. + segna verificato + avvia handshake + copia + Verifica reciproca + Tu e %1$s vi siete verificati + verifica reciproca con %1$s + Verificato + Hai verificato %1$s + verificato %1$s diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index be41d3d4..f9c4fd64 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -70,7 +70,7 @@ お気に入りから削除 ブックマークに追加 - + 戻る チャンネル: %1$s 退出 @@ -170,7 +170,7 @@ オンライン Geohash チャンネル エンドツーエンド暗号化 - + 画像 %1$d / %2$d 画像は利用できません 画像を「ダウンロード」に保存しました @@ -362,4 +362,41 @@ この場所の最初のメモを追加しましょう。 閉じる この場所へのメモを追加 + メッシュ実行中 — %1$d ピア + + 検証 + 私を検証するためにスキャン + 他人のQRをスキャン + 他人のQRをスキャン + QRコードを表示 + 検証を削除 + QR利用不可 + QRスキャンにカメラ権限が必要です + カメラを有効化 + 検証URLを貼り付け + 検証する + 検証をリクエストしました + セキュリティ検証 + 相手の指紋 + あなたの指紋 + ハンドシェイク保留中 + 指紋を見るにはプライベートチャットを開いてください + 暗号化&検証済み + 暗号化済み + ハンドシェイク中 + ハンドシェイク失敗 + 未暗号化 + 検証済み + この人物の身元を検証しました。 + 未検証 + 安全な経路で %1$s と指紋を比較してください。 + 検証済みにする + ハンドシェイク開始 + コピー + 相互検証 + あなたと %1$s は相互に検証しました + %1$s と相互検証しました + 検証済み + %1$s を検証しました + %1$s を検証しました diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index dbf16225..db24c8a9 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -70,7 +70,7 @@ რჩეულებიდან წაშლა სანიშნეს დამატება - + უკან არხი: %1$s გასვლა @@ -170,7 +170,7 @@ ონლაინ geohash არხები ბოლო-მდე დაშიფვრა - + სურათი %1$d %2$d-დან სურათი მიუწვდომელია სურათი შენახულია "ჩამოტვირთვებში" @@ -349,4 +349,41 @@ %d ადამიანი + Mesh გაშვებულია — %1$d პირები + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 3163fa0c..0510fb7d 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -70,7 +70,7 @@ 즐겨찾기에서 제거 북마크 추가 - + 뒤로 채널: %1$s 나가기 @@ -170,7 +170,7 @@ 온라인 geohash 채널 종단간 암호화 - + 이미지 %1$d / %2$d 이미지를 사용할 수 없음 이미지가 \"Downloads\"에 저장됨 @@ -362,4 +362,41 @@ 이 장소에 첫 번째 노트를 추가해 보세요. 닫기 이 장소에 노트 추가 + 메시 실행 중 — %1$d 피어 + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml index 8d359165..5fc53577 100644 --- a/app/src/main/res/values-mg/strings.xml +++ b/app/src/main/res/values-mg/strings.xml @@ -70,7 +70,7 @@ Hanesorana amin\'ny ankafizina Hanampy bookmark - + miverina fantsona: %1$s hiala @@ -170,7 +170,7 @@ Fantsona Geohash an-tserasera Fandokoana Farany amin\'ny Farany - + Sary %1$d amin\'ny %2$d Tsy misy sary Sary voatahiry ao amin\'ny Downloads @@ -376,4 +376,41 @@ Olona %d + Mandeha ny Mesh — %1$d peers + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 21d90e30..0e6dd945 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -1,5 +1,42 @@ + Mesh sedang berjalan — %1$d rakan + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index 80deea92..6632f0dc 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -362,4 +362,41 @@ %d जना + मेश चलिरहेको छ — %1$d पियर्स + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 8d4d3096..7d19b52a 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -70,7 +70,7 @@ Verwijderen uit favorieten Bladwijzer toevoegen - + terug kanaal: %1$s verlaten @@ -170,7 +170,7 @@ Online geohash-kanalen End-to-end-versleuteling - + Afbeelding %1$d van %2$d Afbeelding niet beschikbaar Afbeelding opgeslagen in Downloads @@ -338,7 +338,7 @@ Deelnemen Annuleren - + @ bitchat/ · ⧉ @@ -394,4 +394,41 @@ wees de eerste die een notitie voor deze plek toevoegt. sluiten voeg een notitie toe voor deze plek + Mesh actief — %1$d peers + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml index 2c3b7729..3f08aa32 100644 --- a/app/src/main/res/values-pa-rPK/strings.xml +++ b/app/src/main/res/values-pa-rPK/strings.xml @@ -70,7 +70,7 @@ پسندیدہ توں ہٹاؤ بُک مارک پاؤ - + واپس چینل: %1$s باہر آؤ @@ -170,7 +170,7 @@ آن لائن geohash چینل اینڈ ٹو اینڈ انکرپشن - + تصویر %1$d وچوں %2$d تصویر دستیاب نئیں تصویر \"Downloads\" وچ سیو ہو گئی @@ -349,4 +349,41 @@ %d بندے + میش چل رہا ہے — %1$d ساتھی + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 492b0af0..56cd087a 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -14,5 +14,42 @@ bądź pierwszy, który doda notatkę dla tego miejsca. zamknij dodaj notatkę dla tego miejsca + Mesh działa — %1$d peerów + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 7d1998be..e9686f99 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -70,7 +70,7 @@ Remover dos favoritos Adicionar favorito - + voltar Canal: %1$s sair @@ -170,7 +170,7 @@ Canais geohash online Criptografia de ponta a ponta - + Imagem %1$d de %2$d Imagem indisponível Imagem salva em "Downloads" @@ -362,4 +362,41 @@ seja o primeiro a adicionar uma para este local. fechar adicione uma nota para este local + Mesh rodando — %1$d pares + + verificar + digitalize para verificar-me + digitalizar outro qr + digitalizar outro qr + mostrar meu qr + remover verificação + qr indisponível + permissão de câmera necessária + ativar câmera + colar url de verificação + validar + verificação solicitada + verificação de segurança + impressão digital deles + sua impressão digital + handshake pendente + abra chat privado para ver + criptografado e verificado + criptografado + handshake + falha no handshake + não criptografado + verificado + você verificou esta pessoa. + não verificado + compare com %1$s em canal seguro. + marcar verificado + iniciar handshake + copiar + Verificação mútua + Você e %1$s verificaram-se + verificação mútua com %1$s + Verificado + Você verificou %1$s + verificou %1$s diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 207810b4..8f9baf72 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -362,4 +362,41 @@ seja o primeiro a adicionar uma para este local. fechar adicione uma nota para este local + Mesh em execução — %1$d pares + + verificar + digitalize para verificar-me + digitalizar outro qr + digitalizar outro qr + mostrar meu qr + remover verificação + qr indisponível + permissão de câmera necessária + ativar câmera + colar url de verificação + validar + verificação solicitada + verificação de segurança + impressão digital deles + sua impressão digital + handshake pendente + abra chat privado para ver + criptografado e verificado + criptografado + handshake + falha no handshake + não criptografado + verificado + você verificou esta pessoa. + não verificado + compare com %1$s em canal seguro. + marcar verificado + iniciar handshake + copiar + Verificação mútua + Você e %1$s verificaram-se + verificação mútua com %1$s + Verificado + Você verificou %1$s + verificou %1$s diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 85fd0ba4..cad2a0d1 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -352,4 +352,41 @@ станьте первым, кто добавит заметку для этого места. закрыть добавьте заметку для этого места + Mesh запущен — %1$d пиров + + проверить + сканируй для проверки + сканировать чужой QR + сканировать чужой QR + показать мой QR + удалить проверку + QR недоступен + нужен доступ к камере + включить камеру + вставить URL проверки + проверить + запрос проверки + проверка безопасности + их отпечаток + ваш отпечаток + ожидание рукопожатия + откройте личный чат + шифровано и проверено + шифровано + рукопожатие + сбой рукопожатия + не шифровано + проверено + вы проверили этого человека. + не проверено + сравните с %1$s. + отметить проверенным + начать рукопожатие + копировать + Взаимная проверка + Вы и %1$s проверили друг друга + взаимная проверка с %1$s + Проверено + Вы проверили %1$s + проверен %1$s diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 62d8ef18..ef3307e3 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -350,4 +350,41 @@ var först med att lägga till en anteckning för den här platsen. stäng lägg till en anteckning för den här platsen + Mesh körs — %1$d peers + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 76893af8..98ceef9c 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -1,5 +1,42 @@ + மெஷ் இயங்குகிறது — %1$d பியர்ஸ் + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index c1cbedbf..343f6885 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -70,7 +70,7 @@ เอาออกจากรายการโปรด เพิ่มที่คั่นหน้า - + กลับ ช่อง: %1$s ออก @@ -170,7 +170,7 @@ ช่อง geohash ออนไลน์ การเข้ารหัสแบบปลายทางถึงปลายทาง - + รูปภาพ %1$d จาก %2$d ไม่สามารถใช้รูปภาพได้ บันทึกรูปภาพไว้ใน "ดาวน์โหลด" แล้ว @@ -349,4 +349,41 @@ %d คน + Mesh กำลังทำงาน — %1$d เพื่อน + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index cb728f5c..1209074c 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -350,4 +350,41 @@ bu yer için ilk notu ekleyen siz olun. kapat bu yer için bir not ekleyin + Mesh çalışıyor — %1$d eş + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index c644c198..5545b546 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1,5 +1,42 @@ + Mesh працює — %1$d пірів + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index f6a5832c..1626a041 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -362,4 +362,41 @@ اس جگہ کے لیے پہلا نوٹ شامل کریں۔ بند کریں اس جگہ کے لیے ایک نوٹ شامل کریں + میش چل رہا ہے — %1$d ساتھی + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 7a7df41f..afc227b6 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -70,7 +70,7 @@ Xóa khỏi yêu thích Thêm bookmark - + quay lại Kênh: %1$s rời khỏi @@ -170,7 +170,7 @@ Kênh Geohash trực tuyến Mã hóa đầu cuối - + Hình ảnh %1$d trong %2$d Hình ảnh không có sẵn Hình ảnh đã lưu vào \"Downloads\" @@ -349,4 +349,41 @@ %d người + Mesh đang chạy — %1$d ngang hàng + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 6ab11776..9f4b17d3 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -14,5 +14,42 @@ 成为第一个为此地点添加笔记的人。 关闭 为此地点添加一条笔记 + Mesh 运行中 — %1$d 个节点 + + 验证 + 扫描以验证我 + 扫描他人的QR码 + 扫描他人的QR码 + 显示我的QR码 + 移除验证 + QR码不可用 + 需要相机权限扫描QR码 + 启用相机 + 粘贴验证链接 + 验证 + 已请求验证 + 安全验证 + 对方指纹 + 你的指纹 + 握手中 + 打开私聊查看指纹 + 已加密且已验证 + 已加密 + 握手中 + 握手失败 + 未加密 + 已验证 + 你已验证此人身份。 + 未验证 + 请通过安全渠道与 %1$s 比对指纹。 + 标记为已验证 + 开始握手 + 复制 + 相互验证 + 你和 %1$s 已相互验证 + 与 %1$s 相互验证 + 已验证 + 你已验证 %1$s + 已验证 %1$s diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index b119edfb..a347d5b0 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -14,5 +14,42 @@ 成為第一個為此地點新增筆記的人。 關閉 為此地點新增一則筆記 + Mesh 運行中 — %1$d 個節點 + + 验证 + 扫描以验证我 + 扫描他人的QR码 + 扫描他人的QR码 + 显示我的QR码 + 移除验证 + QR码不可用 + 需要相机权限扫描QR码 + 启用相机 + 粘贴验证链接 + 验证 + 已请求验证 + 安全验证 + 对方指纹 + 你的指纹 + 握手中 + 打开私聊查看指纹 + 已加密且已验证 + 已加密 + 握手中 + 握手失败 + 未加密 + 已验证 + 你已验证此人身份。 + 未验证 + 请通过安全渠道与 %1$s 比对指纹。 + 标记为已验证 + 开始握手 + 复制 + 相互验证 + 你和 %1$s 已相互验证 + 与 %1$s 相互验证 + 已验证 + 你已验证 %1$s + 已验证 %1$s diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 1ae11941..3a4a628f 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -375,4 +375,41 @@ 成为第一个为此地点添加笔记的人。 关闭 为此地点添加一条笔记 + Mesh 运行中 — %1$d 个节点 + + 验证 + 扫描以验证我 + 扫描他人的QR码 + 扫描他人的QR码 + 显示我的QR码 + 移除验证 + QR码不可用 + 需要相机权限扫描QR码 + 启用相机 + 粘贴验证链接 + 验证 + 已请求验证 + 安全验证 + 对方指纹 + 你的指纹 + 握手中 + 打开私聊查看指纹 + 已加密且已验证 + 已加密 + 握手中 + 握手失败 + 未加密 + 已验证 + 你已验证此人身份。 + 未验证 + 请通过安全渠道与 %1$s 比对指纹。 + 标记为已验证 + 开始握手 + 复制 + 相互验证 + 你和 %1$s 已相互验证 + 与 %1$s 相互验证 + 已验证 + 你已验证 %1$s + 已验证 %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cc4903f6..9845fd85 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -61,12 +61,17 @@ and %1$d more locations and %1$d more + + Mesh Background Service + Keeps the Bluetooth mesh running in the background + Mesh running — %1$d peers + Add to favorites Remove from favorites Add bookmark - + back channel: %1$s leave @@ -152,6 +157,9 @@ Remove bookmark Teleport Selected + Quit bitchat + run in background + keep mesh active when app is closed (foreground service) Leave channel Reachable via Nostr Offline favorite @@ -257,6 +265,44 @@ none disconnect recent scan results + + verify + scan to verify me + scan someone elses qr + scan someone elses qr + show my qr + remove verification + qr unavailable + camera permission is needed to scan qr codes + enable camera + paste verification url + validate + verification requested + security verification + their fingerprint + your fingerprint + handshake pending + open a private chat to view fingerprints + encrypted & verified + encrypted + handshaking + handshake failed + not encrypted + verified + you have verified this persons identity. + not verified + compare these fingerprints with %1$s using a secure channel. + mark as verified + start handshake + copy + + Mutual verification + You and %1$s verified each other + mutual verification with %1$s + Verified + You verified %1$s + verified %1$s + connect debug console clear @@ -274,6 +320,15 @@ bitchat does NOT track your location.\n\nLocation services are required for Bluetooth scanning and for the Geohash chat feature. bitchat needs location services for: • Bluetooth device scanning\n• Discovering nearby users on mesh network\n• Geohash chat feature\n• No tracking or location collection + Background Location Recommended + optional, improves mesh reliability + Android recommends background location so bitchat can scan for nearby devices while the app is not open. This keeps the mesh alive after reboot. + When settings opens, choose "Allow all the time". + bitchat uses background location for: + - scan for nearby devices while the app is closed\n- reconnect after reboot\n- keep the mesh running in the background + We NEVER collect or store your location. Your privacy is safe. + Allow Background Location + Continue without background location Open Location Settings Check Again Location Services Unavailable @@ -359,6 +414,8 @@ Allow bitchat to connect to nearby devices Required by Android to discover nearby bitchat users via Bluetooth bitchat needs this to scan for nearby devices + Recommended to scan for nearby devices while the app is in the background + Allow background location access Receive notifications when you receive private messages Allow bitchat to send you notifications Disable battery optimization to ensure bitchat runs reliably in the background and maintains mesh network connections @@ -367,6 +424,7 @@ Nearby Devices Precise Location + Background Location Microphone Notifications Battery Optimization @@ -378,8 +436,9 @@ Password Join Cancel + Tor not available in this build - + and %d more and %d more diff --git a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt index 6afdf6d6..f1b33d32 100644 --- a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt @@ -5,7 +5,9 @@ import androidx.test.core.app.ApplicationProvider import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.model.BitchatMessage import junit.framework.TestCase.assertEquals - +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.junit.Before import org.junit.Ignore import org.junit.Test @@ -18,7 +20,10 @@ import java.util.Date @RunWith(RobolectricTestRunner::class) class CommandProcessorTest() { private val context: Context = ApplicationProvider.getApplicationContext() - private val chatState = ChatState() + @OptIn(ExperimentalCoroutinesApi::class) + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) + private val chatState = ChatState(scope = testScope) private lateinit var commandProcessor: CommandProcessor val messageManager: MessageManager = MessageManager(state = chatState) @@ -26,7 +31,7 @@ class CommandProcessorTest() { state = chatState, messageManager = messageManager, dataManager = DataManager(context = context), - coroutineScope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Main.immediate) + coroutineScope = testScope ) private val meshService: BluetoothMeshService = mock() diff --git a/app/src/test/kotlin/com/bitchat/FileTransferTest.kt b/app/src/test/kotlin/com/bitchat/FileTransferTest.kt index 1798f5fe..31131540 100644 --- a/app/src/test/kotlin/com/bitchat/FileTransferTest.kt +++ b/app/src/test/kotlin/com/bitchat/FileTransferTest.kt @@ -8,12 +8,14 @@ import org.junit.Assert.assertNotNull import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.ConscryptMode import java.io.File import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.Date @RunWith(RobolectricTestRunner::class) +@ConscryptMode(ConscryptMode.Mode.OFF) // Disable Conscrypt to avoid native library loading issues class FileTransferTest { @Test diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt new file mode 100644 index 00000000..aa629ca7 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt @@ -0,0 +1,286 @@ +package com.bitchat.android.mesh + +import android.os.Build +import com.bitchat.android.crypto.EncryptionService +import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.* +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) +class SecurityManagerTest { + + private lateinit var securityManager: SecurityManager + private lateinit var fakeEncryptionService: FakeEncryptionService + private lateinit var mockDelegate: SecurityManagerDelegate + + private val myPeerID = "1111222233334444" + private val otherPeerID = "aaaabbbbccccdddd" + private val unknownPeerID = "9999888877776666" + + private val dummyPayload = "Hello World".toByteArray() + private val validSignature = ByteArray(64) { 1 } + private val invalidSignature = ByteArray(64) { 0 } + + // Key pairs (using dummy bytes for mock verification) + private val otherSigningKey = ByteArray(32) { 0xA } + private val otherNoiseKey = ByteArray(32) { 0xB } + + // Fake implementation to bypass initialization issues in tests + open class FakeEncryptionService : EncryptionService(RuntimeEnvironment.getApplication()) { + var shouldVerify: Boolean = true + var lastVerifySignature: ByteArray? = null + var lastVerifyKey: ByteArray? = null + + override fun initialize() { + // Do nothing to avoid KeyStore access in tests + } + + override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean { + lastVerifySignature = signature + lastVerifyKey = publicKeyBytes + + // Simple logic: if configured to verify, check if signature matches validSignature + // We use the signature bytes passed in setup() + if (shouldVerify) { + return signature.contentEquals(byteArrayOf(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1)) + } + return false + } + } + + @Before + fun setup() { + fakeEncryptionService = FakeEncryptionService() + mockDelegate = mock() + + securityManager = SecurityManager(fakeEncryptionService, myPeerID) + securityManager.delegate = mockDelegate + } + + @After + fun tearDown() { + if (::securityManager.isInitialized) { + securityManager.shutdown() + } + } + + @Test + fun `validatePacket - rejects packet with missing signature`() { + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 10u, + senderID = otherPeerID, + payload = dummyPayload + ) + packet.signature = null + + val result = securityManager.validatePacket(packet, otherPeerID) + + assertFalse("Packet without signature should be rejected", result) + } + + @Test + fun `validatePacket - rejects packet with invalid signature`() { + setupKnownPeer(otherPeerID, otherSigningKey) + + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 10u, + senderID = otherPeerID, + payload = dummyPayload + ) + packet.signature = invalidSignature + + val result = securityManager.validatePacket(packet, otherPeerID) + + assertFalse("Packet with invalid signature should be rejected", result) + } + + @Test + fun `validatePacket - rejects packet from unknown peer (no key)`() { + whenever(mockDelegate.getPeerInfo(unknownPeerID)).thenReturn(null) + + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 10u, + senderID = unknownPeerID, + payload = dummyPayload + ) + packet.signature = validSignature + + val result = securityManager.validatePacket(packet, unknownPeerID) + + assertFalse("Packet from unknown peer should be rejected (cannot verify signature)", result) + } + + @Test + fun `validatePacket - accepts packet with valid signature from known peer`() { + setupKnownPeer(otherPeerID, otherSigningKey) + + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 10u, + senderID = otherPeerID, + payload = dummyPayload + ) + packet.signature = validSignature + + val result = securityManager.validatePacket(packet, otherPeerID) + + assertTrue("Valid signed packet from known peer should be accepted", result) + } + + @Test + fun `validatePacket - accepts ANNOUNCE packet from unknown peer (extracts key)`() { + val announcement = IdentityAnnouncement( + nickname = "New User", + noisePublicKey = otherNoiseKey, + signingPublicKey = otherSigningKey + ) + val payload = announcement.encode()!! + + val packet = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = 10u, + senderID = unknownPeerID, + payload = payload + ) + packet.signature = validSignature + + whenever(mockDelegate.getPeerInfo(unknownPeerID)).thenReturn(null) + + val result = securityManager.validatePacket(packet, unknownPeerID) + + assertTrue("ANNOUNCE from unknown peer should be accepted (key extracted from payload)", result) + // Verify we used the correct key + assertTrue("Should have used extracted key for verification", + fakeEncryptionService.lastVerifyKey.contentEquals(otherSigningKey)) + } + + @Test + fun `validatePacket - rejects ANNOUNCE packet with invalid signature`() { + val announcement = IdentityAnnouncement( + nickname = "New User", + noisePublicKey = otherNoiseKey, + signingPublicKey = otherSigningKey + ) + val payload = announcement.encode()!! + + val packet = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = 10u, + senderID = unknownPeerID, + payload = payload + ) + packet.signature = invalidSignature + + val result = securityManager.validatePacket(packet, unknownPeerID) + + assertFalse("ANNOUNCE with invalid signature should be rejected", result) + } + + @Test + fun `validatePacket - rejects ANNOUNCE packet with malformed payload`() { + val packet = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = 10u, + senderID = unknownPeerID, + payload = byteArrayOf(0x00, 0x01, 0x02) + ) + packet.signature = validSignature + + val result = securityManager.validatePacket(packet, unknownPeerID) + + assertFalse("ANNOUNCE with malformed payload should be rejected (cannot extract key)", result) + } + + @Test + fun `validatePacket - ignores own packets`() { + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 10u, + senderID = myPeerID, + payload = dummyPayload + ) + packet.signature = null + + val result = securityManager.validatePacket(packet, myPeerID) + + assertFalse("Own packets should return false (skipped)", result) + } + + @Test + fun `validatePacket - detects duplicates`() { + setupKnownPeer(otherPeerID, otherSigningKey) + + val packet = BitchatPacket( + type = MessageType.MESSAGE.value, + ttl = 10u, + senderID = otherPeerID, + payload = dummyPayload + ) + packet.signature = validSignature + + val result1 = securityManager.validatePacket(packet, otherPeerID) + assertTrue("First packet should be accepted", result1) + + val result2 = securityManager.validatePacket(packet, otherPeerID) + assertFalse("Duplicate packet should be rejected", result2) + } + + @Test + fun `validatePacket - handles ANNOUNCE duplicates correctly`() { + val announcement = IdentityAnnouncement( + nickname = "New User", + noisePublicKey = otherNoiseKey, + signingPublicKey = otherSigningKey + ) + val payload = announcement.encode()!! + + // 1. Initial Announce (Fresh) + val packet1 = BitchatPacket( + type = MessageType.ANNOUNCE.value, + ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS, // 7u + senderID = unknownPeerID, + payload = payload + ) + packet1.signature = validSignature + + whenever(mockDelegate.getPeerInfo(unknownPeerID)).thenReturn(null) + + assertTrue("First ANNOUNCE should be accepted", securityManager.validatePacket(packet1, unknownPeerID)) + + // 2. Relayed Duplicate (Lower TTL) + val packet2 = packet1.copy(ttl = (com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS - 1u).toUByte()) + assertFalse("Relayed duplicate ANNOUNCE should be rejected", securityManager.validatePacket(packet2, unknownPeerID)) + + // 3. Direct Duplicate (Max TTL) + val packet3 = packet1.copy(ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS) + assertTrue("Fresh duplicate ANNOUNCE should be accepted", securityManager.validatePacket(packet3, unknownPeerID)) + } + + private fun setupKnownPeer(peerID: String, signingKey: ByteArray) { + val info = PeerInfo( + id = peerID, + nickname = "Test User", + isConnected = true, + isDirectConnection = true, + noisePublicKey = ByteArray(32), + signingPublicKey = signingKey, + isVerifiedNickname = false, + lastSeen = System.currentTimeMillis() + ) + whenever(mockDelegate.getPeerInfo(peerID)).thenReturn(info) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e6dda224..b6a5411a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ agp = "8.10.1" kotlin = "2.2.0" compileSdk = "35" minSdk = "26" # API 26 for proper BLE support -targetSdk = "34" +targetSdk = "35" # AndroidX Core core-ktx = "1.16.0" @@ -45,6 +45,13 @@ gms-location = "21.3.0" # Security security-crypto = "1.1.0-beta01" +# QR +zxing-core = "3.5.4" + +# CameraX / ML Kit +camerax = "1.5.2" +mlkit-barcode = "17.3.0" + # Testing junit = "4.13.2" androidx-test-ext = "1.2.1" @@ -54,9 +61,12 @@ mockito-inline = "4.1.0" roboelectric = "4.15" kotlinx-coroutines-test = "1.6" +lifecycle-process = "2.8.7" + [libraries] # AndroidX Core androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" } +androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle-process" } androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle-runtime" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } @@ -68,12 +78,10 @@ androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } androidx-compose-material3 = { module = "androidx.compose.material3:material3" } -androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" } androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } # Lifecycle androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle-runtime" } -androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycle-runtime" } # Navigation androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" } @@ -106,6 +114,15 @@ gms-location = { module = "com.google.android.gms:play-services-location", versi # Security androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "security-crypto" } +# QR +zxing-core = { module = "com.google.zxing:core", version.ref = "zxing-core" } + +# CameraX / ML Kit +androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" } +androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" } +androidx-camera-compose = { module = "androidx.camera:camera-compose", version.ref = "camerax" } +mlkit-barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "mlkit-barcode" } + # Testing junit = { module = "junit:junit", version.ref = "junit" } androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext" } @@ -130,14 +147,12 @@ compose = [ "androidx-compose-ui-graphics", "androidx-compose-ui-tooling-preview", "androidx-compose-material3", - "androidx-compose-runtime-livedata", "androidx-compose-material-icons-extended" ] lifecycle = [ "androidx-lifecycle-runtime-ktx", "androidx-lifecycle-viewmodel-compose", - "androidx-lifecycle-livedata-ktx" ] cryptography = [ diff --git a/tools/arti-build/ARTI_VERSION b/tools/arti-build/ARTI_VERSION new file mode 100644 index 00000000..c68d1ff0 --- /dev/null +++ b/tools/arti-build/ARTI_VERSION @@ -0,0 +1 @@ +arti-v1.7.0 diff --git a/tools/arti-build/Cargo.toml b/tools/arti-build/Cargo.toml new file mode 100644 index 00000000..05664f90 --- /dev/null +++ b/tools/arti-build/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "arti-android-wrapper" +version = "1.7.0" +edition = "2021" + +[workspace] +# Empty workspace table to exclude from parent workspace + +[lib] +crate-type = ["cdylib"] +name = "arti_android" + +[dependencies] +arti-client = { path = "../crates/arti-client", default-features = false, features = ["tokio", "rustls", "compression", "bridge-client", "onion-service-client", "static-sqlite"] } +tor-rtcompat = { path = "../crates/tor-rtcompat", features = ["tokio", "rustls"] } +jni = "0.21" +tokio = { version = "1", features = ["full"] } +anyhow = "1.0" + +[profile.release] +opt-level = "z" # Optimize for size +lto = true # Link-time optimization +codegen-units = 1 # Better optimization +strip = true # Strip symbols +panic = "abort" # Smaller panic handler \ No newline at end of file diff --git a/tools/arti-build/README.md b/tools/arti-build/README.md new file mode 100644 index 00000000..f547b773 --- /dev/null +++ b/tools/arti-build/README.md @@ -0,0 +1,223 @@ +# Arti Android Build Tools + +This directory contains the build scripts and source files for compiling the custom Arti (Tor in Rust) library for Android. + +## Overview + +bitchat-android uses a custom-built Arti library instead of Guardian Project's outdated `arti-mobile-ex` AAR. This provides: + +- **Smaller APK size**: ~11MB total vs ~140MB with Guardian Project AAR (28x reduction) +- **Latest Arti version**: Currently v1.7.0 with pure Rust TLS (rustls) +- **16KB page size support**: Required for Google Play (Nov 2025) +- **Full transparency**: Build from official Arti source + our JNI wrapper + +## Quick Start + +The pre-built `.so` files are committed to the repo, so you don't need to build unless you want to: + +1. **Verify the binaries** match the source +2. **Update to a new Arti version** +3. **Modify the JNI wrapper** + +## Directory Structure + +``` +tools/arti-build/ +├── README.md # This file +├── build-arti.sh # Main build script (clones Arti, builds .so files) +├── ARTI_VERSION # Pinned Arti version tag (e.g., arti-v1.7.0) +├── Cargo.toml # Rust package configuration +├── src/ +│ └── lib.rs # JNI wrapper (Rust -> Kotlin/Java bridge) +└── .arti-source/ # [GITIGNORED] Cloned official Arti repo + +app/src/main/jniLibs/ # [COMMITTED] Pre-built native libraries +├── arm64-v8a/ +│ └── libarti_android.so (~5.3MB) +└── x86_64/ + └── libarti_android.so (~6.2MB) +``` + +## Rebuilding from Source + +### Prerequisites + +1. **Rust toolchain** with Android targets: + ```bash + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + rustup target add aarch64-linux-android x86_64-linux-android + ``` + +2. **cargo-ndk** for Android cross-compilation: + ```bash + cargo install cargo-ndk + ``` + +3. **Android NDK 25+** (for 16KB page size support): + ```bash + # Via Android Studio SDK Manager, or: + $ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager "ndk;27.0.12077973" + + # Set environment variable + export ANDROID_NDK_HOME="$HOME/Library/Android/sdk/ndk/27.0.12077973" + ``` + +### Build Commands + +```bash +cd tools/arti-build + +# Build both architectures (arm64 + x86_64 for emulator) +./build-arti.sh + +# Build ARM64 only (smaller, for production releases) +./build-arti.sh --release + +# Clean rebuild (re-clone Arti source) +./build-arti.sh --clean +``` + +The script will: +1. Clone official Arti from https://gitlab.torproject.org/tpo/core/arti +2. Checkout the version specified in `ARTI_VERSION` +3. Copy our JNI wrapper into the cloned repo +4. Build with `cargo ndk` +5. Copy `.so` files to `app/src/main/jniLibs/` + +### Verification + +After building, verify the libraries: + +```bash +# Check file sizes +ls -lh ../../app/src/main/jniLibs/*/libarti_android.so + +# Verify JNI symbols are exported +nm -gU ../../app/src/main/jniLibs/arm64-v8a/libarti_android.so | grep Java_org_torproject + +# Verify 16KB page alignment +readelf -l ../../app/src/main/jniLibs/arm64-v8a/libarti_android.so | grep LOAD +# Look for: Align 0x4000 (16KB) +``` + +## Updating Arti Version + +1. **Check available versions**: + ```bash + git ls-remote --tags https://gitlab.torproject.org/tpo/core/arti.git | grep arti-v + ``` + +2. **Update the version file**: + ```bash + echo "arti-v1.8.0" > ARTI_VERSION + ``` + +3. **Rebuild from scratch**: + ```bash + ./build-arti.sh --clean + ``` + +4. **Test the build**: + ```bash + cd ../.. + ./gradlew clean assembleDebug + ./gradlew installDebug + # Enable Tor in app and verify it works + ``` + +5. **Commit the new libraries**: + ```bash + git add app/src/main/jniLibs/ tools/arti-build/ARTI_VERSION + git commit -m "chore: update Arti to v1.8.0" + ``` + +## JNI Wrapper Architecture + +The `src/lib.rs` file implements a JNI bridge between Kotlin and Rust: + +``` +Kotlin (ArtiNative.kt) + ↓ JNI +Rust (lib.rs) + ↓ +Arti Client (TorClient) + ↓ +SOCKS5 Proxy (localhost:9060) +``` + +**Exported JNI Functions**: +- `getVersion()` - Returns Arti version string +- `setLogCallback(callback)` - Registers log listener for bootstrap progress +- `initialize(dataDir)` - Creates Tokio runtime and TorClient +- `startSocksProxy(port)` - Starts SOCKS5 proxy on specified port +- `stop()` - Stops SOCKS proxy (TorClient is reused) + +**Key Design Decisions**: +- Global `TorClient` persists across stop/start cycles (fixes Nov 2024 toggle bug) +- Tokio runtime created once and never destroyed +- Log messages bridged to Java via `GlobalRef` callback + +## Feature Configuration + +Edit `Cargo.toml` to customize Arti features: + +```toml +[dependencies] +arti-client = { + path = "../crates/arti-client", + default-features = false, + features = [ + "tokio", # Required: async runtime + "rustls", # Required: pure Rust TLS (no OpenSSL) + "compression", # Optional: directory compression + "bridge-client", # Optional: Tor bridge support + "onion-service-client", # Optional: .onion site support + "static-sqlite" # Required: bundled SQLite + ] +} +``` + +## Size Comparison + +| Configuration | arm64-v8a | x86_64 | Total | APK Size | +|---------------|-----------|--------|-------|----------| +| Guardian Project AAR | - | - | ~140 MB | ~150 MB | +| Custom (both arch) | 5.3 MB | 6.2 MB | 11.5 MB | ~15 MB | +| Custom (ARM-only) | 5.3 MB | - | 5.3 MB | ~10 MB | + +**28x size reduction** vs Guardian Project implementation. + +## Troubleshooting + +### "cargo-ndk not found" +```bash +cargo install cargo-ndk +``` + +### "Android NDK not found" +```bash +export ANDROID_NDK_HOME="$HOME/Library/Android/sdk/ndk/27.0.12077973" +``` + +### "Rust target not installed" +```bash +rustup target add aarch64-linux-android x86_64-linux-android +``` + +### "Version not found" +Check available versions: +```bash +git ls-remote --tags https://gitlab.torproject.org/tpo/core/arti.git | grep arti-v | tail -10 +``` + +### Library too large +1. Ensure building with `--release` flag +2. Verify `strip = true` in `Cargo.toml` `[profile.release]` +3. Consider removing optional features + +## References + +- [Arti Documentation](https://gitlab.torproject.org/tpo/core/arti/-/blob/main/doc/README.md) +- [cargo-ndk](https://github.com/bbqsrc/cargo-ndk) +- [Android NDK Guide](https://developer.android.com/ndk/guides) +- [Google Play 16KB Page Size](https://developer.android.com/guide/practices/page-sizes) diff --git a/tools/arti-build/build-arti.sh b/tools/arti-build/build-arti.sh new file mode 100755 index 00000000..0a592be5 --- /dev/null +++ b/tools/arti-build/build-arti.sh @@ -0,0 +1,570 @@ +#!/usr/bin/env bash +# +# Rebuild Arti native libraries from official source +# +# This script clones the official Arti repository, applies our custom JNI wrapper, +# and builds the native libraries for Android. Use this to: +# - Verify the pre-built .so files match the source +# - Update to a new Arti version +# - Debug or modify the wrapper code +# +# Requirements: +# - Bash 4+ (macOS default bash is 3.2; install via Homebrew: brew install bash) +# - Rust toolchain with Android targets: +# rustup target add aarch64-linux-android x86_64-linux-android +# - cargo-ndk: cargo install cargo-ndk +# - Android NDK 25+ (for 16KB page size support) +# +# Usage: +# ./build-arti.sh # Build both architectures (debug/emulator) +# ./build-arti.sh --release # Build ARM64 only (production) +# ./build-arti.sh --clean # Remove cloned Arti repo and rebuild + +set -euo pipefail + +# ============================================================================== +# Configuration +# ============================================================================== + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Script and project directories +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +ARTI_SOURCE_DIR="$SCRIPT_DIR/.arti-source" +JNILIBS_DIR="$PROJECT_ROOT/app/src/main/jniLibs" + + +detect_default_ndk_home() { + local candidates=( + "$HOME/Library/Android/sdk/ndk/27.0.12077973" + "$HOME/Library/Android/sdk/ndk" + "$HOME/Library/Android/sdk/ndk-bundle" + "$HOME/Android/Sdk/ndk/27.0.12077973" + "$HOME/Android/Sdk/ndk" + "$HOME/Android/Sdk/ndk-bundle" + ) + + for candidate in "${candidates[@]}"; do + if [ -d "$candidate" ]; then + echo "$candidate" + return + fi + done + + local base + for base in "$HOME/Library/Android/sdk/ndk" "$HOME/Android/Sdk/ndk"; do + if [ -d "$base" ]; then + local latest + latest="$(find "$base" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort | tail -1)" + if [ -n "$latest" ]; then + echo "$latest" + return + fi + fi + done + + echo "" +} + +# Read pinned version +if [ ! -f "$SCRIPT_DIR/ARTI_VERSION" ]; then + echo -e "${RED}Error: ARTI_VERSION file not found${NC}" + exit 1 +fi +VERSION="$(tr -d '[:space:]' < "$SCRIPT_DIR/ARTI_VERSION")" + +# Android NDK path +if [ -z "${ANDROID_NDK_HOME:-}" ]; then + AUTO_NDK_HOME="$(detect_default_ndk_home)" + if [ -n "$AUTO_NDK_HOME" ]; then + ANDROID_NDK_HOME="$AUTO_NDK_HOME" + fi +fi +if [ -z "${ANDROID_NDK_HOME:-}" ]; then + echo -e "${RED}Error: ANDROID_NDK_HOME is not set and automatic detection failed.${NC}" + echo "Set ANDROID_NDK_HOME to your NDK installation (e.g., ~/Android/Sdk/ndk/)." + exit 1 +fi +export ANDROID_NDK_HOME + +# Min SDK version (must match bitchat-android minSdk) +MIN_SDK_VERSION=26 + +# Parse arguments +RELEASE_ONLY=false +CLEAN_BUILD=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --release) + RELEASE_ONLY=true + shift + ;; + --clean) + CLEAN_BUILD=true + shift + ;; + --help|-h) + echo "Usage: $0 [--release] [--clean]" + echo "" + echo "Options:" + echo " --release Build ARM64 only (smaller, for production)" + echo " --clean Remove cached Arti source and rebuild from scratch" + echo "" + exit 0 + ;; + *) + echo -e "${RED}Error: Unknown argument: $1${NC}" + echo "Run: $0 --help" + exit 1 + ;; + esac +done + +# Architectures to build +if [ "$RELEASE_ONLY" = true ]; then + TARGETS=("aarch64-linux-android") +else + TARGETS=("aarch64-linux-android" "x86_64-linux-android") +fi + +# Map Rust targets to Android ABI names +declare -A ABI_MAP=( + ["aarch64-linux-android"]="arm64-v8a" + ["x86_64-linux-android"]="x86_64" +) + +# Toolchain placeholders (set in detect_ndk_host) +NDK_HOST="" +NDK_LLVM_BIN="" +LLVM_STRIP="" +LLVM_NM="" +LLVM_READELF="" + +# ============================================================================== +# Functions +# ============================================================================== + +print_header() { + echo -e "${BLUE}=========================================${NC}" + echo -e "${BLUE}$1${NC}" + echo -e "${BLUE}=========================================${NC}" +} + +print_success() { echo -e "${GREEN}$1${NC}"; } +print_error() { echo -e "${RED}$1${NC}"; } +print_info() { echo -e "${YELLOW}$1${NC}"; } + +detect_ndk_host() { + local uname_s + uname_s="$(uname -s)" + local PREBUILT_DIR="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt" + local HOST_CANDIDATES=() + + case "$uname_s" in + Darwin) + HOST_CANDIDATES=("darwin-arm64" "darwin-x86_64") + ;; + Linux) + HOST_CANDIDATES=("linux-x86_64" "linux-arm64" "linux-aarch64") + ;; + *) + print_error "Unsupported host OS: $uname_s" + exit 1 + ;; + esac + + for candidate in "${HOST_CANDIDATES[@]}"; do + if [ -d "$PREBUILT_DIR/$candidate" ]; then + NDK_HOST="$candidate" + NDK_LLVM_BIN="$PREBUILT_DIR/$candidate/bin" + LLVM_STRIP="$NDK_LLVM_BIN/llvm-strip" + LLVM_NM="$NDK_LLVM_BIN/llvm-nm" + LLVM_READELF="$NDK_LLVM_BIN/llvm-readelf" + return 0 + fi + done + + print_error "No compatible NDK toolchain found under $PREBUILT_DIR" + print_info "Searched for: ${HOST_CANDIDATES[*]}" + exit 1 +} + +check_prerequisites() { + print_header "Checking Prerequisites" + + # Bash version + if [ "${BASH_VERSINFO:-0}" -lt 4 ]; then + print_error "Bash 4+ is required. macOS ships bash 3.2 by default." + print_info "macOS: brew install bash" + print_info "Linux: use your distro package manager (e.g., sudo apt install bash)" + print_info "Then run with the installed bash (e.g., /opt/homebrew/bin/bash ./build-arti.sh)" + exit 1 + fi + print_success "Bash found: $BASH_VERSION" + + # Git + if ! command -v git >/dev/null 2>&1; then + print_error "git is not installed." + exit 1 + fi + print_success "git found: $(git --version)" + + # Rust + if ! command -v rustc >/dev/null 2>&1; then + print_error "Rust is not installed. Install from https://rustup.rs/" + exit 1 + fi + print_success "Rust found: $(rustc --version)" + + # rustup + if ! command -v rustup >/dev/null 2>&1; then + print_error "rustup is required (for managing targets). Install from https://rustup.rs/" + exit 1 + fi + print_success "rustup found: $(rustup --version | head -1)" + + # cargo-ndk + if ! command -v cargo-ndk >/dev/null 2>&1; then + print_error "cargo-ndk is not installed. Run: cargo install cargo-ndk" + exit 1 + fi + print_success "cargo-ndk found: $(cargo-ndk --version 2>/dev/null || echo 'installed')" + + # NDK + if [ ! -d "$ANDROID_NDK_HOME" ]; then + print_error "Android NDK not found at: $ANDROID_NDK_HOME" + print_info "Set ANDROID_NDK_HOME environment variable to your NDK location" + exit 1 + fi + print_success "Android NDK found: $ANDROID_NDK_HOME" + + # NDK version (should be 25+) + NDK_VERSION="$(basename "$ANDROID_NDK_HOME" | cut -d'.' -f1)" + if ! [[ "$NDK_VERSION" =~ ^[0-9]+$ ]]; then + print_error "Could not parse NDK version from ANDROID_NDK_HOME: $ANDROID_NDK_HOME" + exit 1 + fi + if [ "$NDK_VERSION" -lt 25 ]; then + print_error "NDK version $NDK_VERSION is too old. NDK 25+ required for 16KB page size support" + exit 1 + fi + print_success "NDK version: $NDK_VERSION (supports 16KB page size)" + + detect_ndk_host + if [ ! -d "$NDK_LLVM_BIN" ]; then + print_error "NDK LLVM toolchain bin directory not found: $NDK_LLVM_BIN" + exit 1 + fi + print_success "NDK host tag: $NDK_HOST" + + if [ ! -x "$LLVM_STRIP" ]; then + print_info "llvm-strip not found at: $LLVM_STRIP (stripping will be skipped)" + else + print_success "llvm-strip found" + fi + + if [ ! -x "$LLVM_NM" ] && ! command -v nm >/dev/null 2>&1; then + print_error "Neither llvm-nm nor nm found. Cannot verify JNI symbols." + exit 1 + fi + if [ -x "$LLVM_NM" ]; then + print_success "llvm-nm found" + else + print_info "llvm-nm not found, will fall back to system nm" + fi + + if [ ! -x "$LLVM_READELF" ] && ! command -v readelf >/dev/null 2>&1; then + print_info "Neither llvm-readelf nor readelf found. Alignment verification may be skipped." + else + print_success "readelf capability available" + fi + # Android targets + for TARGET in "${TARGETS[@]}"; do + if ! rustup target list --installed | grep -qx "$TARGET"; then + print_error "Rust target $TARGET not installed" + print_info "Run: rustup target add $TARGET" + exit 1 + fi + done + print_success "All Rust Android targets installed" + + echo "" +} + +clone_or_update_arti() { + print_header "Setting up Arti Source (version: $VERSION)" + + if [ "$CLEAN_BUILD" = true ] && [ -d "$ARTI_SOURCE_DIR" ]; then + print_info "Cleaning existing Arti source..." + rm -rf "$ARTI_SOURCE_DIR" + fi + + if [ ! -d "$ARTI_SOURCE_DIR" ]; then + print_info "Cloning official Arti repository..." + git clone https://gitlab.torproject.org/tpo/core/arti.git "$ARTI_SOURCE_DIR" + else + print_info "Using cached Arti source at $ARTI_SOURCE_DIR" + fi + + cd "$ARTI_SOURCE_DIR" + + print_info "Fetching tags..." + git fetch --tags --quiet + + print_info "Checking out version: $VERSION" + git checkout "$VERSION" --quiet 2>/dev/null || { + print_error "Version $VERSION not found. Available versions:" + git tag | grep "^arti-v" | tail -10 + exit 1 + } + + # Ensure clean working tree to avoid cached modifications influencing builds + print_info "Resetting repository state (hard) and cleaning untracked files..." + git reset --hard --quiet + git clean -ffdqx --quiet + + print_success "Arti source ready at version $VERSION" + echo "" +} + +setup_wrapper() { + print_header "Setting up JNI Wrapper" + + WRAPPER_DIR="$ARTI_SOURCE_DIR/arti-android-wrapper" + + # Recreate wrapper directory to avoid stale files + rm -rf "$WRAPPER_DIR" + mkdir -p "$WRAPPER_DIR/src" + + cp "$SCRIPT_DIR/src/lib.rs" "$WRAPPER_DIR/src/" + cp "$SCRIPT_DIR/Cargo.toml" "$WRAPPER_DIR/" + + print_success "Wrapper files copied to $WRAPPER_DIR" + echo "" +} + +build_for_target() { + local TARGET="$1" + local ABI="${ABI_MAP[$TARGET]}" + local OUTPUT_PATH="$JNILIBS_DIR/$ABI" + + print_header "Building for $ABI ($TARGET)" + + mkdir -p "$OUTPUT_PATH" + + print_info "Building Arti Android wrapper..." + cargo ndk \ + -t "$TARGET" \ + --platform "$MIN_SDK_VERSION" \ + -o "$OUTPUT_PATH" \ + build --release \ + --locked \ + --manifest-path "$ARTI_SOURCE_DIR/arti-android-wrapper/Cargo.toml" + + local LIB_NAME="libarti_android.so" + local NESTED_PATH="$OUTPUT_PATH/$ABI/$LIB_NAME" + + if [ -f "$NESTED_PATH" ]; then + mv "$NESTED_PATH" "$OUTPUT_PATH/$LIB_NAME" + rmdir "$OUTPUT_PATH/$ABI" 2>/dev/null || true + fi + + if [ -f "$OUTPUT_PATH/$LIB_NAME" ]; then + print_success "Built: $OUTPUT_PATH/$LIB_NAME" + + # Strip debug symbols safely + print_info "Stripping debug symbols..." + if [ -x "$LLVM_STRIP" ]; then + "$LLVM_STRIP" --strip-debug "$OUTPUT_PATH/$LIB_NAME" 2>/dev/null || true + print_success "Stripped debug symbols" + else + print_info "Skipping strip (llvm-strip not available)" + fi + + local SIZE + SIZE="$(du -h "$OUTPUT_PATH/$LIB_NAME" | cut -f1)" + print_success "Final size: $SIZE" + + # Verify 16KB page size alignment (best-effort) + print_info "Verifying 16KB page alignment..." + local READELF_TOOL="" + if [ -x "$LLVM_READELF" ]; then + READELF_TOOL="$LLVM_READELF" + elif command -v readelf >/dev/null 2>&1; then + READELF_TOOL="$(command -v readelf)" + fi + + if [ -n "$READELF_TOOL" ]; then + local ALIGNMENT + ALIGNMENT="$("$READELF_TOOL" -l "$OUTPUT_PATH/$LIB_NAME" 2>/dev/null | grep "LOAD" | head -1 | awk '{print $NF}' || echo "unknown")" + if [ "$ALIGNMENT" = "0x4000" ] || [ "$ALIGNMENT" = "16384" ]; then + print_success "16KB page alignment verified: $ALIGNMENT" + else + print_info "Page alignment: $ALIGNMENT (NDK handles 16KB at link time)" + fi + else + print_info "Skipping alignment check (readelf not available)" + fi + else + print_error "Build failed: $LIB_NAME not found" + return 1 + fi + + echo "" +} + +verify_jni_symbols_for_lib() { + local LIB_PATH="$1" + + if [ ! -f "$LIB_PATH" ]; then + print_error "Library not found: $LIB_PATH" + return 1 + fi + + local EXPECTED_SYMBOLS=( + "Java_org_torproject_arti_ArtiNative_getVersion" + "Java_org_torproject_arti_ArtiNative_setLogCallback" + "Java_org_torproject_arti_ArtiNative_initialize" + "Java_org_torproject_arti_ArtiNative_startSocksProxy" + "Java_org_torproject_arti_ArtiNative_stop" + ) + + local ALL_FOUND=true + for SYMBOL in "${EXPECTED_SYMBOLS[@]}"; do + local FOUND=false + + if [ -x "$LLVM_NM" ]; then + if "$LLVM_NM" -D --defined-only "$LIB_PATH" 2>/dev/null | grep -q "$SYMBOL"; then + FOUND=true + fi + elif command -v nm >/dev/null 2>&1; then + # Fallback (may be unreliable for ELF on macOS) + if nm -g "$LIB_PATH" 2>/dev/null | grep -q "$SYMBOL"; then + FOUND=true + fi + fi + + if [ "$FOUND" = true ]; then + print_success " Found: $SYMBOL" + else + print_error " Missing: $SYMBOL" + ALL_FOUND=false + fi + done + + if [ "$ALL_FOUND" = true ]; then + print_success "All JNI symbols verified for: $LIB_PATH" + else + print_error "Some JNI symbols are missing for: $LIB_PATH" + return 1 + fi + + return 0 +} + +verify_jni_symbols() { + print_header "Verifying JNI Symbols" + print_info "Checking exported JNI symbols..." + + local FAILED=false + + for TARGET in "${TARGETS[@]}"; do + local ABI="${ABI_MAP[$TARGET]}" + local LIB_PATH="$JNILIBS_DIR/$ABI/libarti_android.so" + print_info "Verifying $ABI: $LIB_PATH" + if ! verify_jni_symbols_for_lib "$LIB_PATH"; then + FAILED=true + fi + done + + if [ "$FAILED" = true ]; then + return 1 + fi + + echo "" +} + +show_summary() { + print_header "Build Complete!" + + echo -e "${GREEN}Built libraries:${NC}" + for TARGET in "${TARGETS[@]}"; do + local ABI="${ABI_MAP[$TARGET]}" + local LIB_PATH="$JNILIBS_DIR/$ABI/libarti_android.so" + if [ -f "$LIB_PATH" ]; then + local SIZE + SIZE="$(du -h "$LIB_PATH" | cut -f1)" + echo -e " ${GREEN}$ABI:${NC} $SIZE" + fi + done + + echo "" + echo -e "${GREEN}Arti version:${NC} $VERSION" + echo -e "${GREEN}Source:${NC} https://gitlab.torproject.org/tpo/core/arti" + echo "" + echo -e "${GREEN}Next steps:${NC}" + echo " 1. Test the build: ./gradlew assembleDebug" + echo " 2. Commit the .so files: git add app/src/main/jniLibs/" + echo "" + echo -e "${GREEN}To update Arti version:${NC}" + echo " 1. Edit ARTI_VERSION with new version tag (e.g., arti-v1.8.0)" + echo " 2. Run: ./build-arti.sh --clean" + echo "" +} + +# ============================================================================== +# Main +# ============================================================================== + +main() { + print_header "Arti Android Build Script" + echo -e "${BLUE}Building Arti for Android with 16KB page size support${NC}" + echo -e "${BLUE}Version: $VERSION${NC}" + echo -e "${BLUE}Architectures: ${TARGETS[*]}${NC}" + echo "" + + check_prerequisites + clone_or_update_arti + setup_wrapper + ensure_wrapper_lockfile + + for TARGET in "${TARGETS[@]}"; do + build_for_target "$TARGET" + done + + verify_jni_symbols + show_summary +} + +ensure_wrapper_lockfile() { + print_header "Ensuring wrapper Cargo.lock exists" + + local WRAPPER_DIR="$ARTI_SOURCE_DIR/arti-android-wrapper" + local LOCKFILE="$WRAPPER_DIR/Cargo.lock" + + if [ -f "$LOCKFILE" ]; then + print_success "Cargo.lock already exists" + echo "" + return 0 + fi + + print_info "Cargo.lock missing; generating it once (network access may be required)..." + (cd "$WRAPPER_DIR" && cargo generate-lockfile) + + if [ ! -f "$LOCKFILE" ]; then + print_error "Failed to generate Cargo.lock at $LOCKFILE" + return 1 + fi + + print_success "Generated Cargo.lock" + echo "" +} + +main diff --git a/tools/arti-build/src/lib.rs b/tools/arti-build/src/lib.rs new file mode 100644 index 00000000..941b1386 --- /dev/null +++ b/tools/arti-build/src/lib.rs @@ -0,0 +1,493 @@ +use jni::JNIEnv; +use jni::objects::{JClass, JString, JObject, GlobalRef}; +use jni::sys::{jint, jstring}; +use jni::JavaVM; + +use arti_client::TorClient; +use arti_client::config::TorClientConfigBuilder; +use tor_rtcompat::PreferredRuntime; + +use std::sync::{Arc, Mutex, Once}; +use std::path::PathBuf; +use anyhow::Result; + +// ============================================================================ +// Global State +// ============================================================================ + +/// Global Arti client instance +static ARTI_CLIENT: Mutex>>> = Mutex::new(None); + +/// Global Tokio runtime (must persist for Arti to work) +static TOKIO_RUNTIME: Mutex> = Mutex::new(None); + +/// Global JavaVM reference (cached on first JNI call) +static JAVA_VM: Mutex> = Mutex::new(None); + +/// Global log callback reference +static LOG_CALLBACK: Mutex> = Mutex::new(None); + +/// Handle to SOCKS server task (for graceful shutdown) +static SOCKS_TASK: Mutex>> = Mutex::new(None); + +/// Initialization flag +static INIT_ONCE: Once = Once::new(); + +// ============================================================================ +// Logging Integration +// ============================================================================ + +/// Send log message to Java callback +fn send_log_to_java(message: String) { + let vm_opt = JAVA_VM.lock().unwrap(); + let callback_opt = LOG_CALLBACK.lock().unwrap(); + + if let (Some(vm), Some(callback)) = (vm_opt.as_ref(), callback_opt.as_ref()) { + if let Ok(mut env) = vm.attach_current_thread() { + if let Ok(jmessage) = env.new_string(&message) { + let _ = env.call_method( + callback.as_obj(), + "onLogLine", + "(Ljava/lang/String;)V", + &[(&jmessage).into()] + ); + } + } + } +} + +/// Macro for logging to both Android logcat and Java callback +macro_rules! log_info { + ($($arg:tt)*) => {{ + let msg = format!($($arg)*); + android_logger::log(&format!("Arti: {}", msg)); + send_log_to_java(msg); + }}; +} + +macro_rules! log_error { + ($($arg:tt)*) => {{ + let msg = format!("ERROR: {}", format!($($arg)*)); + android_logger::log(&format!("Arti: {}", msg)); + send_log_to_java(msg); + }}; +} + +// ============================================================================ +// JNI Functions +// ============================================================================ + +/// Get Arti version string +#[no_mangle] +pub extern "C" fn Java_org_torproject_arti_ArtiNative_getVersion( + env: JNIEnv, + _class: JClass, +) -> jstring { + // Cache JavaVM on first call + if JAVA_VM.lock().unwrap().is_none() { + if let Ok(vm) = env.get_java_vm() { + *JAVA_VM.lock().unwrap() = Some(vm); + } + } + + let version = format!("Arti {} (custom build with rustls)", env!("CARGO_PKG_VERSION")); + let output = env.new_string(version).expect("Couldn't create java string!"); + output.into_raw() +} + +/// Set log callback for Arti logs +#[no_mangle] +pub extern "C" fn Java_org_torproject_arti_ArtiNative_setLogCallback( + env: JNIEnv, + _class: JClass, + callback: JObject, +) { + // Cache JavaVM if not already cached + if JAVA_VM.lock().unwrap().is_none() { + if let Ok(vm) = env.get_java_vm() { + *JAVA_VM.lock().unwrap() = Some(vm); + } + } + + // Store global reference to callback + if let Ok(global_ref) = env.new_global_ref(callback) { + *LOG_CALLBACK.lock().unwrap() = Some(global_ref); + log_info!("Log callback registered"); + } +} + +/// Initialize Arti runtime +#[no_mangle] +pub extern "C" fn Java_org_torproject_arti_ArtiNative_initialize( + mut env: JNIEnv, + _class: JClass, + data_dir: JString, +) -> jint { + // Cache JavaVM if not already cached + if JAVA_VM.lock().unwrap().is_none() { + if let Ok(vm) = env.get_java_vm() { + *JAVA_VM.lock().unwrap() = Some(vm); + } + } + + let data_dir_str: String = match env.get_string(&data_dir) { + Ok(s) => s.into(), + Err(e) => { + log_error!("Failed to convert data_dir: {:?}", e); + return -1; + } + }; + + log_info!("AMEx: state changed to Initialized"); + log_info!("Initializing Arti with data directory: {}", data_dir_str); + + // Initialize Tokio runtime (once) + INIT_ONCE.call_once(|| { + match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(rt) => { + log_info!("Tokio runtime created successfully"); + *TOKIO_RUNTIME.lock().unwrap() = Some(rt); + } + Err(e) => { + log_error!("Failed to create Tokio runtime: {:?}", e); + } + } + }); + + // Check if runtime exists + let runtime_guard = TOKIO_RUNTIME.lock().unwrap(); + let runtime = match runtime_guard.as_ref() { + Some(rt) => rt, + None => { + log_error!("Tokio runtime not initialized"); + return -2; + } + }; + + // Create config with explicit Android paths + let data_path = PathBuf::from(data_dir_str); + let cache_dir = data_path.join("cache"); + let state_dir = data_path.join("state"); + + // Create directories if they don't exist + std::fs::create_dir_all(&cache_dir).ok(); + std::fs::create_dir_all(&state_dir).ok(); + + let result: Result<()> = runtime.block_on(async { + log_info!("Creating Arti client..."); + log_info!("Cache dir: {:?}", cache_dir); + log_info!("State dir: {:?}", state_dir); + + // Create config with Android-specific directories + let config = TorClientConfigBuilder::from_directories(state_dir, cache_dir) + .build()?; + + // Create client with Android-specific config + let client = TorClient::create_bootstrapped(config).await?; + + log_info!("Arti client created successfully"); + + // Store client globally + *ARTI_CLIENT.lock().unwrap() = Some(Arc::new(client)); + + Ok(()) + }); + + match result { + Ok(_) => { + log_info!("Arti initialized successfully"); + 0 + } + Err(e) => { + log_error!("Failed to initialize Arti: {:?}", e); + -3 + } + } +} + +/// Start SOCKS proxy on specified port +#[no_mangle] +pub extern "C" fn Java_org_torproject_arti_ArtiNative_startSocksProxy( + _env: JNIEnv, + _class: JClass, + port: jint, +) -> jint { + log_info!("AMEx: state changed to Starting"); + log_info!("Starting SOCKS proxy on port {}", port); + + // Stop any existing SOCKS server first + if let Some(handle) = SOCKS_TASK.lock().unwrap().take() { + log_info!("Aborting previous SOCKS server task"); + handle.abort(); + } + + let client_guard = ARTI_CLIENT.lock().unwrap(); + let client = match client_guard.as_ref() { + Some(c) => Arc::clone(c), + None => { + log_error!("Arti client not initialized - call initialize() first"); + return -1; + } + }; + drop(client_guard); + + let runtime_guard = TOKIO_RUNTIME.lock().unwrap(); + let runtime = match runtime_guard.as_ref() { + Some(rt) => rt, + None => { + log_error!("Tokio runtime not initialized"); + return -2; + } + }; + + // Try to bind IMMEDIATELY to detect port conflicts before returning + let addr = format!("127.0.0.1:{}", port); + + // Use block_on to synchronously attempt binding + let bind_result = runtime.block_on(async { + tokio::net::TcpListener::bind(&addr).await + }); + + let listener = match bind_result { + Ok(l) => { + log_info!("SOCKS proxy bound to {}", addr); + l + } + Err(e) => { + log_error!("Failed to bind SOCKS proxy to {}: {:?}", addr, e); + return -3; + } + }; + + // Now spawn the background task with the already-bound listener + let handle = runtime.spawn(async move { + log_info!("SOCKS proxy listening on {}", addr); + log_info!("Sufficiently bootstrapped; system SOCKS now functional"); + + // Signal bootstrap completion to bitchat-android (expected by ArtiTorManager) + // This sets bootstrapPercent to 100% and stops inactivity restarts + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + log_info!("We have found that guard [scrubbed] is usable."); + + // Accept connections + loop { + match listener.accept().await { + Ok((stream, peer_addr)) => { + log_info!("SOCKS connection from: {}", peer_addr); + let client_clone = Arc::clone(&client); + + tokio::spawn(async move { + if let Err(e) = handle_socks_connection(stream, client_clone).await { + log_error!("SOCKS connection error: {:?}", e); + } + }); + } + Err(e) => { + log_error!("Failed to accept SOCKS connection: {:?}", e); + break; // Exit loop on error + } + } + } + + log_info!("SOCKS proxy task exiting"); + }); + + // Store handle for cleanup + *SOCKS_TASK.lock().unwrap() = Some(handle); + + log_info!("SOCKS proxy started on port {}", port); + 0 +} + +/// Handle a single SOCKS connection +async fn handle_socks_connection( + mut stream: tokio::net::TcpStream, + client: Arc>, +) -> Result<()> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + // Simple SOCKS5 handshake + let mut buf = [0u8; 512]; + + // Read version + methods + let n = stream.read(&mut buf).await?; + if n < 2 { + return Err(anyhow::anyhow!("Invalid SOCKS handshake")); + } + + // Send "no auth required" response + stream.write_all(&[0x05, 0x00]).await?; + + // Read request + let n = stream.read(&mut buf).await?; + if n < 10 { + return Err(anyhow::anyhow!("Invalid SOCKS request")); + } + + // Parse SOCKS5 request: VER(1) CMD(1) RSV(1) ATYP(1) DST.ADDR DST.PORT(2) + let version = buf[0]; + let cmd = buf[1]; + let atyp = buf[3]; + + if version != 0x05 { + return Err(anyhow::anyhow!("Unsupported SOCKS version: {}", version)); + } + + if cmd != 0x01 { + // Only support CONNECT command + stream.write_all(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + return Err(anyhow::anyhow!("Unsupported SOCKS command: {}", cmd)); + } + + // Parse target address and port + let (target_host, target_port) = match atyp { + 0x01 => { + // IPv4: 4 bytes + let ip = format!("{}.{}.{}.{}", buf[4], buf[5], buf[6], buf[7]); + let port = u16::from_be_bytes([buf[8], buf[9]]); + (ip, port) + } + 0x03 => { + // Domain name: length byte + domain + let len = buf[4] as usize; + if n < 5 + len + 2 { + return Err(anyhow::anyhow!("Invalid domain name length")); + } + let domain = String::from_utf8_lossy(&buf[5..5 + len]).to_string(); + let port = u16::from_be_bytes([buf[5 + len], buf[5 + len + 1]]); + (domain, port) + } + 0x04 => { + // IPv6: 16 bytes + 2 bytes port = 22 bytes total + if n < 22 { + stream.write_all(&[0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + return Err(anyhow::anyhow!("Truncated IPv6 request")); + } + let ip = format!( + "{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}", + buf[4], buf[5], buf[6], buf[7], buf[8], buf[9], buf[10], buf[11], + buf[12], buf[13], buf[14], buf[15], buf[16], buf[17], buf[18], buf[19] + ); + let port = u16::from_be_bytes([buf[20], buf[21]]); + (ip, port) + } + _ => { + stream.write_all(&[0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + return Err(anyhow::anyhow!("Unsupported address type: {}", atyp)); + } + }; + + log_info!("SOCKS5 CONNECT to {}:{}", target_host, target_port); + + // Establish Tor connection + let tor_stream = match client.connect((target_host.as_str(), target_port)).await { + Ok(s) => s, + Err(e) => { + log_error!("Failed to connect through Tor: {:?}", e); + // Send SOCKS5 error: general failure + stream.write_all(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + return Err(e.into()); + } + }; + + log_info!("Tor connection established to {}:{}", target_host, target_port); + + // Send SOCKS5 success response + stream.write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + + // Bidirectional data forwarding + let (mut client_read, mut client_write) = stream.split(); + let (mut tor_read, mut tor_write) = tor_stream.split(); + + let client_to_tor = async { + tokio::io::copy(&mut client_read, &mut tor_write).await + }; + + let tor_to_client = async { + tokio::io::copy(&mut tor_read, &mut client_write).await + }; + + // Run both directions concurrently, exit when either completes + tokio::select! { + result = client_to_tor => { + if let Err(ref e) = result { + log_error!("Client->Tor copy error: {:?}", e); + } + } + result = tor_to_client => { + if let Err(ref e) = result { + log_error!("Tor->Client copy error: {:?}", e); + } + } + }; + + log_info!("SOCKS connection closed for {}:{}", target_host, target_port); + + Ok(()) +} + +/// Stop Arti and cleanup +#[no_mangle] +pub extern "C" fn Java_org_torproject_arti_ArtiNative_stop( + _env: JNIEnv, + _class: JClass, +) -> jint { + log_info!("AMEx: state changed to Stopping"); + log_info!("Stopping Arti..."); + + // Abort SOCKS proxy task (releases the port) + if let Some(handle) = SOCKS_TASK.lock().unwrap().take() { + log_info!("Aborting SOCKS server task"); + handle.abort(); + } + + // Give the abort a moment to complete and release the port + if let Some(rt) = TOKIO_RUNTIME.lock().unwrap().as_ref() { + rt.block_on(async { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + }); + } + + // NOTE: We do NOT clear ARTI_CLIENT here! + // The TorClient can be reused for multiple SOCKS proxy start/stop cycles. + // Only clear it if you want to force full reinitialization. + + // Uncomment this line only if you want to force reinitialization on every start: + // *ARTI_CLIENT.lock().unwrap() = None; + + log_info!("AMEx: state changed to Stopped"); + log_info!("Arti stopped successfully"); + + 0 +} + +// ============================================================================ +// Android Logger (simple implementation) +// ============================================================================ + +mod android_logger { + use std::ffi::CString; + + #[allow(non_camel_case_types)] + type c_int = i32; + + #[allow(non_camel_case_types)] + type c_char = i8; + + extern "C" { + fn __android_log_write(prio: c_int, tag: *const c_char, text: *const c_char) -> c_int; + } + + const ANDROID_LOG_INFO: c_int = 4; + + pub fn log(message: &str) { + unsafe { + let tag = CString::new("ArtiNative").unwrap(); + let text = CString::new(message).unwrap(); + __android_log_write(ANDROID_LOG_INFO, tag.as_ptr() as *const c_char, text.as_ptr() as *const c_char); + } + } +}