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