diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index 2f8a7906..9911c273 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -1,6 +1,7 @@ name: Android CI on: + workflow_dispatch: push: branches: [ "main", "develop" ] pull_request: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 79495ee9..f1e1841a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,17 +38,20 @@ jobs: - name: Grant execute permission for Gradlew run: chmod +x ./gradlew - - name: Build Release APK (with Tor) + - 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 -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-android.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: | @@ -59,8 +62,8 @@ jobs: ls -all tree || ls -R - # Optional: Sign APK (requires secrets) - # - name: Sign APK + # 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 @@ -69,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-android-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 @@ -82,25 +85,23 @@ jobs: runs-on: ubuntu-latest steps: - - name: Download APK artifact + - name: Download release artifacts uses: actions/download-artifact@v4 with: - name: bitchat-android-apk-${{ github.ref_name }} + name: bitchat-android-release-${{ github.ref_name }} path: release - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: files: | - release/bitchat-android.apk + release/bitchat-android-arm64.apk + release/bitchat-android-x86_64.apk + release/bitchat-android-universal.apk name: Release ${{ github.ref_name }} body: | - ## bitchat Android Release - - **bitchat-android.apk** (~15MB) - - Secure P2P messaging over Bluetooth mesh and Nostr - - Built-in Tor support (custom Arti build with 16KB page size) - - Compatible with Google Play requirements (Nov 2025+) - - Cross-platform compatible with iOS version + **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/app/build.gradle.kts b/app/build.gradle.kts index f6de2444..a789136e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,7 +13,7 @@ android { applicationId = "com.bitchat.droid" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = 27 + versionCode = 30 versionName = "1.6.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -43,13 +43,20 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) - ndk { - // ARM64-only to minimize APK size (~5.8MB savings) - // Excludes x86_64 as emulator not needed for production builds - abiFilters += listOf("arm64-v8a") - } } } + + // 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 @@ -84,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) 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"/> - - - - + + + + + + + @@ -45,6 +52,11 @@ + + + + + + + + + diff --git a/app/src/main/assets/nostr_relays.csv b/app/src/main/assets/nostr_relays.csv index ab075fb4..345b731b 100644 --- a/app/src/main/assets/nostr_relays.csv +++ b/app/src/main/assets/nostr_relays.csv @@ -1,295 +1,290 @@ Relay URL,Latitude,Longitude -strfry.shock.network,39.0438,-77.4874 -wot.nostr.party,36.1627,-86.7816 -nostr.blankfors.se,60.1699,24.9384 -nostr.now,36.55,139.733 -nostr.simplex.icu,51.5121,-0.0005238 -relay.threenine.services,51.5524,-0.29686 -relay.barine.co,43.6532,-79.3832 -theoutpost.life,64.1476,-21.9392 -purplerelay.com,50.1109,8.68213 -nostrue.com,40.8054,-74.0241 -relay-arg.zombi.cloudrodion.com,1.35208,103.82 -shu02.shugur.net,21.4902,39.2246 -relayone.geektank.ai,18.2148,-63.0574 -relay03.lnfi.network,39.0997,-94.5786 -soloco.nl,43.6532,-79.3832 -relay-testnet.k8s.layer3.news,37.3387,-121.885 -nostr.azzamo.net,52.2633,21.0283 -nostr.red5d.dev,43.6532,-79.3832 -nostr.thebiglake.org,32.71,-96.6745 -nostr.tadryanom.me,43.6532,-79.3832 -schnorr.me,43.6532,-79.3832 -ithurtswhenip.ee,51.223,6.78245 -orly.ft.hn,50.4754,12.3683 -relay02.lnfi.network,39.0997,-94.5786 -relay.nosto.re,51.1792,5.89444 -strfry.openhoofd.nl,51.9229,4.40833 -relays.diggoo.com,43.6532,-79.3832 -relay.nostrhub.tech,49.0291,8.35696 -relay.artx.market,43.652,-79.3633 -relay.sigit.io,50.4754,12.3683 -nostr-relay.psfoundation.info,39.0438,-77.4874 -wot.brightbolt.net,47.6735,-116.781 -relay.bitcoinartclock.com,50.4754,12.3683 -khatru.nostrver.se,51.1792,5.89444 -relay.cosmicbolt.net,37.3986,-121.964 -adre.su,59.9311,30.3609 -nostrelay.memory-art.xyz,43.6532,-79.3832 -temp.iris.to,43.6532,-79.3832 -nproxy.kristapsk.lv,60.1699,24.9384 -relay.ngengine.org,43.6532,-79.3832 -relay.puresignal.news,43.6532,-79.3832 -nostr.stakey.net,52.3676,4.90414 -nostr.commonshub.brussels,49.4543,11.0746 -wot.shaving.kiwi,43.6532,-79.3832 -espelho.girino.org,43.6532,-79.3832 -relay.thibautduchene.fr,43.6532,-79.3832 -nostr-relay.corb.net,38.8353,-104.822 -wot.soundhsa.com,33.1384,-95.6011 -relay.jabato.space,52.52,13.405 -relay.credenso.cafe,43.3601,-80.3127 -relayone.soundhsa.com,33.1384,-95.6011 -relay.bitcoinveneto.org,64.1466,-21.9426 -relay.samt.st,40.8302,-74.1299 -relay.vrtmrz.net,43.6532,-79.3832 -nostr.data.haus,50.4754,12.3683 -relay.npubhaus.com,43.6532,-79.3832 -strfry.elswa-dev.online,50.1109,8.68213 -relay.wavefunc.live,34.0362,-118.443 -wot.sovbit.host,64.1466,-21.9426 -relay.smies.me,33.7501,-84.3885 -nostr.bilthon.dev,25.8128,-80.2377 -relay-fra.zombi.cloudrodion.com,48.8566,2.35222 -santo.iguanatech.net,40.8302,-74.1299 -prl.plus,42.6978,23.3246 -relay-freeharmonypeople.space,38.7223,-9.13934 -nostream.breadslice.com,1.35208,103.82 -nostr.notribe.net,40.8302,-74.1299 -relay.fountain.fm,39.0997,-94.5786 -orangepiller.org,60.1699,24.9384 -nostr.21crypto.ch,47.5356,8.73209 -nostr.camalolo.com,24.1469,120.684 -okn.czas.top,51.267,6.81738 -relay.wolfcoil.com,35.6092,139.73 -nostr.quali.chat,60.1699,24.9384 -nostr-relay.online,43.6532,-79.3832 -alienos.libretechsystems.xyz,55.4724,9.87335 -ribo.eu.nostria.app,52.3676,4.90414 -wot.yesnostr.net,50.9871,2.12554 -nostr.overmind.lol,43.6532,-79.3832 -relay.chakany.systems,43.6532,-79.3832 -relay.hasenpfeffr.com,39.0438,-77.4874 -nostrelites.org,41.8781,-87.6298 -strfry.bonsai.com,37.8715,-122.273 -relay.agora.social,50.7383,15.0648 alien.macneilmediagroup.com,43.6532,-79.3832 -relay.getsafebox.app,43.6532,-79.3832 -relay.nuts.cash,34.0362,-118.443 -notemine.io,52.2026,20.9397 -relay.nostr.place,32.7767,-96.797 -relay.21e6.cz,50.7383,15.0648 -nostr.casa21.space,43.6532,-79.3832 -premium.primal.net,43.6532,-79.3832 -relay5.bitransfer.org,43.6532,-79.3832 -nostr.rblb.it,43.7094,10.6582 -relay.goodmorningbitcoin.com,43.6532,-79.3832 -relay.camelus.app,45.5201,-122.99 -relay.origin.land,35.6673,139.751 -relay.fr13nd5.com,52.5233,13.3426 -nos.lol,50.4754,12.3683 -wot.nostr.net,43.6532,-79.3832 -relay.javi.space,43.4633,11.8796 -relay2.ngengine.org,43.6532,-79.3832 -relay.angor.io,48.1046,11.6002 -pyramid.treegaze.com,43.6532,-79.3832 -relay.letsfo.com,52.2633,21.0283 -relay.nostrzh.org,43.6532,-79.3832 -nostr.hekster.org,37.3986,-121.964 -relay.0xchat.com,1.35208,103.82 -nostr.czas.top,50.1109,8.68213 -nostr.superfriends.online,43.6532,-79.3832 -hsuite-nostr-relay.hbarsuite.workers.dev,43.6532,-79.3832 -relay.mccormick.cx,52.3563,4.95714 -nostr.88mph.life,51.5072,-0.127586 -strfry.ymir.cloud,34.0965,-117.585 -nostr.lkjsxc.com,43.6532,-79.3832 -yabu.me,35.6092,139.73 -relay.chorus.community,50.1109,8.68213 -nostr-02.yakihonne.com,1.32123,103.695 -neuromancer.nettek.io,39.1429,-94.573 -relay04.lnfi.network,39.0997,-94.5786 -nostr-relay.amethyst.name,39.0438,-77.4874 -nostrcheck.tnsor.network,43.6532,-79.3832 -wot.nostr.place,32.7767,-96.797 -cyberspace.nostr1.com,40.7057,-74.0136 -relay.guggero.org,47.3769,8.54169 -nostr.calitabby.net,39.9268,-75.0246 -x.kojira.io,43.6532,-79.3832 -nostr.agentcampfire.com,52.3676,4.90414 -relay.bullishbounty.com,43.6532,-79.3832 -relay.nostrverse.net,43.6532,-79.3832 -nostr.robosats.org,64.1476,-21.9392 -nostr-verified.wellorder.net,45.5201,-122.99 -nostr.huszonegy.world,47.4979,19.0402 -relay.cypherflow.ai,48.8566,2.35222 -nostr-relay.xbytez.io,50.6924,3.20113 -nostr.faultables.net,43.6532,-79.3832 -relay.libernet.app,43.6532,-79.3832 -relay.magiccity.live,25.8128,-80.2377 -relay.wellorder.net,45.5201,-122.99 -black.nostrcity.club,48.8575,2.35138 -relay.jeffg.fyi,43.6532,-79.3832 -freeben666.fr,43.7221,7.15296 -relay.nostr.wirednet.jp,34.706,135.493 -nostrelay.circum.space,52.3676,4.90414 -relay.islandbitcoin.com,12.8498,77.6545 -nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 -relay.nostriot.com,41.5695,-83.9786 -relayrs.notoshi.win,43.6532,-79.3832 -nostr-pub.wellorder.net,45.5201,-122.99 -nostr.vulpem.com,49.4543,11.0746 -nostr.sathoarder.com,48.5734,7.75211 -wheat.happytavern.co,43.6532,-79.3832 -relay.nostr.net,43.6532,-79.3832 -bcast.girino.org,43.6532,-79.3832 -nostr-02.czas.top,51.2277,6.77346 -vault.iris.to,43.6532,-79.3832 -ynostr.yael.at,60.1699,24.9384 -nostr.nodesmap.com,59.3327,18.0656 -nostr.n7ekb.net,47.4941,-122.294 -relayb.uid.ovh,43.6532,-79.3832 -shu05.shugur.net,48.8566,2.35222 -dev-relay.lnfi.network,39.0997,-94.5786 -relay.bitcoindistrict.org,43.6532,-79.3832 -nostr.spicyz.io,43.6532,-79.3832 -nostr.0x7e.xyz,47.4988,8.72369 -relay.dwadziesciajeden.pl,52.2297,21.0122 -relay.zone667.com,60.1699,24.9384 -nostr-dev.wellorder.net,45.5201,-122.99 -nos.xmark.cc,50.6924,3.20113 -relay.etch.social,41.2619,-95.8608 -nostr.na.social,43.6532,-79.3832 -relay.orangepill.ovh,49.1689,-0.358841 -relay.olas.app,50.4754,12.3683 -relay.holzeis.me,43.6532,-79.3832 -relay2.angor.io,48.1046,11.6002 -relay.degmods.com,50.4754,12.3683 -vitor.nostr1.com,40.7128,-74.006 -relay.btcforplebs.com,43.6532,-79.3832 -nostr.luisschwab.net,43.6532,-79.3832 -relay.moinsen.com,50.4754,12.3683 -czas.xyz,48.8566,2.35222 -nostr.bitcoiner.social,39.1585,-94.5728 -nostr.mehdibekhtaoui.com,49.4939,-1.54813 -inbox.azzamo.net,52.2633,21.0283 -nostr.ovia.to,43.6532,-79.3832 -nostr.myshosholoza.co.za,52.3676,4.90414 -fenrir-s.notoshi.win,43.6532,-79.3832 -relay-rpi.edufeed.org,49.4521,11.0767 -chat-relay.zap-work.com,43.6532,-79.3832 -nostr.bond,50.1109,8.68213 -relay01.lnfi.network,39.0997,-94.5786 -relay.seq1.net,43.6532,-79.3832 -nostr.rikmeijer.nl,50.4754,12.3683 -offchain.pub,47.6743,-117.112 -nostr.spaceshell.xyz,43.6532,-79.3832 -nos4smartnkind.tech,40.1872,44.5152 -kotukonostr.onrender.com,37.7775,-122.397 -nostr.ps1829.com,33.8851,130.883 -relay.lightning.pub,39.0438,-77.4874 -nostr-relay.gateway.in.th,15.2634,100.344 -relay.thebluepulse.com,49.4521,11.0767 -relay.malxte.de,52.52,13.405 -relay.usefusion.ai,38.7134,-78.1591 -nostr-relay.cbrx.io,43.6532,-79.3832 -shu01.shugur.net,21.4902,39.2246 -nostr.jerrynya.fun,31.2304,121.474 -dev-nostr.bityacht.io,25.0797,121.234 -bitsat.molonlabe.holdings,51.4012,-1.3147 -relay.primal.net,43.6532,-79.3832 -relay.wavlake.com,41.2619,-95.8608 -nostr-relay.nextblockvending.com,47.2343,-119.853 v-relay.d02.vrtmrz.net,34.6937,135.502 -nostr.mikoshi.de,47.74,12.0917 -nostr.coincards.com,53.5501,-113.469 -relay.snort.social,53.3498,-6.26031 -bitcoiner.social,39.1585,-94.5728 -nostr.noones.com,50.1109,8.68213 -wot.dergigi.com,64.1476,-21.9392 +relay.thebluepulse.com,49.4521,11.0767 +relay.guggero.org,47.3769,8.54169 relay.lumina.rocks,49.0291,8.35695 -nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 -nostr.snowbla.de,60.1699,24.9384 -strfry.felixzieger.de,50.1013,8.62643 -relay.satlantis.io,32.8769,-80.0114 -relay-dev.satlantis.io,40.8302,-74.1299 -nostr.davidebtc.me,51.5072,-0.127586 -relay.mattybs.lol,43.6532,-79.3832 -relay.fundstr.me,42.3601,-71.0589 -relay.tagayasu.xyz,43.6715,-79.38 -nostr-relay.zimage.com,34.0549,-118.243 -nostrcheck.me,43.6532,-79.3832 -bucket.coracle.social,37.7775,-122.397 -relay.siamdev.cc,13.9178,100.424 -r.bitcoinhold.net,43.6532,-79.3832 -skeme.vanderwarker.family,40.8218,-74.45 -articles.layer3.news,37.3387,-121.885 -no.str.cr,9.92857,-84.0528 -srtrelay.c-stellar.net,43.6532,-79.3832 -relay.comcomponent.com,34.7062,135.493 -relay.illuminodes.com,47.6061,-122.333 -nostr.openhoofd.nl,51.9229,4.40833 -nostr.plantroon.com,50.1013,8.62643 -slick.mjex.me,39.048,-77.4817 -relay.minibolt.info,43.6532,-79.3832 -nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 -relay.nostrdice.com,-33.8688,151.209 -wot.dtonon.com,43.6532,-79.3832 -shu04.shugur.net,25.2604,55.2989 -nostr.tavux.tech,48.8575,2.35138 -nostr-02.uid.ovh,43.6532,-79.3832 -relay.nostrcheck.me,43.6532,-79.3832 -relay.nsnip.io,60.1699,24.9384 -relay.toastr.net,40.8054,-74.0241 -relay.divine.video,43.6532,-79.3832 -nostr.4rs.nl,49.0291,8.35696 -wot.sebastix.social,51.1792,5.89444 -relay.openfarmtools.org,60.1699,24.9384 -relay.damus.io,43.6532,-79.3832 -purpura.cloud,43.6532,-79.3832 -wot.sudocarlos.com,51.5072,-0.127586 -relay.arx-ccn.com,50.4754,12.3683 -nostr.zoracle.org,45.6018,-121.185 -nostr.tac.lol,47.4748,-122.273 -relay.coinos.io,43.6532,-79.3832 -nostr.rtvslawenia.com,49.4543,11.0746 -relay.davidebtc.me,51.5072,-0.127586 -ribo.us.nostria.app,41.5868,-93.625 -relay.upleb.uk,51.9194,19.1451 -librerelay.aaroniumii.com,43.6532,-79.3832 -relay.endfiat.money,43.6532,-79.3832 -relay.nostar.org,43.6532,-79.3832 -relay.electriclifestyle.com,26.2897,-80.1293 -relay.nostrhub.fr,48.1045,11.6004 -relay.agorist.space,52.3734,4.89406 -freelay.sovbit.host,64.1476,-21.9392 -nostr.hifish.org,47.4043,8.57398 -nostr-01.yakihonne.com,1.32123,103.695 -relay.routstr.com,43.6532,-79.3832 -nr.yay.so,46.2126,6.1154 -bcast.seutoba.com.br,43.6532,-79.3832 -fanfares.nostr1.com,40.7128,-74.006 -nostr.girino.org,43.6532,-79.3832 -nostr2.girino.org,43.6532,-79.3832 -relay.notoshi.win,13.311,101.112 -nostrja-kari.heguro.com,43.6532,-79.3832 -relay.mostro.network,40.8302,-74.1299 -satsage.xyz,37.3986,-121.964 +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 -nostr-2.21crypto.ch,47.5356,8.73209 +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 +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 -relaynostr.breadslice.com,43.6532,-79.3832 -nostr.chaima.info,51.223,6.78245 -nostr.mom,50.4754,12.3683 +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 +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 +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/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 6e9fbd39..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 @@ -63,15 +65,46 @@ 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() @@ -103,6 +136,9 @@ class MainActivity : OrientationAwareActivity() { activity = this, permissionManager = permissionManager, onOnboardingComplete = ::handleOnboardingComplete, + onBackgroundLocationRequired = { + mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION) + }, onOnboardingFailed = ::handleOnboardingFailed ) @@ -235,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) { @@ -348,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) @@ -624,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) @@ -646,10 +705,13 @@ class MainActivity : OrientationAwareActivity() { 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) } } @@ -659,9 +721,6 @@ class MainActivity : OrientationAwareActivity() { if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { // Reattach mesh delegate to new ChatViewModel instance after Activity recreation try { meshService.delegate = chatViewModel } catch (_: Exception) { } - // Set app foreground state - meshService.connectionManager.setAppBackgroundState(false) - chatViewModel.setAppBackgroundState(false) // Check if Bluetooth was disabled while app was backgrounded val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus() @@ -688,9 +747,6 @@ class MainActivity : OrientationAwareActivity() { 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) { } } @@ -755,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() 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/LocationChannelManager.kt b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt index 9c18a859..3e74a8ee 100644 --- a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt +++ b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt @@ -124,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() } /** @@ -163,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) { + } } /** @@ -297,14 +322,38 @@ class LocationChannelManager private constructor(private val context: Context) { } } + // 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.value = false @@ -315,6 +364,18 @@ 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 @@ -663,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 8e0fa15b..6defd010 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -247,13 +247,6 @@ class BluetoothConnectionManager( return active } - /** - * Set app background state for power optimization - */ - fun setAppBackgroundState(inBackground: Boolean) { - powerManager.setAppBackgroundState(inBackground) - } - /** * 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/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 3de48407..91f88919 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 @@ -72,6 +75,7 @@ class BluetoothMeshService(private val context: Context) { init { Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID") + VerificationService.configure(encryptionService) setupDelegates() messageHandler.packetProcessor = packetProcessor //startPeriodicDebugLogging() @@ -414,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 @@ -939,6 +951,50 @@ class BluetoothMeshService(private val context: Context) { } } } + + // 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 @@ -1127,6 +1183,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 @@ -1283,6 +1343,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/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 d016dd37..ce8b0d9e 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) { @@ -611,4 +619,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 46bc394f..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) } 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 bce69c26..5beb3384 100644 --- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt @@ -56,21 +56,30 @@ class SecurityManager(private val encryptionService: EncryptionService, private // Duplicate detection val messageID = generateMessageID(packet, peerID) - if (messageType != MessageType.ANNOUNCE) { - if (processedMessages.contains(messageID)) { + + 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 } - // Add to processed messages - processedMessages.add(messageID) - messageTimestamps[messageID] = currentTime - } else { - // Do not deduplicate ANNOUNCE at the security layer. - // They are signed/idempotent and we need to ensure first-announce per-connection can bind. + 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 @@ -223,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, @@ -258,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/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/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 index d4e00104..1c3abaf9 100644 --- a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt +++ b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt @@ -9,10 +9,13 @@ 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: @@ -24,6 +27,15 @@ import kotlinx.coroutines.withTimeoutOrNull */ 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, @@ -32,7 +44,16 @@ object AppShutdownCoordinator { stopForeground: () -> Unit, stopService: () -> Unit ) { - scope.launch { + 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) { } @@ -56,12 +77,19 @@ object AppShutdownCoordinator { } // 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/MeshForegroundService.kt b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt index 7510fa06..bf1f236f 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt @@ -7,6 +7,7 @@ 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 @@ -115,6 +116,7 @@ class MeshForegroundService : Service() { 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() @@ -133,6 +135,13 @@ class MeshForegroundService : Service() { } 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 @@ -145,6 +154,12 @@ class MeshForegroundService : Service() { 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, @@ -162,7 +177,7 @@ class MeshForegroundService : Service() { // If we became eligible and are not in foreground yet, promote once if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) { val n = buildNotification(meshService?.getActivePeerCount() ?: 0) - startForeground(NOTIFICATION_ID, n) + startForegroundCompat(n) isInForeground = true } else { updateNotification(force = true) @@ -177,7 +192,7 @@ class MeshForegroundService : Service() { // Promote exactly once when eligible, otherwise stay background (or stop) if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) { val notification = buildNotification(meshService?.getActivePeerCount() ?: 0) - startForeground(NOTIFICATION_ID, notification) + startForegroundCompat(notification) isInForeground = true } @@ -208,6 +223,7 @@ class MeshForegroundService : Service() { } private fun ensureMeshStarted() { + if (isShuttingDown) return if (!hasBluetoothPermissions()) return try { android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started") @@ -218,6 +234,10 @@ class MeshForegroundService : Service() { } 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()) { @@ -257,7 +277,7 @@ class MeshForegroundService : Service() { } else true } - private fun buildNotification(activeUsers: Int): Notification { + private fun buildNotification(activePeers: Int): Notification { val openIntent = Intent(this, MainActivity::class.java) val pendingIntent = PendingIntent.getActivity( this, 0, openIntent, @@ -272,7 +292,7 @@ class MeshForegroundService : Service() { ) val title = getString(R.string.app_name) - val content = getString(R.string.mesh_service_notification_content, activeUsers) + val content = getString(R.string.mesh_service_notification_content, activePeers) return NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle(title) @@ -307,6 +327,35 @@ class MeshForegroundService : Service() { } } + 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 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/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index f741dafa..6d3a9f3a 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -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) ) } 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 d4d33ac0..bf6a73b0 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -57,6 +57,8 @@ fun ChatScreen(viewModel: ChatViewModel) { 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) } @@ -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 6d0f2364..c4ae3db0 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt @@ -122,6 +122,12 @@ class ChatState( // Navigation state 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 = MutableStateFlow(com.bitchat.android.geohash.ChannelID.Mesh) @@ -302,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 1196396d..75101897 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -5,11 +5,15 @@ import android.util.Log import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.AndroidViewModel 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,6 +57,20 @@ 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( scope = viewModelScope, @@ -57,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) @@ -75,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) @@ -134,6 +171,8 @@ class ChatViewModel( 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 @@ -247,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 { @@ -646,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) @@ -660,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 @@ -668,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) { @@ -744,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) @@ -827,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 { @@ -840,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) { } @@ -969,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/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt index a452616e..d27719c7 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -216,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) 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/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 4ab4360a..9b8328d6 100644 --- a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt @@ -23,6 +23,7 @@ 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,6 +35,7 @@ import com.bitchat.android.ui.theme.BASE_FONT_SIZE fun SidebarOverlay( viewModel: ChatViewModel, onDismiss: () -> Unit, + onShowVerification: () -> Unit, modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme @@ -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()) { @@ -295,6 +312,7 @@ fun PeopleSection( 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] @@ -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/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt index 9881cc2f..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,9 +80,9 @@ 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 = 8 const val MAX_CONNECTIONS_ULTRA_LOW: Int = 4 @@ -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/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 4a985c6c..9845fd85 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -64,14 +64,14 @@ Mesh Background Service Keeps the Bluetooth mesh running in the background - Mesh running — %1$d users connected + Mesh running — %1$d peers Add to favorites Remove from favorites Add bookmark - + back channel: %1$s leave @@ -265,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 @@ -282,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 @@ -367,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 @@ -375,6 +424,7 @@ Nearby Devices Precise Location + Background Location Microphone Notifications Battery Optimization 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 8e0790a0..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" } @@ -104,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" }