Merge branch 'main' into gossip-routing-2

This commit is contained in:
callebtc
2026-01-07 05:52:25 +07:00
121 changed files with 9291 additions and 1713 deletions
+19 -60
View File
@@ -1,15 +1,17 @@
name: Android CI name: Android CI
on: on:
workflow_dispatch:
push: push:
branches: [ "main", "develop" ] branches: [ "main", "develop" ]
pull_request: pull_request:
branches: [ "main", "develop" ] branches: [ "main", "develop" ]
jobs: jobs:
test: verify:
name: Test & Lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -39,7 +41,10 @@ jobs:
- name: Run unit tests - name: Run unit tests
run: ./gradlew testDebugUnitTest run: ./gradlew testDebugUnitTest
- name: Upload Test Reports (xml+html) - name: Run lint
run: ./gradlew lintDebug
- name: Upload test reports
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
@@ -48,47 +53,6 @@ jobs:
**/build/test-results/ **/build/test-results/
**/build/reports/tests/ **/build/reports/tests/
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: |
**/build/test-results/
**/build/reports/tests/
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/gradle-build-action@v3
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Cache Gradle packages
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Run lint
run: ./gradlew lintDebug
- name: Upload lint results - name: Upload lint results
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
if: always() if: always()
@@ -97,9 +61,13 @@ jobs:
path: '**/build/reports/lint-results-*.html' path: '**/build/reports/lint-results-*.html'
build: build:
name: Build ${{ matrix.variant }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [test, lint] needs: verify
strategy:
matrix:
variant: [Debug, Release]
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -126,20 +94,11 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-gradle- ${{ runner.os }}-gradle-
- name: Build debug APK - name: Build ${{ matrix.variant }} APK
run: ./gradlew assembleDebug run: ./gradlew assemble${{ matrix.variant }}
- name: Build release APK - name: Upload ${{ matrix.variant }} APK
run: ./gradlew assembleRelease
- name: Upload debug APK
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: debug-apk name: ${{ matrix.variant }}-apk
path: app/build/outputs/apk/debug/*.apk path: app/build/outputs/apk/**/*.apk
- name: Upload release APK
uses: actions/upload-artifact@v4
with:
name: release-apk
path: app/build/outputs/apk/release/*.apk
+27 -20
View File
@@ -19,7 +19,7 @@ jobs:
with: with:
distribution: temurin distribution: temurin
java-version: 17 java-version: 17
- name: Setup Gradle - name: Setup Gradle
uses: gradle/gradle-build-action@v2 uses: gradle/gradle-build-action@v2
with: with:
@@ -38,32 +38,32 @@ jobs:
- name: Grant execute permission for Gradlew - name: Grant execute permission for Gradlew
run: chmod +x ./gradlew run: chmod +x ./gradlew
- name: Build APK - name: Build Release APKs (with architecture splits)
run: ./gradlew assembleRelease --no-daemon --stacktrace run: ./gradlew assembleRelease --no-daemon --stacktrace
- name: List APK files - name: List APK files
run: | run: |
echo "APK files built:" echo "APK files built:"
find app/build/outputs/apk/release -name "*.apk" -type f find app/build/outputs/apk/release -name "*.apk" -type f -exec ls -lh {} \;
- name: Rename APK - name: Rename APKs for GitHub Release
run: | run: |
mv app/build/outputs/apk/release/app-release-unsigned.apk app/build/outputs/apk/release/bitchat.apk cd app/build/outputs/apk/release
[ -f "app-arm64-v8a-release-unsigned.apk" ] && mv app-arm64-v8a-release-unsigned.apk bitchat-android-arm64.apk
[ -f "app-x86_64-release-unsigned.apk" ] && mv app-x86_64-release-unsigned.apk bitchat-android-x86_64.apk
[ -f "app-universal-release-unsigned.apk" ] && mv app-universal-release-unsigned.apk bitchat-android-universal.apk
- name: DEBUG - name: DEBUG
run: | run: |
set -x set -x
pwd pwd
ls -all ls -all
cd app/build/outputs/ cd app/build/outputs/
ls -all ls -all
tree tree || ls -R
# Optional: Sign APK (requires secrets) # Optional: Sign APKs (uncomment and configure secrets when ready)
# - name: Sign APK # - name: Sign APKs
# uses: r0adkll/sign-android-release@v1 # uses: r0adkll/sign-android-release@v1
# with: # with:
# releaseDirectory: app/build/outputs/apk/release # releaseDirectory: app/build/outputs/apk/release
@@ -72,10 +72,10 @@ jobs:
# keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} # keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
# keyPassword: ${{ secrets.KEY_PASSWORD }} # keyPassword: ${{ secrets.KEY_PASSWORD }}
- name: Upload APK as artifact - name: Upload APKs as artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: bitchat-release-apk-${{ github.ref_name }} name: bitchat-android-release-${{ github.ref_name }}
path: app/build/outputs/apk/release/*.apk path: app/build/outputs/apk/release/*.apk
retention-days: 30 retention-days: 30
if-no-files-found: error if-no-files-found: error
@@ -85,16 +85,23 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Download APK artifact - name: Download release artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: bitchat-release-apk-${{ github.ref_name }} name: bitchat-android-release-${{ github.ref_name }}
path: . path: release
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
files: bitchat.apk files: |
release/bitchat-android-arm64.apk
release/bitchat-android-x86_64.apk
release/bitchat-android-universal.apk
name: Release ${{ github.ref_name }} name: Release ${{ github.ref_name }}
body: |
**bitchat-android-arm64.apk** - ARM64 (most phones)
**bitchat-android-x86_64.apk** - x86_64 (Chromebooks, tablets)
**bitchat-android-universal.apk** - All architectures (fallback)
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+7 -1
View File
@@ -43,7 +43,9 @@ dependency-reduced-pom.xml
# Other # Other
*.log *.log
.cxx/ .cxx/
*build/ # Gradle/Android build directories (but not tools/arti-build/)
**/build/
!tools/arti-build/
out/ out/
gen/ gen/
*~ *~
@@ -57,3 +59,7 @@ google-services.json
# Keystore files # Keystore files
*.jks *.jks
*.keystore *.keystore
# Arti build artifacts (cloned repo and Rust build cache)
tools/arti-build/.arti-source/
tools/arti-build/target/
+1
View File
@@ -102,6 +102,7 @@ The app requires the following permissions (automatically requested):
- **Bluetooth**: Core BLE functionality - **Bluetooth**: Core BLE functionality
- **Location**: Required for BLE scanning on Android - **Location**: Required for BLE scanning on Android
- **Network**: Expand your mesh through public internet relays
- **Notifications**: Message alerts and background updates - **Notifications**: Message alerts and background updates
### Hardware Requirements ### Hardware Requirements
+35 -4
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.bitchat.droid" applicationId = "com.bitchat.droid"
minSdk = libs.versions.minSdk.get().toInt() minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = 26 versionCode = 30
versionName = "1.5.1" versionName = "1.6.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@@ -30,6 +30,12 @@ android {
} }
buildTypes { buildTypes {
debug {
ndk {
// Include x86_64 for emulator support during development
abiFilters += listOf("arm64-v8a", "x86_64")
}
}
release { release {
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
@@ -39,6 +45,18 @@ android {
) )
} }
} }
// APK splits for GitHub releases - creates arm64, x86_64, and universal APKs
// AAB for Play Store handles architecture distribution automatically
splits {
abi {
isEnable = true
reset()
include("arm64-v8a", "x86_64")
isUniversalApk = true // For F-Droid and fallback
}
}
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8
@@ -73,12 +91,22 @@ dependencies {
// Lifecycle // Lifecycle
implementation(libs.bundles.lifecycle) implementation(libs.bundles.lifecycle)
implementation(libs.androidx.lifecycle.process)
// Navigation // Navigation
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
// Permissions // Permissions
implementation(libs.accompanist.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 // Cryptography
implementation(libs.bundles.cryptography) implementation(libs.bundles.cryptography)
@@ -95,8 +123,11 @@ dependencies {
// WebSocket // WebSocket
implementation(libs.okhttp) implementation(libs.okhttp)
// Arti (Tor in Rust) Android bridge - use published AAR with native libs // Arti (Tor in Rust) Android bridge - custom build from latest source
implementation("info.guardianproject:arti-mobile-ex:1.2.3") // Built with rustls, 16KB page size support, and onio//un service client
// Native libraries are in src/tor/jniLibs/ (extracted from arti-custom.aar)
// Only included in tor flavor to reduce APK size for standard builds
// Note: AAR is kept in libs/ for reference, but libraries loaded from jniLibs/
// Google Play Services Location // Google Play Services Location
implementation(libs.gms.location) implementation(libs.gms.location)
-11
View File
@@ -243,17 +243,6 @@
column="35"/> column="35"/>
</issue> </issue>
<issue
id="OldTargetApi"
message="Not targeting the latest versions of Android; compatibility modes apply. Consider testing and updating this version. Consult the android.os.Build.VERSION_CODES javadoc for details."
errorLine1=" targetSdk = 34"
errorLine2=" ~~~~~~~~~~~~~~">
<location
file="build.gradle.kts"
line="14"
column="9"/>
</issue>
<issue <issue
id="RedundantLabel" id="RedundantLabel"
message="Redundant label can be removed" message="Redundant label can be removed"
+7 -4
View File
@@ -17,9 +17,12 @@
-keep class com.bitchat.android.nostr.** { *; } -keep class com.bitchat.android.nostr.** { *; }
-keep class com.bitchat.android.identity.** { *; } -keep class com.bitchat.android.identity.** { *; }
# Arti (Tor) ProGuard rules # Keep Tor implementation (always included)
-keep class com.bitchat.android.net.RealTorProvider { *; }
# Arti (Custom Tor implementation in Rust) ProGuard rules
-keep class info.guardianproject.arti.** { *; } -keep class info.guardianproject.arti.** { *; }
-keep class org.torproject.jni.** { *; } -keep class org.torproject.arti.** { *; }
-keepnames class org.torproject.jni.** -keepnames class org.torproject.arti.**
-dontwarn info.guardianproject.arti.** -dontwarn info.guardianproject.arti.**
-dontwarn org.torproject.jni.** -dontwarn org.torproject.arti.**
+54
View File
@@ -16,12 +16,26 @@
<!-- Location permission required for BLE scanning --> <!-- Location permission required for BLE scanning -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- Notification permissions --> <!-- Notification permissions -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Signature permission for internal UI shutdown broadcasts -->
<uses-permission android:name="com.bitchat.android.permission.FORCE_FINISH" />
<!-- Foreground service and boot permissions for long-running background mesh -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- Connected device foreground service type for BLE operations (API 34+) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<!-- Data sync foreground service type (required when declaring dataSync) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Location foreground service type required for BLE scanning in background -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Microphone for voice notes --> <!-- Microphone for voice notes -->
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Camera for QR verification -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Storage permissions for file sharing --> <!-- Storage permissions for file sharing -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="28" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
@@ -38,6 +52,11 @@
<!-- Hardware features --> <!-- Hardware features -->
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" /> <uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<uses-feature android:name="android.hardware.bluetooth" android:required="true" /> <uses-feature android:name="android.hardware.bluetooth" android:required="true" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<permission
android:name="com.bitchat.android.permission.FORCE_FINISH"
android:protectionLevel="signature" />
<application <application
android:name=".BitchatApplication" android:name=".BitchatApplication"
@@ -76,6 +95,41 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="bitchat" android:host="verify" />
</intent-filter>
</activity> </activity>
<!-- Persistent foreground service to run the mesh in background -->
<service
android:name=".service.MeshForegroundService"
android:exported="false"
android:foregroundServiceType="connectedDevice|dataSync|location"
tools:ignore="DataExtractionRules">
</service>
<!-- Listen for in-app broadcast when POST_NOTIFICATIONS is granted -->
<receiver
android:name=".service.NotificationPermissionChangedReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="com.bitchat.android.action.NOTIFICATION_PERMISSION_GRANTED" />
</intent-filter>
</receiver>
<!-- Auto-start mesh service after boot if enabled -->
<receiver
android:name=".service.BootCompletedReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application> </application>
</manifest> </manifest>
+283 -270
View File
@@ -1,277 +1,290 @@
Relay URL,Latitude,Longitude Relay URL,Latitude,Longitude
nostr-relay.amethyst.name,39.0438,-77.4874 alien.macneilmediagroup.com,43.6532,-79.3832
bcast.seutoba.com.br,43.6532,-79.3832 v-relay.d02.vrtmrz.net,34.6937,135.502
premium.primal.net,40.7357,-74.1724 relay.thebluepulse.com,49.4521,11.0767
strfry.openhoofd.nl,51.9229,4.40833
relay.mostro.network,40.8302,-74.1299
ribo.us.nostria.app,41.5868,-93.625
relayone.geektank.ai,18.2148,-63.0574
nostr.mikoshi.de,50.1109,8.68213
relay.lifpay.me,1.35208,103.82
shu02.shugur.net,21.4902,39.2246
nostr.hifish.org,47.4043,8.57398
orangepiller.org,60.1699,24.9384
nostr.myshosholoza.co.za,52.3676,4.90414
relay.nostrcheck.me,43.6532,-79.3832
relay.nostrhub.tech,49.0291,8.35696
nostr.mehdibekhtaoui.com,49.4939,-1.54813
nostrcheck.me,40.7357,-74.1724
relay.arx-ccn.com,50.4754,12.3683
relay2.ngengine.org,43.6532,-79.3832
nostr.21crypto.ch,47.4988,8.72369
nostr.girino.org,43.6532,-79.3832
relay.nostriches.club,43.6532,-79.3832
relay.angor.io,48.1046,11.6002
nostr.data.haus,50.4754,12.3683
nostr.zoracle.org,45.6018,-121.185
relay2.angor.io,48.1046,11.6002
librerelay.aaroniumii.com,43.6532,-79.3832
nostr.tadryanom.me,40.7357,-74.1724
nostr-relay.cbrx.io,43.6532,-79.3832
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
relay.aloftus.io,34.0881,-118.379
relay.barine.co,43.6532,-79.3832
nostr.red5d.dev,43.6532,-79.3832
nos.lol,50.4754,12.3683
ithurtswhenip.ee,51.223,6.78245
strfry.bonsai.com,37.8715,-122.273
relay01.lnfi.network,39.0997,-94.5786
nostrcheck.tnsor.network,43.6532,-79.3832
x.kojira.io,43.6532,-79.3832
nostr.snowbla.de,60.1699,24.9384
purplerelay.com,50.1109,8.68213
wot.sovbit.host,64.1466,-21.9426
shu04.shugur.net,25.2604,55.2989
nr.yay.so,46.2126,6.1154
nostr.smartflowsocial.com,43.6532,-79.3832
shu05.shugur.net,48.8566,2.35222
wot.dtonon.com,43.6532,-79.3832
pyramid.fiatjaf.com,51.5072,-0.127586
nostr.88mph.life,40.7357,-74.1724
dev-nostr.bityacht.io,25.0797,121.234
cyberspace.nostr1.com,40.7128,-74.006
relay.ngengine.org,40.7357,-74.1724
nostr.calitabby.net,39.9268,-75.0246
relay.toastr.net,40.8054,-74.0241
relay.cosmicbolt.net,37.3986,-121.964
nostr.davidebtc.me,50.1109,8.68213
espelho.girino.org,43.6532,-79.3832
srtrelay.c-stellar.net,40.7357,-74.1724
relay.nostriot.com,41.5695,-83.9786
relay.bitcoinveneto.org,64.1466,-21.9426
bitcoiner.social,39.1585,-94.5728
purpura.cloud,40.7357,-74.1724
nostr.blankfors.se,60.1699,24.9384
relay.nostromo.social,49.4543,11.0746
ribo.af.nostria.app,-26.2041,28.0473
relay.olas.app,50.4754,12.3683
nostr.carroarmato0.be,50.9928,3.26317
nostr.oxtr.dev,50.4754,12.3683
relay.upleb.uk,52.2297,21.0122
nostr.stakey.net,52.3676,4.90414
nostr2.girino.org,40.7357,-74.1724
nostr.rikmeijer.nl,50.4754,12.3683
relay5.bitransfer.org,40.7357,-74.1724
relay.mattybs.lol,40.7357,-74.1724
nostr.makibisskey.work,40.7357,-74.1724
wot.basspistol.org,49.4521,11.0767
relay.nostr.net,50.4754,12.3683
a.nos.lol,50.4754,12.3683
wot.dergigi.com,64.1476,-21.9392
fanfares.nostr1.com,40.7057,-74.0136
nostr.faultables.net,43.6532,-79.3832
relay.dwadziesciajeden.pl,52.2297,21.0122
adre.su,59.9311,30.3609
wot.nostr.party,36.1627,-86.7816
relay03.lnfi.network,39.0997,-94.5786
relay.orangepill.ovh,49.1689,-0.358841
gnostr.com,42.6978,23.3246
relay.wellorder.net,45.5201,-122.99
relay.21e6.cz,50.1682,14.0546
relay.tagayasu.xyz,43.6715,-79.38
relay.chatbett.de,28.0199,-82.5248
wot.soundhsa.com,33.1384,-95.6011
prl.plus,38.9072,-77.0369
nostr.einundzwanzig.space,50.1109,8.68213
nostream.breadslice.com,1.35208,103.82
relay.chorus.community,50.1109,8.68213
nostr.mom,50.4754,12.3683
relay.lightning.pub,41.8959,-88.2169
relay.hook.cafe,40.7357,-74.1724
relay-testnet.k8s.layer3.news,37.3387,-121.885
relay.fundstr.me,42.3601,-71.0589
relay.stream.labs.h3.se,59.4016,17.9455
relay.holzeis.me,43.6532,-79.3832
articles.layer3.news,37.3387,-121.885
nproxy.kristapsk.lv,60.1699,24.9384
nostr.thebiglake.org,32.71,-96.6745
relay.coinos.io,40.7357,-74.1724
nostr.camalolo.com,24.1469,120.684
theoutpost.life,64.1476,-21.9392
relay.nostraddress.com,40.7357,-74.1724
nostr.casa21.space,40.7357,-74.1724
relay.agorist.space,52.3734,4.89406
relay.nostr.wirednet.jp,34.706,135.493
nostr.jerrynya.fun,31.2304,121.474
relay.13room.space,43.6532,-79.3832
offchain.pub,47.6743,-117.112
relay.hasenpfeffr.com,39.0438,-77.4874
nostr-verified.wellorder.net,45.5201,-122.99
nostrelay.memory-art.xyz,43.6532,-79.3832
freeben666.fr,43.1204,6.12857
nostr.bilthon.dev,25.8128,-80.2377
nostr.spicyz.io,40.7357,-74.1724
bcast.girino.org,40.7357,-74.1724
nostr.noones.com,50.1109,8.68213
relay.electriclifestyle.com,26.2897,-80.1293
relay.guggero.org,47.3769,8.54169 relay.guggero.org,47.3769,8.54169
relay.siamdev.cc,13.8434,100.363
relay.varke.eu,52.6921,6.19372
nostr.now,36.55,139.733
relay.origin.land,35.6673,139.751
yabu.me,35.6092,139.73
nostr-02.yakihonne.com,1.32123,103.695
nostr.tac.lol,47.4748,-122.273
r.bitcoinhold.net,40.7357,-74.1724
nostr.rtvslawenia.com,49.4543,11.0746
relay.sigit.io,50.4754,12.3683
nostr-rs-relay-ishosta.phamthanh.me,40.7357,-74.1724
relay.bitcoinartclock.com,50.4754,12.3683
relay.snort.social,40.7357,-74.1724
relay.mwaters.net,50.9871,2.12554
nostr-03.dorafactory.org,1.35208,103.82
relay.damus.io,40.7357,-74.1724
black.nostrcity.club,41.8781,-87.6298
noxir.kpherox.dev,34.8587,135.509
relay.javi.space,43.4633,11.8796
fenrir-s.notoshi.win,43.6532,-79.3832
relay02.lnfi.network,39.0997,-94.5786
mhp258zrpiiwn.clorecloud.net,40.7357,-74.1724
relay.libernet.app,43.6532,-79.3832
nostr.coincards.com,53.5501,-113.469
ynostr.yael.at,60.1699,24.9384
slick.mjex.me,39.048,-77.4817
nostr.agentcampfire.com,50.8933,6.05805
nostr-pub.wellorder.net,45.5201,-122.99
relay.artiostr.ch,43.6532,-79.3832
relay.davidebtc.me,50.1109,8.68213
shu01.shugur.net,21.4902,39.2246
relay.nostr.band,60.1699,24.9384
relay.evanverma.com,40.8302,-74.1299
nostr.simplex.icu,50.8198,-1.08798
wot.nostr.net,43.6532,-79.3832
relay.nostr.vet,52.6467,4.7395
r.lostr.net,52.3676,4.90414
relay.getsafebox.app,43.6532,-79.3832
relay.moinsen.com,50.4754,12.3683
nostr.luisschwab.net,43.6532,-79.3832
strfry.elswa-dev.online,48.8566,2.35222
alienos.libretechsystems.xyz,55.4724,9.87335
nostr.overmind.lol,40.7357,-74.1724
nos.xmark.cc,50.6924,3.20113
relay.degmods.com,50.4754,12.3683
relay.seq1.net,40.7128,-74.006
relay.uid.ovh,43.6532,-79.3832
santo.iguanatech.net,40.8302,-74.1299
nostr.spaceshell.xyz,43.6532,-79.3832
nostr.hekster.org,37.3986,-121.964
wheat.happytavern.co,43.6532,-79.3832
nostr-02.czas.top,53.471,9.88208
satsage.xyz,37.3986,-121.964
no.str.cr,9.92857,-84.0528
relay.puresignal.news,40.7357,-74.1724
nostr-relay.nextblockvending.com,47.2343,-119.853
keys.nostr1.com,40.7057,-74.0136
relayone.soundhsa.com,33.1384,-95.6011
nostr-dev.wellorder.net,45.5201,-122.99
alien.macneilmediagroup.com,40.7357,-74.1724
nostr.kalf.org,52.3676,4.90414
nostrelites.org,41.8781,-87.6298
temp.iris.to,43.6532,-79.3832
relay.utxo.farm,35.6916,139.768
freelay.sovbit.host,64.1476,-21.9392
relay.nosto.re,51.8933,4.42083
inbox.azzamo.net,52.2633,21.0283
nostr.ovia.to,43.6532,-79.3832
nostr.lostr.space,40.7357,-74.1724
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
relay.nostar.org,40.7357,-74.1724
relay.mccormick.cx,52.3563,4.95714
nostr.0x7e.xyz,47.4988,8.72369
relay.0xchat.com,1.35208,103.82
relay.thebluepulse.com,40.7357,-74.1724
relay.letsfo.com,51.098,17.0321
nostrelay.circum.space,52.3676,4.90414
nostr-relay.psfoundation.info,39.0438,-77.4874
strfry.shock.network,41.8959,-88.2169
nostr-2.21crypto.ch,47.4988,8.72369
relay1.nostrchat.io,60.1699,24.9384
nostr.plantroon.com,50.1013,8.62643
relay.artx.market,43.652,-79.3633
nostr.chaima.info,51.223,6.78245
wot.nostr.place,30.2672,-97.7431
nostr.rblb.it,43.4633,11.8796
relay.magiccity.live,25.8128,-80.2377
soloco.nl,40.7357,-74.1724
dizzyspells.nostr1.com,40.7128,-74.006
relay.zone667.com,60.1699,24.9384
nostr-01.uid.ovh,43.6532,-79.3832
zap.watch,45.5029,-73.5723
relay.wolfcoil.com,35.6092,139.73
relay.nostrdice.com,-33.8688,151.209
relay.goodmorningbitcoin.com,43.6532,-79.3832
relay.btcforplebs.com,40.7357,-74.1724
nostr.sathoarder.com,48.5734,7.75211
nostr.azzamo.net,52.2633,21.0283
wot.utxo.one,40.7128,-74.006
relay.bitcoindistrict.org,43.6532,-79.3832
relay.lumina.rocks,49.0291,8.35695 relay.lumina.rocks,49.0291,8.35695
relay.primal.net,43.6532,-79.3832 relay-freeharmonypeople.space,38.7223,-9.13934
nostr.notribe.net,40.8302,-74.1299 nostr-relay.online,43.6532,-79.3832
relay.usefusion.ai,38.7134,-78.1591 shu05.shugur.net,48.8566,2.35222
relay-dev.satlantis.io,40.8302,-74.1299 relay.evanverma.com,40.8302,-74.1299
nostr.vulpem.com,49.4543,11.0746 relay2.angor.io,48.1046,11.6002
orangesync.tech,50.1109,8.68213 relay.arx-ccn.com,50.4754,12.3683
relay.fountain.fm,39.0997,-94.5786 relay.davidebtc.me,51.5072,-0.127586
relay.cypherflow.ai,48.8566,2.35222 temp.iris.to,43.6532,-79.3832
nostr-relay.online,40.7357,-74.1724 nostr.superfriends.online,43.6532,-79.3832
relay.internationalright-wing.org,-22.5022,-48.7114
relay.nostr.place,32.7767,-96.797
relay.npubhaus.com,40.7357,-74.1724
relay.jeffg.fyi,43.6532,-79.3832
nostr.night7.space,50.4754,12.3683
nostr.4rs.nl,49.0291,8.35696
nostr.liberty.fans,36.9104,-89.5875
ribo.eu.nostria.app,52.3676,4.90414
nostr.bitcoiner.social,39.1585,-94.5728
relay.basspistol.org,46.2044,6.14316
dev-relay.lnfi.network,39.0997,-94.5786
nostr.openhoofd.nl,51.9229,4.40833
nostr.huszonegy.world,47.4979,19.0402
relay.2nix.de,60.1699,24.9384
vitor.nostr1.com,40.7057,-74.0136 vitor.nostr1.com,40.7057,-74.0136
nostr.zenon.network,43.5009,-70.4428 relay.moinsen.com,50.4754,12.3683
relay.etch.social,41.2619,-95.8608 ithurtswhenip.ee,51.223,6.78245
relay.fr13nd5.com,52.5233,13.3426 nostr.tadryanom.me,43.6532,-79.3832
relay.agora.social,50.7383,15.0648 relay.nostr.wirednet.jp,34.706,135.493
relay.credenso.cafe,43.3601,-80.3127 relay.0xchat.com,1.35208,103.82
relay.illuminodes.com,47.6061,-122.333 nostrcheck.tnsor.network,43.6532,-79.3832
relay.freeplace.nl,52.3676,4.90414 relay.ngengine.org,43.6532,-79.3832
relay-rpi.edufeed.org,49.4543,11.0746 relay.mitchelltribe.com,39.0438,-77.4874
relay.bullishbounty.com,43.6532,-79.3832 wotr.relatr.xyz,53.3498,-6.26031
relay04.lnfi.network,39.0997,-94.5786 relay.chorus.community,50.1109,8.68213
nostr-01.yakihonne.com,1.32123,103.695 orly.ft.hn,50.4754,12.3683
nostr-relay.xbytez.io,50.6924,3.20113 nostr.stakey.net,52.3676,4.90414
wot.sebastix.social,51.8933,4.42083 relay.malxte.de,52.52,13.405
strfry.felixzieger.de,50.1013,8.62643 nostrelay.circum.space,52.3676,4.90414
nostr.n7ekb.net,47.4941,-122.294 nostrcheck.me,43.6532,-79.3832
khatru.nostrver.se,51.8933,4.42083
relayrs.notoshi.win,40.7357,-74.1724
relay.wavlake.com,41.2619,-95.8608
wot.sudocarlos.com,51.5072,-0.127586 wot.sudocarlos.com,51.5072,-0.127586
nostr.sagaciousd.com,49.2827,-123.121 relay.routstr.com,43.6532,-79.3832
relay.siamdev.cc,13.8434,100.363
wot.sovbit.host,64.1466,-21.9426
strfry.bonsai.com,37.8715,-122.273
freeben666.fr,43.7221,7.15296
okn.czas.top,51.267,6.81738
relay.nostar.org,43.6532,-79.3832
relay.jeffg.fyi,43.6532,-79.3832
nostr-02.uid.ovh,43.6532,-79.3832
relay.fr13nd5.com,52.5233,13.3426
srtrelay.c-stellar.net,43.6532,-79.3832
relay.dwadziesciajeden.pl,52.2297,21.0122
relay.sigit.io,50.4754,12.3683
nostr.girino.org,43.6532,-79.3832
bitsat.molonlabe.holdings,51.4012,-1.3147
nostream.breadslice.com,1.35208,103.82
nostr.rtvslawenia.com,49.4543,11.0746
nostr.openhoofd.nl,51.9229,4.40833
librerelay.aaroniumii.com,43.6532,-79.3832
relay.etch.social,41.2619,-95.8608
strfry.felixzieger.de,50.1013,8.62643
relay.wavlake.com,41.2619,-95.8608
relay.fundstr.me,42.3601,-71.0589
slick.mjex.me,39.048,-77.4817
relay.upleb.uk,51.9194,19.1451
nos4smartnkind.tech,40.1872,44.5152
discovery.eu.nostria.app,52.3676,4.90414
nostr.tac.lol,47.4748,-122.273
wot.nostr.net,43.6532,-79.3832
nostr-dev.wellorder.net,45.5201,-122.99
fanfares.nostr1.com,40.7128,-74.006
relay.bitcoindistrict.org,43.6532,-79.3832
nostr-relay.nextblockvending.com,47.2343,-119.853
relay-rpi.edufeed.org,49.4521,11.0767
relay.nsnip.io,60.1699,24.9384
black.nostrcity.club,48.8575,2.35138
nostr-02.czas.top,51.2277,6.77346
schnorr.me,43.6532,-79.3832
relay.fountain.fm,39.0997,-94.5786
nostr-verified.wellorder.net,45.5201,-122.99
relay.divine.video,43.6532,-79.3832
relay.thibautduchene.fr,43.6532,-79.3832
nostr.plantroon.com,50.1013,8.62643
relay.hasenpfeffr.com,39.0438,-77.4874
relay.chakany.systems,43.6532,-79.3832
nostr-01.yakihonne.com,1.32123,103.695
nostr.azzamo.net,52.2633,21.0283
nostr.bond,50.1109,8.68213
nostr.coincards.com,53.5501,-113.469
relay.nostrhub.tech,49.0291,8.35696
relay.cosmicbolt.net,37.3986,-121.964
nostr.21crypto.ch,47.5356,8.73209
relay-dev.satlantis.io,40.8302,-74.1299
offchain.pub,47.6743,-117.112
relay.orangepill.ovh,49.1689,-0.358841
nostr-relay.gateway.in.th,15.2634,100.344
nostr.now,36.55,139.733
relay.usefusion.ai,38.7134,-78.1591
nostr.lkjsxc.com,43.6532,-79.3832
nproxy.kristapsk.lv,60.1699,24.9384
relay.nostrverse.net,43.6532,-79.3832
relay.zone667.com,60.1699,24.9384
relay.origin.land,35.6673,139.751
relay.damus.io,43.6532,-79.3832
relay.bnos.space,1.35208,103.82
relay.endfiat.money,43.6532,-79.3832
relay.bullishbounty.com,43.6532,-79.3832
wot.nostr.party,36.1627,-86.7816
nostr.huszonegy.world,47.4979,19.0402
theoutpost.life,64.1476,-21.9392
nostr.noones.com,50.1109,8.68213
relay.21e6.cz,50.7383,15.0648
nostr-relay.xbytez.io,50.6924,3.20113
wot.shaving.kiwi,43.6532,-79.3832
relay.trustroots.org,43.6532,-79.3832
wot.dtonon.com,43.6532,-79.3832
nostr.camalolo.com,24.1469,120.684
czas.xyz,48.8566,2.35222
relayrs.notoshi.win,43.6532,-79.3832
skeme.vanderwarker.family,40.8218,-74.45
nostr.red5d.dev,43.6532,-79.3832
relay.javi.space,43.4633,11.8796
relay.nuts.cash,34.0362,-118.443
nostr.commonshub.brussels,49.4543,11.0746
relay.artx.market,43.652,-79.3633
satsage.xyz,37.3986,-121.964
relay.nostx.io,43.6532,-79.3832
shu02.shugur.net,21.4902,39.2246
strfry.ymir.cloud,34.0965,-117.585
relay.nostr-check.me,43.6532,-79.3832
relay04.lnfi.network,39.0997,-94.5786
kotukonostr.onrender.com,37.7775,-122.397
nostr.davidebtc.me,51.5072,-0.127586
relay2.ngengine.org,43.6532,-79.3832
ribo.us.nostria.app,41.5868,-93.625
nostr.czas.top,50.1109,8.68213
relay.nostrzh.org,43.6532,-79.3832
relay.angor.io,48.1046,11.6002
alienos.libretechsystems.xyz,55.4724,9.87335
vault.iris.to,43.6532,-79.3832
relay.mccormick.cx,52.3563,4.95714
nostr.quali.chat,60.1699,24.9384
wheat.happytavern.co,43.6532,-79.3832
relay.olas.app,50.4754,12.3683
nostr-relayrs.gateway.in.th,15.2634,100.344
relay.snort.social,53.3498,-6.26031
nostr-2.21crypto.ch,47.5356,8.73209
pyramid.treegaze.com,43.6532,-79.3832
relay.smies.me,33.7501,-84.3885
wot.dergigi.com,64.1476,-21.9392
nostr-relay.zimage.com,34.0549,-118.243
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
nostr.jerrynya.fun,31.2304,121.474
purplerelay.com,50.1109,8.68213
relay.comcomponent.com,34.7062,135.493
relay.unitypay.cash,41.4513,-81.7021
nostr.mikoshi.de,50.1109,8.68213
prl.plus,42.6978,23.3246
shu04.shugur.net,25.2604,55.2989
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
nostr.zoracle.org,45.6018,-121.185
nostriches.club,43.6532,-79.3832
nostr.notribe.net,40.8302,-74.1299
nostr.vulpem.com,49.4543,11.0746
bucket.coracle.social,37.7775,-122.397
wot.yesnostr.net,50.9871,2.12554
wot.nostr.place,32.7767,-96.797
nostr.mehdibekhtaoui.com,49.4939,-1.54813
relay.getsafebox.app,43.6532,-79.3832
nostr2.girino.org,43.6532,-79.3832
nostr.blankfors.se,60.1699,24.9384
premium.primal.net,43.6532,-79.3832
nostr.rikmeijer.nl,50.4754,12.3683
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
adre.su,59.9311,30.3609
nostr.n7ekb.net,47.4941,-122.294
nostr.hekster.org,37.3986,-121.964
relay.jabato.space,52.52,13.405
fenrir-s.notoshi.win,43.6532,-79.3832
notemine.io,52.2026,20.9397
r.bitcoinhold.net,43.6532,-79.3832
nostr-pub.wellorder.net,45.5201,-122.99
articles.layer3.news,37.3387,-121.885
relays.diggoo.com,43.6532,-79.3832
relayb.uid.ovh,43.6532,-79.3832
nostr.thebiglake.org,32.71,-96.6745
chat-relay.zap-work.com,43.6532,-79.3832
nr.yay.so,46.2126,6.1154
shu01.shugur.net,21.4902,39.2246
khatru.nostrver.se,51.1792,5.89444
relay.openfarmtools.org,60.1699,24.9384
relay01.lnfi.network,39.0997,-94.5786
nostrja-kari.heguro.com,43.6532,-79.3832
nostr.snowbla.de,60.1699,24.9384
relay.satlantis.io,32.8769,-80.0114
relay.vrtmrz.net,43.6532,-79.3832
purpura.cloud,43.6532,-79.3832
orangepiller.org,60.1699,24.9384
relay.coinos.io,43.6532,-79.3832
relay.layer.systems,49.0291,8.35695
relay.toastr.net,40.8054,-74.0241
ynostr.yael.at,60.1699,24.9384
ribo.eu.nostria.app,52.3676,4.90414
relay.nostrcheck.me,43.6532,-79.3832
relay.primal.net,43.6532,-79.3832
relay-testnet.k8s.layer3.news,37.3387,-121.885
relay.threenine.services,51.5524,-0.29686
relay.nostr.net,43.6532,-79.3832
nostr.middling.mydns.jp,35.8099,140.12
soloco.nl,43.6532,-79.3832
nostr.4rs.nl,49.0291,8.35696
no.str.cr,9.92857,-84.0528
espelho.girino.org,43.6532,-79.3832
nostr.hifish.org,47.4043,8.57398
wot.sebastix.social,51.1792,5.89444
relay03.lnfi.network,39.0997,-94.5786
relay.nosto.re,51.1792,5.89444
relay.nostrdice.com,-33.8688,151.209
nostr.agentcampfire.com,52.3676,4.90414
nostr.night7.space,50.4754,12.3683
nostr.nodesmap.com,59.3327,18.0656
relayone.soundhsa.com,33.1384,-95.6011
relay.illuminodes.com,47.6061,-122.333
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
nostr.robosats.org,64.1476,-21.9392
relay.seq1.net,43.6532,-79.3832
strfry.shock.network,39.0438,-77.4874
nostr.ovia.to,43.6532,-79.3832
relay.holzeis.me,43.6532,-79.3832
bcast.seutoba.com.br,43.6532,-79.3832
relay.libernet.app,43.6532,-79.3832
nostr-01.uid.ovh,43.6532,-79.3832
nostr.luisschwab.net,43.6532,-79.3832
strfry.elswa-dev.online,50.1109,8.68213
relay.nostriot.com,41.5695,-83.9786
nostr.oxtr.dev,50.4754,12.3683
nostr.mom,50.4754,12.3683
nostr-03.dorafactory.org,1.35208,103.82
relay.camelus.app,45.5201,-122.99
strfry.openhoofd.nl,51.9229,4.40833
nos.lol,50.4754,12.3683
nostr.bitcoiner.social,39.1585,-94.5728
relay.islandbitcoin.com,12.8498,77.6545 relay.islandbitcoin.com,12.8498,77.6545
trizone.dev,1.35208,103.82 relay.tagayasu.xyz,43.6715,-79.38
schnorr.me,40.7357,-74.1724 dev-nostr.bityacht.io,25.0797,121.234
relay.vrtmrz.net,40.7357,-74.1724 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 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
1 Relay URL Latitude Longitude
2 nostr-relay.amethyst.name alien.macneilmediagroup.com 39.0438 43.6532 -77.4874 -79.3832
3 bcast.seutoba.com.br v-relay.d02.vrtmrz.net 43.6532 34.6937 -79.3832 135.502
4 premium.primal.net relay.thebluepulse.com 40.7357 49.4521 -74.1724 11.0767
strfry.openhoofd.nl 51.9229 4.40833
relay.mostro.network 40.8302 -74.1299
ribo.us.nostria.app 41.5868 -93.625
relayone.geektank.ai 18.2148 -63.0574
nostr.mikoshi.de 50.1109 8.68213
relay.lifpay.me 1.35208 103.82
shu02.shugur.net 21.4902 39.2246
nostr.hifish.org 47.4043 8.57398
orangepiller.org 60.1699 24.9384
nostr.myshosholoza.co.za 52.3676 4.90414
relay.nostrcheck.me 43.6532 -79.3832
relay.nostrhub.tech 49.0291 8.35696
nostr.mehdibekhtaoui.com 49.4939 -1.54813
nostrcheck.me 40.7357 -74.1724
relay.arx-ccn.com 50.4754 12.3683
relay2.ngengine.org 43.6532 -79.3832
nostr.21crypto.ch 47.4988 8.72369
nostr.girino.org 43.6532 -79.3832
relay.nostriches.club 43.6532 -79.3832
relay.angor.io 48.1046 11.6002
nostr.data.haus 50.4754 12.3683
nostr.zoracle.org 45.6018 -121.185
relay2.angor.io 48.1046 11.6002
librerelay.aaroniumii.com 43.6532 -79.3832
nostr.tadryanom.me 40.7357 -74.1724
nostr-relay.cbrx.io 43.6532 -79.3832
nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
relay.aloftus.io 34.0881 -118.379
relay.barine.co 43.6532 -79.3832
nostr.red5d.dev 43.6532 -79.3832
nos.lol 50.4754 12.3683
ithurtswhenip.ee 51.223 6.78245
strfry.bonsai.com 37.8715 -122.273
relay01.lnfi.network 39.0997 -94.5786
nostrcheck.tnsor.network 43.6532 -79.3832
x.kojira.io 43.6532 -79.3832
nostr.snowbla.de 60.1699 24.9384
purplerelay.com 50.1109 8.68213
wot.sovbit.host 64.1466 -21.9426
shu04.shugur.net 25.2604 55.2989
nr.yay.so 46.2126 6.1154
nostr.smartflowsocial.com 43.6532 -79.3832
shu05.shugur.net 48.8566 2.35222
wot.dtonon.com 43.6532 -79.3832
pyramid.fiatjaf.com 51.5072 -0.127586
nostr.88mph.life 40.7357 -74.1724
dev-nostr.bityacht.io 25.0797 121.234
cyberspace.nostr1.com 40.7128 -74.006
relay.ngengine.org 40.7357 -74.1724
nostr.calitabby.net 39.9268 -75.0246
relay.toastr.net 40.8054 -74.0241
relay.cosmicbolt.net 37.3986 -121.964
nostr.davidebtc.me 50.1109 8.68213
espelho.girino.org 43.6532 -79.3832
srtrelay.c-stellar.net 40.7357 -74.1724
relay.nostriot.com 41.5695 -83.9786
relay.bitcoinveneto.org 64.1466 -21.9426
bitcoiner.social 39.1585 -94.5728
purpura.cloud 40.7357 -74.1724
nostr.blankfors.se 60.1699 24.9384
relay.nostromo.social 49.4543 11.0746
ribo.af.nostria.app -26.2041 28.0473
relay.olas.app 50.4754 12.3683
nostr.carroarmato0.be 50.9928 3.26317
nostr.oxtr.dev 50.4754 12.3683
relay.upleb.uk 52.2297 21.0122
nostr.stakey.net 52.3676 4.90414
nostr2.girino.org 40.7357 -74.1724
nostr.rikmeijer.nl 50.4754 12.3683
relay5.bitransfer.org 40.7357 -74.1724
relay.mattybs.lol 40.7357 -74.1724
nostr.makibisskey.work 40.7357 -74.1724
wot.basspistol.org 49.4521 11.0767
relay.nostr.net 50.4754 12.3683
a.nos.lol 50.4754 12.3683
wot.dergigi.com 64.1476 -21.9392
fanfares.nostr1.com 40.7057 -74.0136
nostr.faultables.net 43.6532 -79.3832
relay.dwadziesciajeden.pl 52.2297 21.0122
adre.su 59.9311 30.3609
wot.nostr.party 36.1627 -86.7816
relay03.lnfi.network 39.0997 -94.5786
relay.orangepill.ovh 49.1689 -0.358841
gnostr.com 42.6978 23.3246
relay.wellorder.net 45.5201 -122.99
relay.21e6.cz 50.1682 14.0546
relay.tagayasu.xyz 43.6715 -79.38
relay.chatbett.de 28.0199 -82.5248
wot.soundhsa.com 33.1384 -95.6011
prl.plus 38.9072 -77.0369
nostr.einundzwanzig.space 50.1109 8.68213
nostream.breadslice.com 1.35208 103.82
relay.chorus.community 50.1109 8.68213
nostr.mom 50.4754 12.3683
relay.lightning.pub 41.8959 -88.2169
relay.hook.cafe 40.7357 -74.1724
relay-testnet.k8s.layer3.news 37.3387 -121.885
relay.fundstr.me 42.3601 -71.0589
relay.stream.labs.h3.se 59.4016 17.9455
relay.holzeis.me 43.6532 -79.3832
articles.layer3.news 37.3387 -121.885
nproxy.kristapsk.lv 60.1699 24.9384
nostr.thebiglake.org 32.71 -96.6745
relay.coinos.io 40.7357 -74.1724
nostr.camalolo.com 24.1469 120.684
theoutpost.life 64.1476 -21.9392
relay.nostraddress.com 40.7357 -74.1724
nostr.casa21.space 40.7357 -74.1724
relay.agorist.space 52.3734 4.89406
relay.nostr.wirednet.jp 34.706 135.493
nostr.jerrynya.fun 31.2304 121.474
relay.13room.space 43.6532 -79.3832
offchain.pub 47.6743 -117.112
relay.hasenpfeffr.com 39.0438 -77.4874
nostr-verified.wellorder.net 45.5201 -122.99
nostrelay.memory-art.xyz 43.6532 -79.3832
freeben666.fr 43.1204 6.12857
nostr.bilthon.dev 25.8128 -80.2377
nostr.spicyz.io 40.7357 -74.1724
bcast.girino.org 40.7357 -74.1724
nostr.noones.com 50.1109 8.68213
relay.electriclifestyle.com 26.2897 -80.1293
5 relay.guggero.org 47.3769 8.54169
relay.siamdev.cc 13.8434 100.363
relay.varke.eu 52.6921 6.19372
nostr.now 36.55 139.733
relay.origin.land 35.6673 139.751
yabu.me 35.6092 139.73
nostr-02.yakihonne.com 1.32123 103.695
nostr.tac.lol 47.4748 -122.273
r.bitcoinhold.net 40.7357 -74.1724
nostr.rtvslawenia.com 49.4543 11.0746
relay.sigit.io 50.4754 12.3683
nostr-rs-relay-ishosta.phamthanh.me 40.7357 -74.1724
relay.bitcoinartclock.com 50.4754 12.3683
relay.snort.social 40.7357 -74.1724
relay.mwaters.net 50.9871 2.12554
nostr-03.dorafactory.org 1.35208 103.82
relay.damus.io 40.7357 -74.1724
black.nostrcity.club 41.8781 -87.6298
noxir.kpherox.dev 34.8587 135.509
relay.javi.space 43.4633 11.8796
fenrir-s.notoshi.win 43.6532 -79.3832
relay02.lnfi.network 39.0997 -94.5786
mhp258zrpiiwn.clorecloud.net 40.7357 -74.1724
relay.libernet.app 43.6532 -79.3832
nostr.coincards.com 53.5501 -113.469
ynostr.yael.at 60.1699 24.9384
slick.mjex.me 39.048 -77.4817
nostr.agentcampfire.com 50.8933 6.05805
nostr-pub.wellorder.net 45.5201 -122.99
relay.artiostr.ch 43.6532 -79.3832
relay.davidebtc.me 50.1109 8.68213
shu01.shugur.net 21.4902 39.2246
relay.nostr.band 60.1699 24.9384
relay.evanverma.com 40.8302 -74.1299
nostr.simplex.icu 50.8198 -1.08798
wot.nostr.net 43.6532 -79.3832
relay.nostr.vet 52.6467 4.7395
r.lostr.net 52.3676 4.90414
relay.getsafebox.app 43.6532 -79.3832
relay.moinsen.com 50.4754 12.3683
nostr.luisschwab.net 43.6532 -79.3832
strfry.elswa-dev.online 48.8566 2.35222
alienos.libretechsystems.xyz 55.4724 9.87335
nostr.overmind.lol 40.7357 -74.1724
nos.xmark.cc 50.6924 3.20113
relay.degmods.com 50.4754 12.3683
relay.seq1.net 40.7128 -74.006
relay.uid.ovh 43.6532 -79.3832
santo.iguanatech.net 40.8302 -74.1299
nostr.spaceshell.xyz 43.6532 -79.3832
nostr.hekster.org 37.3986 -121.964
wheat.happytavern.co 43.6532 -79.3832
nostr-02.czas.top 53.471 9.88208
satsage.xyz 37.3986 -121.964
no.str.cr 9.92857 -84.0528
relay.puresignal.news 40.7357 -74.1724
nostr-relay.nextblockvending.com 47.2343 -119.853
keys.nostr1.com 40.7057 -74.0136
relayone.soundhsa.com 33.1384 -95.6011
nostr-dev.wellorder.net 45.5201 -122.99
alien.macneilmediagroup.com 40.7357 -74.1724
nostr.kalf.org 52.3676 4.90414
nostrelites.org 41.8781 -87.6298
temp.iris.to 43.6532 -79.3832
relay.utxo.farm 35.6916 139.768
freelay.sovbit.host 64.1476 -21.9392
relay.nosto.re 51.8933 4.42083
inbox.azzamo.net 52.2633 21.0283
nostr.ovia.to 43.6532 -79.3832
nostr.lostr.space 40.7357 -74.1724
nostr-relay-1.trustlessenterprise.com 43.6532 -79.3832
relay.nostar.org 40.7357 -74.1724
relay.mccormick.cx 52.3563 4.95714
nostr.0x7e.xyz 47.4988 8.72369
relay.0xchat.com 1.35208 103.82
relay.thebluepulse.com 40.7357 -74.1724
relay.letsfo.com 51.098 17.0321
nostrelay.circum.space 52.3676 4.90414
nostr-relay.psfoundation.info 39.0438 -77.4874
strfry.shock.network 41.8959 -88.2169
nostr-2.21crypto.ch 47.4988 8.72369
relay1.nostrchat.io 60.1699 24.9384
nostr.plantroon.com 50.1013 8.62643
relay.artx.market 43.652 -79.3633
nostr.chaima.info 51.223 6.78245
wot.nostr.place 30.2672 -97.7431
nostr.rblb.it 43.4633 11.8796
relay.magiccity.live 25.8128 -80.2377
soloco.nl 40.7357 -74.1724
dizzyspells.nostr1.com 40.7128 -74.006
relay.zone667.com 60.1699 24.9384
nostr-01.uid.ovh 43.6532 -79.3832
zap.watch 45.5029 -73.5723
relay.wolfcoil.com 35.6092 139.73
relay.nostrdice.com -33.8688 151.209
relay.goodmorningbitcoin.com 43.6532 -79.3832
relay.btcforplebs.com 40.7357 -74.1724
nostr.sathoarder.com 48.5734 7.75211
nostr.azzamo.net 52.2633 21.0283
wot.utxo.one 40.7128 -74.006
relay.bitcoindistrict.org 43.6532 -79.3832
6 relay.lumina.rocks 49.0291 8.35695
7 relay.primal.net relay-freeharmonypeople.space 43.6532 38.7223 -79.3832 -9.13934
8 nostr.notribe.net nostr-relay.online 40.8302 43.6532 -74.1299 -79.3832
9 relay.usefusion.ai shu05.shugur.net 38.7134 48.8566 -78.1591 2.35222
10 relay-dev.satlantis.io relay.evanverma.com 40.8302 -74.1299
11 nostr.vulpem.com relay2.angor.io 49.4543 48.1046 11.0746 11.6002
12 orangesync.tech relay.arx-ccn.com 50.1109 50.4754 8.68213 12.3683
13 relay.fountain.fm relay.davidebtc.me 39.0997 51.5072 -94.5786 -0.127586
14 relay.cypherflow.ai temp.iris.to 48.8566 43.6532 2.35222 -79.3832
15 nostr-relay.online nostr.superfriends.online 40.7357 43.6532 -74.1724 -79.3832
relay.internationalright-wing.org -22.5022 -48.7114
relay.nostr.place 32.7767 -96.797
relay.npubhaus.com 40.7357 -74.1724
relay.jeffg.fyi 43.6532 -79.3832
nostr.night7.space 50.4754 12.3683
nostr.4rs.nl 49.0291 8.35696
nostr.liberty.fans 36.9104 -89.5875
ribo.eu.nostria.app 52.3676 4.90414
nostr.bitcoiner.social 39.1585 -94.5728
relay.basspistol.org 46.2044 6.14316
dev-relay.lnfi.network 39.0997 -94.5786
nostr.openhoofd.nl 51.9229 4.40833
nostr.huszonegy.world 47.4979 19.0402
relay.2nix.de 60.1699 24.9384
16 vitor.nostr1.com 40.7057 -74.0136
17 nostr.zenon.network relay.moinsen.com 43.5009 50.4754 -70.4428 12.3683
18 relay.etch.social ithurtswhenip.ee 41.2619 51.223 -95.8608 6.78245
19 relay.fr13nd5.com nostr.tadryanom.me 52.5233 43.6532 13.3426 -79.3832
20 relay.agora.social relay.nostr.wirednet.jp 50.7383 34.706 15.0648 135.493
21 relay.credenso.cafe relay.0xchat.com 43.3601 1.35208 -80.3127 103.82
22 relay.illuminodes.com nostrcheck.tnsor.network 47.6061 43.6532 -122.333 -79.3832
23 relay.freeplace.nl relay.ngengine.org 52.3676 43.6532 4.90414 -79.3832
24 relay-rpi.edufeed.org relay.mitchelltribe.com 49.4543 39.0438 11.0746 -77.4874
25 relay.bullishbounty.com wotr.relatr.xyz 43.6532 53.3498 -79.3832 -6.26031
26 relay04.lnfi.network relay.chorus.community 39.0997 50.1109 -94.5786 8.68213
27 nostr-01.yakihonne.com orly.ft.hn 1.32123 50.4754 103.695 12.3683
28 nostr-relay.xbytez.io nostr.stakey.net 50.6924 52.3676 3.20113 4.90414
29 wot.sebastix.social relay.malxte.de 51.8933 52.52 4.42083 13.405
30 strfry.felixzieger.de nostrelay.circum.space 50.1013 52.3676 8.62643 4.90414
31 nostr.n7ekb.net nostrcheck.me 47.4941 43.6532 -122.294 -79.3832
khatru.nostrver.se 51.8933 4.42083
relayrs.notoshi.win 40.7357 -74.1724
relay.wavlake.com 41.2619 -95.8608
32 wot.sudocarlos.com 51.5072 -0.127586
33 nostr.sagaciousd.com relay.routstr.com 49.2827 43.6532 -123.121 -79.3832
34 relay.siamdev.cc 13.8434 100.363
35 wot.sovbit.host 64.1466 -21.9426
36 strfry.bonsai.com 37.8715 -122.273
37 freeben666.fr 43.7221 7.15296
38 okn.czas.top 51.267 6.81738
39 relay.nostar.org 43.6532 -79.3832
40 relay.jeffg.fyi 43.6532 -79.3832
41 nostr-02.uid.ovh 43.6532 -79.3832
42 relay.fr13nd5.com 52.5233 13.3426
43 srtrelay.c-stellar.net 43.6532 -79.3832
44 relay.dwadziesciajeden.pl 52.2297 21.0122
45 relay.sigit.io 50.4754 12.3683
46 nostr.girino.org 43.6532 -79.3832
47 bitsat.molonlabe.holdings 51.4012 -1.3147
48 nostream.breadslice.com 1.35208 103.82
49 nostr.rtvslawenia.com 49.4543 11.0746
50 nostr.openhoofd.nl 51.9229 4.40833
51 librerelay.aaroniumii.com 43.6532 -79.3832
52 relay.etch.social 41.2619 -95.8608
53 strfry.felixzieger.de 50.1013 8.62643
54 relay.wavlake.com 41.2619 -95.8608
55 relay.fundstr.me 42.3601 -71.0589
56 slick.mjex.me 39.048 -77.4817
57 relay.upleb.uk 51.9194 19.1451
58 nos4smartnkind.tech 40.1872 44.5152
59 discovery.eu.nostria.app 52.3676 4.90414
60 nostr.tac.lol 47.4748 -122.273
61 wot.nostr.net 43.6532 -79.3832
62 nostr-dev.wellorder.net 45.5201 -122.99
63 fanfares.nostr1.com 40.7128 -74.006
64 relay.bitcoindistrict.org 43.6532 -79.3832
65 nostr-relay.nextblockvending.com 47.2343 -119.853
66 relay-rpi.edufeed.org 49.4521 11.0767
67 relay.nsnip.io 60.1699 24.9384
68 black.nostrcity.club 48.8575 2.35138
69 nostr-02.czas.top 51.2277 6.77346
70 schnorr.me 43.6532 -79.3832
71 relay.fountain.fm 39.0997 -94.5786
72 nostr-verified.wellorder.net 45.5201 -122.99
73 relay.divine.video 43.6532 -79.3832
74 relay.thibautduchene.fr 43.6532 -79.3832
75 nostr.plantroon.com 50.1013 8.62643
76 relay.hasenpfeffr.com 39.0438 -77.4874
77 relay.chakany.systems 43.6532 -79.3832
78 nostr-01.yakihonne.com 1.32123 103.695
79 nostr.azzamo.net 52.2633 21.0283
80 nostr.bond 50.1109 8.68213
81 nostr.coincards.com 53.5501 -113.469
82 relay.nostrhub.tech 49.0291 8.35696
83 relay.cosmicbolt.net 37.3986 -121.964
84 nostr.21crypto.ch 47.5356 8.73209
85 relay-dev.satlantis.io 40.8302 -74.1299
86 offchain.pub 47.6743 -117.112
87 relay.orangepill.ovh 49.1689 -0.358841
88 nostr-relay.gateway.in.th 15.2634 100.344
89 nostr.now 36.55 139.733
90 relay.usefusion.ai 38.7134 -78.1591
91 nostr.lkjsxc.com 43.6532 -79.3832
92 nproxy.kristapsk.lv 60.1699 24.9384
93 relay.nostrverse.net 43.6532 -79.3832
94 relay.zone667.com 60.1699 24.9384
95 relay.origin.land 35.6673 139.751
96 relay.damus.io 43.6532 -79.3832
97 relay.bnos.space 1.35208 103.82
98 relay.endfiat.money 43.6532 -79.3832
99 relay.bullishbounty.com 43.6532 -79.3832
100 wot.nostr.party 36.1627 -86.7816
101 nostr.huszonegy.world 47.4979 19.0402
102 theoutpost.life 64.1476 -21.9392
103 nostr.noones.com 50.1109 8.68213
104 relay.21e6.cz 50.7383 15.0648
105 nostr-relay.xbytez.io 50.6924 3.20113
106 wot.shaving.kiwi 43.6532 -79.3832
107 relay.trustroots.org 43.6532 -79.3832
108 wot.dtonon.com 43.6532 -79.3832
109 nostr.camalolo.com 24.1469 120.684
110 czas.xyz 48.8566 2.35222
111 relayrs.notoshi.win 43.6532 -79.3832
112 skeme.vanderwarker.family 40.8218 -74.45
113 nostr.red5d.dev 43.6532 -79.3832
114 relay.javi.space 43.4633 11.8796
115 relay.nuts.cash 34.0362 -118.443
116 nostr.commonshub.brussels 49.4543 11.0746
117 relay.artx.market 43.652 -79.3633
118 satsage.xyz 37.3986 -121.964
119 relay.nostx.io 43.6532 -79.3832
120 shu02.shugur.net 21.4902 39.2246
121 strfry.ymir.cloud 34.0965 -117.585
122 relay.nostr-check.me 43.6532 -79.3832
123 relay04.lnfi.network 39.0997 -94.5786
124 kotukonostr.onrender.com 37.7775 -122.397
125 nostr.davidebtc.me 51.5072 -0.127586
126 relay2.ngengine.org 43.6532 -79.3832
127 ribo.us.nostria.app 41.5868 -93.625
128 nostr.czas.top 50.1109 8.68213
129 relay.nostrzh.org 43.6532 -79.3832
130 relay.angor.io 48.1046 11.6002
131 alienos.libretechsystems.xyz 55.4724 9.87335
132 vault.iris.to 43.6532 -79.3832
133 relay.mccormick.cx 52.3563 4.95714
134 nostr.quali.chat 60.1699 24.9384
135 wheat.happytavern.co 43.6532 -79.3832
136 relay.olas.app 50.4754 12.3683
137 nostr-relayrs.gateway.in.th 15.2634 100.344
138 relay.snort.social 53.3498 -6.26031
139 nostr-2.21crypto.ch 47.5356 8.73209
140 pyramid.treegaze.com 43.6532 -79.3832
141 relay.smies.me 33.7501 -84.3885
142 wot.dergigi.com 64.1476 -21.9392
143 nostr-relay.zimage.com 34.0549 -118.243
144 nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
145 nostr.jerrynya.fun 31.2304 121.474
146 purplerelay.com 50.1109 8.68213
147 relay.comcomponent.com 34.7062 135.493
148 relay.unitypay.cash 41.4513 -81.7021
149 nostr.mikoshi.de 50.1109 8.68213
150 prl.plus 42.6978 23.3246
151 shu04.shugur.net 25.2604 55.2989
152 nostr-relay-1.trustlessenterprise.com 43.6532 -79.3832
153 nostr.zoracle.org 45.6018 -121.185
154 nostriches.club 43.6532 -79.3832
155 nostr.notribe.net 40.8302 -74.1299
156 nostr.vulpem.com 49.4543 11.0746
157 bucket.coracle.social 37.7775 -122.397
158 wot.yesnostr.net 50.9871 2.12554
159 wot.nostr.place 32.7767 -96.797
160 nostr.mehdibekhtaoui.com 49.4939 -1.54813
161 relay.getsafebox.app 43.6532 -79.3832
162 nostr2.girino.org 43.6532 -79.3832
163 nostr.blankfors.se 60.1699 24.9384
164 premium.primal.net 43.6532 -79.3832
165 nostr.rikmeijer.nl 50.4754 12.3683
166 relay-fra.zombi.cloudrodion.com 48.8566 2.35222
167 adre.su 59.9311 30.3609
168 nostr.n7ekb.net 47.4941 -122.294
169 nostr.hekster.org 37.3986 -121.964
170 relay.jabato.space 52.52 13.405
171 fenrir-s.notoshi.win 43.6532 -79.3832
172 notemine.io 52.2026 20.9397
173 r.bitcoinhold.net 43.6532 -79.3832
174 nostr-pub.wellorder.net 45.5201 -122.99
175 articles.layer3.news 37.3387 -121.885
176 relays.diggoo.com 43.6532 -79.3832
177 relayb.uid.ovh 43.6532 -79.3832
178 nostr.thebiglake.org 32.71 -96.6745
179 chat-relay.zap-work.com 43.6532 -79.3832
180 nr.yay.so 46.2126 6.1154
181 shu01.shugur.net 21.4902 39.2246
182 khatru.nostrver.se 51.1792 5.89444
183 relay.openfarmtools.org 60.1699 24.9384
184 relay01.lnfi.network 39.0997 -94.5786
185 nostrja-kari.heguro.com 43.6532 -79.3832
186 nostr.snowbla.de 60.1699 24.9384
187 relay.satlantis.io 32.8769 -80.0114
188 relay.vrtmrz.net 43.6532 -79.3832
189 purpura.cloud 43.6532 -79.3832
190 orangepiller.org 60.1699 24.9384
191 relay.coinos.io 43.6532 -79.3832
192 relay.layer.systems 49.0291 8.35695
193 relay.toastr.net 40.8054 -74.0241
194 ynostr.yael.at 60.1699 24.9384
195 ribo.eu.nostria.app 52.3676 4.90414
196 relay.nostrcheck.me 43.6532 -79.3832
197 relay.primal.net 43.6532 -79.3832
198 relay-testnet.k8s.layer3.news 37.3387 -121.885
199 relay.threenine.services 51.5524 -0.29686
200 relay.nostr.net 43.6532 -79.3832
201 nostr.middling.mydns.jp 35.8099 140.12
202 soloco.nl 43.6532 -79.3832
203 nostr.4rs.nl 49.0291 8.35696
204 no.str.cr 9.92857 -84.0528
205 espelho.girino.org 43.6532 -79.3832
206 nostr.hifish.org 47.4043 8.57398
207 wot.sebastix.social 51.1792 5.89444
208 relay03.lnfi.network 39.0997 -94.5786
209 relay.nosto.re 51.1792 5.89444
210 relay.nostrdice.com -33.8688 151.209
211 nostr.agentcampfire.com 52.3676 4.90414
212 nostr.night7.space 50.4754 12.3683
213 nostr.nodesmap.com 59.3327 18.0656
214 relayone.soundhsa.com 33.1384 -95.6011
215 relay.illuminodes.com 47.6061 -122.333
216 nostr-rs-relay-ishosta.phamthanh.me 43.6532 -79.3832
217 nostr.robosats.org 64.1476 -21.9392
218 relay.seq1.net 43.6532 -79.3832
219 strfry.shock.network 39.0438 -77.4874
220 nostr.ovia.to 43.6532 -79.3832
221 relay.holzeis.me 43.6532 -79.3832
222 bcast.seutoba.com.br 43.6532 -79.3832
223 relay.libernet.app 43.6532 -79.3832
224 nostr-01.uid.ovh 43.6532 -79.3832
225 nostr.luisschwab.net 43.6532 -79.3832
226 strfry.elswa-dev.online 50.1109 8.68213
227 relay.nostriot.com 41.5695 -83.9786
228 nostr.oxtr.dev 50.4754 12.3683
229 nostr.mom 50.4754 12.3683
230 nostr-03.dorafactory.org 1.35208 103.82
231 relay.camelus.app 45.5201 -122.99
232 strfry.openhoofd.nl 51.9229 4.40833
233 nos.lol 50.4754 12.3683
234 nostr.bitcoiner.social 39.1585 -94.5728
235 relay.islandbitcoin.com 12.8498 77.6545
236 trizone.dev relay.tagayasu.xyz 1.35208 43.6715 103.82 -79.38
237 schnorr.me dev-nostr.bityacht.io 40.7357 25.0797 -74.1724 121.234
238 relay.vrtmrz.net relay.nostrhub.fr 40.7357 48.1045 -74.1724 11.6004
239 relay.satmaxt.xyz 43.6532 -79.3832
240 yabu.me 35.6092 139.73
241 relay.samt.st 40.8302 -74.1299
242 relay02.lnfi.network 39.0997 -94.5786
243 nostr.88mph.life 51.5072 -0.127586
244 dev-relay.lnfi.network 39.0997 -94.5786
245 relay.goodmorningbitcoin.com 43.6532 -79.3832
246 nostrelay.memory-art.xyz 43.6532 -79.3832
247 relay.degmods.com 50.4754 12.3683
248 nostr-02.yakihonne.com 1.32123 103.695
249 nostr.spicyz.io 43.6532 -79.3832
250 relay.minibolt.info 43.6532 -79.3832
251 bcast.girino.org 43.6532 -79.3832
252 nostr.bilthon.dev 25.8128 -80.2377
253 relay.bitcoinartclock.com 50.4754 12.3683
254 cyberspace.nostr1.com 40.7128 -74.006
255 nostr.data.haus 50.4754 12.3683
256 relay.agora.social 50.7383 15.0648
257 relay.wavefunc.live 34.0362 -118.443
258 nostr.casa21.space 43.6532 -79.3832
259 relay.mostro.network 40.8302 -74.1299
260 relay.ditto.pub 43.6532 -79.3832
261 nostr-relay.amethyst.name 39.0438 -77.4874
262 relay.credenso.cafe 43.3601 -80.3127
263 nostr.calitabby.net 39.9268 -75.0246
264 relay.magiccity.live 25.8128 -80.2377
265 nostr.chaima.info 51.223 6.78245
266 wot.brightbolt.net 47.6735 -116.781
267 nostr.sathoarder.com 48.5734 7.75211
268 inbox.azzamo.net 52.2633 21.0283
269 nostr.na.social 43.6532 -79.3832
270 nostr.myshosholoza.co.za 52.3676 4.90414
271 nostr.spaceshell.xyz 43.6532 -79.3832
272 relay.wellorder.net 45.5201 -122.99
273 nostr.0x7e.xyz 47.4988 8.72369
274 nostr-relay.cbrx.io 43.6532 -79.3832
275 relay.btcforplebs.com 43.6532 -79.3832
276 bitcoiner.social 39.1585 -94.5728
277 relaynostr.breadslice.com 43.6532 -79.3832
278 nostr.overmind.lol 43.6532 -79.3832
279 santo.iguanatech.net 40.8302 -74.1299
280 relay.lightning.pub 39.0438 -77.4874
281 nostr.rblb.it 43.7094 10.6582
282 relay.bitcoinveneto.org 64.1466 -21.9426
283 relay.wolfcoil.com 35.6092 139.73
284 nostr-relay.corb.net 38.8353 -104.822
285 relay.nostr.place 32.7767 -96.797
286 wot.soundhsa.com 33.1384 -95.6011
287 relay.npubhaus.com 43.6532 -79.3832
288 nostr-relay.psfoundation.info 39.0438 -77.4874
289 x.kojira.io 43.6532 -79.3832
290 relay.cypherflow.ai 48.8566 2.35222
@@ -3,18 +3,21 @@ package com.bitchat.android
import android.app.Application import android.app.Application
import com.bitchat.android.nostr.RelayDirectory import com.bitchat.android.nostr.RelayDirectory
import com.bitchat.android.ui.theme.ThemePreferenceManager import com.bitchat.android.ui.theme.ThemePreferenceManager
import com.bitchat.android.net.TorManager import com.bitchat.android.net.ArtiTorManager
/** /**
* Main application class for bitchat Android * Main application class for bitchat Android
*/ */
class BitchatApplication : Application() { class BitchatApplication : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
// Initialize Tor first so any early network goes over Tor // Initialize Tor first so any early network goes over Tor
try { TorManager.init(this) } catch (_: Exception) { } try {
val torProvider = ArtiTorManager.getInstance()
torProvider.init(this)
} catch (_: Exception){}
// Initialize relay directory (loads assets/nostr_relays.csv) // Initialize relay directory (loads assets/nostr_relays.csv)
RelayDirectory.initialize(this) RelayDirectory.initialize(this)
@@ -38,6 +41,12 @@ class BitchatApplication : Application() {
// Initialize debug preference manager (persists debug toggles) // Initialize debug preference manager (persists debug toggles)
try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { } try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { }
// Initialize mesh service preferences
try { com.bitchat.android.service.MeshServicePreferences.init(this) } catch (_: Exception) { }
// Proactively start the foreground service to keep mesh alive
try { com.bitchat.android.service.MeshForegroundService.start(this) } catch (_: Exception) { }
// TorManager already initialized above // TorManager already initialized above
} }
} }
@@ -26,6 +26,7 @@ import com.bitchat.android.onboarding.BatteryOptimizationManager
import com.bitchat.android.onboarding.BatteryOptimizationPreferenceManager import com.bitchat.android.onboarding.BatteryOptimizationPreferenceManager
import com.bitchat.android.onboarding.BatteryOptimizationScreen import com.bitchat.android.onboarding.BatteryOptimizationScreen
import com.bitchat.android.onboarding.BatteryOptimizationStatus import com.bitchat.android.onboarding.BatteryOptimizationStatus
import com.bitchat.android.onboarding.BackgroundLocationPermissionScreen
import com.bitchat.android.onboarding.InitializationErrorScreen import com.bitchat.android.onboarding.InitializationErrorScreen
import com.bitchat.android.onboarding.InitializingScreen import com.bitchat.android.onboarding.InitializingScreen
import com.bitchat.android.onboarding.LocationCheckScreen 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.OrientationAwareActivity
import com.bitchat.android.ui.theme.BitchatTheme import com.bitchat.android.ui.theme.BitchatTheme
import com.bitchat.android.nostr.PoWPreferenceManager import com.bitchat.android.nostr.PoWPreferenceManager
import com.bitchat.android.services.VerificationService
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -51,7 +53,7 @@ class MainActivity : OrientationAwareActivity() {
private lateinit var locationStatusManager: LocationStatusManager private lateinit var locationStatusManager: LocationStatusManager
private lateinit var batteryOptimizationManager: BatteryOptimizationManager private lateinit var batteryOptimizationManager: BatteryOptimizationManager
// Core mesh service - managed at app level // Core mesh service - provided by the foreground service holder
private lateinit var meshService: BluetoothMeshService private lateinit var meshService: BluetoothMeshService
private val mainViewModel: MainViewModel by viewModels() private val mainViewModel: MainViewModel by viewModels()
private val chatViewModel: ChatViewModel by viewModels { private val chatViewModel: ChatViewModel by viewModels {
@@ -63,16 +65,55 @@ class MainActivity : OrientationAwareActivity() {
} }
} }
private val forceFinishReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: android.content.Context, intent: android.content.Intent) {
if (intent.action == com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH) {
android.util.Log.i("MainActivity", "Received force finish broadcast, closing UI")
finishAffinity()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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 // Enable edge-to-edge display for modern Android look
enableEdgeToEdge() enableEdgeToEdge()
// Initialize permission management // Initialize permission management
permissionManager = PermissionManager(this) permissionManager = PermissionManager(this)
// Initialize core mesh service first // Ensure foreground service is running and get mesh instance from holder
meshService = BluetoothMeshService(this) try { com.bitchat.android.service.MeshForegroundService.start(applicationContext) } catch (_: Exception) { }
meshService = com.bitchat.android.service.MeshServiceHolder.getOrCreate(applicationContext)
bluetoothStatusManager = BluetoothStatusManager( bluetoothStatusManager = BluetoothStatusManager(
activity = this, activity = this,
context = this, context = this,
@@ -95,6 +136,9 @@ class MainActivity : OrientationAwareActivity() {
activity = this, activity = this,
permissionManager = permissionManager, permissionManager = permissionManager,
onOnboardingComplete = ::handleOnboardingComplete, onOnboardingComplete = ::handleOnboardingComplete,
onBackgroundLocationRequired = {
mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION)
},
onOnboardingFailed = ::handleOnboardingFailed onOnboardingFailed = ::handleOnboardingFailed
) )
@@ -227,6 +271,21 @@ class MainActivity : OrientationAwareActivity() {
) )
} }
OnboardingState.BACKGROUND_LOCATION_EXPLANATION -> {
BackgroundLocationPermissionScreen(
modifier = modifier,
onContinue = {
onboardingCoordinator.requestBackgroundLocation()
},
onRetry = {
onboardingCoordinator.checkBackgroundLocationAndProceed()
},
onSkip = {
onboardingCoordinator.skipBackgroundLocation()
}
)
}
OnboardingState.CHECKING, OnboardingState.INITIALIZING, OnboardingState.COMPLETE -> { OnboardingState.CHECKING, OnboardingState.INITIALIZING, OnboardingState.COMPLETE -> {
// Set up back navigation handling for the chat screen // Set up back navigation handling for the chat screen
val backCallback = object : OnBackPressedCallback(true) { val backCallback = object : OnBackPressedCallback(true) {
@@ -340,10 +399,17 @@ class MainActivity : OrientationAwareActivity() {
if (permissionManager.isFirstTimeLaunch()) { if (permissionManager.isFirstTimeLaunch()) {
Log.d("MainActivity", "First time launch, showing permission explanation") Log.d("MainActivity", "First time launch, showing permission explanation")
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
} else if (permissionManager.areAllPermissionsGranted()) { } else if (permissionManager.areRequiredPermissionsGranted()) {
Log.d("MainActivity", "Existing user with permissions, initializing app") Log.d("MainActivity", "Existing user with required permissions")
mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING) if (permissionManager.needsBackgroundLocationPermission() &&
initializeApp() !permissionManager.isBackgroundLocationGranted() &&
!com.bitchat.android.onboarding.BackgroundLocationPreferenceManager.isSkipped(this@MainActivity)
) {
mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION)
} else {
mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING)
initializeApp()
}
} else { } else {
Log.d("MainActivity", "Existing user missing permissions, showing explanation") Log.d("MainActivity", "Existing user missing permissions, showing explanation")
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
@@ -616,6 +682,7 @@ class MainActivity : OrientationAwareActivity() {
// Handle any notification intent // Handle any notification intent
handleNotificationIntent(intent) handleNotificationIntent(intent)
handleVerificationIntent(intent)
// Small delay to ensure mesh service is fully initialized // Small delay to ensure mesh service is fully initialized
delay(500) delay(500)
@@ -630,9 +697,21 @@ class MainActivity : OrientationAwareActivity() {
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent)
// Check if this is a quit request from the notification
if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) {
android.util.Log.d("MainActivity", "Quit request received, finishing activity")
finish()
return
}
com.bitchat.android.service.AppShutdownCoordinator.cancelPendingShutdown()
// Handle notification intents when app is already running // Handle notification intents when app is already running
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
handleNotificationIntent(intent) handleNotificationIntent(intent)
handleVerificationIntent(intent)
} }
} }
@@ -640,9 +719,8 @@ class MainActivity : OrientationAwareActivity() {
super.onResume() super.onResume()
// Check Bluetooth and Location status on resume and handle accordingly // Check Bluetooth and Location status on resume and handle accordingly
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
// Set app foreground state // Reattach mesh delegate to new ChatViewModel instance after Activity recreation
meshService.connectionManager.setAppBackgroundState(false) try { meshService.delegate = chatViewModel } catch (_: Exception) { }
chatViewModel.setAppBackgroundState(false)
// Check if Bluetooth was disabled while app was backgrounded // Check if Bluetooth was disabled while app was backgrounded
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus() val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
@@ -665,13 +743,12 @@ class MainActivity : OrientationAwareActivity() {
} }
} }
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
// Only set background state if app is fully initialized // Only set background state if app is fully initialized
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
// Set app background state // Detach UI delegate so the foreground service can own DM notifications while UI is closed
meshService.connectionManager.setAppBackgroundState(true) try { meshService.delegate = null } catch (_: Exception) { }
chatViewModel.setAppBackgroundState(true)
} }
} }
@@ -734,10 +811,23 @@ class MainActivity : OrientationAwareActivity() {
} }
} }
private fun handleVerificationIntent(intent: Intent) {
val uri = intent.data ?: return
if (uri.scheme != "bitchat" || uri.host != "verify") return
chatViewModel.showVerificationSheet()
val qr = VerificationService.verifyScannedQR(uri.toString())
if (qr != null) {
chatViewModel.beginQRVerification(qr)
}
}
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
try { unregisterReceiver(forceFinishReceiver) } catch (_: Exception) { }
// Cleanup location status manager // Cleanup location status manager
try { try {
locationStatusManager.cleanup() locationStatusManager.cleanup()
@@ -746,14 +836,6 @@ class MainActivity : OrientationAwareActivity() {
Log.w("MainActivity", "Error cleaning up location status manager: ${e.message}") Log.w("MainActivity", "Error cleaning up location status manager: ${e.message}")
} }
// Stop mesh services if app was fully initialized // Do not stop mesh here; ForegroundService owns lifecycle for background reliability
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
try {
meshService.stopServices()
Log.d("MainActivity", "Mesh services stopped successfully")
} catch (e: Exception) {
Log.w("MainActivity", "Error stopping mesh services in onDestroy: ${e.message}")
}
}
} }
} }
@@ -0,0 +1,34 @@
package com.bitchat.android.core.ui.component.button
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun CloseButton(
onClick: () -> Unit,
modifier: Modifier = Modifier.Companion
) {
IconButton(
onClick = onClick,
modifier = modifier
.size(32.dp),
colors = IconButtonDefaults.iconButtonColors(
contentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
containerColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.1f)
)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close",
modifier = Modifier.Companion.size(18.dp)
)
}
}
@@ -19,7 +19,7 @@ import java.util.concurrent.ConcurrentHashMap
* This is the main interface for all encryption/decryption operations in bitchat. * 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. * 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 { companion object {
private const val TAG = "EncryptionService" private const val TAG = "EncryptionService"
@@ -27,14 +27,14 @@ class EncryptionService(private val context: Context) {
} }
// Core Noise encryption service // Core Noise encryption service
private val noiseService: NoiseEncryptionService = NoiseEncryptionService(context) private val noiseService: NoiseEncryptionService by lazy { NoiseEncryptionService(context) }
// Session tracking for established connections // Session tracking for established connections
private val establishedSessions = ConcurrentHashMap<String, String>() // peerID -> fingerprint private val establishedSessions = ConcurrentHashMap<String, String>() // peerID -> fingerprint
// Ed25519 signing keys (separate from Noise static keys) // Ed25519 signing keys (separate from Noise static keys)
private val ed25519PrivateKey: Ed25519PrivateKeyParameters private lateinit var ed25519PrivateKey: Ed25519PrivateKeyParameters
private val ed25519PublicKey: Ed25519PublicKeyParameters private lateinit var ed25519PublicKey: Ed25519PublicKeyParameters
// Callbacks for UI state updates // Callbacks for UI state updates
var onSessionEstablished: ((String) -> Unit)? = null // peerID var onSessionEstablished: ((String) -> Unit)? = null // peerID
@@ -42,6 +42,13 @@ class EncryptionService(private val context: Context) {
var onHandshakeRequired: ((String) -> Unit)? = null // peerID var onHandshakeRequired: ((String) -> Unit)? = null // peerID
init { init {
initialize()
}
/**
* Initialization logic moved to method to allow overriding in tests
*/
protected open fun initialize() {
// Initialize or load Ed25519 signing keys // Initialize or load Ed25519 signing keys
val keyPair = loadOrCreateEd25519KeyPair() val keyPair = loadOrCreateEd25519KeyPair()
ed25519PrivateKey = keyPair.private as Ed25519PrivateKeyParameters ed25519PrivateKey = keyPair.private as Ed25519PrivateKeyParameters
@@ -356,7 +363,7 @@ class EncryptionService(private val context: Context) {
/** /**
* Verify Ed25519 signature against data using a public key * 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 { return try {
val publicKey = Ed25519PublicKeyParameters(publicKeyBytes, 0) val publicKey = Ed25519PublicKeyParameters(publicKeyBytes, 0)
val verifier = Ed25519Signer() val verifier = Ed25519Signer()
@@ -5,13 +5,14 @@ import android.location.Geocoder
import android.location.Location import android.location.Location
import android.location.LocationManager import android.location.LocationManager
import android.util.Log import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.Locale import java.util.Locale
/** /**
@@ -46,11 +47,11 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
private val membership = mutableSetOf<String>() private val membership = mutableSetOf<String>()
private val _bookmarks = MutableLiveData<List<String>>(emptyList()) private val _bookmarks = MutableStateFlow<List<String>>(emptyList())
val bookmarks: LiveData<List<String>> = _bookmarks val bookmarks: StateFlow<List<String>> = _bookmarks.asStateFlow()
private val _bookmarkNames = MutableLiveData<Map<String, String>>(emptyMap()) private val _bookmarkNames = MutableStateFlow<Map<String, String>>(emptyMap())
val bookmarkNames: LiveData<Map<String, String>> = _bookmarkNames val bookmarkNames: StateFlow<Map<String, String>> = _bookmarkNames.asStateFlow()
// For throttling / preventing duplicate geocode lookups // For throttling / preventing duplicate geocode lookups
private val resolving = mutableSetOf<String>() private val resolving = mutableSetOf<String>()
@@ -68,8 +69,8 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
val gh = normalize(geohash) val gh = normalize(geohash)
if (gh.isEmpty() || membership.contains(gh)) return if (gh.isEmpty() || membership.contains(gh)) return
membership.add(gh) membership.add(gh)
val updated = listOf(gh) + (_bookmarks.value ?: emptyList()) val updated = listOf(gh) + (_bookmarks.value)
_bookmarks.postValue(updated) _bookmarks.value = updated
persist(updated) persist(updated)
// Resolve friendly name asynchronously // Resolve friendly name asynchronously
resolveNameIfNeeded(gh) resolveNameIfNeeded(gh)
@@ -79,12 +80,12 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
val gh = normalize(geohash) val gh = normalize(geohash)
if (!membership.contains(gh)) return if (!membership.contains(gh)) return
membership.remove(gh) membership.remove(gh)
val updated = (_bookmarks.value ?: emptyList()).filterNot { it == gh } val updated = (_bookmarks.value).filterNot { it == gh }
_bookmarks.postValue(updated) _bookmarks.value = updated
// Remove stored name to avoid stale cache growth // Remove stored name to avoid stale cache growth
val names = _bookmarkNames.value?.toMutableMap() ?: mutableMapOf() val names = _bookmarkNames.value.toMutableMap()
if (names.remove(gh) != null) { if (names.remove(gh) != null) {
_bookmarkNames.postValue(names) _bookmarkNames.value = names
persistNames(names) persistNames(names)
} }
persist(updated) persist(updated)
@@ -108,7 +109,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
} }
} }
membership.clear(); membership.addAll(seen) membership.clear(); membership.addAll(seen)
_bookmarks.postValue(ordered) _bookmarks.value = ordered
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to load bookmarks: ${e.message}") Log.e(TAG, "Failed to load bookmarks: ${e.message}")
@@ -118,7 +119,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
if (!namesJson.isNullOrEmpty()) { if (!namesJson.isNullOrEmpty()) {
val mapType = object : TypeToken<Map<String, String>>() {}.type val mapType = object : TypeToken<Map<String, String>>() {}.type
val dict = gson.fromJson<Map<String, String>>(namesJson, mapType) val dict = gson.fromJson<Map<String, String>>(namesJson, mapType)
_bookmarkNames.postValue(dict) _bookmarkNames.value = dict
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to load bookmark names: ${e.message}") Log.e(TAG, "Failed to load bookmark names: ${e.message}")
@@ -127,14 +128,14 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
private fun persist() { private fun persist() {
try { try {
val json = gson.toJson(_bookmarks.value ?: emptyList<String>()) val json = gson.toJson(_bookmarks.value)
prefs.edit().putString(STORE_KEY, json).apply() prefs.edit().putString(STORE_KEY, json).apply()
} catch (_: Exception) {} } catch (_: Exception) {}
} }
private fun persistNames() { private fun persistNames() {
try { try {
val json = gson.toJson(_bookmarkNames.value ?: emptyMap<String, String>()) val json = gson.toJson(_bookmarkNames.value)
prefs.edit().putString(NAMES_STORE_KEY, json).apply() prefs.edit().putString(NAMES_STORE_KEY, json).apply()
} catch (_: Exception) {} } catch (_: Exception) {}
} }
@@ -144,8 +145,8 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
fun clearAll() { fun clearAll() {
try { try {
membership.clear() membership.clear()
_bookmarks.postValue(emptyList()) _bookmarks.value = emptyList()
_bookmarkNames.postValue(emptyMap()) _bookmarkNames.value = emptyMap()
prefs.edit() prefs.edit()
.remove(STORE_KEY) .remove(STORE_KEY)
.remove(NAMES_STORE_KEY) .remove(NAMES_STORE_KEY)
@@ -209,9 +210,9 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
} }
if (!name.isNullOrEmpty()) { if (!name.isNullOrEmpty()) {
val current = _bookmarkNames.value?.toMutableMap() ?: mutableMapOf() val current = _bookmarkNames.value.toMutableMap()
current[gh] = name current[gh] = name
_bookmarkNames.postValue(current) _bookmarkNames.value = current
persistNames(current) persistNames(current)
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -10,12 +10,12 @@ import android.location.LocationManager
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.util.* import java.util.*
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.JsonSyntaxException import com.google.gson.JsonSyntaxException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/** /**
* Manages location permissions, one-shot location retrieval, and computing geohash channels. * Manages location permissions, one-shot location retrieval, and computing geohash channels.
@@ -53,28 +53,26 @@ class LocationChannelManager private constructor(private val context: Context) {
private var dataManager: com.bitchat.android.ui.DataManager? = null private var dataManager: com.bitchat.android.ui.DataManager? = null
// Published state for UI bindings (matching iOS @Published properties) // Published state for UI bindings (matching iOS @Published properties)
private val _permissionState = MutableLiveData(PermissionState.NOT_DETERMINED) private val _permissionState = MutableStateFlow(PermissionState.NOT_DETERMINED)
val permissionState: LiveData<PermissionState> = _permissionState val permissionState: StateFlow<PermissionState> = _permissionState
private val _availableChannels = MutableLiveData<List<GeohashChannel>>(emptyList()) private val _availableChannels = MutableStateFlow<List<GeohashChannel>>(emptyList())
val availableChannels: LiveData<List<GeohashChannel>> = _availableChannels val availableChannels: StateFlow<List<GeohashChannel>> = _availableChannels
private val _selectedChannel = MutableLiveData<ChannelID>(ChannelID.Mesh) private val _selectedChannel = MutableStateFlow<ChannelID>(ChannelID.Mesh)
val selectedChannel: LiveData<ChannelID> = _selectedChannel val selectedChannel: StateFlow<ChannelID> = _selectedChannel
private val _teleported = MutableLiveData(false) private val _teleported = MutableStateFlow(false)
val teleported: LiveData<Boolean> = _teleported val teleported: StateFlow<Boolean> = _teleported
private val _locationNames = MutableLiveData<Map<GeohashChannelLevel, String>>(emptyMap()) private val _locationNames = MutableStateFlow<Map<GeohashChannelLevel, String>>(emptyMap())
val locationNames: LiveData<Map<GeohashChannelLevel, String>> = _locationNames val locationNames: StateFlow<Map<GeohashChannelLevel, String>> = _locationNames
// Add a new LiveData property to indicate when location is being fetched private val _isLoadingLocation = MutableStateFlow(false)
private val _isLoadingLocation = MutableLiveData(false) val isLoadingLocation: StateFlow<Boolean> = _isLoadingLocation
val isLoadingLocation: LiveData<Boolean> = _isLoadingLocation
// Add a new LiveData property to track if location services are enabled by user private val _locationServicesEnabled = MutableStateFlow(false)
private val _locationServicesEnabled = MutableLiveData(false) val locationServicesEnabled: StateFlow<Boolean> = _locationServicesEnabled
val locationServicesEnabled: LiveData<Boolean> = _locationServicesEnabled
init { init {
updatePermissionState() updatePermissionState()
@@ -102,15 +100,15 @@ class LocationChannelManager private constructor(private val context: Context) {
when (getCurrentPermissionStatus()) { when (getCurrentPermissionStatus()) {
PermissionState.NOT_DETERMINED -> { PermissionState.NOT_DETERMINED -> {
Log.d(TAG, "Permission not determined - user needs to grant in app settings") Log.d(TAG, "Permission not determined - user needs to grant in app settings")
_permissionState.postValue(PermissionState.NOT_DETERMINED) _permissionState.value = PermissionState.NOT_DETERMINED
} }
PermissionState.DENIED, PermissionState.RESTRICTED -> { PermissionState.DENIED, PermissionState.RESTRICTED -> {
Log.d(TAG, "Permission denied or restricted") Log.d(TAG, "Permission denied or restricted")
_permissionState.postValue(PermissionState.DENIED) _permissionState.value = PermissionState.DENIED
} }
PermissionState.AUTHORIZED -> { PermissionState.AUTHORIZED -> {
Log.d(TAG, "Permission authorized - requesting location") Log.d(TAG, "Permission authorized - requesting location")
_permissionState.postValue(PermissionState.AUTHORIZED) _permissionState.value = PermissionState.AUTHORIZED
requestOneShotLocation() requestOneShotLocation()
} }
} }
@@ -126,36 +124,55 @@ class LocationChannelManager private constructor(private val context: Context) {
} }
/** /**
* Begin periodic one-shot location refreshes while a selector UI is visible * Begin real-time location updates while a selector UI is visible
* Uses requestLocationUpdates for continuous updates, plus a one-shot to prime state immediately
*/ */
fun beginLiveRefresh(interval: Long = 5000L) { 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) { if (_permissionState.value != PermissionState.AUTHORIZED) {
Log.w(TAG, "Cannot start live refresh - permission not authorized") Log.w(TAG, "Cannot start live refresh - permission not authorized")
return return
} }
if (!isLocationServicesEnabled()) { if (!isLocationServicesEnabled()) {
Log.w(TAG, "Cannot start live refresh - location services disabled by user") Log.w(TAG, "Cannot start live refresh - location services disabled by user")
return return
} }
// Cancel existing timer // Cancel any existing timer-based refreshers
refreshTimer?.cancel() refreshTimer?.cancel()
refreshTimer = null
// Start new timer with coroutines
refreshTimer = CoroutineScope(Dispatchers.IO).launch { // Register for continuous updates from available providers
while (isActive) { try {
if (isLocationServicesEnabled()) { if (hasLocationPermission()) {
requestOneShotLocation() val providers = listOf(
LocationManager.GPS_PROVIDER,
LocationManager.NETWORK_PROVIDER
)
providers.forEach { provider ->
if (locationManager.isProviderEnabled(provider)) {
// 2s min time, 5m min distance for responsive yet battery-aware updates
locationManager.requestLocationUpdates(
provider,
interval,
5f,
continuousLocationListener
)
Log.d(TAG, "Registered continuous updates for $provider")
}
} }
delay(interval)
// Prime state immediately with last known / current location
requestOneShotLocation()
} }
} catch (e: SecurityException) {
Log.e(TAG, "Missing location permission for continuous updates: ${e.message}")
} catch (e: Exception) {
Log.e(TAG, "Failed to register continuous updates: ${e.message}")
} }
// Kick off immediately
requestOneShotLocation()
} }
/** /**
@@ -165,6 +182,12 @@ class LocationChannelManager private constructor(private val context: Context) {
Log.d(TAG, "Ending live refresh") Log.d(TAG, "Ending live refresh")
refreshTimer?.cancel() refreshTimer?.cancel()
refreshTimer = null refreshTimer = null
// Unregister continuous updates listener
try {
locationManager.removeUpdates(continuousLocationListener)
} catch (_: SecurityException) {
} catch (_: Exception) {
}
} }
/** /**
@@ -180,7 +203,7 @@ class LocationChannelManager private constructor(private val context: Context) {
lastLocation?.let { location -> lastLocation?.let { location ->
when (channel) { when (channel) {
is ChannelID.Mesh -> { is ChannelID.Mesh -> {
_teleported.postValue(false) _teleported.value = false
} }
is ChannelID.Location -> { is ChannelID.Location -> {
val currentGeohash = Geohash.encode( val currentGeohash = Geohash.encode(
@@ -189,7 +212,7 @@ class LocationChannelManager private constructor(private val context: Context) {
precision = channel.channel.level.precision precision = channel.channel.level.precision
) )
val isTeleportedNow = currentGeohash != channel.channel.geohash val isTeleportedNow = currentGeohash != channel.channel.geohash
_teleported.postValue(isTeleportedNow) _teleported.value = isTeleportedNow
Log.d(TAG, "Teleported (immediate recompute): $isTeleportedNow (current: $currentGeohash, selected: ${channel.channel.geohash})") Log.d(TAG, "Teleported (immediate recompute): $isTeleportedNow (current: $currentGeohash, selected: ${channel.channel.geohash})")
} }
} }
@@ -201,7 +224,7 @@ class LocationChannelManager private constructor(private val context: Context) {
*/ */
fun setTeleported(teleported: Boolean) { fun setTeleported(teleported: Boolean) {
Log.d(TAG, "Setting teleported status: $teleported") Log.d(TAG, "Setting teleported status: $teleported")
_teleported.postValue(teleported) _teleported.value = teleported
} }
/** /**
@@ -209,7 +232,7 @@ class LocationChannelManager private constructor(private val context: Context) {
*/ */
fun enableLocationServices() { fun enableLocationServices() {
Log.d(TAG, "enableLocationServices() called by user") Log.d(TAG, "enableLocationServices() called by user")
_locationServicesEnabled.postValue(true) _locationServicesEnabled.value = true
saveLocationServicesState(true) saveLocationServicesState(true)
// If we have permission, start location operations // If we have permission, start location operations
@@ -223,15 +246,15 @@ class LocationChannelManager private constructor(private val context: Context) {
*/ */
fun disableLocationServices() { fun disableLocationServices() {
Log.d(TAG, "disableLocationServices() called by user") Log.d(TAG, "disableLocationServices() called by user")
_locationServicesEnabled.postValue(false) _locationServicesEnabled.value = false
saveLocationServicesState(false) saveLocationServicesState(false)
// Stop any ongoing location operations // Stop any ongoing location operations
endLiveRefresh() endLiveRefresh()
// Clear available channels when location is disabled // Clear available channels when location is disabled
_availableChannels.postValue(emptyList()) _availableChannels.value = emptyList()
_locationNames.postValue(emptyMap()) _locationNames.value = emptyMap()
// If user had a location channel selected, switch back to mesh // If user had a location channel selected, switch back to mesh
if (_selectedChannel.value is ChannelID.Location) { if (_selectedChannel.value is ChannelID.Location) {
@@ -243,7 +266,7 @@ class LocationChannelManager private constructor(private val context: Context) {
* Check if location services are enabled by the user * Check if location services are enabled by the user
*/ */
fun isLocationServicesEnabled(): Boolean { fun isLocationServicesEnabled(): Boolean {
return _locationServicesEnabled.value ?: false return _locationServicesEnabled.value
} }
// MARK: - Location Operations // MARK: - Location Operations
@@ -280,13 +303,13 @@ class LocationChannelManager private constructor(private val context: Context) {
if (lastKnownLocation != null) { if (lastKnownLocation != null) {
Log.d(TAG, "Using last known location: ${lastKnownLocation.latitude}, ${lastKnownLocation.longitude}") Log.d(TAG, "Using last known location: ${lastKnownLocation.latitude}, ${lastKnownLocation.longitude}")
lastLocation = lastKnownLocation lastLocation = lastKnownLocation
_isLoadingLocation.postValue(false) // Make sure loading state is off _isLoadingLocation.value = false // Make sure loading state is off
computeChannels(lastKnownLocation) computeChannels(lastKnownLocation)
reverseGeocodeIfNeeded(lastKnownLocation) reverseGeocodeIfNeeded(lastKnownLocation)
} else { } else {
Log.d(TAG, "No last known location available") Log.d(TAG, "No last known location available")
// Set loading state to true so UI can show a spinner // Set loading state to true so UI can show a spinner
_isLoadingLocation.postValue(true) _isLoadingLocation.value = true
// Request a fresh location only when we don't have a last known location // Request a fresh location only when we don't have a last known location
Log.d(TAG, "Requesting fresh location...") Log.d(TAG, "Requesting fresh location...")
@@ -294,21 +317,45 @@ class LocationChannelManager private constructor(private val context: Context) {
} }
} catch (e: SecurityException) { } catch (e: SecurityException) {
Log.e(TAG, "Security exception requesting location: ${e.message}") Log.e(TAG, "Security exception requesting location: ${e.message}")
_isLoadingLocation.postValue(false) // Turn off loading state on error _isLoadingLocation.value = false // Turn off loading state on error
updatePermissionState() updatePermissionState()
} }
} }
// Continuous location listener for real-time updates
private val continuousLocationListener = object : LocationListener {
override fun onLocationChanged(location: Location) {
Log.d(TAG, "Real-time location: ${location.latitude}, ${location.longitude} acc=${location.accuracy}m")
lastLocation = location
_isLoadingLocation.value = false
computeChannels(location)
reverseGeocodeIfNeeded(location)
}
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {
// Deprecated but can still be called on older devices
Log.v(TAG, "Provider status changed: $provider -> $status")
}
override fun onProviderEnabled(provider: String) {
Log.d(TAG, "Provider enabled: $provider")
}
override fun onProviderDisabled(provider: String) {
Log.d(TAG, "Provider disabled: $provider")
}
}
// One-time location listener to get a fresh location update // One-time location listener to get a fresh location update
private val oneShotLocationListener = object : LocationListener { private val oneShotLocationListener = object : LocationListener {
override fun onLocationChanged(location: Location) { 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 lastLocation = location
computeChannels(location) computeChannels(location)
reverseGeocodeIfNeeded(location) reverseGeocodeIfNeeded(location)
// Update loading state to indicate we have a location now // Update loading state to indicate we have a location now
_isLoadingLocation.postValue(false) _isLoadingLocation.value = false
// Remove this listener after getting the update // Remove this listener after getting the update
try { try {
@@ -317,18 +364,30 @@ class LocationChannelManager private constructor(private val context: Context) {
Log.e(TAG, "Error removing location listener: ${e.message}") 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 // Request a fresh location update using getCurrentLocation instead of continuous updates
private fun requestFreshLocation() { private fun requestFreshLocation() {
if (!hasLocationPermission()) { if (!hasLocationPermission()) {
_isLoadingLocation.postValue(false) // Turn off loading state if no permission _isLoadingLocation.value = false // Turn off loading state if no permission
return return
} }
try { try {
// Set loading state to true to indicate we're actively trying to get a location // Set loading state to true to indicate we're actively trying to get a location
_isLoadingLocation.postValue(true) _isLoadingLocation.value = true
// Try common providers in order of preference // Try common providers in order of preference
val providers = listOf( val providers = listOf(
@@ -358,7 +417,7 @@ class LocationChannelManager private constructor(private val context: Context) {
Log.w(TAG, "Received null location from getCurrentLocation") Log.w(TAG, "Received null location from getCurrentLocation")
} }
// Update loading state to indicate we have a location now // Update loading state to indicate we have a location now
_isLoadingLocation.postValue(false) _isLoadingLocation.value = false
} }
) )
} else { } else {
@@ -378,14 +437,14 @@ class LocationChannelManager private constructor(private val context: Context) {
// If no provider was available, turn off loading state // If no provider was available, turn off loading state
if (!providerFound) { if (!providerFound) {
Log.w(TAG, "No location providers available") Log.w(TAG, "No location providers available")
_isLoadingLocation.postValue(false) _isLoadingLocation.value = false
} }
} catch (e: SecurityException) { } catch (e: SecurityException) {
Log.e(TAG, "Security exception requesting location: ${e.message}") Log.e(TAG, "Security exception requesting location: ${e.message}")
_isLoadingLocation.postValue(false) // Turn off loading state on error _isLoadingLocation.value = false // Turn off loading state on error
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error requesting location: ${e.message}") Log.e(TAG, "Error requesting location: ${e.message}")
_isLoadingLocation.postValue(false) // Turn off loading state on error _isLoadingLocation.value = false // Turn off loading state on error
} }
} }
@@ -408,7 +467,7 @@ class LocationChannelManager private constructor(private val context: Context) {
private fun updatePermissionState() { private fun updatePermissionState() {
val newState = getCurrentPermissionStatus() val newState = getCurrentPermissionStatus()
Log.d(TAG, "Permission state updated to: $newState") Log.d(TAG, "Permission state updated to: $newState")
_permissionState.postValue(newState) _permissionState.value = newState
} }
private fun hasLocationPermission(): Boolean { private fun hasLocationPermission(): Boolean {
@@ -433,13 +492,13 @@ class LocationChannelManager private constructor(private val context: Context) {
Log.v(TAG, "Generated ${level.displayName}: $geohash") Log.v(TAG, "Generated ${level.displayName}: $geohash")
} }
_availableChannels.postValue(result) _availableChannels.value = result
// Recompute teleported status based on current location vs selected channel // Recompute teleported status based on current location vs selected channel
val selectedChannelValue = _selectedChannel.value val selectedChannelValue = _selectedChannel.value
when (selectedChannelValue) { when (selectedChannelValue) {
is ChannelID.Mesh -> { is ChannelID.Mesh -> {
_teleported.postValue(false) _teleported.value = false
} }
is ChannelID.Location -> { is ChannelID.Location -> {
val currentGeohash = Geohash.encode( val currentGeohash = Geohash.encode(
@@ -448,12 +507,9 @@ class LocationChannelManager private constructor(private val context: Context) {
precision = selectedChannelValue.channel.level.precision precision = selectedChannelValue.channel.level.precision
) )
val isTeleported = currentGeohash != selectedChannelValue.channel.geohash val isTeleported = currentGeohash != selectedChannelValue.channel.geohash
_teleported.postValue(isTeleported) _teleported.value = isTeleported
Log.d(TAG, "Teleported status: $isTeleported (current: $currentGeohash, selected: ${selectedChannelValue.channel.geohash})") Log.d(TAG, "Teleported status: $isTeleported (current: $currentGeohash, selected: ${selectedChannelValue.channel.geohash})")
} }
null -> {
_teleported.postValue(false)
}
} }
} }
@@ -482,7 +538,7 @@ class LocationChannelManager private constructor(private val context: Context) {
val names = namesByLevel(address) val names = namesByLevel(address)
Log.d(TAG, "Reverse geocoding result: $names") Log.d(TAG, "Reverse geocoding result: $names")
_locationNames.postValue(names) _locationNames.value = names
} else { } else {
Log.w(TAG, "No reverse geocoding results") Log.w(TAG, "No reverse geocoding results")
} }
@@ -601,26 +657,26 @@ class LocationChannelManager private constructor(private val context: Context) {
} }
if (channel != null) { if (channel != null) {
_selectedChannel.postValue(channel) _selectedChannel.value = channel
Log.d(TAG, "Restored persisted channel: ${channel.displayName}") Log.d(TAG, "Restored persisted channel: ${channel.displayName}")
} else { } else {
Log.d(TAG, "Could not restore persisted channel, defaulting to Mesh") Log.d(TAG, "Could not restore persisted channel, defaulting to Mesh")
_selectedChannel.postValue(ChannelID.Mesh) _selectedChannel.value = ChannelID.Mesh
} }
} else { } else {
Log.w(TAG, "Invalid channel data format in persistence") Log.w(TAG, "Invalid channel data format in persistence")
_selectedChannel.postValue(ChannelID.Mesh) _selectedChannel.value = ChannelID.Mesh
} }
} else { } else {
Log.d(TAG, "No persisted channel found, defaulting to Mesh") Log.d(TAG, "No persisted channel found, defaulting to Mesh")
_selectedChannel.postValue(ChannelID.Mesh) _selectedChannel.value = ChannelID.Mesh
} }
} catch (e: JsonSyntaxException) { } catch (e: JsonSyntaxException) {
Log.e(TAG, "Failed to parse persisted channel data: ${e.message}") Log.e(TAG, "Failed to parse persisted channel data: ${e.message}")
_selectedChannel.postValue(ChannelID.Mesh) _selectedChannel.value = ChannelID.Mesh
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to load persisted channel: ${e.message}") Log.e(TAG, "Failed to load persisted channel: ${e.message}")
_selectedChannel.postValue(ChannelID.Mesh) _selectedChannel.value = ChannelID.Mesh
} }
} }
@@ -629,7 +685,7 @@ class LocationChannelManager private constructor(private val context: Context) {
*/ */
fun clearPersistedChannel() { fun clearPersistedChannel() {
dataManager?.clearLastGeohashChannel() dataManager?.clearLastGeohashChannel()
_selectedChannel.postValue(ChannelID.Mesh) _selectedChannel.value = ChannelID.Mesh
Log.d(TAG, "Cleared persisted channel selection") Log.d(TAG, "Cleared persisted channel selection")
} }
@@ -653,11 +709,11 @@ class LocationChannelManager private constructor(private val context: Context) {
private fun loadLocationServicesState() { private fun loadLocationServicesState() {
try { try {
val enabled = dataManager?.isLocationServicesEnabled() ?: false val enabled = dataManager?.isLocationServicesEnabled() ?: false
_locationServicesEnabled.postValue(enabled) _locationServicesEnabled.value = enabled
Log.d(TAG, "Loaded location services state: $enabled") Log.d(TAG, "Loaded location services state: $enabled")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to load location services state: ${e.message}") Log.e(TAG, "Failed to load location services state: ${e.message}")
_locationServicesEnabled.postValue(false) _locationServicesEnabled.value = false
} }
} }
@@ -668,16 +724,9 @@ class LocationChannelManager private constructor(private val context: Context) {
Log.d(TAG, "Cleaning up LocationChannelManager") Log.d(TAG, "Cleaning up LocationChannelManager")
endLiveRefresh() endLiveRefresh()
// For older Android versions, remove any remaining location listener to prevent memory leaks // Remove listeners to prevent memory leaks
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.R) { try { locationManager.removeUpdates(oneShotLocationListener) } catch (_: Exception) {}
try { try { locationManager.removeUpdates(continuousLocationListener) } catch (_: Exception) {}
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}")
}
}
// For Android 11+, getCurrentLocation doesn't need explicit cleanup as it's a one-time operation // For Android 11+, getCurrentLocation doesn't need explicit cleanup as it's a one-time operation
} }
} }
@@ -5,7 +5,10 @@ import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import java.security.MessageDigest import java.security.MessageDigest
import android.util.Base64
import android.util.Log 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 * 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_STATIC_PUBLIC_KEY = "static_public_key"
private const val KEY_SIGNING_PRIVATE_KEY = "signing_private_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_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 prefs: SharedPreferences
private val lock = Any()
init { init {
// Create master key for encryption // Create master key for encryption
@@ -168,7 +177,7 @@ class SecureIdentityStateManager(private val context: Context) {
fun generateFingerprint(publicKeyData: ByteArray): String { fun generateFingerprint(publicKeyData: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256") val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(publicKeyData) 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 // SHA-256 fingerprint should be 64 hex characters
return fingerprint.matches(Regex("^[a-fA-F0-9]{64}$")) return fingerprint.matches(Regex("^[a-fA-F0-9]{64}$"))
} }
// MARK: - Verified Fingerprints
fun getVerifiedFingerprints(): Set<String> {
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) // MARK: - Peer ID Rotation Management (removed)
// Android now derives peer ID from the persisted Noise identity fingerprint. // Android now derives peer ID from the persisted Noise identity fingerprint.
@@ -84,6 +84,10 @@ class BluetoothConnectionManager(
// Public property for address-peer mapping // Public property for address-peer mapping
val addressPeerMap get() = connectionTracker.addressPeerMap val addressPeerMap get() = connectionTracker.addressPeerMap
// Expose first-announce helpers to higher layers
fun noteAnnounceReceived(address: String) { connectionTracker.noteAnnounceReceived(address) }
fun hasSeenFirstAnnounce(address: String): Boolean = connectionTracker.hasSeenFirstAnnounce(address)
init { init {
powerManager.delegate = this powerManager.delegate = this
@@ -145,6 +149,7 @@ class BluetoothConnectionManager(
try { try {
isActive = true isActive = true
Log.d(TAG, "ConnectionManager activated (permissions and adapter OK)")
// set the adapter's name to our 8-character peerID for iOS privacy, TODO: Make this configurable // set the adapter's name to our 8-character peerID for iOS privacy, TODO: Make this configurable
// try { // try {
@@ -175,6 +180,7 @@ class BluetoothConnectionManager(
this@BluetoothConnectionManager.isActive = false this@BluetoothConnectionManager.isActive = false
return@launch return@launch
} }
Log.d(TAG, "GATT Server started")
} else { } else {
Log.i(TAG, "GATT Server disabled by debug settings; not starting") Log.i(TAG, "GATT Server disabled by debug settings; not starting")
} }
@@ -185,6 +191,7 @@ class BluetoothConnectionManager(
this@BluetoothConnectionManager.isActive = false this@BluetoothConnectionManager.isActive = false
return@launch return@launch
} }
Log.d(TAG, "GATT Client started")
} else { } else {
Log.i(TAG, "GATT Client disabled by debug settings; not starting") Log.i(TAG, "GATT Client disabled by debug settings; not starting")
} }
@@ -210,6 +217,7 @@ class BluetoothConnectionManager(
isActive = false isActive = false
connectionScope.launch { connectionScope.launch {
Log.d(TAG, "Stopping client/server and power components...")
// Stop component managers // Stop component managers
clientManager.stop() clientManager.stop()
serverManager.stop() serverManager.stop()
@@ -226,14 +234,19 @@ class BluetoothConnectionManager(
Log.i(TAG, "All Bluetooth services stopped") Log.i(TAG, "All Bluetooth services stopped")
} }
} }
/**
* Set app background state for power optimization
*/
fun setAppBackgroundState(inBackground: Boolean) {
powerManager.setAppBackgroundState(inBackground)
}
/**
* Indicates whether this instance can be safely reused for a future start.
* Returns false if its coroutine scope has been cancelled.
*/
fun isReusable(): Boolean {
val active = connectionScope.isActive
if (!active) {
Log.d(TAG, "BluetoothConnectionManager isReusable=false (scope cancelled)")
}
return active
}
/** /**
* Broadcast packet to connected devices with connection limit enforcement * Broadcast packet to connected devices with connection limit enforcement
* Automatically fragments large packets to fit within BLE MTU limits * Automatically fragments large packets to fit within BLE MTU limits
@@ -30,6 +30,8 @@ class BluetoothConnectionTracker(
private val connectedDevices = ConcurrentHashMap<String, DeviceConnection>() private val connectedDevices = ConcurrentHashMap<String, DeviceConnection>()
private val subscribedDevices = CopyOnWriteArrayList<BluetoothDevice>() private val subscribedDevices = CopyOnWriteArrayList<BluetoothDevice>()
val addressPeerMap = ConcurrentHashMap<String, String>() val addressPeerMap = ConcurrentHashMap<String, String>()
// Track whether we have seen the first ANNOUNCE on a given device connection
private val firstAnnounceSeen = ConcurrentHashMap<String, Boolean>()
// RSSI tracking from scan results (for devices we discover but may connect as servers) // RSSI tracking from scan results (for devices we discover but may connect as servers)
private val scanRSSI = ConcurrentHashMap<String, Int>() private val scanRSSI = ConcurrentHashMap<String, Int>()
@@ -91,6 +93,8 @@ class BluetoothConnectionTracker(
Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}") Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}")
connectedDevices[deviceAddress] = deviceConn connectedDevices[deviceAddress] = deviceConn
pendingConnections.remove(deviceAddress) pendingConnections.remove(deviceAddress)
// Mark as awaiting first ANNOUNCE on this connection
firstAnnounceSeen[deviceAddress] = false
} }
/** /**
@@ -194,7 +198,8 @@ class BluetoothConnectionTracker(
} }
// Update connection attempt atomically // Update connection attempt atomically
val attempts = (currentAttempt?.attempts ?: 0) + 1 // If the previous attempt window expired, reset backoff to 1; otherwise increment
val attempts = if (currentAttempt?.isExpired() == true) 1 else (currentAttempt?.attempts ?: 0) + 1
pendingConnections[deviceAddress] = ConnectionAttempt(attempts) pendingConnections[deviceAddress] = ConnectionAttempt(attempts)
Log.d(TAG, "Tracker: Added pending connection for $deviceAddress (attempts: $attempts)") Log.d(TAG, "Tracker: Added pending connection for $deviceAddress (attempts: $attempts)")
return true return true
@@ -279,7 +284,7 @@ class BluetoothConnectionTracker(
subscribedDevices.removeAll { it.address == deviceAddress } subscribedDevices.removeAll { it.address == deviceAddress }
addressPeerMap.remove(deviceAddress) addressPeerMap.remove(deviceAddress)
} }
pendingConnections.remove(deviceAddress) firstAnnounceSeen.remove(deviceAddress)
Log.d(TAG, "Cleaned up device connection for $deviceAddress") Log.d(TAG, "Cleaned up device connection for $deviceAddress")
} }
@@ -313,6 +318,21 @@ class BluetoothConnectionTracker(
addressPeerMap.clear() addressPeerMap.clear()
pendingConnections.clear() pendingConnections.clear()
scanRSSI.clear() scanRSSI.clear()
firstAnnounceSeen.clear()
}
/**
* Mark that we have received the first ANNOUNCE over this device connection.
*/
fun noteAnnounceReceived(deviceAddress: String) {
firstAnnounceSeen[deviceAddress] = true
}
/**
* Check whether the first ANNOUNCE has been seen for a device connection.
*/
fun hasSeenFirstAnnounce(deviceAddress: String): Boolean {
return firstAnnounceSeen[deviceAddress] == true
} }
/** /**
@@ -7,12 +7,15 @@ import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.protocol.MessagePadding import com.bitchat.android.protocol.MessagePadding
import com.bitchat.android.model.RoutedPacket import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.model.IdentityAnnouncement 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.BitchatPacket
import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.MessageType
import com.bitchat.android.protocol.SpecialRecipients import com.bitchat.android.protocol.SpecialRecipients
import com.bitchat.android.model.RequestSyncPacket import com.bitchat.android.model.RequestSyncPacket
import com.bitchat.android.sync.GossipSyncManager import com.bitchat.android.sync.GossipSyncManager
import com.bitchat.android.util.toHexString import com.bitchat.android.util.toHexString
import com.bitchat.android.services.VerificationService
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.util.* import java.util.*
import kotlin.math.sign import kotlin.math.sign
@@ -52,6 +55,12 @@ class BluetoothMeshService(private val context: Context) {
internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access
private val packetProcessor = PacketProcessor(myPeerID) private val packetProcessor = PacketProcessor(myPeerID)
private lateinit var gossipSyncManager: GossipSyncManager private lateinit var gossipSyncManager: GossipSyncManager
// Service-level notification manager for background (no-UI) DMs
private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager(
context.applicationContext,
androidx.core.app.NotificationManagerCompat.from(context.applicationContext),
com.bitchat.android.util.NotificationIntervalManager()
)
// Service state management // Service state management
private var isActive = false private var isActive = false
@@ -61,8 +70,12 @@ class BluetoothMeshService(private val context: Context) {
// Coroutines // Coroutines
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// Tracks whether this instance has been terminated via stopServices()
private var terminated = false
init { init {
Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID")
VerificationService.configure(encryptionService)
setupDelegates() setupDelegates()
messageHandler.packetProcessor = packetProcessor messageHandler.packetProcessor = packetProcessor
//startPeriodicDebugLogging() //startPeriodicDebugLogging()
@@ -98,6 +111,7 @@ class BluetoothMeshService(private val context: Context) {
return signPacketBeforeBroadcast(packet) return signPacketBeforeBroadcast(packet)
} }
} }
Log.d(TAG, "Delegates set up; GossipSyncManager initialized")
} }
/** /**
@@ -105,6 +119,7 @@ class BluetoothMeshService(private val context: Context) {
*/ */
private fun startPeriodicDebugLogging() { private fun startPeriodicDebugLogging() {
serviceScope.launch { serviceScope.launch {
Log.d(TAG, "Starting periodic debug logging loop")
while (isActive) { while (isActive) {
try { try {
delay(10000) // 10 seconds delay(10000) // 10 seconds
@@ -116,6 +131,7 @@ class BluetoothMeshService(private val context: Context) {
Log.e(TAG, "Error in periodic debug logging: ${e.message}") Log.e(TAG, "Error in periodic debug logging: ${e.message}")
} }
} }
Log.d(TAG, "Periodic debug logging loop ended (isActive=$isActive)")
} }
} }
@@ -124,6 +140,7 @@ class BluetoothMeshService(private val context: Context) {
*/ */
private fun sendPeriodicBroadcastAnnounce() { private fun sendPeriodicBroadcastAnnounce() {
serviceScope.launch { serviceScope.launch {
Log.d(TAG, "Starting periodic announce loop")
while (isActive) { while (isActive) {
try { try {
delay(30000) // 30 seconds delay(30000) // 30 seconds
@@ -132,6 +149,7 @@ class BluetoothMeshService(private val context: Context) {
Log.e(TAG, "Error in periodic broadcast announce: ${e.message}") Log.e(TAG, "Error in periodic broadcast announce: ${e.message}")
} }
} }
Log.d(TAG, "Periodic announce loop ended (isActive=$isActive)")
} }
} }
@@ -139,6 +157,7 @@ class BluetoothMeshService(private val context: Context) {
* Setup delegate connections between components * Setup delegate connections between components
*/ */
private fun setupDelegates() { private fun setupDelegates() {
Log.d(TAG, "Setting up component delegates")
// Provide nickname resolver to BLE broadcaster for detailed logs // Provide nickname resolver to BLE broadcaster for detailed logs
try { try {
connectionManager.setNicknameResolver { pid -> peerManager.getPeerNickname(pid) } connectionManager.setNicknameResolver { pid -> peerManager.getPeerNickname(pid) }
@@ -146,6 +165,9 @@ class BluetoothMeshService(private val context: Context) {
// PeerManager delegates to main mesh service delegate // PeerManager delegates to main mesh service delegate
peerManager.delegate = object : PeerManagerDelegate { peerManager.delegate = object : PeerManagerDelegate {
override fun onPeerListUpdated(peerIDs: List<String>) { override fun onPeerListUpdated(peerIDs: List<String>) {
// Update process-wide state first
try { com.bitchat.android.services.AppStateStore.setPeers(peerIDs) } catch (_: Exception) { }
// Then notify UI delegate if attached
delegate?.didUpdatePeerList(peerIDs) delegate?.didUpdatePeerList(peerIDs)
} }
override fun onPeerRemoved(peerID: String) { override fun onPeerRemoved(peerID: String) {
@@ -165,6 +187,7 @@ class BluetoothMeshService(private val context: Context) {
override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) { override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) {
// Send announcement and cached messages after key exchange // Send announcement and cached messages after key exchange
serviceScope.launch { serviceScope.launch {
Log.d(TAG, "Key exchange completed with $peerID; sending follow-ups")
delay(100) delay(100)
sendAnnouncementToPeer(peerID) sendAnnouncementToPeer(peerID)
@@ -352,7 +375,36 @@ class BluetoothMeshService(private val context: Context) {
// Callbacks // Callbacks
override fun onMessageReceived(message: BitchatMessage) { override fun onMessageReceived(message: BitchatMessage) {
// Always reflect into process-wide store so UI can hydrate after recreation
try {
when {
message.isPrivate -> {
val peer = message.senderPeerID ?: ""
if (peer.isNotEmpty()) com.bitchat.android.services.AppStateStore.addPrivateMessage(peer, message)
}
message.channel != null -> {
com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel!!, message)
}
else -> {
com.bitchat.android.services.AppStateStore.addPublicMessage(message)
}
}
} catch (_: Exception) { }
// And forward to UI delegate if attached
delegate?.didReceiveMessage(message) delegate?.didReceiveMessage(message)
// If no UI delegate attached (app closed), show DM notification via service manager
if (delegate == null && message.isPrivate) {
try {
val senderPeerID = message.senderPeerID
if (senderPeerID != null) {
val nick = try { peerManager.getPeerNickname(senderPeerID) } catch (_: Exception) { null } ?: senderPeerID
val preview = com.bitchat.android.ui.NotificationTextUtils.buildPrivateMessagePreview(message)
serviceNotificationManager.setAppBackgroundState(true)
serviceNotificationManager.showPrivateMessageNotification(senderPeerID, nick, preview)
}
} catch (_: Exception) { }
}
} }
override fun onChannelLeave(channel: String, fromPeer: String) { override fun onChannelLeave(channel: String, fromPeer: String) {
@@ -366,6 +418,14 @@ class BluetoothMeshService(private val context: Context) {
override fun onReadReceiptReceived(messageID: String, peerID: String) { override fun onReadReceiptReceived(messageID: String, peerID: String) {
delegate?.didReceiveReadReceipt(messageID, peerID) 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 // PacketProcessor delegates
@@ -408,25 +468,17 @@ class BluetoothMeshService(private val context: Context) {
val deviceAddress = routed.relayAddress val deviceAddress = routed.relayAddress
val pid = routed.peerID val pid = routed.peerID
if (deviceAddress != null && pid != null) { if (deviceAddress != null && pid != null) {
// Only set mapping if not already mapped // First ANNOUNCE over a device connection defines a direct neighbor.
if (!connectionManager.addressPeerMap.containsKey(deviceAddress)) { if (!connectionManager.hasSeenFirstAnnounce(deviceAddress)) {
// Bind or rebind this device address to the announcing peer
connectionManager.addressPeerMap[deviceAddress] = pid connectionManager.addressPeerMap[deviceAddress] = pid
Log.d(TAG, "Mapped device $deviceAddress to peer $pid on ANNOUNCE") connectionManager.noteAnnounceReceived(deviceAddress)
Log.d(TAG, "Mapped device $deviceAddress to peer $pid on FIRST-ANNOUNCE for this connection")
// Mark this peer as directly connected for UI // Mark as directly connected (upgrades from routed if needed)
try { try { peerManager.setDirectConnection(pid, true) } catch (_: Exception) { }
peerManager.getPeerInfo(pid)?.let {
// Set direct connection flag
// (This will also trigger a peer list update)
peerManager.setDirectConnection(pid, true)
// Also push reactive directness state to UI (best-effort)
try {
// Note: UI observes via didUpdatePeerList, but we can also update ChatState on a timer
} catch (_: Exception) { }
}
} catch (_: Exception) { }
// Schedule initial sync for this new directly connected peer only // Initial sync for this newly direct peer
try { gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { } try { gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { }
} }
} }
@@ -489,12 +541,23 @@ class BluetoothMeshService(private val context: Context) {
// BluetoothConnectionManager delegates // BluetoothConnectionManager delegates
connectionManager.delegate = object : BluetoothConnectionManagerDelegate { connectionManager.delegate = object : BluetoothConnectionManagerDelegate {
override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) { override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) {
// Log incoming for debug graphs (do not double-count anywhere else)
try {
val nick = getPeerNicknames()[peerID]
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming(
packetType = packet.type.toString(),
fromPeerID = peerID,
fromNickname = nick,
fromDeviceAddress = device?.address
)
} catch (_: Exception) { }
packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address)) packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address))
} }
override fun onDeviceConnected(device: android.bluetooth.BluetoothDevice) { override fun onDeviceConnected(device: android.bluetooth.BluetoothDevice) {
// Send initial announcements after services are ready // Send initial announcements after services are ready
serviceScope.launch { serviceScope.launch {
Log.d(TAG, "Device connected: ${device.address}; scheduling announce")
delay(200) delay(200)
sendBroadcastAnnounce() sendBroadcastAnnounce()
} }
@@ -509,6 +572,7 @@ class BluetoothMeshService(private val context: Context) {
} }
override fun onDeviceDisconnected(device: android.bluetooth.BluetoothDevice) { override fun onDeviceDisconnected(device: android.bluetooth.BluetoothDevice) {
Log.d(TAG, "Device disconnected: ${device.address}")
val addr = device.address val addr = device.address
// Remove mapping and, if that was the last direct path for the peer, clear direct flag // Remove mapping and, if that was the last direct path for the peer, clear direct flag
val peer = connectionManager.addressPeerMap[addr] val peer = connectionManager.addressPeerMap[addr]
@@ -547,6 +611,11 @@ class BluetoothMeshService(private val context: Context) {
Log.w(TAG, "Mesh service already active, ignoring duplicate start request") Log.w(TAG, "Mesh service already active, ignoring duplicate start request")
return return
} }
if (terminated) {
// This instance's scope was cancelled previously; refuse to start to avoid using dead scopes.
Log.e(TAG, "Mesh service instance was terminated; create a new instance instead of restarting")
return
}
Log.i(TAG, "Starting Bluetooth mesh service with peer ID: $myPeerID") Log.i(TAG, "Starting Bluetooth mesh service with peer ID: $myPeerID")
@@ -558,6 +627,7 @@ class BluetoothMeshService(private val context: Context) {
Log.d(TAG, "Started periodic broadcast announcements (every 30 seconds)") Log.d(TAG, "Started periodic broadcast announcements (every 30 seconds)")
// Start periodic syncs // Start periodic syncs
gossipSyncManager.start() gossipSyncManager.start()
Log.d(TAG, "GossipSyncManager started")
} else { } else {
Log.e(TAG, "Failed to start Bluetooth services") Log.e(TAG, "Failed to start Bluetooth services")
} }
@@ -579,11 +649,14 @@ class BluetoothMeshService(private val context: Context) {
sendLeaveAnnouncement() sendLeaveAnnouncement()
serviceScope.launch { serviceScope.launch {
Log.d(TAG, "Stopping subcomponents and cancelling scope...")
delay(200) // Give leave message time to send delay(200) // Give leave message time to send
// Stop all components // Stop all components
gossipSyncManager.stop() gossipSyncManager.stop()
Log.d(TAG, "GossipSyncManager stopped")
connectionManager.stopServices() connectionManager.stopServices()
Log.d(TAG, "BluetoothConnectionManager stop requested")
peerManager.shutdown() peerManager.shutdown()
fragmentManager.shutdown() fragmentManager.shutdown()
securityManager.shutdown() securityManager.shutdown()
@@ -591,9 +664,24 @@ class BluetoothMeshService(private val context: Context) {
messageHandler.shutdown() messageHandler.shutdown()
packetProcessor.shutdown() packetProcessor.shutdown()
// Mark this instance as terminated and cancel its scope so it won't be reused
terminated = true
serviceScope.cancel() serviceScope.cancel()
Log.i(TAG, "BluetoothMeshService terminated and scope cancelled")
} }
} }
/**
* Whether this instance can be safely reused. Returns false after stopServices() or if
* any critical internal scope has been cancelled.
*/
fun isReusable(): Boolean {
val reusable = !terminated && serviceScope.isActive && connectionManager.isReusable()
if (!reusable) {
Log.d(TAG, "isReusable=false (terminated=$terminated, scopeActive=${serviceScope.isActive}, connReusable=${connectionManager.isReusable()})")
}
return reusable
}
/** /**
* Send public message * Send public message
@@ -813,7 +901,7 @@ class BluetoothMeshService(private val context: Context) {
fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) { fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) {
serviceScope.launch { serviceScope.launch {
Log.d(TAG, "📖 Sending read receipt for message $messageID to $recipientPeerID") Log.d(TAG, "📖 Sending read receipt for message $messageID to $recipientPeerID")
// Route geohash read receipts via MessageRouter instead of here // Route geohash read receipts via MessageRouter instead of here
val geo = runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance() }.getOrNull() val geo = runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance() }.getOrNull()
val isGeoAlias = try { val isGeoAlias = try {
@@ -824,8 +912,15 @@ class BluetoothMeshService(private val context: Context) {
geo.sendReadReceipt(com.bitchat.android.model.ReadReceipt(messageID), recipientPeerID) geo.sendReadReceipt(com.bitchat.android.model.ReadReceipt(messageID), recipientPeerID)
return@launch return@launch
} }
try { try {
// Avoid duplicate read receipts: check persistent store first
val seenStore = try { com.bitchat.android.services.SeenMessageStore.getInstance(context.applicationContext) } catch (_: Exception) { null }
if (seenStore?.hasRead(messageID) == true) {
Log.d(TAG, "Skipping read receipt for $messageID - already marked read")
return@launch
}
// Create read receipt payload using NoisePayloadType exactly like iOS // Create read receipt payload using NoisePayloadType exactly like iOS
val readReceiptPayload = com.bitchat.android.model.NoisePayload( val readReceiptPayload = com.bitchat.android.model.NoisePayload(
type = com.bitchat.android.model.NoisePayloadType.READ_RECEIPT, type = com.bitchat.android.model.NoisePayloadType.READ_RECEIPT,
@@ -851,12 +946,59 @@ class BluetoothMeshService(private val context: Context) {
val signedPacket = signPacketBeforeBroadcast(packet) val signedPacket = signPacketBeforeBroadcast(packet)
connectionManager.broadcastPacket(RoutedPacket(signedPacket)) connectionManager.broadcastPacket(RoutedPacket(signedPacket))
Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID") Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID")
// Persist as read after successful send
try { seenStore?.markRead(messageID) } catch (_: Exception) { }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to send read receipt to $recipientPeerID: ${e.message}") Log.e(TAG, "Failed to send read receipt to $recipientPeerID: ${e.message}")
} }
} }
} }
// MARK: QR Verification over Noise
fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
val tlv = VerificationService.buildVerifyChallenge(noiseKeyHex, nonceA)
val payload = NoisePayload(
type = NoisePayloadType.VERIFY_CHALLENGE,
data = tlv
)
sendNoisePayloadToPeer(payload, peerID, "verify challenge")
}
fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
val tlv = VerificationService.buildVerifyResponse(noiseKeyHex, nonceA) ?: return
val payload = NoisePayload(
type = NoisePayloadType.VERIFY_RESPONSE,
data = tlv
)
sendNoisePayloadToPeer(payload, peerID, "verify response")
}
private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String, label: String) {
serviceScope.launch {
try {
val encrypted = encryptionService.encrypt(payload.encode(), recipientPeerID)
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(recipientPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = encrypted,
signature = null,
ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
)
val signedPacket = signPacketBeforeBroadcast(packet)
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
Log.d(TAG, "📤 Sent $label to $recipientPeerID (${payload.data.size} bytes)")
} catch (e: Exception) {
Log.e(TAG, "Failed to send $label to $recipientPeerID: ${e.message}")
}
}
}
/** /**
* Send broadcast announce with TLV-encoded identity announcement - exactly like iOS * Send broadcast announce with TLV-encoded identity announcement - exactly like iOS
@@ -864,7 +1006,7 @@ class BluetoothMeshService(private val context: Context) {
fun sendBroadcastAnnounce() { fun sendBroadcastAnnounce() {
Log.d(TAG, "Sending broadcast announce") Log.d(TAG, "Sending broadcast announce")
serviceScope.launch { serviceScope.launch {
val nickname = delegate?.getNickname() ?: myPeerID val nickname = try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { myPeerID }
// Get the static public key for the announcement // Get the static public key for the announcement
val staticKey = encryptionService.getStaticPublicKey() val staticKey = encryptionService.getStaticPublicKey()
@@ -927,7 +1069,7 @@ class BluetoothMeshService(private val context: Context) {
fun sendAnnouncementToPeer(peerID: String) { fun sendAnnouncementToPeer(peerID: String) {
if (peerManager.hasAnnouncedToPeer(peerID)) return if (peerManager.hasAnnouncedToPeer(peerID)) return
val nickname = delegate?.getNickname() ?: myPeerID val nickname = try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { myPeerID }
// Get the static public key for the announcement // Get the static public key for the announcement
val staticKey = encryptionService.getStaticPublicKey() val staticKey = encryptionService.getStaticPublicKey()
@@ -1003,12 +1145,11 @@ class BluetoothMeshService(private val context: Context) {
* Send leave announcement * Send leave announcement
*/ */
private fun sendLeaveAnnouncement() { private fun sendLeaveAnnouncement() {
val nickname = delegate?.getNickname() ?: myPeerID
val packet = BitchatPacket( val packet = BitchatPacket(
type = MessageType.LEAVE.value, type = MessageType.LEAVE.value,
ttl = MAX_TTL, ttl = MAX_TTL,
senderID = myPeerID, senderID = myPeerID,
payload = nickname.toByteArray() payload = byteArrayOf()
) )
// Sign the packet before broadcasting // Sign the packet before broadcasting
@@ -1055,6 +1196,13 @@ class BluetoothMeshService(private val context: Context) {
return peerManager.getFingerprintForPeer(peerID) return peerManager.getFingerprintForPeer(peerID)
} }
/**
* Get current active peer count (for status/notifications)
*/
fun getActivePeerCount(): Int {
return try { peerManager.getActivePeerCount() } catch (_: Exception) { 0 }
}
/** /**
* Get peer info for verification purposes * Get peer info for verification purposes
*/ */
@@ -1081,6 +1229,10 @@ class BluetoothMeshService(private val context: Context) {
fun getIdentityFingerprint(): String { fun getIdentityFingerprint(): String {
return encryptionService.getIdentityFingerprint() return encryptionService.getIdentityFingerprint()
} }
fun getStaticNoisePublicKey(): ByteArray? {
return encryptionService.getStaticPublicKey()
}
/** /**
* Check if encryption icon should be shown for a peer * Check if encryption icon should be shown for a peer
@@ -1253,6 +1405,8 @@ interface BluetoothMeshDelegate {
fun didReceiveChannelLeave(channel: String, fromPeer: String) fun didReceiveChannelLeave(channel: String, fromPeer: String)
fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String)
fun didReceiveReadReceipt(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 decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
fun getNickname(): String? fun getNickname(): String?
fun isFavorite(peerID: String): Boolean fun isFavorite(peerID: String): Boolean
@@ -73,9 +73,17 @@ class BluetoothPacketBroadcaster(
try { try {
val fromNick = incomingPeer?.let { nicknameResolver?.invoke(it) } val fromNick = incomingPeer?.let { nicknameResolver?.invoke(it) }
val toNick = toPeer?.let { nicknameResolver?.invoke(it) } val toNick = toPeer?.let { nicknameResolver?.invoke(it) }
val isRelay = (incomingAddr != null || incomingPeer != null) val manager = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance()
// Always log outgoing for the actual transmission target
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logPacketRelayDetailed( manager.logOutgoing(
packetType = typeName,
toPeerID = toPeer,
toNickname = toNick,
toDeviceAddress = toDeviceAddress,
previousHopPeerID = incomingPeer
)
// Keep the verbose relay message for human readability
manager.logPacketRelayDetailed(
packetType = typeName, packetType = typeName,
senderPeerID = senderPeerID, senderPeerID = senderPeerID,
senderNickname = senderNick, senderNickname = senderNick,
@@ -86,7 +94,7 @@ class BluetoothPacketBroadcaster(
toNickname = toNick, toNickname = toNick,
toDeviceAddress = toDeviceAddress, toDeviceAddress = toDeviceAddress,
ttl = ttl, ttl = ttl,
isRelay = isRelay isRelay = true
) )
} catch (_: Exception) { } catch (_: Exception) {
// Silently ignore debug logging failures // Silently ignore debug logging failures
@@ -33,9 +33,9 @@ class BluetoothPermissionManager(private val context: Context) {
Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION Manifest.permission.ACCESS_FINE_LOCATION
)) ))
return permissions.all { return permissions.all {
ActivityCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED ActivityCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
} }
} }
} }
@@ -157,6 +157,14 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
// Simplified: Call delegate with messageID and peerID directly // Simplified: Call delegate with messageID and peerID directly
delegate?.onReadReceiptReceived(messageID, peerID) 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) { } catch (e: Exception) {
@@ -618,4 +626,6 @@ interface MessageHandlerDelegate {
fun onChannelLeave(channel: String, fromPeer: String) fun onChannelLeave(channel: String, fromPeer: String)
fun onDeliveryAckReceived(messageID: String, peerID: String) fun onDeliveryAckReceived(messageID: String, peerID: String)
fun onReadReceiptReceived(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)
} }
@@ -299,8 +299,7 @@ class PeerManager {
*/ */
fun isPeerActive(peerID: String): Boolean { fun isPeerActive(peerID: String): Boolean {
val info = peers[peerID] ?: return false val info = peers[peerID] ?: return false
val now = System.currentTimeMillis() return info.isConnected
return (now - info.lastSeen) <= stalePeerTimeoutMs && info.isConnected
} }
/** /**
@@ -328,8 +327,7 @@ class PeerManager {
* Get list of active peer IDs * Get list of active peer IDs
*/ */
fun getActivePeerIDs(): List<String> { fun getActivePeerIDs(): List<String> {
val now = System.currentTimeMillis() return peers.filterValues { it.isConnected }
return peers.filterValues { (now - it.lastSeen) <= stalePeerTimeoutMs && it.isConnected }
.keys .keys
.toList() .toList()
.sorted() .sorted()
@@ -7,7 +7,13 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.os.BatteryManager import android.os.BatteryManager
import android.os.Handler
import android.os.Looper
import android.util.Log import android.util.Log
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlin.math.max import kotlin.math.max
@@ -15,7 +21,7 @@ import kotlin.math.max
* Power-aware Bluetooth management for bitchat * Power-aware Bluetooth management for bitchat
* Adjusts scanning, advertising, and connection behavior based on battery state * 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 { companion object {
private const val TAG = "PowerManager" private const val TAG = "PowerManager"
@@ -49,7 +55,7 @@ class PowerManager(private val context: Context) {
private var currentMode = PowerMode.BALANCED private var currentMode = PowerMode.BALANCED
private var isCharging = false private var isCharging = false
private var batteryLevel = 100 private var batteryLevel = 100
private var isAppInBackground = false private var isAppInBackground = true
private val powerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val powerScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var dutyCycleJob: Job? = null private var dutyCycleJob: Job? = null
@@ -87,6 +93,16 @@ class PowerManager(private val context: Context) {
init { init {
registerBatteryReceiver() 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() updatePowerMode()
} }
@@ -99,13 +115,30 @@ class PowerManager(private val context: Context) {
Log.i(TAG, "Stopping power management") Log.i(TAG, "Stopping power management")
powerScope.cancel() powerScope.cancel()
unregisterBatteryReceiver() 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) { override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (isAppInBackground != inBackground) { when (event) {
isAppInBackground = inBackground Lifecycle.Event.ON_START -> {
Log.d(TAG, "App background state changed: $inBackground") Log.d(TAG, "Process lifecycle: ON_START (App coming to foreground)")
updatePowerMode() 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) .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
PowerMode.ULTRA_LOW_POWER -> builder PowerMode.ULTRA_LOW_POWER -> builder
.setScanMode(ScanSettings.SCAN_MODE_OPPORTUNISTIC) .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
.setMatchMode(ScanSettings.MATCH_MODE_STICKY) .setMatchMode(ScanSettings.MATCH_MODE_STICKY)
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT) .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
} }
@@ -224,26 +257,29 @@ class PowerManager(private val context: Context) {
} }
private fun updatePowerMode() { private fun updatePowerMode() {
val newMode = when { // Determine the base mode from battery/charging state only
// Always use performance mode when charging (unless in background too long) val baseMode = when {
// Charging in foreground may use performance
isCharging && !isAppInBackground -> PowerMode.PERFORMANCE isCharging && !isAppInBackground -> PowerMode.PERFORMANCE
// Critical battery - use ultra low power // Critical battery - force ultra low power regardless of foreground/background
batteryLevel <= CRITICAL_BATTERY -> PowerMode.ULTRA_LOW_POWER batteryLevel <= CRITICAL_BATTERY -> PowerMode.ULTRA_LOW_POWER
// Low battery - use power saver // Low battery - prefer power saver
batteryLevel <= LOW_BATTERY -> PowerMode.POWER_SAVER batteryLevel <= LOW_BATTERY -> PowerMode.POWER_SAVER
// Background app with medium battery - use power saver // Otherwise balanced
isAppInBackground && batteryLevel <= MEDIUM_BATTERY -> PowerMode.POWER_SAVER
// Background app with good battery - use balanced
isAppInBackground -> PowerMode.BALANCED
// Foreground with good battery - use balanced
else -> PowerMode.BALANCED else -> PowerMode.BALANCED
} }
// If app is in background (including when running as a foreground service),
// cap the power mode to at least POWER_SAVER. Preserve ULTRA_LOW_POWER.
val newMode = if (isAppInBackground) {
if (baseMode == PowerMode.ULTRA_LOW_POWER) PowerMode.ULTRA_LOW_POWER else PowerMode.POWER_SAVER
} else {
baseMode
}
if (newMode != currentMode) { if (newMode != currentMode) {
val oldMode = currentMode val oldMode = currentMode
currentMode = newMode currentMode = newMode
@@ -50,35 +50,36 @@ class SecurityManager(private val encryptionService: EncryptionService, private
return false return false
} }
// Validate packet payload
if (packet.payload.isEmpty()) {
Log.d(TAG, "Dropping packet with empty payload")
return false
}
// Replay attack protection (same 5-minute window as iOS) // Replay attack protection (same 5-minute window as iOS)
val currentTime = System.currentTimeMillis() val currentTime = System.currentTimeMillis()
val packetTime = packet.timestamp.toLong() val messageType = MessageType.fromValue(packet.type)
val timeDiff = kotlin.math.abs(currentTime - packetTime)
// if (timeDiff > MESSAGE_TIMEOUT) {
// Log.d(TAG, "Dropping old packet from $peerID, time diff: ${timeDiff/1000}s")
// return false
// }
// Duplicate detection // Duplicate detection
val messageID = generateMessageID(packet, peerID) val messageID = generateMessageID(packet, peerID)
if (processedMessages.contains(messageID)) {
Log.d(TAG, "Dropping duplicate packet: $messageID")
return false
}
if (processedMessages.contains(messageID)) {
// Check for ANNOUNCE exception: allow if it looks like a direct neighbor (max TTL)
// This ensures we catch the "first announce" on a new connection for binding,
// while still dropping looped/relayed duplicates.
val isFreshAnnounce = messageType == MessageType.ANNOUNCE &&
packet.ttl >= com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
if (!isFreshAnnounce) {
Log.d(TAG, "Dropping duplicate packet: $messageID")
return false
}
Log.d(TAG, "Allowing duplicate ANNOUNCE from direct neighbor: $messageID")
}
// Add to processed messages // Add to processed messages
processedMessages.add(messageID) processedMessages.add(messageID)
messageTimestamps[messageID] = currentTime messageTimestamps[messageID] = currentTime
// NEW: Signature verification logging (not rejecting yet) // Enforce mandatory signature verification
verifyPacketSignatureWithLogging(packet, peerID) 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") Log.d(TAG, "Packet validation passed for $peerID, messageID: $messageID")
return true return true
@@ -231,34 +232,58 @@ class SecurityManager(private val encryptionService: EncryptionService, private
} }
/** /**
* Verify packet signature using peer's signing public key and log the result * Verify packet signature using peer's signing public key
* Returns true only if signature is present and valid
*/ */
private fun verifyPacketSignatureWithLogging(packet: BitchatPacket, peerID: String) { private fun verifyPacketSignature(packet: BitchatPacket, peerID: String): Boolean {
try { 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) { if (packet.signature == null) {
Log.d(TAG, "📝 Signature check for $peerID: NO_SIGNATURE (packet type ${packet.type})") Log.w(TAG, " Signature check for $peerID: NO_SIGNATURE (packet type ${packet.type})")
return return false
} }
// Try to get peer's signing public key from peer info // 2. Get Signing Public Key
val peerInfo = delegate?.getPeerInfo(peerID) var signingPublicKey: ByteArray? = null
val signingPublicKey = peerInfo?.signingPublicKey
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) { if (signingPublicKey == null) {
Log.d(TAG, "📝 Signature check for $peerID: NO_SIGNING_KEY (packet type ${packet.type})") // If we don't have a key (and it's not an announce), we can't verify.
return // 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() val packetDataForSigning = packet.toBinaryDataForSigning()
if (packetDataForSigning == null) { if (packetDataForSigning == null) {
Log.w(TAG, "📝 Signature check for $peerID: ENCODING_ERROR (packet type ${packet.type})") Log.w(TAG, " Signature check for $peerID: ENCODING_ERROR (packet type ${packet.type})")
return return false
} }
// Verify the signature using the peer's signing public key // 4. Verify Signature
val signature = packet.signature!! // We already checked for null above val signature = packet.signature!!
val isSignatureValid = encryptionService.verifyEd25519Signature( val isSignatureValid = encryptionService.verifyEd25519Signature(
signature, signature,
packetDataForSigning, packetDataForSigning,
@@ -266,13 +291,16 @@ class SecurityManager(private val encryptionService: EncryptionService, private
) )
if (isSignatureValid) { 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 { } 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) { } 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
} }
} }
@@ -21,6 +21,8 @@ enum class NoisePayloadType(val value: UByte) {
PRIVATE_MESSAGE(0x01u), // Private chat message with TLV encoding PRIVATE_MESSAGE(0x01u), // Private chat message with TLV encoding
READ_RECEIPT(0x02u), // Message was read READ_RECEIPT(0x02u), // Message was read
DELIVERED(0x03u), // Message was delivered DELIVERED(0x03u), // Message was delivered
VERIFY_CHALLENGE(0x10u), // Verification challenge
VERIFY_RESPONSE(0x11u), // Verification response
FILE_TRANSFER(0x20u); FILE_TRANSFER(0x20u);
@@ -2,6 +2,7 @@ package com.bitchat.android.net
import android.app.Application import android.app.Application
import android.util.Log import android.util.Log
import com.bitchat.android.util.AppConstants
import info.guardianproject.arti.ArtiLogListener import info.guardianproject.arti.ArtiLogListener
import info.guardianproject.arti.ArtiProxy import info.guardianproject.arti.ArtiProxy
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -11,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -24,54 +26,97 @@ import java.util.concurrent.atomic.AtomicReference
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
/** /**
* Manages embedded Tor lifecycle & provides SOCKS proxy address. * Tor provider implementation using custom-built Arti (Tor-in-Rust).
* Uses Arti (Tor in Rust) for improved security and reliability. *
* This singleton provides Tor anonymity features using a custom Arti build
* compiled with 16KB page size support for Google Play compliance.
*
* Based on the original TorManager implementation.
*/ */
object TorManager { class ArtiTorManager private constructor() {
private const val TAG = "TorManager" enum class TorState {
private const val DEFAULT_SOCKS_PORT = com.bitchat.android.util.AppConstants.Tor.DEFAULT_SOCKS_PORT OFF,
private const val RESTART_DELAY_MS = com.bitchat.android.util.AppConstants.Tor.RESTART_DELAY_MS // 2 seconds between stop/start STARTING,
private const val INACTIVITY_TIMEOUT_MS = com.bitchat.android.util.AppConstants.Tor.INACTIVITY_TIMEOUT_MS // 5 seconds of no activity before restart BOOTSTRAPPING,
private const val MAX_RETRY_ATTEMPTS = com.bitchat.android.util.AppConstants.Tor.MAX_RETRY_ATTEMPTS RUNNING,
private const val STOP_TIMEOUT_MS = com.bitchat.android.util.AppConstants.Tor.STOP_TIMEOUT_MS STOPPING,
ERROR
}
data class TorStatus(
val mode: TorMode = TorMode.OFF,
val running: Boolean = false,
val bootstrapPercent: Int = 0,
val lastLogLine: String = "",
val state: TorState = TorState.OFF
)
companion object {
private const val TAG = "ArtiTorManager"
private const val DEFAULT_SOCKS_PORT = AppConstants.Tor.DEFAULT_SOCKS_PORT
private const val RESTART_DELAY_MS = AppConstants.Tor.RESTART_DELAY_MS
private const val INACTIVITY_TIMEOUT_MS = AppConstants.Tor.INACTIVITY_TIMEOUT_MS
private const val MAX_RETRY_ATTEMPTS = AppConstants.Tor.MAX_RETRY_ATTEMPTS
private const val STOP_TIMEOUT_MS = AppConstants.Tor.STOP_TIMEOUT_MS
@Volatile
private var INSTANCE: ArtiTorManager? = null
fun getInstance(): ArtiTorManager {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: ArtiTorManager().also { INSTANCE = it }
}
}
}
private val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@Volatile private var initialized = false @Volatile
@Volatile private var socksAddr: InetSocketAddress? = null private var initialized = false
private val artiProxyRef = AtomicReference<ArtiProxy?>(null) @Volatile
@Volatile private var lastMode: TorMode = TorMode.OFF private var socksAddr: InetSocketAddress? = null
@Volatile
private var artiProxy: ArtiProxy? = null
@Volatile
private var lastMode: TorMode = TorMode.OFF
private val applyMutex = Mutex() private val applyMutex = Mutex()
@Volatile private var desiredMode: TorMode = TorMode.OFF @Volatile
@Volatile private var currentSocksPort: Int = DEFAULT_SOCKS_PORT private var desiredMode: TorMode = TorMode.OFF
@Volatile private var lastLogTime = AtomicLong(0L) @Volatile
@Volatile private var retryAttempts = 0 private var currentSocksPort: Int = DEFAULT_SOCKS_PORT
@Volatile private var bindRetryAttempts = 0 @Volatile
private var lastLogTime = AtomicLong(0L)
@Volatile
private var retryAttempts = 0
@Volatile
private var bindRetryAttempts = 0
private var inactivityJob: Job? = null private var inactivityJob: Job? = null
private var retryJob: Job? = null private var retryJob: Job? = null
private var currentApplication: Application? = null private var currentApplication: Application? = null
private enum class LifecycleState { STOPPED, STARTING, RUNNING, STOPPING } private enum class LifecycleState { STOPPED, STARTING, RUNNING, STOPPING }
@Volatile private var lifecycleState: LifecycleState = LifecycleState.STOPPED
enum class TorState { OFF, STARTING, BOOTSTRAPPING, RUNNING, STOPPING, ERROR } @Volatile
private var lifecycleState: LifecycleState = LifecycleState.STOPPED
data class TorStatus( private val _statusFlow = MutableStateFlow(
val mode: TorMode = TorMode.OFF, TorStatus(
val running: Boolean = false, mode = TorMode.OFF,
val bootstrapPercent: Int = 0, // kept for backwards compatibility with UI; 0 or 100 only running = false,
val lastLogLine: String = "", bootstrapPercent = 0,
val state: TorState = TorState.OFF lastLogLine = "",
state = TorState.OFF
)
) )
private val _status = MutableStateFlow(TorStatus()) val statusFlow: StateFlow<TorStatus> = _statusFlow.asStateFlow()
val statusFlow: StateFlow<TorStatus> = _status.asStateFlow()
private val stateChangeDeferred = AtomicReference<CompletableDeferred<TorState>?>(null) private val stateChangeDeferred = AtomicReference<CompletableDeferred<TorState>?>(null)
fun isProxyEnabled(): Boolean { fun isProxyEnabled(): Boolean {
val s = _status.value val s = _statusFlow.value
return s.mode != TorMode.OFF && s.running && s.bootstrapPercent >= 100 && socksAddr != null && s.state == TorState.RUNNING return s.mode != TorMode.OFF && s.running && s.bootstrapPercent >= 100 &&
socksAddr != null && s.state == TorState.RUNNING
} }
fun init(application: Application) { fun init(application: Application) {
@@ -82,7 +127,21 @@ object TorManager {
currentApplication = application currentApplication = application
TorPreferenceManager.init(application) TorPreferenceManager.init(application)
// Apply saved mode at startup. If ON, set planned SOCKS immediately to avoid any leak. val logListener = ArtiLogListener { logLine ->
val text = logLine ?: return@ArtiLogListener
val s = text
Log.i(TAG, "arti: $s")
lastLogTime.set(System.currentTimeMillis())
_statusFlow.update { it.copy(lastLogLine = s) }
handleArtiLogLine(s)
}
artiProxy = ArtiProxy.Builder(application)
.setSocksPort(currentSocksPort)
.setDnsPort(currentSocksPort + 1)
.setLogListener(logListener)
.build()
val savedMode = TorPreferenceManager.get(application) val savedMode = TorPreferenceManager.get(application)
if (savedMode == TorMode.ON) { if (savedMode == TorMode.ON) {
if (currentSocksPort < DEFAULT_SOCKS_PORT) { if (currentSocksPort < DEFAULT_SOCKS_PORT) {
@@ -90,13 +149,15 @@ object TorManager {
} }
desiredMode = savedMode desiredMode = savedMode
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort) socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
try { OkHttpProvider.reset() } catch (_: Throwable) { } try {
OkHttpProvider.reset()
} catch (_: Throwable) {
} // Only reset OkHttp during init
} }
appScope.launch { appScope.launch {
applyMode(application, savedMode) applyMode(application, savedMode)
} }
// Observe changes
appScope.launch { appScope.launch {
TorPreferenceManager.modeFlow.collect { mode -> TorPreferenceManager.modeFlow.collect { mode ->
applyMode(application, mode) applyMode(application, mode)
@@ -112,53 +173,63 @@ object TorManager {
try { try {
desiredMode = mode desiredMode = mode
lastMode = mode lastMode = mode
val s = _status.value val s = _statusFlow.value
if (mode == s.mode && mode != TorMode.OFF && (lifecycleState == LifecycleState.STARTING || lifecycleState == LifecycleState.RUNNING)) { if (mode == s.mode && mode != TorMode.OFF &&
Log.i(TAG, "applyMode: already in progress/running mode=$mode, state=$lifecycleState; skip") (lifecycleState == LifecycleState.STARTING || lifecycleState == LifecycleState.RUNNING)
) {
Log.i(
TAG,
"applyMode: already in progress/running mode=$mode, state=$lifecycleState; skip"
)
return return
} }
when (mode) { when (mode) {
TorMode.OFF -> { TorMode.OFF -> {
Log.i(TAG, "applyMode: OFF -> stopping tor") Log.i(TAG, "applyMode: OFF -> stopping tor")
lifecycleState = LifecycleState.STOPPING lifecycleState = LifecycleState.STOPPING
_status.value = _status.value.copy(mode = TorMode.OFF, running = false, bootstrapPercent = 0, state = TorState.STOPPING) _statusFlow.value = _statusFlow.value.copy(
stopArti() // non-suspending immediate request mode = TorMode.OFF,
// Best-effort wait for STOPPED before we declare OFF running = false,
bootstrapPercent = 0,
state = TorState.STOPPING
)
stopArti()
waitForStateTransition(target = TorState.OFF, timeoutMs = STOP_TIMEOUT_MS) waitForStateTransition(target = TorState.OFF, timeoutMs = STOP_TIMEOUT_MS)
socksAddr = null socksAddr = null
_status.value = _status.value.copy(mode = TorMode.OFF, running = false, bootstrapPercent = 0, state = TorState.OFF) _statusFlow.value = _statusFlow.value.copy(
mode = TorMode.OFF,
running = false,
bootstrapPercent = 0,
state = TorState.OFF
)
currentSocksPort = DEFAULT_SOCKS_PORT currentSocksPort = DEFAULT_SOCKS_PORT
bindRetryAttempts = 0 bindRetryAttempts = 0
lifecycleState = LifecycleState.STOPPED lifecycleState = LifecycleState.STOPPED
// Rebuild clients WITHOUT proxy and reconnect relays resetNetworkConnections()
try {
OkHttpProvider.reset()
com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections()
} catch (_: Throwable) { }
} }
TorMode.ON -> { TorMode.ON -> {
Log.i(TAG, "applyMode: ON -> starting arti") Log.i(TAG, "applyMode: ON -> starting arti")
// Reset port to default unless we're already using a higher port
if (currentSocksPort < DEFAULT_SOCKS_PORT) { if (currentSocksPort < DEFAULT_SOCKS_PORT) {
currentSocksPort = DEFAULT_SOCKS_PORT currentSocksPort = DEFAULT_SOCKS_PORT
} }
bindRetryAttempts = 0 bindRetryAttempts = 0
lifecycleState = LifecycleState.STARTING lifecycleState = LifecycleState.STARTING
_status.value = _status.value.copy(mode = TorMode.ON, running = false, bootstrapPercent = 0, state = TorState.STARTING) _statusFlow.value = _statusFlow.value.copy(
// Immediately set the planned SOCKS address so all traffic is forced through it, mode = TorMode.ON,
// even before Tor is fully bootstrapped. This prevents any direct connections. running = false,
bootstrapPercent = 0,
state = TorState.STARTING
)
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort) socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
try { OkHttpProvider.reset() } catch (_: Throwable) { } resetNetworkConnections()
try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { }
startArti(application, useDelay = false) startArti(application, useDelay = false)
// Defer enabling proxy until bootstrap completes
appScope.launch { appScope.launch {
waitUntilBootstrapped() waitUntilBootstrapped()
if (_status.value.running && desiredMode == TorMode.ON) { if (_statusFlow.value.running && desiredMode == TorMode.ON) {
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort) socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
Log.i(TAG, "Tor ON: proxy set to ${socksAddr}") Log.i(TAG, "Tor ON: proxy set to ${socksAddr}")
OkHttpProvider.reset() resetNetworkConnections()
try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { }
} }
} }
} }
@@ -171,84 +242,95 @@ object TorManager {
private suspend fun startArti(application: Application, useDelay: Boolean = false) { private suspend fun startArti(application: Application, useDelay: Boolean = false) {
try { try {
// Ensure any previous instance is fully stopped before starting a new one
stopArtiAndWait() stopArtiAndWait()
Log.i(TAG, "Starting Arti on port $currentSocksPort") Log.i(TAG, "Starting Arti on port $currentSocksPort")
if (useDelay) { if (useDelay) {
delay(RESTART_DELAY_MS) delay(RESTART_DELAY_MS)
} }
val logListener = ArtiLogListener { logLine -> val proxy = artiProxy ?: run {
val text = logLine ?: return@ArtiLogListener Log.e(TAG, "ArtiProxy not initialized! This should not happen.")
val s = text.toString() _statusFlow.update { it.copy(state = TorState.ERROR) }
Log.i(TAG, "arti: $s") return
lastLogTime.set(System.currentTimeMillis())
_status.value = _status.value.copy(lastLogLine = s)
handleArtiLogLine(s)
} }
val proxy = ArtiProxy.Builder(application)
.setSocksPort(currentSocksPort)
.setDnsPort(currentSocksPort + 1)
.setLogListener(logListener)
.build()
artiProxyRef.set(proxy)
proxy.start() proxy.start()
lastLogTime.set(System.currentTimeMillis()) lastLogTime.set(System.currentTimeMillis())
_status.value = _status.value.copy(running = true, bootstrapPercent = 0, state = TorState.STARTING) _statusFlow.update {
it.copy(
running = true,
bootstrapPercent = 0,
state = TorState.STARTING
)
}
lifecycleState = LifecycleState.RUNNING lifecycleState = LifecycleState.RUNNING
startInactivityMonitoring() startInactivityMonitoring()
// Removed onion service startup (BLE-only file transfer in this branch)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error starting Arti on port $currentSocksPort: ${e.message}") Log.e(TAG, "Error starting Arti on port $currentSocksPort: ${e.message}")
_status.value = _status.value.copy(state = TorState.ERROR) _statusFlow.update { it.copy(state = TorState.ERROR) }
// Check if this is a bind error
val isBindError = isBindError(e) val isBindError = isBindError(e)
if (isBindError && bindRetryAttempts < MAX_RETRY_ATTEMPTS) { if (isBindError && bindRetryAttempts < MAX_RETRY_ATTEMPTS) {
bindRetryAttempts++ bindRetryAttempts++
currentSocksPort++ currentSocksPort++
Log.w(TAG, "Port bind failed (attempt $bindRetryAttempts/$MAX_RETRY_ATTEMPTS), retrying with port $currentSocksPort") Log.w(
// Update planned SOCKS address immediately so all new connections target the new port TAG,
"Port bind failed (attempt $bindRetryAttempts/$MAX_RETRY_ATTEMPTS), retrying with port $currentSocksPort"
)
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort) socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
try { OkHttpProvider.reset() } catch (_: Throwable) { } resetNetworkConnections()
try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { }
// Immediate retry with incremented port, no exponential backoff for bind errors
startArti(application, useDelay = false) startArti(application, useDelay = false)
} else if (isBindError) { } else if (isBindError) {
Log.e(TAG, "Max bind retry attempts reached ($MAX_RETRY_ATTEMPTS), giving up") Log.e(TAG, "Max bind retry attempts reached ($MAX_RETRY_ATTEMPTS), giving up")
lifecycleState = LifecycleState.STOPPED lifecycleState = LifecycleState.STOPPED
_status.value = _status.value.copy(running = false, bootstrapPercent = 0, state = TorState.ERROR) _statusFlow.update {
it.copy(
running = false,
bootstrapPercent = 0,
state = TorState.ERROR
)
}
} else { } else {
// For non-bind errors, use the existing retry mechanism
scheduleRetry(application) scheduleRetry(application)
} }
} }
} }
/**
* Checks if the exception indicates a port binding failure
*/
private fun isBindError(exception: Exception): Boolean { private fun isBindError(exception: Exception): Boolean {
val message = exception.message?.lowercase() ?: "" val message = exception.message?.lowercase() ?: ""
return message.contains("bind") || return message.contains("bind") ||
message.contains("address already in use") || message.contains("address already in use") ||
message.contains("port") && message.contains("use") || message.contains("port") && message.contains("use") ||
message.contains("permission denied") && message.contains("port") || message.contains("permission denied") && message.contains("port") ||
message.contains("could not bind") message.contains("could not bind")
}
/**
* Reset network connections after Tor state changes.
* Rebuilds OkHttp clients and reconnects Nostr relays.
*/
private fun resetNetworkConnections() {
try {
OkHttpProvider.reset()
} catch (_: Throwable) {
}
try {
com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections()
} catch (_: Throwable) {
}
} }
private fun stopArtiInternal() { private fun stopArtiInternal() {
try { try {
val proxy = artiProxyRef.getAndSet(null) val proxy = artiProxy
if (proxy != null) { if (proxy != null) {
Log.i(TAG, "Stopping Arti…") Log.i(TAG, "Stopping Arti…")
try { proxy.stop() } catch (_: Throwable) {} try {
proxy.stop()
} catch (_: Throwable) {
}
} }
stopInactivityMonitoring() stopInactivityMonitoring()
stopRetryMonitoring() stopRetryMonitoring()
@@ -260,15 +342,16 @@ object TorManager {
private fun stopArti() { private fun stopArti() {
stopArtiInternal() stopArtiInternal()
socksAddr = null socksAddr = null
_status.value = _status.value.copy(running = false, bootstrapPercent = 0, state = TorState.STOPPING) _statusFlow.value = _statusFlow.value.copy(
running = false,
bootstrapPercent = 0,
state = TorState.STOPPING
)
} }
private suspend fun stopArtiAndWait(timeoutMs: Long = STOP_TIMEOUT_MS) { private suspend fun stopArtiAndWait(timeoutMs: Long = STOP_TIMEOUT_MS) {
// Request stop
stopArtiInternal() stopArtiInternal()
// Wait for confirmation via logs (Stopped) or timeout
waitForStateTransition(target = TorState.OFF, timeoutMs = timeoutMs) waitForStateTransition(target = TorState.OFF, timeoutMs = timeoutMs)
// Small grace period before relaunch to let file locks clear
delay(200) delay(200)
} }
@@ -276,7 +359,7 @@ object TorManager {
Log.i(TAG, "Restarting Arti (keeping SOCKS proxy enabled)...") Log.i(TAG, "Restarting Arti (keeping SOCKS proxy enabled)...")
stopArtiAndWait() stopArtiAndWait()
delay(RESTART_DELAY_MS) delay(RESTART_DELAY_MS)
startArti(application, useDelay = false) // Already delayed above startArti(application, useDelay = false)
} }
private fun startInactivityMonitoring() { private fun startInactivityMonitoring() {
@@ -287,13 +370,16 @@ object TorManager {
val currentTime = System.currentTimeMillis() val currentTime = System.currentTimeMillis()
val lastActivity = lastLogTime.get() val lastActivity = lastLogTime.get()
val timeSinceLastActivity = currentTime - lastActivity val timeSinceLastActivity = currentTime - lastActivity
if (timeSinceLastActivity > INACTIVITY_TIMEOUT_MS) { if (timeSinceLastActivity > INACTIVITY_TIMEOUT_MS) {
val currentMode = _status.value.mode val currentMode = _statusFlow.value.mode
if (currentMode == TorMode.ON) { if (currentMode == TorMode.ON) {
val bootstrapPercent = _status.value.bootstrapPercent val bootstrapPercent = _statusFlow.value.bootstrapPercent
if (bootstrapPercent < 100) { if (bootstrapPercent < 100) {
Log.w(TAG, "Inactivity detected (${timeSinceLastActivity}ms), restarting Arti") Log.w(
TAG,
"Inactivity detected (${timeSinceLastActivity}ms), restarting Arti"
)
currentApplication?.let { app -> currentApplication?.let { app ->
appScope.launch { appScope.launch {
restartArti(app) restartArti(app)
@@ -316,11 +402,11 @@ object TorManager {
retryJob?.cancel() retryJob?.cancel()
if (retryAttempts < MAX_RETRY_ATTEMPTS) { if (retryAttempts < MAX_RETRY_ATTEMPTS) {
retryAttempts++ retryAttempts++
val delayMs = (1000L * (1 shl retryAttempts)).coerceAtMost(30000L) // Exponential backoff, max 30s val delayMs = (1000L * (1 shl retryAttempts)).coerceAtMost(30000L)
Log.w(TAG, "Scheduling Arti retry attempt $retryAttempts in ${delayMs}ms") Log.w(TAG, "Scheduling Arti retry attempt $retryAttempts in ${delayMs}ms")
retryJob = appScope.launch { retryJob = appScope.launch {
delay(delayMs) delay(delayMs)
val currentMode = _status.value.mode val currentMode = _statusFlow.value.mode
if (currentMode == TorMode.ON) { if (currentMode == TorMode.ON) {
Log.i(TAG, "Retrying Arti start (attempt $retryAttempts)") Log.i(TAG, "Retrying Arti start (attempt $retryAttempts)")
restartArti(application) restartArti(application)
@@ -337,50 +423,110 @@ object TorManager {
} }
private suspend fun waitUntilBootstrapped() { private suspend fun waitUntilBootstrapped() {
val current = _status.value val current = _statusFlow.value
if (!current.running) return if (!current.running) return
if (current.bootstrapPercent >= 100 && current.state == TorState.RUNNING) return if (current.bootstrapPercent >= 100 && current.state == TorState.RUNNING) return
// Suspend until we observe RUNNING at least once
while (true) { while (true) {
val s = statusFlow.first { (it.bootstrapPercent >= 100 && it.state == TorState.RUNNING) || !it.running || it.state == TorState.ERROR } val s = statusFlow.first {
(it.bootstrapPercent >= 100 && it.state == TorState.RUNNING) ||
!it.running ||
it.state == TorState.ERROR
}
if (!s.running || s.state == TorState.ERROR) return if (!s.running || s.state == TorState.ERROR) return
if (s.bootstrapPercent >= 100 && s.state == TorState.RUNNING) return if (s.bootstrapPercent >= 100 && s.state == TorState.RUNNING) return
} }
} }
private fun handleArtiLogLine(s: String) { private fun handleArtiLogLine(s: String) {
val currentState = _statusFlow.value.state
val currentLifecycle = lifecycleState
when { when {
s.contains("AMEx: state changed to Initialized", ignoreCase = true) -> { s.contains("AMEx: state changed to Initialized", ignoreCase = true) -> {
_status.value = _status.value.copy(state = TorState.STARTING) if (currentLifecycle != LifecycleState.STARTING && currentLifecycle != LifecycleState.RUNNING) {
Log.w(TAG, "Ignoring stale 'Initialized' log (lifecycle: $currentLifecycle)")
return
}
_statusFlow.update { it.copy(state = TorState.STARTING) }
completeWaitersIf(TorState.STARTING) completeWaitersIf(TorState.STARTING)
} }
s.contains("AMEx: state changed to Starting", ignoreCase = true) -> { s.contains("AMEx: state changed to Starting", ignoreCase = true) -> {
_status.value = _status.value.copy(state = TorState.STARTING) if (currentLifecycle != LifecycleState.STARTING && currentLifecycle != LifecycleState.RUNNING) {
Log.w(TAG, "Ignoring stale 'Starting' log (lifecycle: $currentLifecycle)")
return
}
_statusFlow.update { it.copy(state = TorState.STARTING) }
completeWaitersIf(TorState.STARTING) completeWaitersIf(TorState.STARTING)
} }
s.contains("Sufficiently bootstrapped; system SOCKS now functional", ignoreCase = true) -> {
_status.value = _status.value.copy(bootstrapPercent = 75, state = TorState.BOOTSTRAPPING) s.contains(
"Sufficiently bootstrapped; system SOCKS now functional",
ignoreCase = true
) -> {
if (currentLifecycle != LifecycleState.RUNNING) {
Log.w(TAG, "Ignoring bootstrap log (lifecycle: $currentLifecycle)")
return
}
_statusFlow.update {
it.copy(
bootstrapPercent = 75,
state = TorState.BOOTSTRAPPING
)
}
retryAttempts = 0 retryAttempts = 0
bindRetryAttempts = 0 bindRetryAttempts = 0
startInactivityMonitoring() startInactivityMonitoring()
} }
//s.contains("AMEx: state changed to Running", ignoreCase = true) -> {
s.contains("We have found that guard [scrubbed] is usable.", ignoreCase = true) -> { s.contains("We have found that guard [scrubbed] is usable.", ignoreCase = true) -> {
// If we already saw Sufficiently bootstrapped, mark as RUNNING and ready. if (currentLifecycle != LifecycleState.RUNNING) {
val bp = if (_status.value.bootstrapPercent >= 100) 100 else 100 // treat Running as ready Log.w(TAG, "Ignoring guard discovery log (lifecycle: $currentLifecycle)")
_status.value = _status.value.copy(state = TorState.RUNNING, bootstrapPercent = bp, running = true) return
}
_statusFlow.update {
it.copy(
state = TorState.RUNNING,
bootstrapPercent = 100,
running = true
)
}
completeWaitersIf(TorState.RUNNING) completeWaitersIf(TorState.RUNNING)
} }
s.contains("AMEx: state changed to Stopping", ignoreCase = true) -> { s.contains("AMEx: state changed to Stopping", ignoreCase = true) -> {
_status.value = _status.value.copy(state = TorState.STOPPING, running = false) if (currentLifecycle != LifecycleState.STOPPING) {
Log.w(TAG, "Ignoring stale 'Stopping' log (lifecycle: $currentLifecycle)")
return
}
_statusFlow.update {
it.copy(
state = TorState.STOPPING,
running = false
)
}
} }
s.contains("AMEx: state changed to Stopped", ignoreCase = true) -> { s.contains("AMEx: state changed to Stopped", ignoreCase = true) -> {
_status.value = _status.value.copy(state = TorState.OFF, running = false, bootstrapPercent = 0) if (currentLifecycle != LifecycleState.STOPPING && currentLifecycle != LifecycleState.STOPPED) {
Log.w(
TAG,
"Ignoring stale 'Stopped' log (lifecycle: $currentLifecycle, preventing state corruption)"
)
return
}
_statusFlow.update {
it.copy(
state = TorState.OFF,
running = false,
bootstrapPercent = 0
)
}
completeWaitersIf(TorState.OFF) completeWaitersIf(TorState.OFF)
} }
s.contains("Another process has the lock on our state files", ignoreCase = true) -> { s.contains("Another process has the lock on our state files", ignoreCase = true) -> {
// Signal error; we'll likely need to wait longer before restart _statusFlow.update { it.copy(state = TorState.ERROR) }
_status.value = _status.value.copy(state = TorState.ERROR)
} }
} }
} }
@@ -395,13 +541,11 @@ object TorManager {
val def = CompletableDeferred<TorState>() val def = CompletableDeferred<TorState>()
stateChangeDeferred.getAndSet(def)?.cancel() stateChangeDeferred.getAndSet(def)?.cancel()
return withTimeoutOrNull(timeoutMs) { return withTimeoutOrNull(timeoutMs) {
// Fast-path: if we're already there val cur = _statusFlow.value.state
val cur = _status.value.state
if (cur == target) return@withTimeoutOrNull cur if (cur == target) return@withTimeoutOrNull cur
def.await() def.await()
} }
} }
// Visible for instrumentation tests to validate installation fun isTorAvailable(): Boolean = true
fun installResourcesForTest(application: Application): Boolean { return true }
} }
@@ -42,8 +42,9 @@ object OkHttpProvider {
private fun baseBuilderForCurrentProxy(): OkHttpClient.Builder { private fun baseBuilderForCurrentProxy(): OkHttpClient.Builder {
val builder = OkHttpClient.Builder() val builder = OkHttpClient.Builder()
val socks: InetSocketAddress? = TorManager.currentSocksAddress() val torProvider = ArtiTorManager.getInstance()
// If a SOCKS address is defined, always use it. TorManager sets this as soon as Tor mode is ON, val socks: InetSocketAddress? = torProvider.currentSocksAddress()
// If a SOCKS address is defined, always use it. TorProvider sets this as soon as Tor mode is ON,
// even before bootstrap, to prevent any direct connections from occurring. // even before bootstrap, to prevent any direct connections from occurring.
if (socks != null) { if (socks != null) {
val proxy = Proxy(Proxy.Type.SOCKS, socks) val proxy = Proxy(Proxy.Type.SOCKS, socks)
@@ -2,7 +2,6 @@ package com.bitchat.android.nostr
import android.app.Application import android.app.Application
import android.util.Log import android.util.Log
import androidx.lifecycle.LiveData
import com.bitchat.android.ui.ChatState import com.bitchat.android.ui.ChatState
import com.bitchat.android.ui.GeoPerson import com.bitchat.android.ui.GeoPerson
import java.util.Date import java.util.Date
@@ -112,7 +111,7 @@ class GeohashRepository(
val geohash = currentGeohash val geohash = currentGeohash
if (geohash == null) { if (geohash == null) {
// Use postValue for thread safety - this can be called from background threads // Use postValue for thread safety - this can be called from background threads
state.postGeohashPeople(emptyList()) state.setGeohashPeople(emptyList())
return return
} }
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
@@ -143,7 +142,7 @@ class GeohashRepository(
) )
}.sortedByDescending { it.lastSeen } }.sortedByDescending { it.lastSeen }
// Use postValue for thread safety - this can be called from background threads // Use postValue for thread safety - this can be called from background threads
state.postGeohashPeople(people) state.setGeohashPeople(people)
} }
fun updateReactiveParticipantCounts() { fun updateReactiveParticipantCounts() {
@@ -155,7 +154,7 @@ class GeohashRepository(
counts[gh] = active counts[gh] = active
} }
// Use postValue for thread safety - this can be called from background threads // Use postValue for thread safety - this can be called from background threads
state.postGeohashParticipantCounts(counts) state.setGeohashParticipantCounts(counts)
} }
fun putNostrKeyMapping(tempKeyOrPeer: String, pubkeyHex: String) { fun putNostrKeyMapping(tempKeyOrPeer: String, pubkeyHex: String) {
@@ -2,13 +2,14 @@ package com.bitchat.android.nostr
import android.util.Log import android.util.Log
import androidx.annotation.MainThread import androidx.annotation.MainThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/** /**
* Manages location notes (kind=1 text notes with geohash tags) * Manages location notes (kind=1 text notes with geohash tags)
* iOS-compatible implementation with LiveData for Android UI binding * iOS-compatible implementation with StateFlow for Android UI binding
*/ */
@MainThread @MainThread
class LocationNotesManager private constructor() { class LocationNotesManager private constructor() {
@@ -63,21 +64,21 @@ class LocationNotesManager private constructor() {
NO_RELAYS NO_RELAYS
} }
// Published state (LiveData for Android) // Published state (StateFlow for Android)
private val _notes = MutableLiveData<List<Note>>(emptyList()) private val _notes = MutableStateFlow<List<Note>>(emptyList())
val notes: LiveData<List<Note>> = _notes val notes: StateFlow<List<Note>> = _notes.asStateFlow()
private val _geohash = MutableLiveData<String?>(null) private val _geohash = MutableStateFlow<String?>(null)
val geohash: LiveData<String?> = _geohash val geohash: StateFlow<String?> = _geohash.asStateFlow()
private val _initialLoadComplete = MutableLiveData(false) private val _initialLoadComplete = MutableStateFlow(false)
val initialLoadComplete: LiveData<Boolean> = _initialLoadComplete val initialLoadComplete: StateFlow<Boolean> = _initialLoadComplete.asStateFlow()
private val _state = MutableLiveData(State.IDLE) private val _state = MutableStateFlow(State.IDLE)
val state: LiveData<State> = _state val state: StateFlow<State> = _state.asStateFlow()
private val _errorMessage = MutableLiveData<String?>(null) private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: LiveData<String?> = _errorMessage val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
// Private state // Private state
private var subscriptionIDs: MutableMap<String, String> = mutableMapOf() private var subscriptionIDs: MutableMap<String, String> = mutableMapOf()
@@ -2,9 +2,10 @@ package com.bitchat.android.nostr
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/** /**
* High-level Nostr client that manages identity, connections, and messaging * High-level Nostr client that manages identity, connections, and messaging
@@ -30,11 +31,11 @@ class NostrClient private constructor(private val context: Context) {
private var currentIdentity: NostrIdentity? = null private var currentIdentity: NostrIdentity? = null
// Client state // Client state
private val _isInitialized = MutableLiveData<Boolean>() private val _isInitialized = MutableStateFlow(false)
val isInitialized: LiveData<Boolean> = _isInitialized val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
private val _currentNpub = MutableLiveData<String>() private val _currentNpub = MutableStateFlow<String?>(null)
val currentNpub: LiveData<String> = _currentNpub val currentNpub: StateFlow<String?> = _currentNpub.asStateFlow()
// Message processing // Message processing
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
@@ -53,21 +54,21 @@ class NostrClient private constructor(private val context: Context) {
currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context) currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context)
if (currentIdentity != null) { if (currentIdentity != null) {
_currentNpub.postValue(currentIdentity!!.npub) _currentNpub.value = currentIdentity!!.npub
Log.i(TAG, "✅ Nostr identity loaded: ${currentIdentity!!.getShortNpub()}") Log.i(TAG, "✅ Nostr identity loaded: ${currentIdentity!!.getShortNpub()}")
// Connect to relays // Connect to relays
relayManager.connect() relayManager.connect()
_isInitialized.postValue(true) _isInitialized.value = true
Log.i(TAG, "✅ Nostr client initialized successfully") Log.i(TAG, "✅ Nostr client initialized successfully")
} else { } else {
Log.e(TAG, "❌ Failed to load/create Nostr identity") Log.e(TAG, "❌ Failed to load/create Nostr identity")
_isInitialized.postValue(false) _isInitialized.value = false
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "❌ Failed to initialize Nostr client: ${e.message}") Log.e(TAG, "❌ Failed to initialize Nostr client: ${e.message}")
_isInitialized.postValue(false) _isInitialized.value = false
} }
} }
} }
@@ -78,7 +79,7 @@ class NostrClient private constructor(private val context: Context) {
fun shutdown() { fun shutdown() {
Log.d(TAG, "Shutting down Nostr client") Log.d(TAG, "Shutting down Nostr client")
relayManager.disconnect() relayManager.disconnect()
_isInitialized.postValue(false) _isInitialized.value = false
} }
/** /**
@@ -227,12 +228,12 @@ class NostrClient private constructor(private val context: Context) {
/** /**
* Get relay connection status * Get relay connection status
*/ */
val relayConnectionStatus: LiveData<Boolean> = relayManager.isConnected val relayConnectionStatus: StateFlow<Boolean> = relayManager.isConnected
/** /**
* Get relay information * Get relay information
*/ */
val relayInfo: LiveData<List<NostrRelayManager.Relay>> = relayManager.relays val relayInfo: StateFlow<List<NostrRelayManager.Relay>> = relayManager.relays
// MARK: - Private Methods // MARK: - Private Methods
@@ -2,8 +2,12 @@ package com.bitchat.android.nostr
import android.app.Application import android.app.Application
import android.util.Log import android.util.Log
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus 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.protocol.BitchatPacket
import com.bitchat.android.services.SeenMessageStore import com.bitchat.android.services.SeenMessageStore
import com.bitchat.android.ui.ChatState 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 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 messageTimestamp = Date(giftWrap.createdAt * 1000L)
val convKey = "nostr_${senderPubkey.take(16)}" val convKey = "nostr_${senderPubkey.take(16)}"
repo.putNostrKeyMapping(convKey, senderPubkey) repo.putNostrKeyMapping(convKey, senderPubkey)
@@ -104,7 +108,7 @@ class NostrDirectMessageHandler(
} }
private suspend fun processNoisePayload( private suspend fun processNoisePayload(
payload: com.bitchat.android.model.NoisePayload, payload: NoisePayload,
convKey: String, convKey: String,
senderNickname: String, senderNickname: String,
timestamp: Date, timestamp: Date,
@@ -112,8 +116,8 @@ class NostrDirectMessageHandler(
recipientIdentity: NostrIdentity recipientIdentity: NostrIdentity
) { ) {
when (payload.type) { when (payload.type) {
com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE -> { NoisePayloadType.PRIVATE_MESSAGE -> {
val pm = com.bitchat.android.model.PrivateMessagePacket.decode(payload.data) ?: return val pm = PrivateMessagePacket.decode(payload.data) ?: return
val existingMessages = state.getPrivateChatsValue()[convKey] ?: emptyList() val existingMessages = state.getPrivateChatsValue()[convKey] ?: emptyList()
if (existingMessages.any { it.id == pm.messageID }) return if (existingMessages.any { it.id == pm.messageID }) return
@@ -148,21 +152,21 @@ class NostrDirectMessageHandler(
seenStore.markRead(pm.messageID) seenStore.markRead(pm.messageID)
} }
} }
com.bitchat.android.model.NoisePayloadType.DELIVERED -> { NoisePayloadType.DELIVERED -> {
val messageId = String(payload.data, Charsets.UTF_8) val messageId = String(payload.data, Charsets.UTF_8)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
meshDelegateHandler.didReceiveDeliveryAck(messageId, convKey) meshDelegateHandler.didReceiveDeliveryAck(messageId, convKey)
} }
} }
com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> { NoisePayloadType.READ_RECEIPT -> {
val messageId = String(payload.data, Charsets.UTF_8) val messageId = String(payload.data, Charsets.UTF_8)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
meshDelegateHandler.didReceiveReadReceipt(messageId, convKey) meshDelegateHandler.didReceiveReadReceipt(messageId, convKey)
} }
} }
com.bitchat.android.model.NoisePayloadType.FILE_TRANSFER -> { NoisePayloadType.FILE_TRANSFER -> {
// Properly handle encrypted 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) { if (file != null) {
val uniqueMsgId = java.util.UUID.randomUUID().toString().uppercase() val uniqueMsgId = java.util.UUID.randomUUID().toString().uppercase()
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(application, file) 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") 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
} }
} }
@@ -1,9 +1,10 @@
package com.bitchat.android.nostr package com.bitchat.android.nostr
import android.util.Log import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.google.gson.Gson import com.google.gson.Gson
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import com.google.gson.JsonArray import com.google.gson.JsonArray
import com.google.gson.JsonParser import com.google.gson.JsonParser
import kotlinx.coroutines.* import kotlinx.coroutines.*
@@ -72,11 +73,11 @@ class NostrRelayManager private constructor() {
) )
// Published state // Published state
private val _relays = MutableLiveData<List<Relay>>() private val _relays = MutableStateFlow<List<Relay>>(emptyList())
val relays: LiveData<List<Relay>> = _relays val relays: StateFlow<List<Relay>> = _relays.asStateFlow()
private val _isConnected = MutableLiveData<Boolean>() private val _isConnected = MutableStateFlow<Boolean>(false)
val isConnected: LiveData<Boolean> = _isConnected val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
// Internal state // Internal state
private val relaysList = mutableListOf<Relay>() private val relaysList = mutableListOf<Relay>()
@@ -226,14 +227,14 @@ class NostrRelayManager private constructor() {
"wss://nostr21.com" "wss://nostr21.com"
) )
relaysList.addAll(defaultRelayUrls.map { Relay(it) }) relaysList.addAll(defaultRelayUrls.map { Relay(it) })
_relays.postValue(relaysList.toList()) _relays.value = relaysList.toList()
updateConnectionStatus() updateConnectionStatus()
Log.d(TAG, "✅ NostrRelayManager initialized with ${relaysList.size} default relays") Log.d(TAG, "✅ NostrRelayManager initialized with ${relaysList.size} default relays")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to initialize NostrRelayManager: ${e.message}", e) Log.e(TAG, "Failed to initialize NostrRelayManager: ${e.message}", e)
// Initialize with empty list as fallback // Initialize with empty list as fallback
_relays.postValue(emptyList()) _relays.value = emptyList()
_isConnected.postValue(false) _isConnected.value = false
} }
} }
@@ -797,12 +798,12 @@ class NostrRelayManager private constructor() {
} }
private fun updateRelaysList() { private fun updateRelaysList() {
_relays.postValue(relaysList.toList()) _relays.value = relaysList.toList()
} }
private fun updateConnectionStatus() { private fun updateConnectionStatus() {
val connected = relaysList.any { it.isConnected } val connected = relaysList.any { it.isConnected }
_isConnected.postValue(connected) _isConnected.value = connected
} }
private fun generateSubscriptionId(): String { private fun generateSubscriptionId(): String {
@@ -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)
)
}
}
@@ -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)
}
}
@@ -19,6 +19,7 @@ class OnboardingCoordinator(
private val activity: ComponentActivity, private val activity: ComponentActivity,
private val permissionManager: PermissionManager, private val permissionManager: PermissionManager,
private val onOnboardingComplete: () -> Unit, private val onOnboardingComplete: () -> Unit,
private val onBackgroundLocationRequired: () -> Unit,
private val onOnboardingFailed: (String) -> Unit private val onOnboardingFailed: (String) -> Unit
) { ) {
@@ -27,9 +28,11 @@ class OnboardingCoordinator(
} }
private var permissionLauncher: ActivityResultLauncher<Array<String>>? = null private var permissionLauncher: ActivityResultLauncher<Array<String>>? = null
private var backgroundLocationLauncher: ActivityResultLauncher<String>? = null
init { init {
setupPermissionLauncher() setupPermissionLauncher()
setupBackgroundLocationLauncher()
} }
/** /**
@@ -43,6 +46,14 @@ class OnboardingCoordinator(
} }
} }
private fun setupBackgroundLocationLauncher() {
backgroundLocationLauncher = activity.registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
handleBackgroundLocationResult(granted)
}
}
/** /**
* Start the onboarding process * Start the onboarding process
*/ */
@@ -50,9 +61,14 @@ class OnboardingCoordinator(
Log.d(TAG, "Starting onboarding process") Log.d(TAG, "Starting onboarding process")
permissionManager.logPermissionStatus() permissionManager.logPermissionStatus()
if (permissionManager.areAllPermissionsGranted()) { if (permissionManager.areRequiredPermissionsGranted()) {
Log.d(TAG, "All permissions already granted, completing onboarding") if (shouldRequestBackgroundLocation()) {
completeOnboarding() Log.d(TAG, "Foreground permissions granted; background location recommended")
onBackgroundLocationRequired()
} else {
Log.d(TAG, "Required permissions already granted, completing onboarding")
completeOnboarding()
}
} else { } else {
Log.d(TAG, "Missing permissions, need to start explanation flow") Log.d(TAG, "Missing permissions, need to start explanation flow")
// The explanation screen will be shown by the calling activity // The explanation screen will be shown by the calling activity
@@ -76,7 +92,11 @@ class OnboardingCoordinator(
val missingPermissions = (missingRequired + optionalToRequest).distinct() val missingPermissions = (missingRequired + optionalToRequest).distinct()
if (missingPermissions.isEmpty()) { if (missingPermissions.isEmpty()) {
completeOnboarding() if (shouldRequestBackgroundLocation()) {
onBackgroundLocationRequired()
} else {
completeOnboarding()
}
return return
} }
@@ -98,13 +118,19 @@ class OnboardingCoordinator(
val criticalGranted = criticalPermissions.all { permissions[it] == true } val criticalGranted = criticalPermissions.all { permissions[it] == true }
when { when {
allGranted -> {
Log.d(TAG, "All permissions granted successfully")
completeOnboarding()
}
criticalGranted -> { criticalGranted -> {
Log.d(TAG, "Critical permissions granted, can proceed with limited functionality") if (shouldRequestBackgroundLocation()) {
showPartialPermissionWarning(permissions) 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 -> { else -> {
Log.d(TAG, "Critical permissions denied") 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 * Get the list of critical permissions that are absolutely required
*/ */
private fun getCriticalPermissions(): List<String> { private fun getCriticalPermissions(): List<String> {
// For bitchat, Bluetooth and location permissions are critical // For bitchat, Bluetooth and location permissions are critical
// Notifications are nice-to-have but not critical // Notifications are nice-to-have but not critical and are not included in getRequiredPermissions()
return permissionManager.getRequiredPermissions().filter { permission -> return permissionManager.getRequiredPermissions()
!permission.contains("POST_NOTIFICATIONS")
}
} }
/** /**
@@ -208,6 +269,7 @@ class OnboardingCoordinator(
private fun getPermissionDisplayName(permission: String): String { private fun getPermissionDisplayName(permission: String): String {
return when { return when {
permission.contains("BLUETOOTH") -> "Bluetooth/Nearby Devices" permission.contains("BLUETOOTH") -> "Bluetooth/Nearby Devices"
permission.contains("BACKGROUND") -> "Background Location"
permission.contains("LOCATION") -> "Location (for Bluetooth scanning)" permission.contains("LOCATION") -> "Location (for Bluetooth scanning)"
permission.contains("NOTIFICATION") -> "Notifications" permission.contains("NOTIFICATION") -> "Notifications"
else -> permission.substringAfterLast(".") else -> permission.substringAfterLast(".")
@@ -6,8 +6,9 @@ enum class OnboardingState {
LOCATION_CHECK, LOCATION_CHECK,
BATTERY_OPTIMIZATION_CHECK, BATTERY_OPTIMIZATION_CHECK,
PERMISSION_EXPLANATION, PERMISSION_EXPLANATION,
BACKGROUND_LOCATION_EXPLANATION,
PERMISSION_REQUESTING, PERMISSION_REQUESTING,
INITIALIZING, INITIALIZING,
COMPLETE, COMPLETE,
ERROR ERROR
} }
@@ -12,12 +12,10 @@ import androidx.compose.material.icons.filled.Power
import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -209,29 +207,6 @@ private fun PermissionCategoryCard(
color = colorScheme.onBackground.copy(alpha = 0.8f) 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) { return when (permissionType) {
PermissionType.NEARBY_DEVICES -> Icons.Filled.Bluetooth PermissionType.NEARBY_DEVICES -> Icons.Filled.Bluetooth
PermissionType.PRECISE_LOCATION -> Icons.Filled.LocationOn PermissionType.PRECISE_LOCATION -> Icons.Filled.LocationOn
PermissionType.BACKGROUND_LOCATION -> Icons.Filled.LocationOn
PermissionType.MICROPHONE -> Icons.Filled.Mic PermissionType.MICROPHONE -> Icons.Filled.Mic
PermissionType.NOTIFICATIONS -> Icons.Filled.Notifications PermissionType.NOTIFICATIONS -> Icons.Filled.Notifications
PermissionType.BATTERY_OPTIMIZATION -> Icons.Filled.Power PermissionType.BATTERY_OPTIMIZATION -> Icons.Filled.Power
@@ -7,6 +7,7 @@ import android.os.Build
import android.os.PowerManager import android.os.PowerManager
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.bitchat.android.R
/** /**
* Centralized permission management for bitchat app * 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, * Note: Notification permission is optional and not included here,
* so the app works without notification access. * so the app works without notification access.
*/ */
@@ -72,6 +74,27 @@ class PermissionManager(private val context: Context) {
return permissions 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. * Get optional permissions that improve the experience but aren't required.
* Currently includes POST_NOTIFICATIONS on Android 13+. * 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 { fun areAllPermissionsGranted(): Boolean {
return areRequiredPermissionsGranted()
}
fun areRequiredPermissionsGranted(): Boolean {
return getRequiredPermissions().all { isPermissionGranted(it) } return getRequiredPermissions().all { isPermissionGranted(it) }
} }
@@ -131,6 +158,11 @@ class PermissionManager(private val context: Context) {
return getRequiredPermissions().filter { !isPermissionGranted(it) } return getRequiredPermissions().filter { !isPermissionGranted(it) }
} }
fun getMissingBackgroundLocationPermission(): List<String> {
val permission = getBackgroundLocationPermission() ?: return emptyList()
return if (isPermissionGranted(permission)) emptyList() else listOf(permission)
}
/** /**
* Get categorized permission information for display * 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) // Notifications category (if applicable)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
categories.add( categories.add(
@@ -216,7 +261,7 @@ class PermissionManager(private val context: Context) {
appendLine("Permission Diagnostics:") appendLine("Permission Diagnostics:")
appendLine("Android SDK: ${Build.VERSION.SDK_INT}") appendLine("Android SDK: ${Build.VERSION.SDK_INT}")
appendLine("First time launch: ${isFirstTimeLaunch()}") appendLine("First time launch: ${isFirstTimeLaunch()}")
appendLine("All permissions granted: ${areAllPermissionsGranted()}") appendLine("Required permissions granted: ${areAllPermissionsGranted()}")
appendLine() appendLine()
getCategorizedPermissions().forEach { category -> getCategorizedPermissions().forEach { category ->
@@ -228,7 +273,7 @@ class PermissionManager(private val context: Context) {
appendLine() appendLine()
} }
val missing = getMissingPermissions() val missing = getMissingPermissions() + getMissingBackgroundLocationPermission()
if (missing.isNotEmpty()) { if (missing.isNotEmpty()) {
appendLine("Missing permissions:") appendLine("Missing permissions:")
missing.forEach { permission -> missing.forEach { permission ->
@@ -260,6 +305,7 @@ data class PermissionCategory(
enum class PermissionType(val nameValue: String) { enum class PermissionType(val nameValue: String) {
NEARBY_DEVICES("Nearby Devices"), NEARBY_DEVICES("Nearby Devices"),
PRECISE_LOCATION("Precise Location"), PRECISE_LOCATION("Precise Location"),
BACKGROUND_LOCATION("Background Location"),
MICROPHONE("Microphone"), MICROPHONE("Microphone"),
NOTIFICATIONS("Notifications"), NOTIFICATIONS("Notifications"),
BATTERY_OPTIMIZATION("Battery Optimization"), BATTERY_OPTIMIZATION("Battery Optimization"),
@@ -0,0 +1,95 @@
package com.bitchat.android.service
import android.app.Application
import android.os.Process
import androidx.core.app.NotificationManagerCompat
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.net.ArtiTorManager
import com.bitchat.android.net.TorMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.atomic.AtomicLong
/**
* Coordinates a full application shutdown:
* - Stop mesh cleanly
* - Stop Tor without changing persistent setting
* - Clear in-memory AppState
* - Stop foreground service/notification
* - Kill the process after completion or after a 5s timeout
*/
object AppShutdownCoordinator {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val shutdownToken = AtomicLong(0L)
@Volatile
private var shutdownJob: Job? = null
fun cancelPendingShutdown() {
shutdownToken.incrementAndGet()
shutdownJob?.cancel()
shutdownJob = null
}
fun requestFullShutdownAndKill(
app: Application,
mesh: BluetoothMeshService?,
notificationManager: NotificationManagerCompat,
stopForeground: () -> Unit,
stopService: () -> Unit
) {
val token = shutdownToken.incrementAndGet()
shutdownJob?.cancel()
val job = scope.launch {
// Signal UI to finish gracefully before we kill the process
try {
val intent = android.content.Intent(com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH)
.setPackage(app.packageName)
app.sendBroadcast(intent, com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH)
} catch (_: Exception) { }
// Stop mesh (best-effort)
try { mesh?.stopServices() } catch (_: Exception) { }
// Stop Tor temporarily (do not change user setting)
val torProvider = ArtiTorManager.getInstance()
val torStop = async {
try { torProvider.applyMode(app, TorMode.OFF) } catch (_: Exception) { }
}
// Clear AppState in-memory store
try { com.bitchat.android.services.AppStateStore.clear() } catch (_: Exception) { }
// Stop foreground and clear notification
try { stopForeground() } catch (_: Exception) { }
try { notificationManager.cancel(10001) } catch (_: Exception) { }
// Wait up to 5 seconds for shutdown tasks
withTimeoutOrNull(5000) {
try { torStop.await() } catch (_: Exception) { }
delay(100)
}
// Stop the service itself
if (!isActive || shutdownToken.get() != token) return@launch
try { stopService() } catch (_: Exception) { }
// Hard kill the app process
if (!isActive || shutdownToken.get() != token) return@launch
try { Process.killProcess(Process.myPid()) } catch (_: Exception) { }
try { System.exit(0) } catch (_: Exception) { }
}
shutdownJob = job
job.invokeOnCompletion {
if (shutdownJob === job) {
shutdownJob = null
}
}
}
}
@@ -0,0 +1,16 @@
package com.bitchat.android.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
class BootCompletedReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
// Ensure preferences are initialized on cold boot before reading values
try { MeshServicePreferences.init(context.applicationContext) } catch (_: Exception) { }
if (MeshServicePreferences.isAutoStartEnabled(true)) {
MeshForegroundService.start(context.applicationContext)
}
}
}
@@ -0,0 +1,373 @@
package com.bitchat.android.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.bitchat.android.MainActivity
import com.bitchat.android.R
import com.bitchat.android.mesh.BluetoothMeshService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class MeshForegroundService : Service() {
companion object {
private const val CHANNEL_ID = "bitchat_mesh_service"
private const val NOTIFICATION_ID = 10001
const val ACTION_START = "com.bitchat.android.service.START"
const val ACTION_STOP = "com.bitchat.android.service.STOP"
const val ACTION_QUIT = "com.bitchat.android.service.QUIT"
const val ACTION_UPDATE_NOTIFICATION = "com.bitchat.android.service.UPDATE_NOTIFICATION"
const val ACTION_NOTIFICATION_PERMISSION_GRANTED = "com.bitchat.android.action.NOTIFICATION_PERMISSION_GRANTED"
fun start(context: Context) {
val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_START }
// On API >= 26, avoid background-service start restrictions by using startForegroundService
// only when we can actually post a notification (Android 13+ requires runtime notif permission)
val bgEnabled = MeshServicePreferences.isBackgroundEnabled(true)
val hasNotifPerm = hasNotificationPermissionStatic(context)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (bgEnabled && hasNotifPerm) {
context.startForegroundService(intent)
} else {
// Do not attempt to start a background service from headless context without notif permission
// or when background is disabled, to avoid BackgroundServiceStartNotAllowedException.
android.util.Log.i(
"MeshForegroundService",
"Not starting service on API>=26 (bgEnabled=$bgEnabled, hasNotifPerm=$hasNotifPerm)"
)
}
} else {
if (bgEnabled) {
context.startService(intent)
} else {
android.util.Log.i("MeshForegroundService", "Background disabled; not starting service (pre-O)")
}
}
}
/**
* Helper to be invoked right after POST_NOTIFICATIONS is granted to try
* promoting/starting the foreground service immediately without polling.
*/
fun onNotificationPermissionGranted(context: Context) {
// If background is enabled and permission now granted, start/promo service
val hasNotifPerm = hasNotificationPermissionStatic(context)
if (!MeshServicePreferences.isBackgroundEnabled(true) || !hasNotifPerm) return
val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_UPDATE_NOTIFICATION }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Safe now that we can show a notification
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_STOP }
context.startService(intent)
}
private fun shouldStartAsForeground(context: Context): Boolean {
return MeshServicePreferences.isBackgroundEnabled(true) &&
hasBluetoothPermissionsStatic(context) &&
hasNotificationPermissionStatic(context)
}
private fun hasBluetoothPermissionsStatic(ctx: Context): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.BLUETOOTH_ADVERTISE) == android.content.pm.PackageManager.PERMISSION_GRANTED &&
androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.BLUETOOTH_CONNECT) == android.content.pm.PackageManager.PERMISSION_GRANTED &&
androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.BLUETOOTH_SCAN) == android.content.pm.PackageManager.PERMISSION_GRANTED
} else {
val fine = androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.ACCESS_FINE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED
val coarse = androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.ACCESS_COARSE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED
fine || coarse
}
}
private fun hasNotificationPermissionStatic(ctx: Context): Boolean {
return if (Build.VERSION.SDK_INT >= 33) {
androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED
} else true
}
}
private lateinit var notificationManager: NotificationManagerCompat
private var updateJob: Job? = null
private var meshService: BluetoothMeshService? = null
private val serviceJob = Job()
private val scope = CoroutineScope(Dispatchers.Default + serviceJob)
private var isInForeground: Boolean = false
private var isShuttingDown: Boolean = false
override fun onCreate() {
super.onCreate()
notificationManager = NotificationManagerCompat.from(this)
createChannel()
// Adopt or create the mesh service
val existing = MeshServiceHolder.meshService
meshService = existing ?: MeshServiceHolder.getOrCreate(applicationContext)
if (existing != null) {
android.util.Log.d("MeshForegroundService", "Adopted existing BluetoothMeshService from holder")
} else {
android.util.Log.i("MeshForegroundService", "Created/adopted new BluetoothMeshService via holder")
}
MeshServiceHolder.attach(meshService!!)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (isShuttingDown && intent?.action == ACTION_START) {
AppShutdownCoordinator.cancelPendingShutdown()
isShuttingDown = false
}
if (isShuttingDown && intent?.action != ACTION_QUIT) {
return START_NOT_STICKY
}
when (intent?.action) {
ACTION_STOP -> {
// Stop FGS and mesh cleanly
try { meshService?.stopServices() } catch (_: Exception) { }
try { MeshServiceHolder.clear() } catch (_: Exception) { }
try { stopForeground(true) } catch (_: Exception) { }
notificationManager.cancel(NOTIFICATION_ID)
isInForeground = false
stopSelf()
return START_NOT_STICKY
}
ACTION_QUIT -> {
isShuttingDown = true
updateJob?.cancel()
updateJob = null
try { stopForeground(true) } catch (_: Exception) { }
notificationManager.cancel(NOTIFICATION_ID)
isInForeground = false
// Fully stop all background activity, stop Tor (without changing setting), then kill the app
AppShutdownCoordinator.requestFullShutdownAndKill(
app = application,
mesh = meshService,
notificationManager = notificationManager,
stopForeground = {
try { stopForeground(true) } catch (_: Exception) { }
isInForeground = false
},
stopService = { stopSelf() }
)
return START_NOT_STICKY
}
ACTION_UPDATE_NOTIFICATION -> {
// If we became eligible and are not in foreground yet, promote once
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
val n = buildNotification(meshService?.getActivePeerCount() ?: 0)
startForegroundCompat(n)
isInForeground = true
} else {
updateNotification(force = true)
}
}
else -> { /* ACTION_START or null */ }
}
// Ensure mesh is running (only after permissions are granted)
ensureMeshStarted()
// Promote exactly once when eligible, otherwise stay background (or stop)
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
val notification = buildNotification(meshService?.getActivePeerCount() ?: 0)
startForegroundCompat(notification)
isInForeground = true
}
// Periodically refresh the notification with live network size
if (updateJob == null) {
updateJob = scope.launch {
while (isActive) {
// Retry enabling mesh/foreground once permissions become available
ensureMeshStarted()
val eligible = MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()
if (eligible) {
// Only update the notification; do not re-call startForeground()
updateNotification(force = false)
} else {
// If disabled or perms missing, ensure we are not in foreground and clear notif
if (isInForeground) {
try { stopForeground(false) } catch (_: Exception) { }
isInForeground = false
}
notificationManager.cancel(NOTIFICATION_ID)
}
delay(5000)
}
}
}
return START_STICKY
}
private fun ensureMeshStarted() {
if (isShuttingDown) return
if (!hasBluetoothPermissions()) return
try {
android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started")
meshService?.startServices()
} catch (e: Exception) {
android.util.Log.e("MeshForegroundService", "Failed to start mesh service: ${e.message}")
}
}
private fun updateNotification(force: Boolean) {
if (isShuttingDown) {
notificationManager.cancel(NOTIFICATION_ID)
return
}
val count = meshService?.getActivePeerCount() ?: 0
val notification = buildNotification(count)
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) {
notificationManager.notify(NOTIFICATION_ID, notification)
} else if (force) {
// If disabled and forced, make sure to remove any prior foreground state
try { stopForeground(false) } catch (_: Exception) { }
notificationManager.cancel(NOTIFICATION_ID)
isInForeground = false
}
}
private fun hasAllRequiredPermissions(): Boolean {
// For starting FGS with connectedDevice|dataSync, we need:
// - Foreground service permissions (declared in manifest)
// - One of the device-related permissions (we request BL perms at runtime)
// - On Android 13+, POST_NOTIFICATIONS to actually show notification
return hasBluetoothPermissions() && hasNotificationPermission()
}
private fun hasBluetoothPermissions(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_ADVERTISE) == android.content.pm.PackageManager.PERMISSION_GRANTED &&
androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_CONNECT) == android.content.pm.PackageManager.PERMISSION_GRANTED &&
androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_SCAN) == android.content.pm.PackageManager.PERMISSION_GRANTED
} else {
// Prior to S, scanning requires location permissions
val fine = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED
val coarse = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_COARSE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED
fine || coarse
}
}
private fun hasNotificationPermission(): Boolean {
return if (Build.VERSION.SDK_INT >= 33) {
androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED
} else true
}
private fun buildNotification(activePeers: Int): Notification {
val openIntent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this, 0, openIntent,
PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0)
)
// Action: Quit Bitchat
val quitIntent = Intent(this, MeshForegroundService::class.java).apply { action = ACTION_QUIT }
val quitPendingIntent = PendingIntent.getService(
this, 1, quitIntent,
PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0)
)
val title = getString(R.string.app_name)
val content = getString(R.string.mesh_service_notification_content, activePeers)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setContentText(content)
.setSmallIcon(R.mipmap.ic_launcher)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setContentIntent(pendingIntent)
// Add an action button that appears when notification is expanded
.addAction(
android.R.drawable.ic_menu_close_clear_cancel,
getString(R.string.notification_action_quit_bitchat),
quitPendingIntent
)
.build()
}
private fun createChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.mesh_service_channel_name),
NotificationManager.IMPORTANCE_LOW
).apply {
description = getString(R.string.mesh_service_channel_desc)
setShowBadge(false)
}
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
.createNotificationChannel(channel)
}
}
private fun hasLocationPermission(): Boolean {
val fine = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED
val coarse = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_COARSE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED
return fine || coarse
}
private fun startForegroundCompat(notification: Notification) {
if (Build.VERSION.SDK_INT >= 34) {
val type = if (hasLocationPermission()) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
} else {
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
}
try {
startForeground(NOTIFICATION_ID, notification, type)
} catch (e: SecurityException) {
// Fallback for cases where "While In Use" permission exists but background start is restricted
if (type and ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION != 0) {
android.util.Log.w("MeshForegroundService", "Failed to start with LOCATION type, falling back to CONNECTED_DEVICE: ${e.message}")
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE)
} else {
throw e
}
}
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
override fun onDestroy() {
updateJob?.cancel()
updateJob = null
// Cancel the service coroutine scope to prevent leaks
try { serviceJob.cancel() } catch (_: Exception) { }
// Best-effort ensure we are not marked foreground
if (isInForeground) {
try { stopForeground(true) } catch (_: Exception) { }
isInForeground = false
}
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
}
@@ -0,0 +1,60 @@
package com.bitchat.android.service
import android.content.Context
import com.bitchat.android.mesh.BluetoothMeshService
/**
* Process-wide holder to share a single BluetoothMeshService instance
* between the foreground service and UI (MainActivity/ViewModels).
*/
object MeshServiceHolder {
private const val TAG = "MeshServiceHolder"
@Volatile
var meshService: BluetoothMeshService? = null
private set
@Synchronized
fun getOrCreate(context: Context): BluetoothMeshService {
val existing = meshService
if (existing != null) {
// If the existing instance is healthy, reuse it; otherwise, replace it.
return try {
if (existing.isReusable()) {
android.util.Log.d(TAG, "Reusing existing BluetoothMeshService instance")
existing
} else {
android.util.Log.w(TAG, "Existing BluetoothMeshService not reusable; replacing with a fresh instance")
// Best-effort stop before replacing
try { existing.stopServices() } catch (e: Exception) {
android.util.Log.w(TAG, "Error while stopping non-reusable instance: ${e.message}")
}
val created = BluetoothMeshService(context.applicationContext)
android.util.Log.i(TAG, "Created new BluetoothMeshService (replacement)")
meshService = created
created
}
} catch (e: Exception) {
android.util.Log.e(TAG, "Error checking service reusability; creating new instance: ${e.message}")
val created = BluetoothMeshService(context.applicationContext)
meshService = created
created
}
}
val created = BluetoothMeshService(context.applicationContext)
android.util.Log.i(TAG, "Created new BluetoothMeshService (no existing instance)")
meshService = created
return created
}
@Synchronized
fun attach(service: BluetoothMeshService) {
android.util.Log.d(TAG, "Attaching BluetoothMeshService to holder")
meshService = service
}
@Synchronized
fun clear() {
android.util.Log.d(TAG, "Clearing BluetoothMeshService from holder")
meshService = null
}
}
@@ -0,0 +1,32 @@
package com.bitchat.android.service
import android.content.Context
import android.content.SharedPreferences
object MeshServicePreferences {
private const val PREFS_NAME = "bitchat_mesh_service_prefs"
private const val KEY_AUTO_START = "auto_start_on_boot"
private const val KEY_BACKGROUND_ENABLED = "background_enabled"
private lateinit var prefs: SharedPreferences
fun init(context: Context) {
prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
fun isAutoStartEnabled(default: Boolean = true): Boolean {
return prefs.getBoolean(KEY_AUTO_START, default)
}
fun setAutoStartEnabled(enabled: Boolean) {
prefs.edit().putBoolean(KEY_AUTO_START, enabled).apply()
}
fun isBackgroundEnabled(default: Boolean = true): Boolean {
return prefs.getBoolean(KEY_BACKGROUND_ENABLED, default)
}
fun setBackgroundEnabled(enabled: Boolean) {
prefs.edit().putBoolean(KEY_BACKGROUND_ENABLED, enabled).apply()
}
}
@@ -0,0 +1,109 @@
package com.bitchat.android.services
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Process-wide in-memory state store that survives Activity recreation.
* The foreground Mesh service updates this store; UI subscribes/hydrates from it.
*/
object AppStateStore {
// Global de-dup set by message id to avoid duplicate keys in Compose lists
private val seenMessageIds = mutableSetOf<String>()
// Connected peer IDs (mesh ephemeral IDs)
private val _peers = MutableStateFlow<List<String>>(emptyList())
val peers: StateFlow<List<String>> = _peers.asStateFlow()
// Public mesh timeline messages (non-channel)
private val _publicMessages = MutableStateFlow<List<BitchatMessage>>(emptyList())
val publicMessages: StateFlow<List<BitchatMessage>> = _publicMessages.asStateFlow()
// Private messages by peerID
private val _privateMessages = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
val privateMessages: StateFlow<Map<String, List<BitchatMessage>>> = _privateMessages.asStateFlow()
// Channel messages by channel name
private val _channelMessages = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = _channelMessages.asStateFlow()
fun setPeers(ids: List<String>) {
_peers.value = ids
}
fun addPublicMessage(msg: BitchatMessage) {
synchronized(this) {
if (seenMessageIds.contains(msg.id)) return
seenMessageIds.add(msg.id)
_publicMessages.value = _publicMessages.value + msg
}
}
fun addPrivateMessage(peerID: String, msg: BitchatMessage) {
synchronized(this) {
if (seenMessageIds.contains(msg.id)) return
seenMessageIds.add(msg.id)
val map = _privateMessages.value.toMutableMap()
val list = (map[peerID] ?: emptyList()) + msg
map[peerID] = list
_privateMessages.value = map
}
}
private fun statusPriority(status: DeliveryStatus?): Int = when (status) {
null -> 0
is DeliveryStatus.Sending -> 1
is DeliveryStatus.Sent -> 2
is DeliveryStatus.PartiallyDelivered -> 3
is DeliveryStatus.Delivered -> 4
is DeliveryStatus.Read -> 5
is DeliveryStatus.Failed -> 0
}
fun updatePrivateMessageStatus(messageID: String, status: DeliveryStatus) {
synchronized(this) {
val map = _privateMessages.value.toMutableMap()
var changed = false
map.keys.toList().forEach { peer ->
val list = map[peer]?.toMutableList() ?: mutableListOf()
val idx = list.indexOfFirst { it.id == messageID }
if (idx >= 0) {
val current = list[idx].deliveryStatus
// Do not downgrade (e.g., Read -> Delivered)
if (statusPriority(status) >= statusPriority(current)) {
list[idx] = list[idx].copy(deliveryStatus = status)
map[peer] = list
changed = true
}
}
}
if (changed) {
_privateMessages.value = map
}
}
}
fun addChannelMessage(channel: String, msg: BitchatMessage) {
synchronized(this) {
if (seenMessageIds.contains(msg.id)) return
seenMessageIds.add(msg.id)
val map = _channelMessages.value.toMutableMap()
val list = (map[channel] ?: emptyList()) + msg
map[channel] = list
_channelMessages.value = map
}
}
// Clear all in-memory state (used for full app shutdown)
fun clear() {
synchronized(this) {
seenMessageIds.clear()
_peers.value = emptyList()
_publicMessages.value = emptyList()
_privateMessages.value = emptyMap()
_channelMessages.value = emptyMap()
}
}
}
@@ -0,0 +1,21 @@
package com.bitchat.android.services
import android.content.Context
import com.bitchat.android.ui.DataManager
/**
* Provides current user's nickname for announcements and leave messages.
* If no nickname saved, falls back to the provided peerID.
*/
object NicknameProvider {
fun getNickname(context: Context, myPeerID: String): String {
return try {
val dm = DataManager(context.applicationContext)
val nick = dm.loadNickname()
if (nick.isNullOrBlank()) myPeerID else nick
} catch (_: Exception) {
myPeerID
}
}
}
@@ -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<EncryptionService>? = 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<String, ByteArray>? {
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
}
}
File diff suppressed because it is too large Load Diff
@@ -14,7 +14,6 @@ import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.* import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -28,9 +27,9 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.bitchat.android.core.ui.utils.singleOrTripleClickable import com.bitchat.android.core.ui.utils.singleOrTripleClickable
import com.bitchat.android.geohash.LocationChannelManager.PermissionState
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.lifecycle.compose.collectAsStateWithLifecycle
/** /**
* Header components for ChatScreen * Header components for ChatScreen
@@ -59,7 +58,8 @@ fun isFavoriteReactive(
fun TorStatusDot( fun TorStatusDot(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState() val torProvider = remember { com.bitchat.android.net.ArtiTorManager.getInstance() }
val torStatus by torProvider.statusFlow.collectAsState()
if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) { if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) {
val dotColor = when { val dotColor = when {
@@ -248,10 +248,10 @@ fun ChatHeaderContent(
when { when {
selectedPrivatePeer != null -> { selectedPrivatePeer != null -> {
// Private chat header - Fully reactive state tracking // Private chat header - Fully reactive state tracking
val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet()) val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap()) val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
val peerSessionStates by viewModel.peerSessionStates.observeAsState(emptyMap()) val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle()
val peerNicknames by viewModel.peerNicknames.observeAsState(emptyMap()) val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
// Reactive favorite computation - no more static lookups! // Reactive favorite computation - no more static lookups!
val isFavorite = isFavoriteReactive( val isFavorite = isFavoriteReactive(
@@ -264,8 +264,8 @@ fun ChatHeaderContent(
Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, isFav=$isFavorite, sessionState=$sessionState") Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, isFav=$isFavorite, sessionState=$sessionState")
// Pass geohash context and people for NIP-17 chat title formatting // Pass geohash context and people for NIP-17 chat title formatting
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList()) val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
PrivateChatHeader( PrivateChatHeader(
peerID = selectedPrivatePeer, peerID = selectedPrivatePeer,
@@ -318,6 +318,10 @@ private fun PrivateChatHeader(
) { ) {
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
val isNostrDM = peerID.startsWith("nostr_") || peerID.startsWith("nostr:") 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) // Determine mutual favorite state for this peer (supports mesh ephemeral 16-hex via favorites lookup)
val isMutualFavorite = remember(peerID, peerNicknames) { val isMutualFavorite = remember(peerID, peerNicknames) {
try { 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 // 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 showGlobe = isNostrDM || (sessionState != "established" && isMutualFavorite)
val securityModifier = if (!isNostrDM) {
Modifier.clickable { viewModel.showSecurityVerificationSheet() }
} else {
Modifier
}
if (showGlobe) { if (showGlobe) {
Icon( Icon(
imageVector = Icons.Outlined.Public, imageVector = Icons.Outlined.Public,
contentDescription = stringResource(R.string.cd_nostr_reachable), 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 tint = Color(0xFF9B59B6) // Purple like iOS
) )
} else { } else {
NoiseSessionIcon( NoiseSessionIcon(
sessionState = sessionState, sessionState = sessionState,
modifier = Modifier.size(14.dp) modifier = Modifier.size(14.dp).then(securityModifier)
)
}
if (isVerified) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.Filled.Verified,
contentDescription = stringResource(R.string.verify_title),
modifier = Modifier.size(14.dp).then(securityModifier),
tint = Color(0xFF32D74B)
) )
} }
@@ -523,18 +543,18 @@ private fun MainHeader(
viewModel: ChatViewModel viewModel: ChatViewModel
) { ) {
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList()) val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
val joinedChannels by viewModel.joinedChannels.observeAsState(emptySet()) val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap()) val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet()) val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
val isConnected by viewModel.isConnected.observeAsState(false) val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList()) val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
// Bookmarks store for current geohash toggle (iOS parity) // Bookmarks store for current geohash toggle (iOS parity)
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
val bookmarksStore = remember { com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(context) } val bookmarksStore = remember { com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(context) }
val bookmarks by bookmarksStore.bookmarks.observeAsState(emptyList()) val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle()
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -653,8 +673,8 @@ private fun LocationChannelsButton(
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
// Get current channel selection from location manager // Get current channel selection from location manager
val selectedChannel by viewModel.selectedLocationChannel.observeAsState() val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val teleported by viewModel.isTeleported.observeAsState(false) val teleported by viewModel.isTeleported.collectAsStateWithLifecycle()
val (badgeText, badgeColor) = when (selectedChannel) { val (badgeText, badgeColor) = when (selectedChannel) {
is com.bitchat.android.geohash.ChannelID.Mesh -> { is com.bitchat.android.geohash.ChannelID.Mesh -> {
@@ -10,7 +10,6 @@ import androidx.compose.foundation.*
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -25,6 +24,7 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.ui.media.FullScreenImageViewer import com.bitchat.android.ui.media.FullScreenImageViewer
@@ -41,22 +41,24 @@ import com.bitchat.android.ui.media.FullScreenImageViewer
@Composable @Composable
fun ChatScreen(viewModel: ChatViewModel) { fun ChatScreen(viewModel: ChatViewModel) {
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
val messages by viewModel.messages.observeAsState(emptyList()) val messages by viewModel.messages.collectAsStateWithLifecycle()
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList()) val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.observeAsState("") val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState() val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
val currentChannel by viewModel.currentChannel.observeAsState() val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
val joinedChannels by viewModel.joinedChannels.observeAsState(emptySet()) val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap()) val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet()) val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
val privateChats by viewModel.privateChats.observeAsState(emptyMap()) val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
val channelMessages by viewModel.channelMessages.observeAsState(emptyMap()) val channelMessages by viewModel.channelMessages.collectAsStateWithLifecycle()
val showSidebar by viewModel.showSidebar.observeAsState(false) val showSidebar by viewModel.showSidebar.collectAsStateWithLifecycle()
val showCommandSuggestions by viewModel.showCommandSuggestions.observeAsState(false) val showCommandSuggestions by viewModel.showCommandSuggestions.collectAsStateWithLifecycle()
val commandSuggestions by viewModel.commandSuggestions.observeAsState(emptyList()) val commandSuggestions by viewModel.commandSuggestions.collectAsStateWithLifecycle()
val showMentionSuggestions by viewModel.showMentionSuggestions.observeAsState(false) val showMentionSuggestions by viewModel.showMentionSuggestions.collectAsStateWithLifecycle()
val mentionSuggestions by viewModel.mentionSuggestions.observeAsState(emptyList()) val mentionSuggestions by viewModel.mentionSuggestions.collectAsStateWithLifecycle()
val showAppInfo by viewModel.showAppInfo.observeAsState(false) 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 messageText by remember { mutableStateOf(TextFieldValue("")) }
var showPasswordPrompt by remember { mutableStateOf(false) } var showPasswordPrompt by remember { mutableStateOf(false) }
@@ -78,11 +80,11 @@ fun ChatScreen(viewModel: ChatViewModel) {
showPasswordDialog = showPasswordPrompt showPasswordDialog = showPasswordPrompt
} }
val isConnected by viewModel.isConnected.observeAsState(false) val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
val passwordPromptChannel by viewModel.passwordPromptChannel.observeAsState(null) val passwordPromptChannel by viewModel.passwordPromptChannel.collectAsStateWithLifecycle()
// Get location channel info for timeline switching // Get location channel info for timeline switching
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
// Determine what messages to show based on current context (unified timelines) // Determine what messages to show based on current context (unified timelines)
val displayMessages = when { val displayMessages = when {
@@ -327,6 +329,10 @@ fun ChatScreen(viewModel: ChatViewModel) {
SidebarOverlay( SidebarOverlay(
viewModel = viewModel, viewModel = viewModel,
onDismiss = { viewModel.hideSidebar() }, onDismiss = { viewModel.hideSidebar() },
onShowVerification = {
viewModel.showVerificationSheet(fromSidebar = true)
viewModel.hideSidebar()
},
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} }
@@ -373,7 +379,11 @@ fun ChatScreen(viewModel: ChatViewModel) {
}, },
selectedUserForSheet = selectedUserForSheet, selectedUserForSheet = selectedUserForSheet,
selectedMessageForSheet = selectedMessageForSheet, 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, onUserSheetDismiss: () -> Unit,
selectedUserForSheet: String, selectedUserForSheet: String,
selectedMessageForSheet: BitchatMessage?, selectedMessageForSheet: BitchatMessage?,
viewModel: ChatViewModel viewModel: ChatViewModel,
showVerificationSheet: Boolean,
onVerificationSheetDismiss: () -> Unit,
showSecurityVerificationSheet: Boolean,
onSecurityVerificationSheetDismiss: () -> Unit
) { ) {
// Password dialog // Password dialog
PasswordPromptDialog( PasswordPromptDialog(
@@ -567,4 +581,20 @@ private fun ChatDialogs(
viewModel = viewModel viewModel = viewModel
) )
} }
if (showVerificationSheet) {
VerificationSheet(
isPresented = showVerificationSheet,
onDismiss = onVerificationSheetDismiss,
viewModel = viewModel
)
}
if (showSecurityVerificationSheet) {
SecurityVerificationSheet(
isPresented = showSecurityVerificationSheet,
onDismiss = onSecurityVerificationSheetDismiss,
viewModel = viewModel
)
}
} }
@@ -1,10 +1,17 @@
package com.bitchat.android.ui package com.bitchat.android.ui
import android.util.Log import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/** /**
* Centralized state definitions and data classes for the chat system * Centralized state definitions and data classes for the chat system
@@ -21,171 +28,168 @@ data class CommandSuggestion(
/** /**
* Contains all the observable state for the chat system * Contains all the observable state for the chat system
*/ */
class ChatState { class ChatState(
scope: CoroutineScope
) {
// Core messages and peer state // Core messages and peer state
private val _messages = MutableLiveData<List<BitchatMessage>>(emptyList()) private val _messages = MutableStateFlow<List<BitchatMessage>>(emptyList())
val messages: LiveData<List<BitchatMessage>> = _messages val messages: StateFlow<List<BitchatMessage>> = _messages.asStateFlow()
private val _connectedPeers = MutableLiveData<List<String>>(emptyList()) private val _connectedPeers = MutableStateFlow<List<String>>(emptyList())
val connectedPeers: LiveData<List<String>> = _connectedPeers val connectedPeers: StateFlow<List<String>> = _connectedPeers.asStateFlow()
private val _nickname = MutableLiveData<String>() private val _nickname = MutableStateFlow<String>("")
val nickname: LiveData<String> = _nickname val nickname: StateFlow<String> = _nickname.asStateFlow()
private val _isConnected = MutableLiveData<Boolean>(false) private val _isConnected = MutableStateFlow<Boolean>(false)
val isConnected: LiveData<Boolean> = _isConnected val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
// Private chats // Private chats
private val _privateChats = MutableLiveData<Map<String, List<BitchatMessage>>>(emptyMap()) private val _privateChats = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
val privateChats: LiveData<Map<String, List<BitchatMessage>>> = _privateChats val privateChats: StateFlow<Map<String, List<BitchatMessage>>> = _privateChats.asStateFlow()
private val _selectedPrivateChatPeer = MutableLiveData<String?>(null) private val _selectedPrivateChatPeer = MutableStateFlow<String?>(null)
val selectedPrivateChatPeer: LiveData<String?> = _selectedPrivateChatPeer val selectedPrivateChatPeer: StateFlow<String?> = _selectedPrivateChatPeer.asStateFlow()
private val _unreadPrivateMessages = MutableLiveData<Set<String>>(emptySet()) private val _unreadPrivateMessages = MutableStateFlow<Set<String>>(emptySet())
val unreadPrivateMessages: LiveData<Set<String>> = _unreadPrivateMessages val unreadPrivateMessages: StateFlow<Set<String>> = _unreadPrivateMessages.asStateFlow()
// Channels // Channels
private val _joinedChannels = MutableLiveData<Set<String>>(emptySet()) private val _joinedChannels = MutableStateFlow<Set<String>>(emptySet())
val joinedChannels: LiveData<Set<String>> = _joinedChannels val joinedChannels: StateFlow<Set<String>> = _joinedChannels.asStateFlow()
private val _currentChannel = MutableLiveData<String?>(null) private val _currentChannel = MutableStateFlow<String?>(null)
val currentChannel: LiveData<String?> = _currentChannel val currentChannel: StateFlow<String?> = _currentChannel.asStateFlow()
private val _channelMessages = MutableLiveData<Map<String, List<BitchatMessage>>>(emptyMap()) private val _channelMessages = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
val channelMessages: LiveData<Map<String, List<BitchatMessage>>> = _channelMessages val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = _channelMessages.asStateFlow()
private val _unreadChannelMessages = MutableLiveData<Map<String, Int>>(emptyMap()) private val _unreadChannelMessages = MutableStateFlow<Map<String, Int>>(emptyMap())
val unreadChannelMessages: LiveData<Map<String, Int>> = _unreadChannelMessages val unreadChannelMessages: StateFlow<Map<String, Int>> = _unreadChannelMessages.asStateFlow()
private val _passwordProtectedChannels = MutableLiveData<Set<String>>(emptySet()) private val _passwordProtectedChannels = MutableStateFlow<Set<String>>(emptySet())
val passwordProtectedChannels: LiveData<Set<String>> = _passwordProtectedChannels val passwordProtectedChannels: StateFlow<Set<String>> = _passwordProtectedChannels.asStateFlow()
private val _showPasswordPrompt = MutableLiveData<Boolean>(false) private val _showPasswordPrompt = MutableStateFlow<Boolean>(false)
val showPasswordPrompt: LiveData<Boolean> = _showPasswordPrompt val showPasswordPrompt: StateFlow<Boolean> = _showPasswordPrompt.asStateFlow()
private val _passwordPromptChannel = MutableLiveData<String?>(null) private val _passwordPromptChannel = MutableStateFlow<String?>(null)
val passwordPromptChannel: LiveData<String?> = _passwordPromptChannel val passwordPromptChannel: StateFlow<String?> = _passwordPromptChannel.asStateFlow()
// Sidebar state // Sidebar state
private val _showSidebar = MutableLiveData(false) private val _showSidebar = MutableStateFlow(false)
val showSidebar: LiveData<Boolean> = _showSidebar val showSidebar: StateFlow<Boolean> = _showSidebar.asStateFlow()
// Command autocomplete // Command autocomplete
private val _showCommandSuggestions = MutableLiveData(false) private val _showCommandSuggestions = MutableStateFlow(false)
val showCommandSuggestions: LiveData<Boolean> = _showCommandSuggestions val showCommandSuggestions: StateFlow<Boolean> = _showCommandSuggestions.asStateFlow()
private val _commandSuggestions = MutableLiveData<List<CommandSuggestion>>(emptyList()) private val _commandSuggestions = MutableStateFlow<List<CommandSuggestion>>(emptyList())
val commandSuggestions: LiveData<List<CommandSuggestion>> = _commandSuggestions val commandSuggestions: StateFlow<List<CommandSuggestion>> = _commandSuggestions.asStateFlow()
// Mention autocomplete // Mention autocomplete
private val _showMentionSuggestions = MutableLiveData(false) private val _showMentionSuggestions = MutableStateFlow(false)
val showMentionSuggestions: LiveData<Boolean> = _showMentionSuggestions val showMentionSuggestions: StateFlow<Boolean> = _showMentionSuggestions.asStateFlow()
private val _mentionSuggestions = MutableLiveData<List<String>>(emptyList()) private val _mentionSuggestions = MutableStateFlow<List<String>>(emptyList())
val mentionSuggestions: LiveData<List<String>> = _mentionSuggestions val mentionSuggestions: StateFlow<List<String>> = _mentionSuggestions.asStateFlow()
// Favorites // Favorites
private val _favoritePeers = MutableLiveData<Set<String>>(emptySet()) private val _favoritePeers = MutableStateFlow<Set<String>>(emptySet())
val favoritePeers: LiveData<Set<String>> = _favoritePeers val favoritePeers: StateFlow<Set<String>> = _favoritePeers.asStateFlow()
// Noise session states for peers (for reactive UI updates) // Noise session states for peers (for reactive UI updates)
private val _peerSessionStates = MutableLiveData<Map<String, String>>(emptyMap()) private val _peerSessionStates = MutableStateFlow<Map<String, String>>(emptyMap())
val peerSessionStates: LiveData<Map<String, String>> = _peerSessionStates val peerSessionStates: StateFlow<Map<String, String>> = _peerSessionStates.asStateFlow()
// Peer fingerprint state for reactive favorites (for reactive UI updates) // Peer fingerprint state for reactive favorites (for reactive UI updates)
private val _peerFingerprints = MutableLiveData<Map<String, String>>(emptyMap()) private val _peerFingerprints = MutableStateFlow<Map<String, String>>(emptyMap())
val peerFingerprints: LiveData<Map<String, String>> = _peerFingerprints val peerFingerprints: StateFlow<Map<String, String>> = _peerFingerprints.asStateFlow()
private val _peerNicknames = MutableLiveData<Map<String, String>>(emptyMap()) private val _peerNicknames = MutableStateFlow<Map<String, String>>(emptyMap())
val peerNicknames: LiveData<Map<String, String>> = _peerNicknames val peerNicknames: StateFlow<Map<String, String>> = _peerNicknames.asStateFlow()
private val _peerRSSI = MutableLiveData<Map<String, Int>>(emptyMap()) private val _peerRSSI = MutableStateFlow<Map<String, Int>>(emptyMap())
val peerRSSI: LiveData<Map<String, Int>> = _peerRSSI val peerRSSI: StateFlow<Map<String, Int>> = _peerRSSI.asStateFlow()
// Direct connection status per peer (for live UI updates) // Direct connection status per peer (for live UI updates)
private val _peerDirect = MutableLiveData<Map<String, Boolean>>(emptyMap()) private val _peerDirect = MutableStateFlow<Map<String, Boolean>>(emptyMap())
val peerDirect: LiveData<Map<String, Boolean>> = _peerDirect val peerDirect: StateFlow<Map<String, Boolean>> = _peerDirect.asStateFlow()
// peerIDToPublicKeyFingerprint REMOVED - fingerprints now handled centrally in PeerManager // peerIDToPublicKeyFingerprint REMOVED - fingerprints now handled centrally in PeerManager
// Navigation state // Navigation state
private val _showAppInfo = MutableLiveData<Boolean>(false) private val _showAppInfo = MutableStateFlow<Boolean>(false)
val showAppInfo: LiveData<Boolean> = _showAppInfo val showAppInfo: StateFlow<Boolean> = _showAppInfo.asStateFlow()
private val _showVerificationSheet = MutableStateFlow(false)
val showVerificationSheet: StateFlow<Boolean> = _showVerificationSheet.asStateFlow()
private val _showSecurityVerificationSheet = MutableStateFlow(false)
val showSecurityVerificationSheet: StateFlow<Boolean> = _showSecurityVerificationSheet.asStateFlow()
// Location channels state (for Nostr geohash features) // Location channels state (for Nostr geohash features)
private val _selectedLocationChannel = MutableLiveData<com.bitchat.android.geohash.ChannelID?>(com.bitchat.android.geohash.ChannelID.Mesh) private val _selectedLocationChannel = MutableStateFlow<com.bitchat.android.geohash.ChannelID?>(com.bitchat.android.geohash.ChannelID.Mesh)
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = _selectedLocationChannel val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = _selectedLocationChannel.asStateFlow()
private val _isTeleported = MutableLiveData<Boolean>(false) private val _isTeleported = MutableStateFlow<Boolean>(false)
val isTeleported: LiveData<Boolean> = _isTeleported val isTeleported: StateFlow<Boolean> = _isTeleported.asStateFlow()
// Geohash people state (iOS-compatible) // Geohash people state (iOS-compatible)
private val _geohashPeople = MutableLiveData<List<GeoPerson>>(emptyList()) private val _geohashPeople = MutableStateFlow<List<GeoPerson>>(emptyList())
val geohashPeople: LiveData<List<GeoPerson>> = _geohashPeople val geohashPeople: StateFlow<List<GeoPerson>> = _geohashPeople.asStateFlow()
// For background thread updates by repositories/handlers in their own scopes
val geohashPeopleMutable: MutableLiveData<List<GeoPerson>> get() = _geohashPeople
private val _teleportedGeo = MutableStateFlow<Set<String>>(emptySet())
private val _teleportedGeo = MutableLiveData<Set<String>>(emptySet()) val teleportedGeo: StateFlow<Set<String>> = _teleportedGeo.asStateFlow()
val teleportedGeo: LiveData<Set<String>> = _teleportedGeo
// Geohash participant counts reactive state (for real-time location channel counts) // Geohash participant counts reactive state (for real-time location channel counts)
private val _geohashParticipantCounts = MutableLiveData<Map<String, Int>>(emptyMap()) private val _geohashParticipantCounts = MutableStateFlow<Map<String, Int>>(emptyMap())
val geohashParticipantCounts: LiveData<Map<String, Int>> = _geohashParticipantCounts val geohashParticipantCounts: StateFlow<Map<String, Int>> = _geohashParticipantCounts.asStateFlow()
// Unread state computed properties
val hasUnreadChannels: MediatorLiveData<Boolean> = MediatorLiveData<Boolean>() val hasUnreadChannels: StateFlow<Boolean> = _unreadChannelMessages
val hasUnreadPrivateMessages: MediatorLiveData<Boolean> = MediatorLiveData<Boolean>() .map { unreadMap -> unreadMap.values.any { it > 0 } }
.stateIn(
scope = scope,
started = WhileSubscribed(5_000),
initialValue = false
)
init { val hasUnreadPrivateMessages: StateFlow<Boolean> = _unreadPrivateMessages
// Initialize unread state mediators .map { unreadSet -> unreadSet.isNotEmpty() }
hasUnreadChannels.addSource(_unreadChannelMessages) { unreadMap -> .stateIn(
hasUnreadChannels.value = unreadMap.values.any { it > 0 } scope = scope,
} started = WhileSubscribed(5_000),
initialValue = false
hasUnreadPrivateMessages.addSource(_unreadPrivateMessages) { unreadSet -> )
hasUnreadPrivateMessages.value = unreadSet.isNotEmpty()
}
}
// Getters for internal state access // Getters for internal state access
fun getMessagesValue() = _messages.value ?: emptyList() fun getMessagesValue() = _messages.value
fun getConnectedPeersValue() = _connectedPeers.value ?: emptyList() fun getConnectedPeersValue() = _connectedPeers.value
fun getNicknameValue() = _nickname.value fun getNicknameValue() = _nickname.value
fun getPrivateChatsValue() = _privateChats.value ?: emptyMap() fun getPrivateChatsValue() = _privateChats.value
fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value
fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value ?: emptySet() fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value
fun getJoinedChannelsValue() = _joinedChannels.value ?: emptySet() fun getJoinedChannelsValue() = _joinedChannels.value
// Thread-safe posting helpers for background updates
fun postGeohashPeople(people: List<GeoPerson>) {
_geohashPeople.postValue(people)
}
fun postGeohashParticipantCounts(counts: Map<String, Int>) {
_geohashParticipantCounts.postValue(counts)
}
fun getCurrentChannelValue() = _currentChannel.value fun getCurrentChannelValue() = _currentChannel.value
fun getChannelMessagesValue() = _channelMessages.value ?: emptyMap() fun getChannelMessagesValue() = _channelMessages.value
fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value ?: emptyMap() fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value
fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value ?: emptySet() fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value
fun getShowPasswordPromptValue() = _showPasswordPrompt.value ?: false fun getShowPasswordPromptValue() = _showPasswordPrompt.value
fun getPasswordPromptChannelValue() = _passwordPromptChannel.value fun getPasswordPromptChannelValue() = _passwordPromptChannel.value
fun getShowSidebarValue() = _showSidebar.value ?: false fun getShowSidebarValue() = _showSidebar.value
fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value ?: false fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value
fun getCommandSuggestionsValue() = _commandSuggestions.value ?: emptyList() fun getCommandSuggestionsValue() = _commandSuggestions.value
fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value ?: false fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value
fun getMentionSuggestionsValue() = _mentionSuggestions.value ?: emptyList() fun getMentionSuggestionsValue() = _mentionSuggestions.value
fun getFavoritePeersValue() = _favoritePeers.value ?: emptySet() fun getFavoritePeersValue() = _favoritePeers.value
fun getPeerSessionStatesValue() = _peerSessionStates.value ?: emptyMap() fun getPeerSessionStatesValue() = _peerSessionStates.value
fun getPeerFingerprintsValue() = _peerFingerprints.value ?: emptyMap() fun getPeerFingerprintsValue() = _peerFingerprints.value
fun getShowAppInfoValue() = _showAppInfo.value ?: false fun getShowAppInfoValue() = _showAppInfo.value
fun getGeohashPeopleValue() = _geohashPeople.value ?: emptyList() fun getGeohashPeopleValue() = _geohashPeople.value
fun getTeleportedGeoValue() = _teleportedGeo.value ?: emptySet() fun getTeleportedGeoValue() = _teleportedGeo.value
fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value ?: emptyMap() fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value
// Setters for state updates // Setters for state updates
fun setMessages(messages: List<BitchatMessage>) { fun setMessages(messages: List<BitchatMessage>) {
@@ -197,7 +201,7 @@ class ChatState {
} }
fun postTeleportedGeo(teleported: Set<String>) { fun postTeleportedGeo(teleported: Set<String>) {
_teleportedGeo.postValue(teleported) _teleportedGeo.value = teleported
} }
fun setNickname(nickname: String) { fun setNickname(nickname: String) {
@@ -269,7 +273,7 @@ class ChatState {
} }
fun setFavoritePeers(favorites: Set<String>) { fun setFavoritePeers(favorites: Set<String>) {
val currentValue = _favoritePeers.value ?: emptySet() val currentValue = _favoritePeers.value
Log.d("ChatState", "setFavoritePeers called with ${favorites.size} favorites: $favorites") Log.d("ChatState", "setFavoritePeers called with ${favorites.size} favorites: $favorites")
Log.d("ChatState", "Current value: $currentValue") Log.d("ChatState", "Current value: $currentValue")
Log.d("ChatState", "Values equal: ${currentValue == favorites}") Log.d("ChatState", "Values equal: ${currentValue == favorites}")
@@ -278,8 +282,7 @@ class ChatState {
// Always set the value - even if equal, this ensures observers are triggered // Always set the value - even if equal, this ensures observers are triggered
_favoritePeers.value = favorites _favoritePeers.value = favorites
Log.d("ChatState", "LiveData value after set: ${_favoritePeers.value}") Log.d("ChatState", "StateFlow value after set: ${_favoritePeers.value}")
Log.d("ChatState", "LiveData has active observers: ${_favoritePeers.hasActiveObservers()}")
} }
fun setPeerSessionStates(states: Map<String, String>) { fun setPeerSessionStates(states: Map<String, String>) {
@@ -305,6 +308,14 @@ class ChatState {
fun setShowAppInfo(show: Boolean) { fun setShowAppInfo(show: Boolean) {
_showAppInfo.value = show _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?) { fun setSelectedLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) {
_selectedLocationChannel.value = channel _selectedLocationChannel.value = channel
@@ -4,12 +4,16 @@ import android.app.Application
import android.util.Log import android.util.Log
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope 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.BluetoothMeshDelegate
import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.nostr.NostrIdentityBridge
import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.BitchatPacket
@@ -19,6 +23,13 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Date import java.util.Date
import kotlin.random.Random 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 * Refactored ChatViewModel - Main coordinator for bitchat functionality
@@ -46,8 +57,24 @@ class ChatViewModel(
mediaSendingManager.sendImageNote(toPeerIDOrNull, channelOrNull, filePath) 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 // MARK: - State management
private val state = ChatState() private val state = ChatState(
scope = viewModelScope,
)
// Transfer progress tracking // Transfer progress tracking
private val transferMessageMap = mutableMapOf<String, String>() private val transferMessageMap = mutableMapOf<String, String>()
@@ -55,6 +82,7 @@ class ChatViewModel(
// Specialized managers // Specialized managers
private val dataManager = DataManager(application.applicationContext) private val dataManager = DataManager(application.applicationContext)
private val identityManager by lazy { SecureIdentityStateManager(getApplication()) }
private val messageManager = MessageManager(state) private val messageManager = MessageManager(state)
private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope) private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope)
@@ -73,6 +101,17 @@ class ChatViewModel(
NotificationIntervalManager() 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 // Media file sending manager
private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager, meshService) private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager, meshService)
@@ -103,44 +142,81 @@ class ChatViewModel(
// Expose state through LiveData (maintaining the same interface)
val messages: LiveData<List<BitchatMessage>> = state.messages val messages: StateFlow<List<BitchatMessage>> = state.messages
val connectedPeers: LiveData<List<String>> = state.connectedPeers val connectedPeers: StateFlow<List<String>> = state.connectedPeers
val nickname: LiveData<String> = state.nickname val nickname: StateFlow<String> = state.nickname
val isConnected: LiveData<Boolean> = state.isConnected val isConnected: StateFlow<Boolean> = state.isConnected
val privateChats: LiveData<Map<String, List<BitchatMessage>>> = state.privateChats val privateChats: StateFlow<Map<String, List<BitchatMessage>>> = state.privateChats
val selectedPrivateChatPeer: LiveData<String?> = state.selectedPrivateChatPeer val selectedPrivateChatPeer: StateFlow<String?> = state.selectedPrivateChatPeer
val unreadPrivateMessages: LiveData<Set<String>> = state.unreadPrivateMessages val unreadPrivateMessages: StateFlow<Set<String>> = state.unreadPrivateMessages
val joinedChannels: LiveData<Set<String>> = state.joinedChannels val joinedChannels: StateFlow<Set<String>> = state.joinedChannels
val currentChannel: LiveData<String?> = state.currentChannel val currentChannel: StateFlow<String?> = state.currentChannel
val channelMessages: LiveData<Map<String, List<BitchatMessage>>> = state.channelMessages val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = state.channelMessages
val unreadChannelMessages: LiveData<Map<String, Int>> = state.unreadChannelMessages val unreadChannelMessages: StateFlow<Map<String, Int>> = state.unreadChannelMessages
val passwordProtectedChannels: LiveData<Set<String>> = state.passwordProtectedChannels val passwordProtectedChannels: StateFlow<Set<String>> = state.passwordProtectedChannels
val showPasswordPrompt: LiveData<Boolean> = state.showPasswordPrompt val showPasswordPrompt: StateFlow<Boolean> = state.showPasswordPrompt
val passwordPromptChannel: LiveData<String?> = state.passwordPromptChannel val passwordPromptChannel: StateFlow<String?> = state.passwordPromptChannel
val showSidebar: LiveData<Boolean> = state.showSidebar val showSidebar: StateFlow<Boolean> = state.showSidebar
val hasUnreadChannels = state.hasUnreadChannels val hasUnreadChannels = state.hasUnreadChannels
val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages
val showCommandSuggestions: LiveData<Boolean> = state.showCommandSuggestions val showCommandSuggestions: StateFlow<Boolean> = state.showCommandSuggestions
val commandSuggestions: LiveData<List<CommandSuggestion>> = state.commandSuggestions val commandSuggestions: StateFlow<List<CommandSuggestion>> = state.commandSuggestions
val showMentionSuggestions: LiveData<Boolean> = state.showMentionSuggestions val showMentionSuggestions: StateFlow<Boolean> = state.showMentionSuggestions
val mentionSuggestions: LiveData<List<String>> = state.mentionSuggestions val mentionSuggestions: StateFlow<List<String>> = state.mentionSuggestions
val favoritePeers: LiveData<Set<String>> = state.favoritePeers val favoritePeers: StateFlow<Set<String>> = state.favoritePeers
val peerSessionStates: LiveData<Map<String, String>> = state.peerSessionStates val peerSessionStates: StateFlow<Map<String, String>> = state.peerSessionStates
val peerFingerprints: LiveData<Map<String, String>> = state.peerFingerprints val peerFingerprints: StateFlow<Map<String, String>> = state.peerFingerprints
val peerNicknames: LiveData<Map<String, String>> = state.peerNicknames val peerNicknames: StateFlow<Map<String, String>> = state.peerNicknames
val peerRSSI: LiveData<Map<String, Int>> = state.peerRSSI val peerRSSI: StateFlow<Map<String, Int>> = state.peerRSSI
val peerDirect: LiveData<Map<String, Boolean>> = state.peerDirect val peerDirect: StateFlow<Map<String, Boolean>> = state.peerDirect
val showAppInfo: LiveData<Boolean> = state.showAppInfo val showAppInfo: StateFlow<Boolean> = state.showAppInfo
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel val showVerificationSheet: StateFlow<Boolean> = state.showVerificationSheet
val isTeleported: LiveData<Boolean> = state.isTeleported val showSecurityVerificationSheet: StateFlow<Boolean> = state.showSecurityVerificationSheet
val geohashPeople: LiveData<List<GeoPerson>> = state.geohashPeople val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
val teleportedGeo: LiveData<Set<String>> = state.teleportedGeo val isTeleported: StateFlow<Boolean> = state.isTeleported
val geohashParticipantCounts: LiveData<Map<String, Int>> = state.geohashParticipantCounts val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
val teleportedGeo: StateFlow<Set<String>> = state.teleportedGeo
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
init { init {
// Note: Mesh service delegate is now set by MainActivity // Note: Mesh service delegate is now set by MainActivity
loadAndInitialize() loadAndInitialize()
// Hydrate UI state from process-wide AppStateStore to survive Activity recreation
viewModelScope.launch {
try { com.bitchat.android.services.AppStateStore.peers.collect { peers ->
state.setConnectedPeers(peers)
state.setIsConnected(peers.isNotEmpty())
} } catch (_: Exception) { }
}
viewModelScope.launch {
try { com.bitchat.android.services.AppStateStore.publicMessages.collect { msgs ->
// Source of truth is AppStateStore; replace to avoid duplicate keys in LazyColumn
state.setMessages(msgs)
} } catch (_: Exception) { }
}
viewModelScope.launch {
try { com.bitchat.android.services.AppStateStore.privateMessages.collect { byPeer ->
// Replace with store snapshot
state.setPrivateChats(byPeer)
// Recompute unread set using SeenMessageStore for robustness across Activity recreation
try {
val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication())
val myNick = state.getNicknameValue() ?: meshService.myPeerID
val unread = mutableSetOf<String>()
byPeer.forEach { (peer, list) ->
if (list.any { msg -> msg.sender != myNick && !seen.hasRead(msg.id) }) unread.add(peer)
}
state.setUnreadPrivateMessages(unread)
} catch (_: Exception) { }
} } catch (_: Exception) { }
}
viewModelScope.launch {
try { com.bitchat.android.services.AppStateStore.channelMessages.collect { byChannel ->
// Replace with store snapshot
state.setChannelMessages(byChannel)
} } catch (_: Exception) { }
}
// Subscribe to BLE transfer progress and reflect in message deliveryStatus // Subscribe to BLE transfer progress and reflect in message deliveryStatus
viewModelScope.launch { viewModelScope.launch {
com.bitchat.android.mesh.TransferProgressManager.events.collect { evt -> com.bitchat.android.mesh.TransferProgressManager.events.collect { evt ->
@@ -210,6 +286,9 @@ class ChatViewModel(
// Initialize favorites persistence service // Initialize favorites persistence service
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication()) com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication())
// Load verified fingerprints from secure storage
verificationHandler.loadVerifiedFingerprints()
// Ensure NostrTransport knows our mesh peer ID for embedded packets // Ensure NostrTransport knows our mesh peer ID for embedded packets
try { try {
@@ -565,7 +644,7 @@ class ChatViewModel(
private fun logCurrentFavoriteState() { private fun logCurrentFavoriteState() {
Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===") Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===")
Log.i("ChatViewModel", "LiveData favorite peers: ${favoritePeers.value}") Log.i("ChatViewModel", "StateFlow favorite peers: ${favoritePeers.value}")
Log.i("ChatViewModel", "DataManager favorite peers: ${dataManager.favoritePeers}") Log.i("ChatViewModel", "DataManager favorite peers: ${dataManager.favoritePeers}")
Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}") Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}")
Log.i("ChatViewModel", "==============================") Log.i("ChatViewModel", "==============================")
@@ -609,6 +688,18 @@ class ChatViewModel(
// Update fingerprint mappings from centralized manager // Update fingerprint mappings from centralized manager
val fingerprints = privateChatManager.getAllPeerFingerprints() val fingerprints = privateChatManager.getAllPeerFingerprints()
state.setPeerFingerprints(fingerprints) state.setPeerFingerprints(fingerprints)
fingerprints.forEach { (peerID, fingerprint) ->
identityManager.cachePeerFingerprint(peerID, fingerprint)
val info = try { meshService.getPeerInfo(peerID) } catch (_: Exception) { null }
val noiseKeyHex = info?.noisePublicKey?.hexEncodedString()
if (noiseKeyHex != null) {
identityManager.cachePeerNoiseKey(peerID, noiseKeyHex)
identityManager.cacheNoiseFingerprint(noiseKeyHex, fingerprint)
}
info?.nickname?.takeIf { it.isNotBlank() }?.let { nickname ->
identityManager.cacheFingerprintNickname(fingerprint, nickname)
}
}
val nicknames = meshService.getPeerNicknames() val nicknames = meshService.getPeerNicknames()
state.setPeerNicknames(nicknames) state.setPeerNicknames(nicknames)
@@ -623,6 +714,34 @@ class ChatViewModel(
} }
state.setPeerDirect(directMap) state.setPeerDirect(directMap)
} catch (_: Exception) { } } catch (_: Exception) { }
// Flush any pending QR verification once a Noise session is established
currentPeers.forEach { peerID ->
if (meshService.getSessionState(peerID) is NoiseSession.NoiseSessionState.Established) {
verificationHandler.sendPendingVerificationIfNeeded(peerID)
}
}
}
// MARK: - QR Verification
fun isPeerVerified(peerID: String, verifiedFingerprints: Set<String>): Boolean {
if (peerID.startsWith("nostr_") || peerID.startsWith("nostr:")) return false
val fingerprint = verificationHandler.getPeerFingerprintForDisplay(peerID)
return fingerprint != null && verifiedFingerprints.contains(fingerprint)
}
fun isNoisePublicKeyVerified(noisePublicKey: ByteArray, verifiedFingerprints: Set<String>): Boolean {
val fingerprint = verificationHandler.fingerprintFromNoiseBytes(noisePublicKey)
return verifiedFingerprints.contains(fingerprint)
}
fun unverifyFingerprint(peerID: String) {
verificationHandler.unverifyFingerprint(peerID)
}
fun beginQRVerification(qr: VerificationService.VerificationQR): Boolean {
return verificationHandler.beginQRVerification(qr)
} }
// MARK: - Debug and Troubleshooting // MARK: - Debug and Troubleshooting
@@ -631,41 +750,71 @@ class ChatViewModel(
return meshService.getDebugStatus() return meshService.getDebugStatus()
} }
// Note: Mesh service restart is now handled by MainActivity
// This function is no longer needed
fun setAppBackgroundState(inBackground: Boolean) {
// Forward to notification manager for notification logic
notificationManager.setAppBackgroundState(inBackground)
}
fun setCurrentPrivateChatPeer(peerID: String?) { fun setCurrentPrivateChatPeer(peerID: String?) {
// Update notification manager with current private chat peer
notificationManager.setCurrentPrivateChatPeer(peerID) notificationManager.setCurrentPrivateChatPeer(peerID)
} }
fun setCurrentGeohash(geohash: String?) { fun setCurrentGeohash(geohash: String?) {
// Update notification manager with current geohash for notification logic
notificationManager.setCurrentGeohash(geohash) notificationManager.setCurrentGeohash(geohash)
} }
fun clearNotificationsForSender(peerID: String) { fun clearNotificationsForSender(peerID: String) {
// Clear notifications when user opens a chat
notificationManager.clearNotificationsForSender(peerID) notificationManager.clearNotificationsForSender(peerID)
} }
fun clearNotificationsForGeohash(geohash: String) { fun clearNotificationsForGeohash(geohash: String) {
// Clear notifications when user opens a geohash chat
notificationManager.clearNotificationsForGeohash(geohash) notificationManager.clearNotificationsForGeohash(geohash)
} }
/**
* Clear mesh mention notifications when user opens mesh chat
*/
fun clearMeshMentionNotifications() { fun clearMeshMentionNotifications() {
notificationManager.clearMeshMentionNotifications() notificationManager.clearMeshMentionNotifications()
} }
private var reopenSidebarAfterVerification = false
fun showVerificationSheet(fromSidebar: Boolean = false) {
if (fromSidebar) {
reopenSidebarAfterVerification = true
}
state.setShowVerificationSheet(true)
}
fun hideVerificationSheet() {
state.setShowVerificationSheet(false)
if (reopenSidebarAfterVerification) {
reopenSidebarAfterVerification = false
state.setShowSidebar(true)
}
}
fun showSecurityVerificationSheet() {
state.setShowSecurityVerificationSheet(true)
}
fun hideSecurityVerificationSheet() {
state.setShowSecurityVerificationSheet(false)
}
fun getPeerFingerprintForDisplay(peerID: String): String? {
return verificationHandler.getPeerFingerprintForDisplay(peerID)
}
fun getMyFingerprint(): String {
return verificationHandler.getMyFingerprint()
}
fun resolvePeerDisplayNameForFingerprint(peerID: String): String {
return verificationHandler.resolvePeerDisplayNameForFingerprint(peerID)
}
fun verifyFingerprintValue(fingerprint: String) {
verificationHandler.verifyFingerprintValue(fingerprint)
}
fun unverifyFingerprintValue(fingerprint: String) {
verificationHandler.unverifyFingerprintValue(fingerprint)
}
// MARK: - Command Autocomplete (delegated) // MARK: - Command Autocomplete (delegated)
fun updateCommandSuggestions(input: String) { fun updateCommandSuggestions(input: String) {
@@ -707,6 +856,14 @@ class ChatViewModel(
override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) { override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) {
meshDelegateHandler.didReceiveReadReceipt(messageID, recipientPeerID) meshDelegateHandler.didReceiveReadReceipt(messageID, recipientPeerID)
} }
override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {
verificationHandler.didReceiveVerifyChallenge(peerID, payload)
}
override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {
verificationHandler.didReceiveVerifyResponse(peerID, payload)
}
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
return meshDelegateHandler.decryptChannelMessage(encryptedContent, channel) return meshDelegateHandler.decryptChannelMessage(encryptedContent, channel)
@@ -790,7 +947,7 @@ class ChatViewModel(
// Clear secure identity state (if used) // Clear secure identity state (if used)
try { try {
val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication()) val identityManager = SecureIdentityStateManager(getApplication())
identityManager.clearIdentityData() identityManager.clearIdentityData()
// Also clear secure values used by FavoritesPersistenceService (favorites + peerID index) // Also clear secure values used by FavoritesPersistenceService (favorites + peerID index)
try { try {
@@ -803,7 +960,7 @@ class ChatViewModel(
// Clear FavoritesPersistenceService persistent relationships // Clear FavoritesPersistenceService persistent relationships
try { try {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.clearAllFavorites() FavoritesPersistenceService.shared.clearAllFavorites()
Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships") Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships")
} catch (_: Exception) { } } catch (_: Exception) { }
@@ -932,5 +1089,6 @@ class ChatViewModel(
*/ */
fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color { fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark) return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark)
} }
} }
@@ -9,7 +9,6 @@ import androidx.compose.material.icons.outlined.Explore
import androidx.compose.material.icons.outlined.LocationOn import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -21,6 +20,7 @@ import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import java.util.* import java.util.*
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R import com.bitchat.android.R
/** /**
@@ -46,11 +46,11 @@ fun GeohashPeopleList(
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
// Observe geohash people from ChatViewModel // Observe geohash people from ChatViewModel
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList()) val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val isTeleported by viewModel.isTeleported.observeAsState(false) val isTeleported by viewModel.isTeleported.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.observeAsState("") val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val unreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet()) val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
Column { Column {
// Header matching iOS style // Header matching iOS style
@@ -3,7 +3,6 @@ package com.bitchat.android.ui
import android.app.Application import android.app.Application
import android.util.Log import android.util.Log
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.bitchat.android.nostr.GeohashMessageHandler import com.bitchat.android.nostr.GeohashMessageHandler
import com.bitchat.android.nostr.GeohashRepository import com.bitchat.android.nostr.GeohashRepository
@@ -15,6 +14,7 @@ import com.bitchat.android.nostr.NostrSubscriptionManager
import com.bitchat.android.nostr.PoWPreferenceManager import com.bitchat.android.nostr.PoWPreferenceManager
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Date import java.util.Date
@@ -55,9 +55,9 @@ class GeohashViewModel(
private var geoTimer: Job? = null private var geoTimer: Job? = null
private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null
val geohashPeople: LiveData<List<GeoPerson>> = state.geohashPeople val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
val geohashParticipantCounts: LiveData<Map<String, Int>> = state.geohashParticipantCounts val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
fun initialize() { fun initialize() {
subscriptionManager.connect() subscriptionManager.connect()
@@ -73,12 +73,16 @@ class GeohashViewModel(
} }
try { try {
locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()) locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication())
locationChannelManager?.selectedChannel?.observeForever { channel -> viewModelScope.launch {
state.setSelectedLocationChannel(channel) locationChannelManager?.selectedChannel?.collect { channel ->
switchLocationChannel(channel) state.setSelectedLocationChannel(channel)
switchLocationChannel(channel)
}
} }
locationChannelManager?.teleported?.observeForever { teleported -> viewModelScope.launch {
state.setIsTeleported(teleported) locationChannelManager?.teleported?.collect { teleported ->
state.setIsTeleported(teleported)
}
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to initialize location channel state: ${e.message}") Log.e(TAG, "Failed to initialize location channel state: ${e.message}")
@@ -120,7 +124,7 @@ class GeohashViewModel(
} }
try { try {
val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication()) val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication())
val teleported = state.isTeleported.value ?: false val teleported = state.isTeleported.value
val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported) val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported)
val relayManager = NostrRelayManager.getInstance(getApplication()) val relayManager = NostrRelayManager.getInstance(getApplication())
relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5) relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5)
@@ -231,7 +235,7 @@ class GeohashViewModel(
try { try {
val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication()) val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication())
repo.updateParticipant(channel.channel.geohash, identity.publicKeyHex, Date()) repo.updateParticipant(channel.channel.geohash, identity.publicKeyHex, Date())
val teleported = state.isTeleported.value ?: false val teleported = state.isTeleported.value
if (teleported) repo.markTeleported(identity.publicKeyHex) if (teleported) repo.markTeleported(identity.publicKeyHex)
} catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") } } catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") }
@@ -18,7 +18,6 @@ import androidx.compose.material.icons.filled.PinDrop
import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material.icons.outlined.BookmarkBorder
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
@@ -38,7 +37,9 @@ import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.geohash.GeohashBookmarksStore import com.bitchat.android.geohash.GeohashBookmarksStore
import com.bitchat.android.ui.theme.BASE_FONT_SIZE import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R import com.bitchat.android.R
import com.bitchat.android.core.ui.component.button.CloseButton
/** /**
* Location Channels Sheet for selecting geohash-based location channels * Location Channels Sheet for selecting geohash-based location channels
@@ -57,18 +58,18 @@ fun LocationChannelsSheet(
val bookmarksStore = remember { GeohashBookmarksStore.getInstance(context) } val bookmarksStore = remember { GeohashBookmarksStore.getInstance(context) }
// Observe location manager state // Observe location manager state
val permissionState by locationManager.permissionState.observeAsState() val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
val availableChannels by locationManager.availableChannels.observeAsState(emptyList()) val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
val selectedChannel by locationManager.selectedChannel.observeAsState() val selectedChannel by locationManager.selectedChannel.collectAsStateWithLifecycle()
val locationNames by locationManager.locationNames.observeAsState(emptyMap()) val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
val locationServicesEnabled by locationManager.locationServicesEnabled.observeAsState(false) val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle()
// Observe bookmarks state // Observe bookmarks state
val bookmarks by bookmarksStore.bookmarks.observeAsState(emptyList()) val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle()
val bookmarkNames by bookmarksStore.bookmarkNames.observeAsState(emptyMap()) val bookmarkNames by bookmarksStore.bookmarkNames.collectAsStateWithLifecycle()
// Observe reactive participant counts // Observe reactive participant counts
val geohashParticipantCounts by viewModel.geohashParticipantCounts.observeAsState(emptyMap()) val geohashParticipantCounts by viewModel.geohashParticipantCounts.collectAsStateWithLifecycle()
// UI state // UI state
var customGeohash by remember { mutableStateOf("") } var customGeohash by remember { mutableStateOf("") }
@@ -551,18 +552,12 @@ fun LocationChannelsSheet(
.height(56.dp) .height(56.dp)
.background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha)) .background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha))
) { ) {
TextButton( CloseButton(
onClick = onDismiss, onClick = onDismiss,
modifier = Modifier modifier = modifier
.align(Alignment.CenterEnd) .align(Alignment.CenterEnd)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp),
) { )
Text(
text = stringResource(R.string.close_plain),
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onBackground
)
}
} }
} }
} }
@@ -7,8 +7,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -35,10 +35,10 @@ fun LocationNotesButton(
val context = LocalContext.current val context = LocalContext.current
// Get channel and permission state // Get channel and permission state
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val locationManager = remember { LocationChannelManager.getInstance(context) } val locationManager = remember { LocationChannelManager.getInstance(context) }
val permissionState by locationManager.permissionState.observeAsState() val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
val locationServicesEnabled by locationManager.locationServicesEnabled.observeAsState(false) val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle(false)
// Check both permission AND location services enabled // Check both permission AND location services enabled
val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED
@@ -46,7 +46,7 @@ fun LocationNotesButton(
// Get notes count from LocationNotesManager // Get notes count from LocationNotesManager
val notesManager = remember { LocationNotesManager.getInstance() } val notesManager = remember { LocationNotesManager.getInstance() }
val notes by notesManager.notes.observeAsState(emptyList()) val notes by notesManager.notes.collectAsStateWithLifecycle()
val notesCount = notes.size val notesCount = notes.size
// Only show in mesh mode when location is authorized (iOS pattern) // Only show in mesh mode when location is authorized (iOS pattern)
@@ -12,7 +12,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -25,6 +24,7 @@ import com.bitchat.android.R
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.geohash.GeohashChannelLevel import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.nostr.LocationNotesManager import com.bitchat.android.nostr.LocationNotesManager
@@ -57,16 +57,16 @@ fun LocationNotesSheet(
val locationManager = remember { LocationChannelManager.getInstance(context) } val locationManager = remember { LocationChannelManager.getInstance(context) }
// State // State
val notes by notesManager.notes.observeAsState(emptyList()) val notes by notesManager.notes.collectAsStateWithLifecycle()
val state by notesManager.state.observeAsState(LocationNotesManager.State.IDLE) val state by notesManager.state.collectAsStateWithLifecycle(LocationNotesManager.State.IDLE)
val errorMessage by notesManager.errorMessage.observeAsState() val errorMessage by notesManager.errorMessage.collectAsStateWithLifecycle()
val initialLoadComplete by notesManager.initialLoadComplete.observeAsState(false) val initialLoadComplete by notesManager.initialLoadComplete.collectAsStateWithLifecycle(false)
// SIMPLIFIED: Get count directly from notes list (no separate counter needed) // SIMPLIFIED: Get count directly from notes list (no separate counter needed)
val count = notes.size val count = notes.size
// Get location name (building or block) - matches iOS locationNames lookup // Get location name (building or block) - matches iOS locationNames lookup
val locationNames by locationManager.locationNames.observeAsState(emptyMap()) val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
val displayLocationName = locationNames[GeohashChannelLevel.BUILDING]?.takeIf { it.isNotEmpty() } val displayLocationName = locationNames[GeohashChannelLevel.BUILDING]?.takeIf { it.isNotEmpty() }
?: locationNames[GeohashChannelLevel.BLOCK]?.takeIf { it.isNotEmpty() } ?: locationNames[GeohashChannelLevel.BLOCK]?.takeIf { it.isNotEmpty() }
@@ -4,12 +4,12 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.geohash.GeohashChannelLevel import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.geohash.LocationChannelManager
@@ -26,15 +26,15 @@ fun LocationNotesSheetPresenter(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val locationManager = remember { LocationChannelManager.getInstance(context) } val locationManager = remember { LocationChannelManager.getInstance(context) }
val availableChannels by locationManager.availableChannels.observeAsState(emptyList()) val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.observeAsState("") val nickname by viewModel.nickname.collectAsStateWithLifecycle()
// iOS pattern: notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash // iOS pattern: notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash
val buildingGeohash = availableChannels.firstOrNull { it.level == GeohashChannelLevel.BUILDING }?.geohash val buildingGeohash = availableChannels.firstOrNull { it.level == GeohashChannelLevel.BUILDING }?.geohash
if (buildingGeohash != null) { if (buildingGeohash != null) {
// Get location name from locationManager // Get location name from locationManager
val locationNames by locationManager.locationNames.observeAsState(emptyMap()) val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
val locationName = locationNames[GeohashChannelLevel.BUILDING] val locationName = locationNames[GeohashChannelLevel.BUILDING]
?: locationNames[GeohashChannelLevel.BLOCK] ?: locationNames[GeohashChannelLevel.BLOCK]
@@ -5,6 +5,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -26,7 +27,7 @@ private enum class CharacterAnimationState {
*/ */
@Composable @Composable
fun shouldAnimateMessage(messageId: String): Boolean { fun shouldAnimateMessage(messageId: String): Boolean {
val miningMessages by PoWMiningTracker.miningMessages.collectAsState() val miningMessages by PoWMiningTracker.miningMessages.collectAsStateWithLifecycle()
return miningMessages.contains(messageId) return miningMessages.contains(messageId)
} }
@@ -46,10 +46,10 @@ class MeshDelegateHandler(
if (message.isPrivate) { if (message.isPrivate) {
// Private message // Private message
privateChatManager.handleIncomingPrivateMessage(message) privateChatManager.handleIncomingPrivateMessage(message)
// Reactive read receipts: Send immediately if user is currently viewing this chat // Reactive read receipts: if chat is focused, send immediately for this message
message.senderPeerID?.let { senderPeerID -> message.senderPeerID?.let { senderPeerID ->
sendReadReceiptIfFocused(senderPeerID) sendReadReceiptIfFocused(message)
} }
// Show notification with enhanced information - now includes senderPeerID // Show notification with enhanced information - now includes senderPeerID
@@ -64,15 +64,26 @@ class MeshDelegateHandler(
) )
} }
} else if (message.channel != null) { } else if (message.channel != null) {
// Channel message // Channel message: AppStateStore is the source of truth for list; only manage unread
if (state.getJoinedChannelsValue().contains(message.channel)) { if (state.getJoinedChannelsValue().contains(message.channel)) {
channelManager.addChannelMessage(message.channel, message, message.senderPeerID) val channel = message.channel
val viewingClassic = state.getCurrentChannelValue() == channel
val viewingGeohash = try {
if (channel.startsWith("geo:")) {
val geo = channel.removePrefix("geo:")
val selected = state.selectedLocationChannel.value
selected is com.bitchat.android.geohash.ChannelID.Location && selected.channel.geohash.equals(geo, ignoreCase = true)
} else false
} catch (_: Exception) { false }
if (!viewingClassic && !viewingGeohash) {
val currentUnread = state.getUnreadChannelMessagesValue().toMutableMap()
currentUnread[channel] = (currentUnread[channel] ?: 0) + 1
state.setUnreadChannelMessages(currentUnread)
}
} }
} else { } else {
// Public mesh message - always store to preserve message history // Public mesh message: AppStateStore is the source of truth; avoid double-adding to UI state
messageManager.addMessage(message) // Still run mention detection/notifications
// Check for mentions in mesh chat
checkAndTriggerMeshMentionNotification(message) checkAndTriggerMeshMentionNotification(message)
} }
@@ -205,6 +216,14 @@ class MeshDelegateHandler(
messageManager.updateMessageDeliveryStatus(messageID, DeliveryStatus.Read(recipientPeerID, Date())) messageManager.updateMessageDeliveryStatus(messageID, DeliveryStatus.Read(recipientPeerID, Date()))
} }
} }
override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {
// Handled by ChatViewModel for verification flow
}
override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {
// Handled by ChatViewModel for verification flow
}
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? { override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
return channelManager.decryptChannelMessage(encryptedContent, channel) return channelManager.decryptChannelMessage(encryptedContent, channel)
@@ -263,21 +282,31 @@ class MeshDelegateHandler(
* Uses same logic as notification system - send read receipt if user is currently * Uses same logic as notification system - send read receipt if user is currently
* viewing the private chat with this sender AND app is in foreground. * viewing the private chat with this sender AND app is in foreground.
*/ */
private fun sendReadReceiptIfFocused(senderPeerID: String) { private fun sendReadReceiptIfFocused(message: BitchatMessage) {
// Get notification manager's focus state (mirror the notification logic) // Get notification manager's focus state (mirror the notification logic)
val isAppInBackground = notificationManager.getAppBackgroundState() val isAppInBackground = notificationManager.getAppBackgroundState()
val currentPrivateChatPeer = notificationManager.getCurrentPrivateChatPeer() val currentPrivateChatPeer = notificationManager.getCurrentPrivateChatPeer()
// Send read receipt if user is currently focused on this specific chat // Send read receipt if user is currently focused on this specific chat
val shouldSendReadReceipt = !isAppInBackground && currentPrivateChatPeer == senderPeerID val senderPeerID = message.senderPeerID
val shouldSendReadReceipt = !isAppInBackground && senderPeerID != null && currentPrivateChatPeer == senderPeerID
if (shouldSendReadReceipt) { if (shouldSendReadReceipt) {
android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID") android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID (message=${message.id})")
privateChatManager.sendReadReceiptsForPeer(senderPeerID, getMeshService()) val nickname = state.getNicknameValue() ?: "unknown"
} else { // Send directly for this message to avoid relying on unread queues
android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)") getMeshService().sendReadReceipt(message.id, senderPeerID!!, nickname)
// Ensure unread badge is cleared for this peer immediately
try {
val current = state.getUnreadPrivateMessagesValue().toMutableSet()
if (current.remove(senderPeerID)) {
state.setUnreadPrivateMessages(current)
}
} catch (_: Exception) { }
} else {
android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)")
}
} }
}
// registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager // registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager
@@ -22,6 +22,8 @@ class MessageManager(private val state: ChatState) {
val currentMessages = state.getMessagesValue().toMutableList() val currentMessages = state.getMessagesValue().toMutableList()
currentMessages.add(message) currentMessages.add(message)
state.setMessages(currentMessages) state.setMessages(currentMessages)
// Reflect into process-wide store so snapshot replacements don't drop local outgoing messages
try { com.bitchat.android.services.AppStateStore.addPublicMessage(message) } catch (_: Exception) { }
} }
// Log a system message into the main chat (visible to user) // Log a system message into the main chat (visible to user)
@@ -52,6 +54,8 @@ class MessageManager(private val state: ChatState) {
channelMessageList.add(message) channelMessageList.add(message)
currentChannelMessages[channel] = channelMessageList currentChannelMessages[channel] = channelMessageList
state.setChannelMessages(currentChannelMessages) state.setChannelMessages(currentChannelMessages)
// Reflect into process-wide store
try { com.bitchat.android.services.AppStateStore.addChannelMessage(channel, message) } catch (_: Exception) { }
// Update unread count if not currently viewing this channel // Update unread count if not currently viewing this channel
// Consider both classic channels (state.currentChannel) and geohash location channel selection // Consider both classic channels (state.currentChannel) and geohash location channel selection
@@ -105,6 +109,8 @@ class MessageManager(private val state: ChatState) {
chatMessages.add(message) chatMessages.add(message)
currentPrivateChats[peerID] = chatMessages currentPrivateChats[peerID] = chatMessages
state.setPrivateChats(currentPrivateChats) state.setPrivateChats(currentPrivateChats)
// Reflect into process-wide store
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(peerID, message) } catch (_: Exception) { }
// Mark as unread if not currently viewing this chat // Mark as unread if not currently viewing this chat
if (state.getSelectedPrivateChatPeerValue() != peerID && message.sender != state.getNicknameValue()) { if (state.getSelectedPrivateChatPeerValue() != peerID && message.sender != state.getNicknameValue()) {
@@ -124,6 +130,8 @@ class MessageManager(private val state: ChatState) {
chatMessages.add(message) chatMessages.add(message)
currentPrivateChats[peerID] = chatMessages currentPrivateChats[peerID] = chatMessages
state.setPrivateChats(currentPrivateChats) state.setPrivateChats(currentPrivateChats)
// Reflect into process-wide store
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(peerID, message) } catch (_: Exception) { }
} }
fun clearPrivateMessages(peerID: String) { fun clearPrivateMessages(peerID: String) {
@@ -206,6 +214,21 @@ class MessageManager(private val state: ChatState) {
// MARK: - Delivery Status Updates // MARK: - Delivery Status Updates
private fun statusPriority(status: DeliveryStatus?): Int = when (status) {
null -> 0
is DeliveryStatus.Sending -> 1
is DeliveryStatus.Sent -> 2
is DeliveryStatus.PartiallyDelivered -> 3
is DeliveryStatus.Delivered -> 4
is DeliveryStatus.Read -> 5
is DeliveryStatus.Failed -> 0 // treat as lowest for UI check marks ordering
}
private fun chooseStatus(old: DeliveryStatus?, new: DeliveryStatus): DeliveryStatus? {
// Never downgrade (e.g., Read -> Delivered). Keep the higher priority.
return if (statusPriority(new) >= statusPriority(old)) new else old
}
fun updateMessageDeliveryStatus(messageID: String, status: DeliveryStatus) { fun updateMessageDeliveryStatus(messageID: String, status: DeliveryStatus) {
// Update in private chats // Update in private chats
val updatedPrivateChats = state.getPrivateChatsValue().toMutableMap() val updatedPrivateChats = state.getPrivateChatsValue().toMutableMap()
@@ -215,22 +238,32 @@ class MessageManager(private val state: ChatState) {
val updatedMessages = messages.toMutableList() val updatedMessages = messages.toMutableList()
val messageIndex = updatedMessages.indexOfFirst { it.id == messageID } val messageIndex = updatedMessages.indexOfFirst { it.id == messageID }
if (messageIndex >= 0) { if (messageIndex >= 0) {
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = status) val current = updatedMessages[messageIndex].deliveryStatus
updatedPrivateChats[peerID] = updatedMessages val finalStatus = chooseStatus(current, status)
updated = true if (finalStatus !== current) {
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = finalStatus)
updatedPrivateChats[peerID] = updatedMessages
updated = true
}
} }
} }
if (updated) { if (updated) {
state.setPrivateChats(updatedPrivateChats) state.setPrivateChats(updatedPrivateChats)
// Keep process-wide store in sync to prevent snapshot overwrites resetting status
try { com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(messageID, status) } catch (_: Exception) { }
} }
// Update in main messages // Update in main messages
val updatedMessages = state.getMessagesValue().toMutableList() val updatedMessages = state.getMessagesValue().toMutableList()
val messageIndex = updatedMessages.indexOfFirst { it.id == messageID } val messageIndex = updatedMessages.indexOfFirst { it.id == messageID }
if (messageIndex >= 0) { if (messageIndex >= 0) {
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = status) val current = updatedMessages[messageIndex].deliveryStatus
state.setMessages(updatedMessages) val finalStatus = chooseStatus(current, status)
if (finalStatus !== current) {
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = finalStatus)
state.setMessages(updatedMessages)
}
} }
// Update in channel messages // Update in channel messages
@@ -239,8 +272,12 @@ class MessageManager(private val state: ChatState) {
val channelMessagesList = messages.toMutableList() val channelMessagesList = messages.toMutableList()
val channelMessageIndex = channelMessagesList.indexOfFirst { it.id == messageID } val channelMessageIndex = channelMessagesList.indexOfFirst { it.id == messageID }
if (channelMessageIndex >= 0) { if (channelMessageIndex >= 0) {
channelMessagesList[channelMessageIndex] = channelMessagesList[channelMessageIndex].copy(deliveryStatus = status) val current = channelMessagesList[channelMessageIndex].deliveryStatus
updatedChannelMessages[channel] = channelMessagesList val finalStatus = chooseStatus(current, status)
if (finalStatus !== current) {
channelMessagesList[channelMessageIndex] = channelMessagesList[channelMessageIndex].copy(deliveryStatus = finalStatus)
updatedChannelMessages[channel] = channelMessagesList
}
} }
} }
state.setChannelMessages(updatedChannelMessages) state.setChannelMessages(updatedChannelMessages)
@@ -280,6 +280,37 @@ class NotificationManager(
Log.d(TAG, "Displayed notification for $contentTitle with ID $notificationId") Log.d(TAG, "Displayed notification for $contentTitle with ID $notificationId")
} }
fun showVerificationNotification(title: String, body: String, peerID: String? = null) {
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
if (peerID != null) {
putExtra(EXTRA_OPEN_PRIVATE_CHAT, true)
putExtra(EXTRA_PEER_ID, peerID)
putExtra(EXTRA_SENDER_NICKNAME, body)
}
}
val pendingIntent = PendingIntent.getActivity(
context,
(System.currentTimeMillis() and 0x7FFFFFFF).toInt(),
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setShowWhen(true)
.setWhen(System.currentTimeMillis())
notificationManager.notify((System.currentTimeMillis() and 0x7FFFFFFF).toInt(), builder.build())
}
private fun showNotificationForActivePeers(peersSize: Int) { private fun showNotificationForActivePeers(peersSize: Int) {
// Create intent to open the app // Create intent to open the app
val intent = Intent(context, MainActivity::class.java).apply { val intent = Intent(context, MainActivity::class.java).apply {
@@ -16,6 +16,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.bitchat.android.nostr.NostrProofOfWork import com.bitchat.android.nostr.NostrProofOfWork
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R import com.bitchat.android.R
import com.bitchat.android.nostr.PoWPreferenceManager import com.bitchat.android.nostr.PoWPreferenceManager
@@ -27,9 +28,9 @@ fun PoWStatusIndicator(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
style: PoWIndicatorStyle = PoWIndicatorStyle.COMPACT style: PoWIndicatorStyle = PoWIndicatorStyle.COMPACT
) { ) {
val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() val powEnabled by PoWPreferenceManager.powEnabled.collectAsStateWithLifecycle()
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState() val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsStateWithLifecycle()
val isMining by PoWPreferenceManager.isMining.collectAsState() val isMining by PoWPreferenceManager.isMining.collectAsStateWithLifecycle()
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
@@ -292,28 +292,30 @@ class PrivateChatManager(
} }
fun handleIncomingPrivateMessage(message: BitchatMessage, suppressUnread: Boolean) { fun handleIncomingPrivateMessage(message: BitchatMessage, suppressUnread: Boolean) {
message.senderPeerID?.let { senderPeerID -> val senderPeerID = message.senderPeerID
if (senderPeerID != null) {
// Mesh-origin private message: AppStateStore updates the list; avoid double-add here.
if (!isPeerBlocked(senderPeerID)) { if (!isPeerBlocked(senderPeerID)) {
// Add to private messages // Ensure chat exists
if (suppressUnread) { messageManager.initializePrivateChat(senderPeerID)
messageManager.addPrivateMessageNoUnread(senderPeerID, message) // Track as unread for read receipt purposes if not focused
} else { if (!suppressUnread && state.getSelectedPrivateChatPeerValue() != senderPeerID) {
messageManager.addPrivateMessage(senderPeerID, message)
}
// Track as unread for read receipt purposes
var unreadCount = 0
if (!suppressUnread) {
val unreadList = unreadReceivedMessages.getOrPut(senderPeerID) { mutableListOf() } val unreadList = unreadReceivedMessages.getOrPut(senderPeerID) { mutableListOf() }
unreadList.add(message) unreadList.add(message)
unreadCount = unreadList.size Log.d(TAG, "Queued unread from $senderPeerID (count=${unreadList.size})")
val currentUnread = state.getUnreadPrivateMessagesValue().toMutableSet()
currentUnread.add(senderPeerID)
state.setUnreadPrivateMessages(currentUnread)
} }
Log.d(
TAG,
"Added received message ${message.id} from $senderPeerID to unread list (${unreadCount} unread)"
)
} }
return
}
// Non-mesh path (e.g., Nostr): add to UI state using existing logic
val inferredPeer = state.getSelectedPrivateChatPeerValue() ?: return
if (suppressUnread) {
messageManager.addPrivateMessageNoUnread(inferredPeer, message)
} else {
messageManager.addPrivateMessage(inferredPeer, message)
} }
} }
@@ -322,27 +324,33 @@ class PrivateChatManager(
* Called when the user focuses on a private chat * Called when the user focuses on a private chat
*/ */
fun sendReadReceiptsForPeer(peerID: String, meshService: BluetoothMeshService) { fun sendReadReceiptsForPeer(peerID: String, meshService: BluetoothMeshService) {
val unreadList = unreadReceivedMessages[peerID] // Collect candidate messages: all incoming messages from this peer in the conversation
if (unreadList.isNullOrEmpty()) { val chats = try { state.getPrivateChatsValue() } catch (_: Exception) { emptyMap<String, List<BitchatMessage>>() }
Log.d(TAG, "No unread messages to send read receipts for peer $peerID") val messages = chats[peerID].orEmpty()
return
if (messages.isEmpty()) {
Log.d(TAG, "No messages found for peer $peerID to send read receipts")
} }
Log.d(TAG, "Sending read receipts for ${unreadList.size} unread messages from $peerID") val myNickname = state.getNicknameValue() ?: "unknown"
var sentCount = 0
// Send read receipt for each unread message - now using direct method call messages.forEach { msg ->
unreadList.forEach { message -> // Only for incoming messages from this peer
try { if (msg.senderPeerID == peerID) {
val myNickname = state.getNicknameValue() ?: "unknown" try {
meshService.sendReadReceipt(message.id, peerID, myNickname) meshService.sendReadReceipt(msg.id, peerID, myNickname)
Log.d(TAG, "Sent read receipt for message ${message.id} to $peerID") sentCount += 1
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed to send read receipt for message ${message.id}: ${e.message}") Log.w(TAG, "Failed to send read receipt for message ${msg.id}: ${e.message}")
}
} }
} }
// Clear the unread list since we've sent read receipts // Clear any locally tracked unread queue for this peer
unreadReceivedMessages.remove(peerID) unreadReceivedMessages.remove(peerID)
// Also clear UI unread marker for this peer now that chat is focused/read
try { messageManager.clearPrivateUnreadMessages(peerID) } catch (_: Exception) { }
Log.d(TAG, "Sent $sentCount read receipts for peer $peerID (from conversation messages)")
} }
fun cleanupDisconnectedPeer(peerID: String) { fun cleanupDisconnectedPeer(peerID: String) {
@@ -0,0 +1,443 @@
package com.bitchat.android.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.outlined.NoEncryption
import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material.icons.outlined.Warning as OutlinedWarning
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
private data class SecurityStatusInfo(
val text: String,
val icon: ImageVector,
val tint: Color
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SecurityVerificationSheet(
isPresented: Boolean,
onDismiss: () -> Unit,
viewModel: ChatViewModel,
modifier: Modifier = Modifier
) {
if (!isPresented) return
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val peerID by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle()
val isDark = isSystemInDarkTheme()
val accent = if (isDark) Color.Green else Color(0xFF008000)
val boxColor = if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.06f)
val peerHexRegex = remember { Regex("^[0-9a-fA-F]{16}$") }
ModalBottomSheet(
modifier = modifier.statusBarsPadding(),
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.background,
dragHandle = null
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
SecurityVerificationHeader(
accent = accent,
onClose = onDismiss
)
if (peerID == null) {
Text(
text = stringResource(R.string.fingerprint_no_peer),
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
} else {
val selectedPeerID = peerID!!
val displayName = viewModel.resolvePeerDisplayNameForFingerprint(selectedPeerID)
val fingerprint = viewModel.getPeerFingerprintForDisplay(selectedPeerID)
val isVerified = fingerprint != null && verifiedFingerprints.contains(fingerprint)
val sessionState = peerSessionStates[selectedPeerID]
val statusInfo = buildStatusInfo(
isVerified = isVerified,
sessionState = sessionState,
accent = accent
)
SecurityStatusCard(
displayName = displayName,
accent = accent,
boxColor = boxColor,
statusInfo = statusInfo
)
FingerprintBlock(
title = stringResource(R.string.fingerprint_their),
fingerprint = fingerprint,
boxColor = boxColor,
accent = accent
)
FingerprintBlock(
title = stringResource(R.string.fingerprint_yours),
fingerprint = viewModel.getMyFingerprint(),
boxColor = boxColor,
accent = accent
)
SecurityVerificationActions(
isVerified = isVerified,
fingerprint = fingerprint,
displayName = displayName,
accent = accent,
canStartHandshake = fingerprint == null && selectedPeerID.matches(peerHexRegex),
onStartHandshake = { viewModel.meshService.initiateNoiseHandshake(selectedPeerID) },
onVerify = { fp -> viewModel.verifyFingerprintValue(fp) },
onUnverify = { fp -> viewModel.unverifyFingerprintValue(fp) }
)
}
}
}
}
@Composable
private fun SecurityVerificationHeader(
accent: Color,
onClose: () -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.security_verification_title),
style = MaterialTheme.typography.titleSmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
),
color = accent
)
Spacer(modifier = Modifier.weight(1f))
CloseButton(onClick = onClose)
}
}
@Composable
private fun buildStatusInfo(
isVerified: Boolean,
sessionState: String?,
accent: Color
): SecurityStatusInfo {
val text = when {
isVerified -> stringResource(R.string.fingerprint_status_verified)
sessionState == "established" -> stringResource(R.string.fingerprint_status_encrypted)
sessionState == "handshaking" -> stringResource(R.string.fingerprint_status_handshaking)
sessionState == "failed" -> stringResource(R.string.fingerprint_status_failed)
else -> stringResource(R.string.fingerprint_status_uninitialized)
}
val icon = when {
isVerified -> Icons.Filled.Verified
sessionState == "handshaking" -> Icons.Outlined.Sync
sessionState == "failed" -> Icons.Outlined.OutlinedWarning
sessionState == "established" -> Icons.Filled.Lock
else -> Icons.Outlined.NoEncryption
}
val tint = when {
isVerified -> Color(0xFF32D74B)
sessionState == "failed" -> Color(0xFFFF3B30)
sessionState == "handshaking" -> Color(0xFFFF9500)
sessionState == "established" -> Color(0xFF32D74B)
else -> accent.copy(alpha = 0.6f)
}
return SecurityStatusInfo(text, icon, tint)
}
@Composable
private fun SecurityStatusCard(
displayName: String,
accent: Color,
boxColor: Color,
statusInfo: SecurityStatusInfo
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(boxColor, shape = MaterialTheme.shapes.medium)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = statusInfo.icon,
contentDescription = null,
tint = statusInfo.tint
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = displayName,
style = MaterialTheme.typography.titleMedium.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
),
color = accent
)
Text(
text = statusInfo.text,
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
),
color = accent.copy(alpha = 0.8f)
)
}
}
}
@Composable
private fun SecurityVerificationActions(
isVerified: Boolean,
fingerprint: String?,
displayName: String,
accent: Color,
canStartHandshake: Boolean,
onStartHandshake: () -> Unit,
onVerify: (String) -> Unit,
onUnverify: (String) -> Unit
) {
if (canStartHandshake) {
Button(
onClick = onStartHandshake,
colors = ButtonDefaults.buttonColors(
containerColor = accent,
contentColor = Color.White
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.fingerprint_start_handshake),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
}
}
if (isVerified) {
VerificationStatusRow(
icon = Icons.Filled.Verified,
iconTint = Color(0xFF32D74B),
text = stringResource(R.string.fingerprint_verified_label),
textTint = Color(0xFF32D74B)
)
Text(
text = stringResource(R.string.fingerprint_verified_message),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
),
color = accent.copy(alpha = 0.7f),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
Button(
onClick = { fingerprint?.let(onUnverify) },
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFFF3B30),
contentColor = Color.White
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.verify_remove),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
}
} else {
VerificationStatusRow(
icon = Icons.Filled.Warning,
iconTint = Color(0xFFFF9500),
text = stringResource(R.string.fingerprint_not_verified_label),
textTint = Color(0xFFFF9500)
)
Text(
text = stringResource(R.string.fingerprint_not_verified_message_fmt, displayName),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
),
color = accent.copy(alpha = 0.7f),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
if (fingerprint != null) {
Button(
onClick = { onVerify(fingerprint) },
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF34C759),
contentColor = Color.White
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.fingerprint_mark_verified),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
}
}
}
}
@Composable
private fun VerificationStatusRow(
icon: ImageVector,
iconTint: Color,
text: String,
textTint: Color
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = iconTint
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = text,
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
),
color = textTint
)
}
}
@Composable
private fun FingerprintBlock(
title: String,
fingerprint: String?,
boxColor: Color,
accent: Color
) {
val clipboardManager = LocalClipboardManager.current
var showMenu by remember(fingerprint) { mutableStateOf(false) }
val interactionSource = remember { MutableInteractionSource() }
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.labelSmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
),
color = accent.copy(alpha = 0.8f)
)
if (fingerprint != null) {
Column {
Text(
text = formatFingerprint(fingerprint),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp
),
color = accent,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
interactionSource = interactionSource,
indication = null,
onClick = {},
onLongClick = { showMenu = true }
)
.background(boxColor, shape = MaterialTheme.shapes.small)
.padding(16.dp),
)
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
text = { Text(text = stringResource(R.string.fingerprint_copy)) },
onClick = {
clipboardManager.setText(AnnotatedString(fingerprint))
showMenu = false
}
)
}
}
} else {
Text(
text = stringResource(R.string.fingerprint_pending),
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
color = Color(0xFFFF9500),
modifier = Modifier.padding(16.dp)
)
}
}
}
private fun formatFingerprint(fingerprint: String): String {
val upper = fingerprint.uppercase()
val sb = StringBuilder()
upper.forEachIndexed { index, c ->
if (index > 0 && index % 4 == 0) {
if (index % 16 == 0) sb.append('\n') else sb.append(' ')
}
sb.append(c)
}
return sb.toString()
}
@@ -12,7 +12,6 @@ import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.* import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -22,7 +21,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.ui.theme.BASE_FONT_SIZE import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import com.bitchat.android.util.hexEncodedString
/** /**
@@ -34,19 +35,20 @@ import com.bitchat.android.ui.theme.BASE_FONT_SIZE
fun SidebarOverlay( fun SidebarOverlay(
viewModel: ChatViewModel, viewModel: ChatViewModel,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onShowVerification: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList()) val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
val joinedChannels by viewModel.joinedChannels.observeAsState(emptyList()) val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
val currentChannel by viewModel.currentChannel.observeAsState() val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState() val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.observeAsState("") val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val unreadChannelMessages by viewModel.unreadChannelMessages.observeAsState(emptyMap()) val unreadChannelMessages by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
val peerNicknames by viewModel.peerNicknames.observeAsState(emptyMap()) val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
val peerRSSI by viewModel.peerRSSI.observeAsState(emptyMap()) val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
Box( Box(
modifier = modifier modifier = modifier
@@ -110,7 +112,7 @@ fun SidebarOverlay(
// People section - switch between mesh and geohash lists (iOS-compatible) // People section - switch between mesh and geohash lists (iOS-compatible)
item { item {
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsState()
when (selectedLocationChannel) { when (selectedLocationChannel) {
is com.bitchat.android.geohash.ChannelID.Location -> { is com.bitchat.android.geohash.ChannelID.Location -> {
@@ -131,6 +133,7 @@ fun SidebarOverlay(
colorScheme = colorScheme, colorScheme = colorScheme,
selectedPrivatePeer = selectedPrivatePeer, selectedPrivatePeer = selectedPrivatePeer,
viewModel = viewModel, viewModel = viewModel,
onShowVerification = onShowVerification,
onPrivateChatStart = { peerID -> onPrivateChatStart = { peerID ->
viewModel.startPrivateChat(peerID) viewModel.startPrivateChat(peerID)
onDismiss() onDismiss()
@@ -257,8 +260,11 @@ fun PeopleSection(
colorScheme: ColorScheme, colorScheme: ColorScheme,
selectedPrivatePeer: String?, selectedPrivatePeer: String?,
viewModel: ChatViewModel, viewModel: ChatViewModel,
onShowVerification: () -> Unit,
onPrivateChatStart: (String) -> Unit onPrivateChatStart: (String) -> Unit
) { ) {
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
Column(modifier = modifier) { Column(modifier = modifier) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -279,6 +285,17 @@ fun PeopleSection(
color = colorScheme.onSurface.copy(alpha = 0.6f), color = colorScheme.onSurface.copy(alpha = 0.6f),
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Spacer(modifier = Modifier.weight(1f))
if (selectedLocationChannel !is com.bitchat.android.geohash.ChannelID.Location) {
IconButton(onClick = onShowVerification, modifier = Modifier.size(24.dp)) {
Icon(
imageVector = Icons.Outlined.QrCode,
contentDescription = stringResource(R.string.verify_title),
tint = colorScheme.onSurface.copy(alpha = 0.8f),
modifier = Modifier.size(16.dp)
)
}
}
} }
if (connectedPeers.isEmpty()) { if (connectedPeers.isEmpty()) {
@@ -291,10 +308,11 @@ fun PeopleSection(
} }
// Observe reactive state for favorites and fingerprints // Observe reactive state for favorites and fingerprints
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet()) val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
val privateChats by viewModel.privateChats.observeAsState(emptyMap()) val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet()) val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap()) val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
// Reactive favorite computation for all peers // Reactive favorite computation for all peers
val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) { val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) {
@@ -308,10 +326,16 @@ fun PeopleSection(
// Build mapping of connected peerID -> noise key hex to unify with offline favorites // Build mapping of connected peerID -> noise key hex to unify with offline favorites
val noiseHexByPeerID: Map<String, String> = connectedPeers.associateWith { pid -> val noiseHexByPeerID: Map<String, String> = connectedPeers.associateWith { pid ->
try { try {
viewModel.meshService.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) } viewModel.meshService.getPeerInfo(pid)?.noisePublicKey?.hexEncodedString()
} catch (_: Exception) { null } } catch (_: Exception) { null }
}.filterValues { it != null }.mapValues { it.value!! } }.filterValues { it != null }.mapValues { it.value!! }
val peerVerifiedStates = remember(verifiedFingerprints, peerFingerprints, connectedPeers) {
connectedPeers.associateWith { peerID ->
viewModel.isPeerVerified(peerID, verifiedFingerprints)
}
}
Log.d("SidebarComponents", "Recomposing with ${favoritePeers.size} favorites, peer states: $peerFavoriteStates") Log.d("SidebarComponents", "Recomposing with ${favoritePeers.size} favorites, peer states: $peerFavoriteStates")
// Smart sorting: unread DMs first, then by most recent DM, then favorites, then alphabetical // Smart sorting: unread DMs first, then by most recent DM, then favorites, then alphabetical
@@ -342,7 +366,7 @@ fun PeopleSection(
// Offline favorites (exclude ones mapped to connected) // Offline favorites (exclude ones mapped to connected)
val offlineFavorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() val offlineFavorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites()
offlineFavorites.forEach { fav -> offlineFavorites.forEach { fav ->
val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) } val favPeerID = fav.peerNoisePublicKey.hexEncodedString()
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) } val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
if (!isMappedToConnected) { if (!isMappedToConnected) {
val dn = peerNicknames[favPeerID] ?: fav.peerNickname val dn = peerNicknames[favPeerID] ?: fav.peerNickname
@@ -368,6 +392,7 @@ fun PeopleSection(
sortedPeers.forEach { peerID -> sortedPeers.forEach { peerID ->
val isFavorite = peerFavoriteStates[peerID] ?: false val isFavorite = peerFavoriteStates[peerID] ?: false
val isVerified = peerVerifiedStates[peerID] ?: false
// fingerprint and favorite relationship resolution not needed here; UI will show Nostr globe for appended offline favorites below // fingerprint and favorite relationship resolution not needed here; UI will show Nostr globe for appended offline favorites below
val noiseHex = noiseHexByPeerID[peerID] val noiseHex = noiseHexByPeerID[peerID]
@@ -384,7 +409,7 @@ fun PeopleSection(
val (bName, _) = com.bitchat.android.ui.splitSuffix(displayName) val (bName, _) = com.bitchat.android.ui.splitSuffix(displayName)
val showHash = (baseNameCounts[bName] ?: 0) > 1 val showHash = (baseNameCounts[bName] ?: 0) > 1
val directMap by viewModel.peerDirect.observeAsState(emptyMap()) val directMap by viewModel.peerDirect.collectAsStateWithLifecycle()
val isDirectLive = directMap[peerID] ?: try { viewModel.meshService.getPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false } val isDirectLive = directMap[peerID] ?: try { viewModel.meshService.getPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false }
PeerItem( PeerItem(
peerID = peerID, peerID = peerID,
@@ -392,6 +417,7 @@ fun PeopleSection(
isDirect = isDirectLive, isDirect = isDirectLive,
isSelected = peerID == selectedPrivatePeer, isSelected = peerID == selectedPrivatePeer,
isFavorite = isFavorite, isFavorite = isFavorite,
isVerified = isVerified,
hasUnreadDM = combinedHasUnread, hasUnreadDM = combinedHasUnread,
colorScheme = colorScheme, colorScheme = colorScheme,
viewModel = viewModel, viewModel = viewModel,
@@ -408,7 +434,7 @@ fun PeopleSection(
// Append offline favorites we actively favorite (and not currently connected) // Append offline favorites we actively favorite (and not currently connected)
offlineFavorites.forEach { fav -> offlineFavorites.forEach { fav ->
val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) } val favPeerID = fav.peerNoisePublicKey.hexEncodedString()
// If any connected peer maps to this noise key, skip showing the offline entry // If any connected peer maps to this noise key, skip showing the offline entry
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) } val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
if (isMappedToConnected) return@forEach if (isMappedToConnected) return@forEach
@@ -419,7 +445,7 @@ fun PeopleSection(
if (npubOrHex != null) { if (npubOrHex != null) {
val hex = if (npubOrHex.startsWith("npub")) { val hex = if (npubOrHex.startsWith("npub")) {
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npubOrHex) val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npubOrHex)
if (hrp == "npub") data.joinToString("") { "%02x".format(it) } else null if (hrp == "npub") data.hexEncodedString() else null
} else { } else {
npubOrHex.lowercase() npubOrHex.lowercase()
} }
@@ -435,6 +461,7 @@ fun PeopleSection(
val dn = peerNicknames[favPeerID] ?: fav.peerNickname val dn = peerNicknames[favPeerID] ?: fav.peerNickname
val (bName, _) = com.bitchat.android.ui.splitSuffix(dn) val (bName, _) = com.bitchat.android.ui.splitSuffix(dn)
val showHash = (baseNameCounts[bName] ?: 0) > 1 val showHash = (baseNameCounts[bName] ?: 0) > 1
val isVerified = viewModel.isNoisePublicKeyVerified(fav.peerNoisePublicKey, verifiedFingerprints)
// Compute unreadCount from either noise conversation or Nostr conversation // Compute unreadCount from either noise conversation or Nostr conversation
val unreadCount = ( val unreadCount = (
@@ -449,6 +476,7 @@ fun PeopleSection(
isDirect = false, isDirect = false,
isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer, isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer,
isFavorite = true, isFavorite = true,
isVerified = isVerified,
hasUnreadDM = hasUnread, hasUnreadDM = hasUnread,
colorScheme = colorScheme, colorScheme = colorScheme,
viewModel = viewModel, viewModel = viewModel,
@@ -516,6 +544,7 @@ private fun PeerItem(
isDirect: Boolean, isDirect: Boolean,
isSelected: Boolean, isSelected: Boolean,
isFavorite: Boolean, isFavorite: Boolean,
isVerified: Boolean,
hasUnreadDM: Boolean, hasUnreadDM: Boolean,
colorScheme: ColorScheme, colorScheme: ColorScheme,
viewModel: ChatViewModel, viewModel: ChatViewModel,
@@ -616,6 +645,16 @@ private fun PeerItem(
color = baseColor.copy(alpha = 0.6f) color = baseColor.copy(alpha = 0.6f)
) )
} }
if (isVerified) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.Filled.Verified,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = Color(0xFF32D74B)
)
}
} }
// Favorite star with proper filled/outlined states // Favorite star with proper filled/outlined states
@@ -0,0 +1,368 @@
package com.bitchat.android.ui
import android.content.Context
import com.bitchat.android.R
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.noise.NoiseSession
import com.bitchat.android.nostr.GeohashAliasRegistry
import com.bitchat.android.services.VerificationService
import com.bitchat.android.util.dataFromHexString
import com.bitchat.android.util.hexEncodedString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.security.MessageDigest
import java.util.Date
import java.util.concurrent.ConcurrentHashMap
/**
* Handles QR verification logic and state, extracted from ChatViewModel.
*/
class VerificationHandler(
private val context: Context,
private val scope: CoroutineScope,
private val meshService: BluetoothMeshService,
private val identityManager: SecureIdentityStateManager,
private val state: ChatState,
private val notificationManager: NotificationManager,
private val messageManager: MessageManager
) {
private val _verifiedFingerprints = MutableStateFlow<Set<String>>(emptySet())
val verifiedFingerprints: StateFlow<Set<String>> = _verifiedFingerprints.asStateFlow()
private val pendingQRVerifications = ConcurrentHashMap<String, PendingVerification>()
private val lastVerifyNonceByPeer = ConcurrentHashMap<String, ByteArray>()
private val lastInboundVerifyChallengeAt = ConcurrentHashMap<String, Long>()
private val lastMutualToastAt = ConcurrentHashMap<String, Long>()
fun loadVerifiedFingerprints() {
_verifiedFingerprints.value = identityManager.getVerifiedFingerprints()
}
fun isPeerVerified(peerID: String): Boolean {
if (peerID.startsWith("nostr_") || peerID.startsWith("nostr:")) return false
val fingerprint = getPeerFingerprintForDisplay(peerID)
return fingerprint != null && _verifiedFingerprints.value.contains(fingerprint)
}
fun isNoisePublicKeyVerified(noisePublicKey: ByteArray): Boolean {
val fingerprint = fingerprintFromNoiseBytes(noisePublicKey)
return _verifiedFingerprints.value.contains(fingerprint)
}
fun unverifyFingerprint(peerID: String) {
val fingerprint = meshService.getPeerFingerprint(peerID) ?: return
identityManager.setVerifiedFingerprint(fingerprint, false)
val current = _verifiedFingerprints.value.toMutableSet()
current.remove(fingerprint)
_verifiedFingerprints.value = current
}
fun beginQRVerification(qr: VerificationService.VerificationQR): Boolean {
val targetNoise = qr.noiseKeyHex.lowercase()
val peerID = state.getConnectedPeersValue().firstOrNull { pid ->
val noiseKeyHex = meshService.getPeerInfo(pid)?.noisePublicKey?.hexEncodedString()?.lowercase()
noiseKeyHex == targetNoise
} ?: return false
if (pendingQRVerifications.containsKey(peerID)) return true
val nonce = ByteArray(16)
java.security.SecureRandom().nextBytes(nonce)
val pending = PendingVerification(qr.noiseKeyHex, qr.signKeyHex, nonce, System.currentTimeMillis(), false)
pendingQRVerifications[peerID] = pending
if (meshService.getSessionState(peerID) is NoiseSession.NoiseSessionState.Established) {
meshService.sendVerifyChallenge(peerID, qr.noiseKeyHex, nonce)
pendingQRVerifications[peerID] = pending.copy(sent = true)
} else {
meshService.initiateNoiseHandshake(peerID)
}
fingerprintFromNoiseHex(qr.noiseKeyHex)?.let { fp ->
identityManager.cacheFingerprintNickname(fp, qr.nickname)
identityManager.cacheNoiseFingerprint(qr.noiseKeyHex, fp)
identityManager.cachePeerNoiseKey(peerID, qr.noiseKeyHex)
}
return true
}
fun sendPendingVerificationIfNeeded(peerID: String) {
val pending = pendingQRVerifications[peerID] ?: return
if (pending.sent) return
meshService.sendVerifyChallenge(peerID, pending.noiseKeyHex, pending.nonceA)
pendingQRVerifications[peerID] = pending.copy(sent = true)
}
fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray) {
scope.launch {
val parsed = VerificationService.parseVerifyChallenge(payload) ?: return@launch
val myNoiseHex = meshService.getStaticNoisePublicKey()?.hexEncodedString()?.lowercase() ?: return@launch
if (parsed.first.lowercase() != myNoiseHex) return@launch
val lastNonce = lastVerifyNonceByPeer[peerID]
if (lastNonce != null && lastNonce.contentEquals(parsed.second)) return@launch
lastVerifyNonceByPeer[peerID] = parsed.second
val fp = meshService.getPeerFingerprint(peerID)
if (fp != null) {
lastInboundVerifyChallengeAt[fp] = System.currentTimeMillis()
if (_verifiedFingerprints.value.contains(fp)) {
val lastToast = lastMutualToastAt[fp] ?: 0L
if (System.currentTimeMillis() - lastToast > 60_000L) {
lastMutualToastAt[fp] = System.currentTimeMillis()
val name = resolvePeerDisplayName(peerID)
val body = context.getString(R.string.verify_mutual_match_body, name)
addVerificationSystemMessage(peerID, context.getString(R.string.verify_mutual_system_message, name))
sendVerificationNotification(context.getString(R.string.verify_mutual_match_title), body, peerID)
}
}
}
meshService.sendVerifyResponse(peerID, parsed.first, parsed.second)
}
}
fun didReceiveVerifyResponse(peerID: String, payload: ByteArray) {
scope.launch {
val resp = VerificationService.parseVerifyResponse(payload) ?: return@launch
val pending = pendingQRVerifications[peerID] ?: return@launch
if (!resp.noiseKeyHex.equals(pending.noiseKeyHex, ignoreCase = true)) return@launch
if (!resp.nonceA.contentEquals(pending.nonceA)) return@launch
val ok = VerificationService.verifyResponseSignature(
noiseKeyHex = resp.noiseKeyHex,
nonceA = resp.nonceA,
signature = resp.signature,
signerPublicKeyHex = pending.signKeyHex
)
if (!ok) return@launch
pendingQRVerifications.remove(peerID)
val fp = meshService.getPeerFingerprint(peerID) ?: return@launch
identityManager.setVerifiedFingerprint(fp, true)
val current = _verifiedFingerprints.value.toMutableSet()
current.add(fp)
_verifiedFingerprints.value = current
val name = resolvePeerDisplayName(peerID)
identityManager.cacheFingerprintNickname(fp, name)
val noiseKeyHex = try {
meshService.getPeerInfo(peerID)?.noisePublicKey?.hexEncodedString()
} catch (_: Exception) {
null
}
if (noiseKeyHex != null) {
identityManager.cachePeerNoiseKey(peerID, noiseKeyHex)
identityManager.cacheNoiseFingerprint(noiseKeyHex, fp)
}
addVerificationSystemMessage(peerID, context.getString(R.string.verify_success_system_message, name))
sendVerificationNotification(context.getString(R.string.verify_success_title), context.getString(R.string.verify_success_body, name), peerID)
val lastChallenge = lastInboundVerifyChallengeAt[fp] ?: 0L
if (System.currentTimeMillis() - lastChallenge < 600_000L) {
val lastToast = lastMutualToastAt[fp] ?: 0L
if (System.currentTimeMillis() - lastToast > 60_000L) {
lastMutualToastAt[fp] = System.currentTimeMillis()
val body = context.getString(R.string.verify_mutual_match_body, name)
addVerificationSystemMessage(peerID, context.getString(R.string.verify_mutual_system_message, name))
sendVerificationNotification(context.getString(R.string.verify_mutual_match_title), body, peerID)
}
}
}
}
fun getPeerFingerprintForDisplay(peerID: String): String? {
val fromMap = state.getPeerFingerprintsValue()[peerID]
if (fromMap != null) return fromMap
val hexRegex = Regex("^[0-9a-fA-F]+$")
return try {
when {
peerID.length == 64 && peerID.matches(hexRegex) -> {
identityManager.getCachedNoiseFingerprint(peerID)?.let { return it }
fingerprintFromNoiseHex(peerID)?.also { identityManager.cacheNoiseFingerprint(peerID, it) }
}
peerID.length == 16 && peerID.matches(hexRegex) -> {
val meshFp = meshService.getPeerFingerprint(peerID)
if (meshFp != null) return meshFp
identityManager.getCachedPeerFingerprint(peerID)?.let { return it }
identityManager.getCachedNoiseKey(peerID)?.let { noiseHex ->
identityManager.getCachedNoiseFingerprint(noiseHex)?.let { return it }
return fingerprintFromNoiseHex(noiseHex)?.also { identityManager.cacheNoiseFingerprint(noiseHex, it) }
}
val favorite = try {
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
} catch (_: Exception) {
null
}
favorite?.peerNoisePublicKey?.let { fingerprintFromNoiseBytes(it) }
}
peerID.startsWith("nostr_") -> {
val pubHex = GeohashAliasRegistry.get(peerID)
val noiseKey = pubHex?.let {
FavoritesPersistenceService.shared.findNoiseKey(it)
}
noiseKey?.let {
val noiseHex = it.hexEncodedString()
identityManager.getCachedNoiseFingerprint(noiseHex) ?: fingerprintFromNoiseBytes(it)
}
}
peerID.startsWith("nostr:") -> {
val prefix = peerID.removePrefix("nostr:").lowercase()
val pubHex = GeohashAliasRegistry
.snapshot()
.values
.firstOrNull { it.lowercase().startsWith(prefix) }
val noiseKey = pubHex?.let {
FavoritesPersistenceService.shared.findNoiseKey(it)
}
noiseKey?.let {
val noiseHex = it.hexEncodedString()
identityManager.getCachedNoiseFingerprint(noiseHex) ?: fingerprintFromNoiseBytes(it)
}
}
else -> {
val meshFp = meshService.getPeerFingerprint(peerID)
if (meshFp != null) return meshFp
identityManager.getCachedPeerFingerprint(peerID)?.let { return it }
identityManager.getCachedNoiseKey(peerID)?.let { noiseHex ->
identityManager.getCachedNoiseFingerprint(noiseHex)?.let { return it }
return fingerprintFromNoiseHex(noiseHex)?.also { identityManager.cacheNoiseFingerprint(noiseHex, it) }
}
val favorite = try {
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
} catch (_: Exception) {
null
}
favorite?.peerNoisePublicKey?.let { fingerprintFromNoiseBytes(it) }
}
}
} catch (_: Exception) {
null
}
}
fun resolvePeerDisplayNameForFingerprint(peerID: String): String {
val nicknameMap = state.peerNicknames.value
nicknameMap[peerID]?.let { return it }
try {
meshService.getPeerInfo(peerID)?.nickname?.let { return it }
} catch (_: Exception) { }
val fingerprint = getPeerFingerprintForDisplay(peerID)
fingerprint?.let { fp ->
identityManager.getCachedFingerprintNickname(fp)?.let { cached ->
if (cached.isNotBlank()) return cached
}
}
val hexRegex = Regex("^[0-9a-fA-F]+$")
if (peerID.length == 64 && peerID.matches(hexRegex)) {
val noiseKeyBytes = try {
peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
} catch (_: Exception) { null }
val favorite = noiseKeyBytes?.let {
FavoritesPersistenceService.shared.getFavoriteStatus(it)
}
favorite?.peerNickname?.takeIf { it.isNotBlank() }?.let { return it }
}
if (peerID.length == 16 && peerID.matches(hexRegex)) {
val favorite = try {
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
} catch (_: Exception) {
null
}
favorite?.peerNickname?.takeIf { it.isNotBlank() }?.let { return it }
}
return peerID.take(8)
}
fun getMyFingerprint(): String {
return meshService.getIdentityFingerprint()
}
fun verifyFingerprintValue(fingerprint: String) {
if (fingerprint.isBlank()) return
identityManager.setVerifiedFingerprint(fingerprint, true)
val current = _verifiedFingerprints.value.toMutableSet()
current.add(fingerprint)
_verifiedFingerprints.value = current
}
fun unverifyFingerprintValue(fingerprint: String) {
if (fingerprint.isBlank()) return
identityManager.setVerifiedFingerprint(fingerprint, false)
val current = _verifiedFingerprints.value.toMutableSet()
current.remove(fingerprint)
_verifiedFingerprints.value = current
}
private fun addVerificationSystemMessage(peerID: String, text: String) {
val msg = BitchatMessage(
sender = "system",
content = text,
timestamp = Date(),
isRelay = false,
isPrivate = true,
senderPeerID = peerID
)
messageManager.addPrivateMessageNoUnread(peerID, msg)
}
private fun resolvePeerDisplayName(peerID: String): String {
val nick = try { meshService.getPeerInfo(peerID)?.nickname } catch (_: Exception) { null }
return nick ?: peerID.take(8)
}
private fun sendVerificationNotification(title: String, body: String, peerID: String) {
notificationManager.showVerificationNotification(title, body, peerID)
}
private fun fingerprintFromNoiseHex(noiseHex: String): String? {
val bytes = noiseHex.dataFromHexString() ?: return null
return fingerprintFromNoiseBytes(bytes)
}
fun fingerprintFromNoiseBytes(bytes: ByteArray): String {
val hash = MessageDigest.getInstance("SHA-256").digest(bytes)
return hash.hexEncodedString()
}
private data class PendingVerification(
val noiseKeyHex: String,
val signKeyHex: String,
val nonceA: ByteArray,
val startedAtMs: Long,
val sent: Boolean
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as PendingVerification
if (startedAtMs != other.startedAtMs) return false
if (sent != other.sent) return false
if (noiseKeyHex != other.noiseKeyHex) return false
if (signKeyHex != other.signKeyHex) return false
if (!nonceA.contentEquals(other.nonceA)) return false
return true
}
override fun hashCode(): Int {
var result = startedAtMs.hashCode()
result = 31 * result + sent.hashCode()
result = 31 * result + noiseKeyHex.hashCode()
result = 31 * result + signKeyHex.hashCode()
result = 31 * result + nonceA.contentHashCode()
return result
}
}
}
@@ -0,0 +1,496 @@
package com.bitchat.android.ui
import android.graphics.Bitmap
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.camera.compose.CameraXViewfinder
import androidx.camera.core.CameraSelector
import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.core.SurfaceRequest
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.viewfinder.core.ImplementationMode
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.QrCode
import androidx.compose.material.icons.outlined.QrCodeScanner
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.core.graphics.createBitmap
import androidx.core.graphics.set
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.services.VerificationService
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import kotlinx.coroutines.flow.MutableStateFlow
import com.google.zxing.BarcodeFormat
import com.google.zxing.common.BitMatrix
import com.google.zxing.qrcode.QRCodeWriter
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VerificationSheet(
isPresented: Boolean,
onDismiss: () -> Unit,
viewModel: ChatViewModel,
modifier: Modifier = Modifier
) {
if (!isPresented) return
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val isDark = isSystemInDarkTheme()
val accent = if (isDark) Color.Green else Color(0xFF008000)
val boxColor = if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.06f)
var showingScanner by remember { mutableStateOf(false) }
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val npub = remember {
viewModel.getCurrentNpub()
}
val qrString = remember(nickname, npub) {
viewModel.buildMyQRString(nickname, npub)
}
ModalBottomSheet(
modifier = modifier.statusBarsPadding(),
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.background,
dragHandle = null
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
VerificationHeader(
accent = accent,
onClose = onDismiss
)
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (showingScanner) {
QRScannerPanel(
accent = accent,
boxColor = boxColor,
onScan = { code ->
val qr = VerificationService.verifyScannedQR(code)
if (qr != null && viewModel.beginQRVerification(qr)) {
showingScanner = false
}
}
)
} else {
MyQrPanel(
qrString = qrString,
accent = accent,
boxColor = boxColor,
)
}
}
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
ToggleVerificationModeButton(
showingScanner = showingScanner,
onToggle = { showingScanner = !showingScanner }
)
val peerID by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
val fingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
if (peerID != null) {
val fingerprint = viewModel.meshService.getPeerFingerprint(peerID!!)
if (fingerprint != null && fingerprints.contains(fingerprint)) {
Button(
onClick = { viewModel.unverifyFingerprint(peerID!!) },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
contentColor = MaterialTheme.colorScheme.onSurface
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.verify_remove),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
}
}
}
}
}
}
}
@Composable
private fun VerificationHeader(
accent: Color,
onClose: () -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.verify_title).uppercase(),
fontSize = 14.sp,
fontFamily = FontFamily.Monospace,
color = accent
)
CloseButton(onClick = onClose)
}
}
@Composable
private fun MyQrPanel(
qrString: String,
accent: Color,
boxColor: Color,
) {
Text(
text = stringResource(R.string.verify_my_qr_title),
fontSize = 16.sp,
fontFamily = FontFamily.Monospace,
color = accent,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
QRCodeCard(qrString = qrString, boxColor = boxColor)
}
@Composable
private fun ToggleVerificationModeButton(
showingScanner: Boolean,
onToggle: () -> Unit
) {
Button(
onClick = onToggle,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
contentColor = MaterialTheme.colorScheme.onSurface
),
modifier = Modifier.fillMaxWidth()
) {
if (showingScanner) {
Icon(
imageVector = Icons.Outlined.QrCode,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = stringResource(R.string.verify_show_my_qr),
fontFamily = FontFamily.Monospace,
fontSize = 13.sp
)
} else {
Icon(
imageVector = Icons.Outlined.QrCodeScanner,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = stringResource(R.string.verify_scan_someone),
fontFamily = FontFamily.Monospace,
fontSize = 13.sp
)
}
}
}
@Composable
private fun QRCodeCard(qrString: String, boxColor: Color) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(boxColor, RoundedCornerShape(12.dp))
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (qrString.isNotBlank()) {
QRCodeImage(data = qrString, size = 220.dp)
} else {
Box(
modifier = Modifier
.size(220.dp)
.background(Color.Transparent, RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.verify_qr_unavailable),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
SelectionContainer {
Text(
text = qrString,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
@Composable
private fun QRCodeImage(data: String, size: Dp) {
val sizePx = with(LocalDensity.current) { size.toPx().toInt() }
val bitmap = remember(data, sizePx) { generateQrBitmap(data, sizePx) }
if (bitmap != null) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.size(size)
)
}
}
private fun generateQrBitmap(data: String, sizePx: Int): Bitmap? {
if (data.isBlank() || sizePx <= 0) return null
return try {
val matrix = QRCodeWriter().encode(data, BarcodeFormat.QR_CODE, sizePx, sizePx)
bitmapFromMatrix(matrix)
} catch (_: Exception) {
null
}
}
private fun bitmapFromMatrix(matrix: BitMatrix): Bitmap {
val width = matrix.width
val height = matrix.height
val bitmap = createBitmap(width, height)
for (x in 0 until width) {
for (y in 0 until height) {
bitmap[x, y] =
if (matrix[x, y]) android.graphics.Color.BLACK else android.graphics.Color.WHITE
}
}
return bitmap
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
private fun QRScannerPanel(
onScan: (String) -> Unit,
accent: Color,
boxColor: Color,
modifier: Modifier = Modifier
) {
val permissionState = rememberPermissionState(android.Manifest.permission.CAMERA)
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var lastValid by remember { mutableStateOf<String?>(null) }
val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }
val cameraExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() }
val surfaceRequests = remember { MutableStateFlow<SurfaceRequest?>(null) }
val surfaceRequest by surfaceRequests.collectAsState(initial = null)
val mainHandler = remember { Handler(Looper.getMainLooper()) }
val onCodeState = rememberUpdatedState(onScan)
val analyzer = remember {
QRCodeAnalyzer { text ->
mainHandler.post {
if (text == lastValid) return@post
lastValid = text
onCodeState.value(text)
}
}
}
DisposableEffect(permissionState.status.isGranted) {
var cameraProvider: ProcessCameraProvider? = null
if (permissionState.status.isGranted) {
val executor = ContextCompat.getMainExecutor(context)
cameraProviderFuture.addListener(
{
val provider = cameraProviderFuture.get()
cameraProvider = provider
val preview = Preview.Builder()
.build()
.also { it.setSurfaceProvider { request -> surfaceRequests.value = request } }
val analysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also { it.setAnalyzer(cameraExecutor, analyzer) }
runCatching {
provider.unbindAll()
provider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
analysis
)
}.onFailure {
Log.w("VerificationSheet", "Failed to bind camera: ${it.message}")
}
},
executor
)
}
onDispose {
surfaceRequests.value = null
runCatching { cameraProvider?.unbindAll() }
}
}
DisposableEffect(Unit) {
onDispose { cameraExecutor.shutdown() }
}
Column(
modifier = modifier
.fillMaxWidth()
.background(boxColor, RoundedCornerShape(12.dp))
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(R.string.verify_scan_prompt_friend),
fontSize = 16.sp,
fontFamily = FontFamily.Monospace,
color = accent,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
if (permissionState.status.isGranted) {
surfaceRequest?.let { request ->
CameraXViewfinder(
surfaceRequest = request,
implementationMode = ImplementationMode.EMBEDDED,
modifier = Modifier
.size(220.dp)
.clipToBounds()
)
}
} else {
Text(
text = stringResource(R.string.verify_camera_permission),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
Button(
onClick = { permissionState.launchPermissionRequest() },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
contentColor = MaterialTheme.colorScheme.onSurface
)
) {
Text(
text = stringResource(R.string.verify_request_camera),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
}
}
}
}
private class QRCodeAnalyzer(
private val onCode: (String) -> Unit
) : ImageAnalysis.Analyzer {
private val scanner = BarcodeScanning.getClient(
BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
.build()
)
@ExperimentalGetImage
override fun analyze(imageProxy: ImageProxy) {
val mediaImage = imageProxy.image ?: run {
imageProxy.close()
return
}
val input = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
scanner.process(input)
.addOnSuccessListener { barcodes ->
val text = barcodes.firstOrNull()?.rawValue
if (!text.isNullOrBlank()) onCode(text)
}
.addOnCompleteListener { imageProxy.close() }
}
}
@@ -20,6 +20,7 @@ object DebugPreferenceManager {
// GCS keys (no migration/back-compat) // GCS keys (no migration/back-compat)
private const val KEY_GCS_MAX_BYTES = "gcs_max_filter_bytes" private const val KEY_GCS_MAX_BYTES = "gcs_max_filter_bytes"
private const val KEY_GCS_FPR = "gcs_filter_fpr_percent" private const val KEY_GCS_FPR = "gcs_filter_fpr_percent"
// Removed: persistent notification toggle is now governed by MeshServicePreferences.isBackgroundEnabled
private lateinit var prefs: SharedPreferences private lateinit var prefs: SharedPreferences
@@ -100,4 +101,6 @@ object DebugPreferenceManager {
fun setGcsFprPercent(value: Double) { fun setGcsFprPercent(value: Double) {
if (ready()) prefs.edit().putLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(value)).apply() if (ready()) prefs.edit().putLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(value)).apply()
} }
// No longer storing persistent notification in debug prefs.
} }
@@ -36,6 +36,11 @@ class DebugSettingsManager private constructor() {
private val _packetRelayEnabled = MutableStateFlow(true) private val _packetRelayEnabled = MutableStateFlow(true)
val packetRelayEnabled: StateFlow<Boolean> = _packetRelayEnabled.asStateFlow() val packetRelayEnabled: StateFlow<Boolean> = _packetRelayEnabled.asStateFlow()
// Visibility of the debug sheet; gates heavy work
private val _debugSheetVisible = MutableStateFlow(false)
val debugSheetVisible: StateFlow<Boolean> = _debugSheetVisible.asStateFlow()
fun setDebugSheetVisible(visible: Boolean) { _debugSheetVisible.value = visible }
// Connection limit overrides (debug) // Connection limit overrides (debug)
private val _maxConnectionsOverall = MutableStateFlow(8) private val _maxConnectionsOverall = MutableStateFlow(8)
val maxConnectionsOverall: StateFlow<Int> = _maxConnectionsOverall.asStateFlow() val maxConnectionsOverall: StateFlow<Int> = _maxConnectionsOverall.asStateFlow()
@@ -75,12 +80,63 @@ class DebugSettingsManager private constructor() {
// Timestamps to compute rolling window stats // Timestamps to compute rolling window stats
private val relayTimestamps = ConcurrentLinkedQueue<Long>() private val relayTimestamps = ConcurrentLinkedQueue<Long>()
// Per-device and per-peer rolling timestamps for stacked graphs
private val perDeviceRelayTimestamps = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
private val perPeerRelayTimestamps = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
// Additional buckets to split incoming vs outgoing
private val incomingTimestamps = ConcurrentLinkedQueue<Long>()
private val outgoingTimestamps = ConcurrentLinkedQueue<Long>()
private val perDeviceIncoming = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
private val perDeviceOutgoing = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
private val perPeerIncoming = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
private val perPeerOutgoing = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
// Expose current per-second rates (updated when logging/pruning occurs)
private val _perDeviceLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perDeviceLastSecond: StateFlow<Map<String, Int>> = _perDeviceLastSecond.asStateFlow()
private val _perPeerLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perPeerLastSecond: StateFlow<Map<String, Int>> = _perPeerLastSecond.asStateFlow()
// New flows used by UI for incoming/outgoing stacked plots
private val _perDeviceIncomingLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perDeviceIncomingLastSecond: StateFlow<Map<String, Int>> = _perDeviceIncomingLastSecond.asStateFlow()
private val _perDeviceOutgoingLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perDeviceOutgoingLastSecond: StateFlow<Map<String, Int>> = _perDeviceOutgoingLastSecond.asStateFlow()
private val _perPeerIncomingLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perPeerIncomingLastSecond: StateFlow<Map<String, Int>> = _perPeerIncomingLastSecond.asStateFlow()
private val _perPeerOutgoingLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perPeerOutgoingLastSecond: StateFlow<Map<String, Int>> = _perPeerOutgoingLastSecond.asStateFlow()
// Per-minute counts per key
private val _perDeviceIncomingLastMinute: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perDeviceIncomingLastMinute: StateFlow<Map<String, Int>> = _perDeviceIncomingLastMinute.asStateFlow()
private val _perDeviceOutgoingLastMinute: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perDeviceOutgoingLastMinute: StateFlow<Map<String, Int>> = _perDeviceOutgoingLastMinute.asStateFlow()
private val _perPeerIncomingLastMinute: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perPeerIncomingLastMinute: StateFlow<Map<String, Int>> = _perPeerIncomingLastMinute.asStateFlow()
private val _perPeerOutgoingLastMinute: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perPeerOutgoingLastMinute: StateFlow<Map<String, Int>> = _perPeerOutgoingLastMinute.asStateFlow()
// Totals per key (since app start)
private val deviceIncomingTotalsMap = mutableMapOf<String, Long>()
private val deviceOutgoingTotalsMap = mutableMapOf<String, Long>()
private val peerIncomingTotalsMap = mutableMapOf<String, Long>()
private val peerOutgoingTotalsMap = mutableMapOf<String, Long>()
private val _perDeviceIncomingTotalsFlow: MutableStateFlow<Map<String, Long>> = MutableStateFlow(emptyMap())
val perDeviceIncomingTotal: StateFlow<Map<String, Long>> = _perDeviceIncomingTotalsFlow.asStateFlow()
private val _perDeviceOutgoingTotalsFlow: MutableStateFlow<Map<String, Long>> = MutableStateFlow(emptyMap())
val perDeviceOutgoingTotal: StateFlow<Map<String, Long>> = _perDeviceOutgoingTotalsFlow.asStateFlow()
private val _perPeerIncomingTotalsFlow: MutableStateFlow<Map<String, Long>> = MutableStateFlow(emptyMap())
val perPeerIncomingTotal: StateFlow<Map<String, Long>> = _perPeerIncomingTotalsFlow.asStateFlow()
private val _perPeerOutgoingTotalsFlow: MutableStateFlow<Map<String, Long>> = MutableStateFlow(emptyMap())
val perPeerOutgoingTotal: StateFlow<Map<String, Long>> = _perPeerOutgoingTotalsFlow.asStateFlow()
// Internal data storage for managing debug data // Internal data storage for managing debug data
private val debugMessageQueue = ConcurrentLinkedQueue<DebugMessage>() private val debugMessageQueue = ConcurrentLinkedQueue<DebugMessage>()
private val scanResultsQueue = ConcurrentLinkedQueue<DebugScanResult>() private val scanResultsQueue = ConcurrentLinkedQueue<DebugScanResult>()
private fun updateRelayStatsFromTimestamps() { private fun updateRelayStatsFromTimestamps() {
if (!_debugSheetVisible.value) return
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
// prune older than 15m // prune older than 15m
while (true) { while (true) {
@@ -89,18 +145,84 @@ class DebugSettingsManager private constructor() {
relayTimestamps.poll() relayTimestamps.poll()
} else break } else break
} }
// prune per-device and per-peer and compute 1s/60s rates
fun pruneAndCount1s(map: MutableMap<String, ConcurrentLinkedQueue<Long>>): Map<String, Int> {
val result = mutableMapOf<String, Int>()
val iterator = map.entries.iterator()
while (iterator.hasNext()) {
val (key, q) = iterator.next()
// prune this queue
while (true) {
val ts = q.peek() ?: break
if (now - ts > 15 * 60 * 1000L) {
q.poll()
} else break
}
// count last 1s only
val count1s = q.count { now - it <= 1_000L }
if (q.isEmpty()) {
// cleanup empty queues to prevent unbounded growth
iterator.remove()
}
if (count1s > 0) result[key] = count1s
}
return result
}
fun pruneAndCount60s(map: MutableMap<String, ConcurrentLinkedQueue<Long>>): Map<String, Int> {
val result = mutableMapOf<String, Int>()
map.forEach { (key, q) ->
val count60 = q.count { now - it <= 60_000L }
if (count60 > 0) result[key] = count60
}
return result
}
val perDevice1s = pruneAndCount1s(perDeviceRelayTimestamps)
val perPeer1s = pruneAndCount1s(perPeerRelayTimestamps)
_perDeviceLastSecond.value = perDevice1s
_perPeerLastSecond.value = perPeer1s
// Also compute incoming/outgoing per-key rates
_perDeviceIncomingLastSecond.value = pruneAndCount1s(perDeviceIncoming)
_perDeviceOutgoingLastSecond.value = pruneAndCount1s(perDeviceOutgoing)
_perPeerIncomingLastSecond.value = pruneAndCount1s(perPeerIncoming)
_perPeerOutgoingLastSecond.value = pruneAndCount1s(perPeerOutgoing)
_perDeviceIncomingLastMinute.value = pruneAndCount60s(perDeviceIncoming)
_perDeviceOutgoingLastMinute.value = pruneAndCount60s(perDeviceOutgoing)
_perPeerIncomingLastMinute.value = pruneAndCount60s(perPeerIncoming)
_perPeerOutgoingLastMinute.value = pruneAndCount60s(perPeerOutgoing)
val last1s = relayTimestamps.count { now - it <= 1_000L } val last1s = relayTimestamps.count { now - it <= 1_000L }
val last10s = relayTimestamps.count { now - it <= 10_000L } val last10s = relayTimestamps.count { now - it <= 10_000L }
val last1m = relayTimestamps.count { now - it <= 60_000L } val last1m = relayTimestamps.count { now - it <= 60_000L }
val last15m = relayTimestamps.size val last15m = relayTimestamps.size
val total = _relayStats.value.totalRelaysCount + 1 // And incoming/outgoing per-second counters
val last1sIncoming = incomingTimestamps.count { now - it <= 1_000L }
val last1sOutgoing = outgoingTimestamps.count { now - it <= 1_000L }
val last10sIncoming = incomingTimestamps.count { now - it <= 10_000L }
val last10sOutgoing = outgoingTimestamps.count { now - it <= 10_000L }
val last1mIncoming = incomingTimestamps.count { now - it <= 60_000L }
val last1mOutgoing = outgoingTimestamps.count { now - it <= 60_000L }
val last15mIncoming = incomingTimestamps.size
val last15mOutgoing = outgoingTimestamps.size
val totalIncoming = _relayStats.value.totalIncomingCount
val totalOutgoing = _relayStats.value.totalOutgoingCount
_relayStats.value = PacketRelayStats( _relayStats.value = PacketRelayStats(
totalRelaysCount = total, totalRelaysCount = totalIncoming + totalOutgoing,
lastSecondRelays = last1s, lastSecondRelays = last1s,
last10SecondRelays = last10s, last10SecondRelays = last10s,
lastMinuteRelays = last1m, lastMinuteRelays = last1m,
last15MinuteRelays = last15m, last15MinuteRelays = last15m,
lastResetTime = _relayStats.value.lastResetTime lastResetTime = _relayStats.value.lastResetTime,
lastSecondIncoming = last1sIncoming,
lastSecondOutgoing = last1sOutgoing,
last10SecondIncoming = last10sIncoming,
last10SecondOutgoing = last10sOutgoing,
lastMinuteIncoming = last1mIncoming,
lastMinuteOutgoing = last1mOutgoing,
last15MinuteIncoming = last15mIncoming,
last15MinuteOutgoing = last15mOutgoing,
totalIncomingCount = totalIncoming,
totalOutgoingCount = totalOutgoing
) )
} }
@@ -336,11 +458,61 @@ class DebugSettingsManager private constructor() {
} }
} }
// Update rolling statistics only for relays // Do not update counters here; this path is for readable logs only.
if (isRelay) { }
relayTimestamps.offer(System.currentTimeMillis())
updateRelayStatsFromTimestamps() // Explicit incoming/outgoing logging to avoid double counting
fun logIncoming(packetType: String, fromPeerID: String?, fromNickname: String?, fromDeviceAddress: String?) {
if (verboseLoggingEnabled.value) {
val who = fromNickname ?: fromPeerID ?: "unknown"
addDebugMessage(DebugMessage.PacketEvent("📥 Incoming $packetType from $who (${fromPeerID ?: "?"}, ${fromDeviceAddress ?: "?"})"))
} }
val now = System.currentTimeMillis()
val visible = _debugSheetVisible.value
if (visible) incomingTimestamps.offer(now)
fromDeviceAddress?.let {
perDeviceIncoming.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now)
deviceIncomingTotalsMap[it] = (deviceIncomingTotalsMap[it] ?: 0L) + 1L
_perDeviceIncomingTotalsFlow.value = deviceIncomingTotalsMap.toMap()
}
fromPeerID?.let {
perPeerIncoming.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now)
peerIncomingTotalsMap[it] = (peerIncomingTotalsMap[it] ?: 0L) + 1L
_perPeerIncomingTotalsFlow.value = peerIncomingTotalsMap.toMap()
}
// bump totals
val cur = _relayStats.value
_relayStats.value = cur.copy(
totalIncomingCount = cur.totalIncomingCount + 1,
totalRelaysCount = cur.totalRelaysCount + 1
)
if (visible) updateRelayStatsFromTimestamps()
}
fun logOutgoing(packetType: String, toPeerID: String?, toNickname: String?, toDeviceAddress: String?, previousHopPeerID: String? = null) {
if (verboseLoggingEnabled.value) {
val who = toNickname ?: toPeerID ?: "unknown"
addDebugMessage(DebugMessage.PacketEvent("📤 Outgoing $packetType to $who (${toPeerID ?: "?"}, ${toDeviceAddress ?: "?"})"))
}
val now = System.currentTimeMillis()
val visible = _debugSheetVisible.value
if (visible) outgoingTimestamps.offer(now)
toDeviceAddress?.let {
perDeviceOutgoing.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now)
deviceOutgoingTotalsMap[it] = (deviceOutgoingTotalsMap[it] ?: 0L) + 1L
_perDeviceOutgoingTotalsFlow.value = deviceOutgoingTotalsMap.toMap()
}
(toPeerID ?: previousHopPeerID)?.let {
perPeerOutgoing.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now)
peerOutgoingTotalsMap[it] = (peerOutgoingTotalsMap[it] ?: 0L) + 1L
_perPeerOutgoingTotalsFlow.value = peerOutgoingTotalsMap.toMap()
}
val cur = _relayStats.value
_relayStats.value = cur.copy(
totalOutgoingCount = cur.totalOutgoingCount + 1,
totalRelaysCount = cur.totalRelaysCount + 1
)
if (visible) updateRelayStatsFromTimestamps()
} }
// MARK: - Clear Data // MARK: - Clear Data
@@ -407,5 +579,15 @@ data class PacketRelayStats(
val last10SecondRelays: Int = 0, val last10SecondRelays: Int = 0,
val lastMinuteRelays: Int = 0, val lastMinuteRelays: Int = 0,
val last15MinuteRelays: Int = 0, val last15MinuteRelays: Int = 0,
val lastResetTime: Date = Date() val lastResetTime: Date = Date(),
val lastSecondIncoming: Int = 0,
val lastSecondOutgoing: Int = 0,
val last10SecondIncoming: Int = 0,
val last10SecondOutgoing: Int = 0,
val lastMinuteIncoming: Int = 0,
val lastMinuteOutgoing: Int = 0,
val last15MinuteIncoming: Int = 0,
val last15MinuteOutgoing: Int = 0,
val totalIncomingCount: Long = 0,
val totalOutgoingCount: Long = 0
) )
@@ -3,9 +3,11 @@ package com.bitchat.android.ui.debug
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.BugReport
@@ -15,6 +17,7 @@ import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material.icons.filled.SettingsEthernet import androidx.compose.material.icons.filled.SettingsEthernet
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -31,6 +34,9 @@ import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import com.bitchat.android.R import com.bitchat.android.R
import androidx.compose.ui.platform.LocalContext
import com.bitchat.android.service.MeshServicePreferences
import com.bitchat.android.service.MeshForegroundService
@Composable @Composable
fun MeshTopologySection() { fun MeshTopologySection() {
@@ -113,7 +119,9 @@ fun MeshTopologySection() {
} }
} }
@OptIn(ExperimentalMaterial3Api::class) private enum class GraphMode { OVERALL, PER_DEVICE, PER_PEER }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun DebugSettingsSheet( fun DebugSettingsSheet(
isPresented: Boolean, isPresented: Boolean,
@@ -138,6 +146,8 @@ fun DebugSettingsSheet(
val seenCapacity by manager.seenPacketCapacity.collectAsState() val seenCapacity by manager.seenPacketCapacity.collectAsState()
val gcsMaxBytes by manager.gcsMaxBytes.collectAsState() val gcsMaxBytes by manager.gcsMaxBytes.collectAsState()
val gcsFpr by manager.gcsFprPercent.collectAsState() val gcsFpr by manager.gcsFprPercent.collectAsState()
val context = LocalContext.current
// Persistent notification is now controlled solely by MeshServicePreferences.isBackgroundEnabled
// Push live connected devices from mesh service whenever sheet is visible // Push live connected devices from mesh service whenever sheet is visible
LaunchedEffect(isPresented) { LaunchedEffect(isPresented) {
@@ -174,6 +184,11 @@ fun DebugSettingsSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
sheetState = sheetState sheetState = sheetState
) { ) {
// Mark debug sheet visible/invisible to gate heavy work
LaunchedEffect(Unit) { DebugSettingsManager.getInstance().setDebugSheetVisible(true) }
DisposableEffect(Unit) {
onDispose { DebugSettingsManager.getInstance().setDebugSheetVisible(false) }
}
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -292,135 +307,260 @@ fun DebugSettingsSheet(
item { item {
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
// Persistent notification is controlled by About sheet (MeshServicePreferences.isBackgroundEnabled)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, tint = Color(0xFFFF9500)) Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, tint = Color(0xFFFF9500))
Text(stringResource(R.string.debug_packet_relay), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) Text(stringResource(R.string.debug_packet_relay), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
Switch(checked = packetRelayEnabled, onCheckedChange = { manager.setPacketRelayEnabled(it) }) Switch(checked = packetRelayEnabled, onCheckedChange = { manager.setPacketRelayEnabled(it) })
} }
Text(stringResource(R.string.debug_since_start_fmt, relayStats.totalRelaysCount), fontFamily = FontFamily.Monospace, fontSize = 11.sp) // Removed aggregate labels; we will show per-direction compact labels below titles
Text(stringResource(R.string.debug_relays_window_fmt, relayStats.last10SecondRelays, relayStats.lastMinuteRelays, relayStats.last15MinuteRelays), fontFamily = FontFamily.Monospace, fontSize = 11.sp) // Toggle: overall vs per-connection vs per-peer
// Realtime graph: per-second relays, full-width canvas, bottom-up bars, fast decay var graphMode by rememberSaveable { mutableStateOf(GraphMode.OVERALL) }
var series by remember { mutableStateOf(List(60) { 0f }) } val perDeviceIncoming by manager.perDeviceIncomingLastSecond.collectAsState()
LaunchedEffect(isPresented) { val perPeerIncoming by manager.perPeerIncomingLastSecond.collectAsState()
val perDeviceOutgoing by manager.perDeviceOutgoingLastSecond.collectAsState()
val perPeerOutgoing by manager.perPeerOutgoingLastSecond.collectAsState()
val perDeviceIncoming1m by manager.perDeviceIncomingLastMinute.collectAsState()
val perDeviceOutgoing1m by manager.perDeviceOutgoingLastMinute.collectAsState()
val perPeerIncoming1m by manager.perPeerIncomingLastMinute.collectAsState()
val perPeerOutgoing1m by manager.perPeerOutgoingLastMinute.collectAsState()
val perDeviceIncomingTotal by manager.perDeviceIncomingTotal.collectAsState()
val perDeviceOutgoingTotal by manager.perDeviceOutgoingTotal.collectAsState()
val perPeerIncomingTotal by manager.perPeerIncomingTotal.collectAsState()
val perPeerOutgoingTotal by manager.perPeerOutgoingTotal.collectAsState()
val nicknameMap = remember { mutableStateOf<Map<String, String?>>(emptyMap()) }
val devicePeerMap = remember { mutableStateOf<Map<String, String>>(emptyMap()) }
LaunchedEffect(Unit) {
try { nicknameMap.value = meshService.getPeerNicknames() } catch (_: Exception) { }
// Try to fetch device->peer map periodically for legend resolution
while (isPresented) { while (isPresented) {
val s = relayStats.lastSecondRelays.toFloat() try { devicePeerMap.value = meshService.getDeviceAddressToPeerMapping() } catch (_: Exception) { }
val last = series.lastOrNull() ?: 0f kotlinx.coroutines.delay(1000)
// Faster decay and smoothing
val v = last * 0.5f + s * 0.5f
series = (series + v).takeLast(60)
kotlinx.coroutines.delay(400)
} }
} }
val maxValRaw = series.maxOrNull() ?: 0f
val maxVal = if (maxValRaw > 0f) maxValRaw else 0f Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
val leftGutter = 40.dp // Mode selector
Box(Modifier.fillMaxWidth().height(56.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// Graph canvas FilterChip(
androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) { selected = graphMode == GraphMode.OVERALL,
val axisPx = leftGutter.toPx() // reserved left gutter for labels onClick = { graphMode = GraphMode.OVERALL },
val barCount = series.size label = { Text("Overall") }
val availW = (size.width - axisPx).coerceAtLeast(1f)
val w = availW / barCount
val h = size.height
// Baseline at bottom (y = 0)
drawLine(
color = Color(0x33888888),
start = androidx.compose.ui.geometry.Offset(axisPx, h - 1f),
end = androidx.compose.ui.geometry.Offset(size.width, h - 1f),
strokeWidth = 1f
) )
// Bars from bottom-up; skip zeros entirely FilterChip(
series.forEachIndexed { i, value -> selected = graphMode == GraphMode.PER_DEVICE,
if (value > 0f && maxVal > 0f) { onClick = { graphMode = GraphMode.PER_DEVICE },
val ratio = (value / maxVal).coerceIn(0f, 1f) label = { Text("Per Device") },
val barHeight = (h * ratio).coerceAtLeast(0f) leadingIcon = { Icon(Icons.Filled.Devices, contentDescription = null) }
if (barHeight > 0.5f) { )
drawRect( FilterChip(
color = Color(0xFF00C851), selected = graphMode == GraphMode.PER_PEER,
topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = h - barHeight), onClick = { graphMode = GraphMode.PER_PEER },
size = androidx.compose.ui.geometry.Size(w, barHeight) label = { Text("Per Peer") },
) leadingIcon = { Icon(Icons.Filled.SettingsEthernet, contentDescription = null) }
} )
} }
// Time series state
var overallSeriesIncoming by rememberSaveable { mutableStateOf(List(60) { 0f }) }
var overallSeriesOutgoing by rememberSaveable { mutableStateOf(List(60) { 0f }) }
var stackedKeysIncoming by rememberSaveable { mutableStateOf(listOf<String>()) }
var stackedKeysOutgoing by rememberSaveable { mutableStateOf(listOf<String>()) }
var stackedSeriesIncoming by rememberSaveable { mutableStateOf<Map<String, List<Float>>>(emptyMap()) }
var stackedSeriesOutgoing by rememberSaveable { mutableStateOf<Map<String, List<Float>>>(emptyMap()) }
var highlightedKey by rememberSaveable { mutableStateOf<String?>(null) }
// Color palette for stacked legend
val palette = remember {
listOf(
Color(0xFF00C851), Color(0xFF007AFF), Color(0xFFFF9500), Color(0xFFFF3B30),
Color(0xFF5AC8FA), Color(0xFFAF52DE), Color(0xFFFF2D55), Color(0xFF34C759),
Color(0xFFFFCC00), Color(0xFF5856D6)
)
}
val colorForKey = remember { mutableStateMapOf<String, Color>() }
fun stableColorFor(key: String): Color {
// Deterministic fallback color based on key hash using HSV palette
val h = (key.hashCode().toUInt().toInt() and 0x7FFFFFFF) % 360
return Color.hsv(h.toFloat(), 0.65f, 0.95f)
}
// Ensure colors are assigned for current keys before drawing
fun ensureColors(keys: List<String>) {
keys.forEachIndexed { idx, k ->
colorForKey.putIfAbsent(k, palette.getOrNull(idx) ?: stableColorFor(k))
} }
} }
// Y-axis ticks (min/max) in the left margin
Text("0", fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.BottomStart).padding(start = 4.dp, bottom = 2.dp))
Text("${maxVal.toInt()}", fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.TopStart).padding(start = 4.dp, top = 2.dp))
// Y-axis unit label (vertical)
Text("p/s", fontFamily = FontFamily.Monospace, fontSize = 9.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp).rotate(-90f))
}
}
}
}
@Composable LaunchedEffect(isPresented, graphMode) {
fun MeshTopologySection() { while (isPresented) {
val colorScheme = MaterialTheme.colorScheme when (graphMode) {
val graphService = remember { MeshGraphService.getInstance() } GraphMode.OVERALL -> {
val snapshot by graphService.graphState.collectAsState() val sIn = relayStats.lastSecondIncoming.toFloat()
val sOut = relayStats.lastSecondOutgoing.toFloat()
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { overallSeriesIncoming = (overallSeriesIncoming + sIn).takeLast(60)
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { overallSeriesOutgoing = (overallSeriesOutgoing + sOut).takeLast(60)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { }
Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF8E8E93)) GraphMode.PER_DEVICE -> {
Text("mesh topology", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) val snapshotIn = perDeviceIncoming
} val snapshotOut = perDeviceOutgoing
val nodes = snapshot.nodes fun advance(base: Map<String, List<Float>>, snap: Map<String, Int>): Map<String, List<Float>> {
val edges = snapshot.edges val next = mutableMapOf<String, List<Float>>()
val empty = nodes.isEmpty() val union = (base.keys + snap.keys).toSet()
if (empty) { union.forEach { k ->
Text("no gossip yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) val prev = base[k] ?: List(60) { 0f }
} else { val s = (snap[k] ?: 0).toFloat()
androidx.compose.foundation.Canvas(Modifier.fillMaxWidth().height(220.dp).background(colorScheme.surface.copy(alpha = 0.4f))) { next[k] = (prev + s).takeLast(60)
val w = size.width }
val h = size.height return next
val cx = w / 2f }
val cy = h / 2f // Advance and prune fully-stale series (all-zero in visible window)
val radius = (minOf(w, h) * 0.36f) stackedSeriesIncoming = advance(stackedSeriesIncoming, snapshotIn).filterValues { series -> series.any { it != 0f } }
val n = nodes.size stackedSeriesOutgoing = advance(stackedSeriesOutgoing, snapshotOut).filterValues { series -> series.any { it != 0f } }
if (n == 1) { stackedKeysIncoming = stackedSeriesIncoming.keys.sorted()
// Single node centered stackedKeysOutgoing = stackedSeriesOutgoing.keys.sorted()
drawCircle(color = Color(0xFF00C851), radius = 12f, center = androidx.compose.ui.geometry.Offset(cx, cy)) }
} else { GraphMode.PER_PEER -> {
// Circular layout val snapshotIn = perPeerIncoming
val positions = nodes.mapIndexed { i, node -> val snapshotOut = perPeerOutgoing
val angle = (2 * Math.PI * i.toDouble()) / n fun advance(base: Map<String, List<Float>>, snap: Map<String, Int>): Map<String, List<Float>> {
val x = cx + (radius * Math.cos(angle)).toFloat() val next = mutableMapOf<String, List<Float>>()
val y = cy + (radius * Math.sin(angle)).toFloat() val union = (base.keys + snap.keys).toSet()
node.peerID to androidx.compose.ui.geometry.Offset(x, y) union.forEach { k ->
}.toMap() val prev = base[k] ?: List(60) { 0f }
val s = (snap[k] ?: 0).toFloat()
// Draw edges next[k] = (prev + s).takeLast(60)
edges.forEach { e -> }
val p1 = positions[e.a] return next
val p2 = positions[e.b] }
if (p1 != null && p2 != null) { stackedSeriesIncoming = advance(stackedSeriesIncoming, snapshotIn).filterValues { series -> series.any { it != 0f } }
drawLine(color = Color(0xFF4A90E2), start = p1, end = p2, strokeWidth = 2f) stackedSeriesOutgoing = advance(stackedSeriesOutgoing, snapshotOut).filterValues { series -> series.any { it != 0f } }
stackedKeysIncoming = stackedSeriesIncoming.keys.sorted()
stackedKeysOutgoing = stackedSeriesOutgoing.keys.sorted()
}
}
kotlinx.coroutines.delay(1000)
}
} }
}
// Helper functions moved to top-level composable below to avoid scope issues
// Draw nodes // Render two blocks: Incoming and Outgoing
nodes.forEach { node -> Text("Incoming", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
val pos = positions[node.peerID] ?: androidx.compose.ui.geometry.Offset(cx, cy) Text(
drawCircle(color = Color(0xFF00C851), radius = 10f, center = pos) "${relayStats.lastSecondIncoming}/s • ${relayStats.lastMinuteIncoming}/m • ${relayStats.last15MinuteIncoming}/15m • total ${relayStats.totalIncomingCount}",
fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)
)
DrawGraphBlock(
title = "Incoming",
stackedKeys = stackedKeysIncoming,
stackedSeries = stackedSeriesIncoming,
overallSeries = if (graphMode == GraphMode.OVERALL) overallSeriesIncoming else null,
graphMode = graphMode,
highlightedKey = highlightedKey,
onToggleHighlight = { key -> highlightedKey = if (highlightedKey == key) null else key },
ensureColors = { keys -> ensureColors(keys) },
colorForKey = { k -> colorForKey[k] ?: stableColorFor(k) },
legendTitleFor = { key ->
when (graphMode) {
GraphMode.PER_PEER -> {
val nick = nicknameMap.value[key]
val prefix = key.take(6)
if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix
}
GraphMode.PER_DEVICE -> {
val device = key
val pid = connectedDevices.firstOrNull { it.deviceAddress == device }?.peerID
?: devicePeerMap.value[device]
if (pid != null) {
val nick = nicknameMap.value[pid]
val prefix = pid.take(6)
"$device (${if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix})"
} else device
}
else -> key
}
},
legendMetricsFor = { key ->
when (graphMode) {
GraphMode.PER_PEER -> {
val s = perPeerIncoming[key] ?: 0
val m = perPeerIncoming1m[key] ?: 0
val t = (perPeerIncomingTotal[key] ?: 0L)
"${s}/s • ${m}/m • total ${t}"
}
GraphMode.PER_DEVICE -> {
val s = perDeviceIncoming[key] ?: 0
val m = perDeviceIncoming1m[key] ?: 0
val t = (perDeviceIncomingTotal[key] ?: 0L)
"${s}/s • ${m}/m • total ${t}"
}
else -> ""
}
}
)
if (graphMode != GraphMode.OVERALL && stackedKeysIncoming.isNotEmpty()) { /* legend printed inside DrawGraphBlock */ }
Spacer(Modifier.height(8.dp))
Text("Outgoing", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(
"${relayStats.lastSecondOutgoing}/s • ${relayStats.lastMinuteOutgoing}/m • ${relayStats.last15MinuteOutgoing}/15m • total ${relayStats.totalOutgoingCount}",
fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)
)
DrawGraphBlock(
title = "Outgoing",
stackedKeys = stackedKeysOutgoing,
stackedSeries = stackedSeriesOutgoing,
overallSeries = if (graphMode == GraphMode.OVERALL) overallSeriesOutgoing else null,
graphMode = graphMode,
highlightedKey = highlightedKey,
onToggleHighlight = { key -> highlightedKey = if (highlightedKey == key) null else key },
ensureColors = { keys -> ensureColors(keys) },
colorForKey = { k -> colorForKey[k] ?: stableColorFor(k) },
legendTitleFor = { key ->
when (graphMode) {
GraphMode.PER_PEER -> {
val nick = nicknameMap.value[key]
val prefix = key.take(6)
if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix
}
GraphMode.PER_DEVICE -> {
val device = key
val pid = connectedDevices.firstOrNull { it.deviceAddress == device }?.peerID
?: devicePeerMap.value[device]
if (pid != null) {
val nick = nicknameMap.value[pid]
val prefix = pid.take(6)
"$device (${if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix})"
} else device
}
else -> key
}
},
legendMetricsFor = { key ->
when (graphMode) {
GraphMode.PER_PEER -> {
val s = perPeerOutgoing[key] ?: 0
val m = perPeerOutgoing1m[key] ?: 0
val t = (perPeerOutgoingTotal[key] ?: 0L)
"${s}/s • ${m}/m • total ${t}"
}
GraphMode.PER_DEVICE -> {
val s = perDeviceOutgoing[key] ?: 0
val m = perDeviceOutgoing1m[key] ?: 0
val t = (perDeviceOutgoingTotal[key] ?: 0L)
"${s}/s • ${m}/m • total ${t}"
}
else -> ""
}
}
)
if (graphMode != GraphMode.OVERALL && stackedKeysOutgoing.isNotEmpty()) { /* legend printed inside DrawGraphBlock */ }
} }
} }
} }
// Label list for clarity under the canvas
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 140.dp)) {
items(nodes) { node ->
val label = "${node.peerID.take(8)}${node.nickname ?: "unknown"}"
Text(label, fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.85f))
}
}
} }
}
}
}
// Connected devices // Connected devices
item { item {
@@ -443,9 +583,6 @@ fun MeshTopologySection() {
} }
} }
// Connected devices // Connected devices
item { item {
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
@@ -532,3 +669,146 @@ fun MeshTopologySection() {
} }
} }
} }
@Composable
private fun DrawGraphBlock(
title: String,
stackedKeys: List<String>,
stackedSeries: Map<String, List<Float>>,
overallSeries: List<Float>?,
graphMode: GraphMode,
highlightedKey: String?,
onToggleHighlight: (String) -> Unit,
ensureColors: (List<String>) -> Unit,
colorForKey: (String) -> Color,
legendTitleFor: (String) -> String,
legendMetricsFor: (String) -> String
) {
val colorScheme = MaterialTheme.colorScheme
val leftGutter = 40.dp
Box(Modifier.fillMaxWidth().height(56.dp)) {
androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) {
val axisPx = leftGutter.toPx()
val barCount = 60
val availW = (size.width - axisPx).coerceAtLeast(1f)
val w = availW / barCount
val h = size.height
drawLine(
color = Color(0x33888888),
start = androidx.compose.ui.geometry.Offset(axisPx, h - 1f),
end = androidx.compose.ui.geometry.Offset(size.width, h - 1f),
strokeWidth = 1f
)
when (graphMode) {
GraphMode.OVERALL -> {
val maxValRaw = (overallSeries?.maxOrNull() ?: 0f)
val maxVal = if (maxValRaw > 0f) maxValRaw else 0f
(overallSeries ?: emptyList()).forEachIndexed { i, value ->
if (value > 0f && maxVal > 0f) {
val ratio = (value / maxVal).coerceIn(0f, 1f)
val barHeight = (h * ratio).coerceAtLeast(0f)
if (barHeight > 0.5f) {
drawRect(
color = Color(0xFF00C851),
topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = h - barHeight),
size = androidx.compose.ui.geometry.Size(w, barHeight)
)
}
}
}
}
else -> {
val indices = 0 until 60
val totals = indices.map { idx ->
stackedSeries.values.sumOf { it.getOrNull(idx)?.toDouble() ?: 0.0 }.toFloat()
}
val maxTotal = (totals.maxOrNull() ?: 0f)
val drawKeysBars = if (stackedKeys.isNotEmpty()) stackedKeys else stackedSeries.keys.sorted()
indices.forEach { i ->
var yTop = h
if (maxTotal > 0f) {
ensureColors(drawKeysBars)
drawKeysBars.forEach { k ->
val v = stackedSeries[k]?.getOrNull(i) ?: 0f
if (v > 0f) {
val ratio = (v / maxTotal).coerceIn(0f, 1f)
val segH = (h * ratio)
if (segH > 0.5f) {
val top = (yTop - segH)
val baseColor = colorForKey(k)
val c = if (highlightedKey == null || highlightedKey == k) baseColor else baseColor.copy(alpha = 0.35f)
drawRect(
color = c,
topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = top),
size = androidx.compose.ui.geometry.Size(w, segH)
)
yTop = top
}
}
}
}
}
}
}
}
Row(Modifier.fillMaxSize()) {
Box(Modifier.width(leftGutter).fillMaxHeight()) {
Text(
"p/s",
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp).rotate(-90f)
)
val topLabel = when (graphMode) {
GraphMode.OVERALL -> (overallSeries?.maxOrNull() ?: 0f).toInt().toString()
else -> {
val totals = (0 until 60).map { idx -> stackedSeries.values.sumOf { it.getOrNull(idx)?.toDouble() ?: 0.0 }.toFloat() }
(totals.maxOrNull() ?: 0f).toInt().toString()
}
}
Text(
topLabel,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.align(Alignment.TopEnd).padding(end = 4.dp)
)
Text(
"0",
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 4.dp)
)
}
Spacer(Modifier.weight(1f))
}
}
val drawKeys = if (stackedKeys.isNotEmpty()) stackedKeys else stackedSeries.keys.sorted()
if (graphMode != GraphMode.OVERALL && drawKeys.isNotEmpty()) {
Column(Modifier.fillMaxWidth()) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
drawKeys.forEach { key ->
val baseColor = colorForKey(key)
val dimmed = highlightedKey != null && highlightedKey != key
val swatchColor = if (dimmed) baseColor.copy(alpha = 0.35f) else baseColor
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.clickable { onToggleHighlight(key) }
) {
Box(Modifier.size(10.dp).background(swatchColor, RoundedCornerShape(2.dp)))
Column {
Text(legendTitleFor(key), fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.6f else 0.95f))
Text(legendMetricsFor(key), fontFamily = FontFamily.Monospace, fontSize = 9.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.45f else 0.75f))
}
}
}
}
}
}
}
@@ -58,6 +58,10 @@ object AppConstants {
const val HIGH_NONCE_WARNING_THRESHOLD: Long = 1_000_000_000L const val HIGH_NONCE_WARNING_THRESHOLD: Long = 1_000_000_000L
} }
object Verification {
const val QR_MAX_AGE_SECONDS: Long = 300L // 5 minutes
}
object Protocol { object Protocol {
const val COMPRESSION_THRESHOLD_BYTES: Int = 100 const val COMPRESSION_THRESHOLD_BYTES: Int = 100
} }
@@ -76,12 +80,12 @@ object AppConstants {
const val SCAN_ON_DURATION_NORMAL_MS: Long = 8_000L const val SCAN_ON_DURATION_NORMAL_MS: Long = 8_000L
const val SCAN_OFF_DURATION_NORMAL_MS: Long = 2_000L const val SCAN_OFF_DURATION_NORMAL_MS: Long = 2_000L
const val SCAN_ON_DURATION_POWER_SAVE_MS: Long = 2_000L const val SCAN_ON_DURATION_POWER_SAVE_MS: Long = 2_000L
const val SCAN_OFF_DURATION_POWER_SAVE_MS: Long = 8_000L const val SCAN_OFF_DURATION_POWER_SAVE_MS: Long = 28_000L
const val SCAN_ON_DURATION_ULTRA_LOW_MS: Long = 1_000L const val SCAN_ON_DURATION_ULTRA_LOW_MS: Long = 1_000L
const val SCAN_OFF_DURATION_ULTRA_LOW_MS: Long = 10_000L const val SCAN_OFF_DURATION_ULTRA_LOW_MS: Long = 29_000L
const val MAX_CONNECTIONS_NORMAL: Int = 8 const val MAX_CONNECTIONS_NORMAL: Int = 8
const val MAX_CONNECTIONS_POWER_SAVE: Int = 4 const val MAX_CONNECTIONS_POWER_SAVE: Int = 8
const val MAX_CONNECTIONS_ULTRA_LOW: Int = 2 const val MAX_CONNECTIONS_ULTRA_LOW: Int = 4
} }
object Nostr { object Nostr {
@@ -115,6 +119,8 @@ object AppConstants {
const val MESSAGE_DEDUP_TIMEOUT_MS: Long = 30_000L const val MESSAGE_DEDUP_TIMEOUT_MS: Long = 30_000L
const val SYSTEM_EVENT_DEDUP_TIMEOUT_MS: Long = 5_000L const val SYSTEM_EVENT_DEDUP_TIMEOUT_MS: Long = 5_000L
const val ACTIVE_PEERS_NOTIFICATION_INTERVAL_MS: Long = 300_000L const val ACTIVE_PEERS_NOTIFICATION_INTERVAL_MS: Long = 300_000L
const val ACTION_FORCE_FINISH: String = "com.bitchat.android.ACTION_FORCE_FINISH"
const val PERMISSION_FORCE_FINISH: String = "com.bitchat.android.permission.FORCE_FINISH"
} }
object Media { object Media {
@@ -0,0 +1,9 @@
package info.guardianproject.arti
/**
* Exception thrown by Arti operations.
*
* This exception is thrown when the native Arti library encounters
* an error during initialization, startup, or other operations.
*/
class ArtiException(message: String, cause: Throwable? = null) : Exception(message, cause)
@@ -0,0 +1,17 @@
package info.guardianproject.arti
/**
* Listener interface for Arti log messages.
*
* This interface is called from the native Arti implementation whenever
* a log line is produced. It allows the application to monitor Tor's
* bootstrap progress and connection status.
*/
fun interface ArtiLogListener {
/**
* Called when Arti produces a log line.
*
* @param logLine The log message from Arti, or null if no message
*/
fun onLogLine(logLine: String?)
}
@@ -0,0 +1,183 @@
package info.guardianproject.arti
import android.app.Application
import android.util.Log
import org.torproject.arti.ArtiNative
import java.io.File
/**
* Arti Tor proxy implementation compatible with Guardian Project API.
*
* This class provides a wrapper around the custom-built Arti native library,
* maintaining API compatibility with Guardian Project's arti-mobile-ex library.
*
* Usage:
* ```
* val proxy = ArtiProxy.Builder(application)
* .setSocksPort(9050)
* .setDnsPort(9051)
* .setLogListener { logLine -> Log.i("Arti", logLine ?: "") }
* .build()
*
* proxy.start()
* // ... use proxy ...
* proxy.stop()
* ```
*/
class ArtiProxy private constructor(
private val application: Application,
private val socksPort: Int,
private val dnsPort: Int,
private val logListener: ArtiLogListener?
) {
companion object {
private const val TAG = "ArtiProxy"
}
@Volatile
private var isRunning = false
/**
* Start the Arti Tor proxy.
*
* This method:
* 1. Registers log callback
* 2. Initializes Arti runtime with data directory
* 3. Starts SOCKS proxy on configured port
*
* @throws ArtiException if initialization or startup fails
*/
fun start() {
if (isRunning) {
Log.w(TAG, "Arti already running")
return
}
try {
logListener?.let { listener ->
Log.d(TAG, "Registering log callback")
ArtiNative.setLogCallback(listener)
}
val dataDir = getDataDirectory()
Log.i(TAG, "Initializing Arti with data directory: $dataDir")
val initResult = ArtiNative.initialize(dataDir.absolutePath)
if (initResult != 0) {
throw ArtiException("Failed to initialize Arti: error code $initResult")
}
Log.i(TAG, "Starting SOCKS proxy on port $socksPort (DNS port: $dnsPort)")
val startResult = ArtiNative.startSocksProxy(socksPort)
when (startResult) {
0 -> {
isRunning = true
Log.i(TAG, "Arti started successfully")
}
-1 -> throw ArtiException("Arti client not initialized")
-2 -> throw ArtiException("Tokio runtime not initialized")
-3 -> throw ArtiException("Failed to bind SOCKS proxy to port $socksPort (port already in use)")
else -> throw ArtiException("Failed to start SOCKS proxy: error code $startResult")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to start Arti", e)
if (e is ArtiException) {
throw e
} else {
throw ArtiException("Failed to start Arti: ${e.message}", e)
}
}
}
/**
* Stop the Arti Tor proxy.
*
* This method gracefully shuts down the Tor client and SOCKS proxy.
*/
fun stop() {
if (!isRunning) {
Log.w(TAG, "Arti not running")
return
}
try {
Log.i(TAG, "Stopping Arti...")
val stopResult = ArtiNative.stop()
if (stopResult != 0) {
Log.w(TAG, "Stop returned error code: $stopResult")
}
isRunning = false
Log.i(TAG, "Arti stopped successfully")
} catch (e: Exception) {
Log.e(TAG, "Error stopping Arti", e)
}
}
/**
* Get or create Arti data directory.
*
* Directory structure:
* - {app_data}/arti/cache - Tor directory cache
* - {app_data}/arti/state - Tor persistent state
*/
private fun getDataDirectory(): File {
val artiDir = File(application.filesDir, "arti")
if (!artiDir.exists()) {
artiDir.mkdirs()
}
File(artiDir, "cache").apply { if (!exists()) mkdirs() }
File(artiDir, "state").apply { if (!exists()) mkdirs() }
return artiDir
}
/**
* Builder for ArtiProxy instances.
*
* Provides fluent API for configuring proxy settings before creation.
*/
class Builder(private val application: Application) {
private var socksPort: Int = 9050
private var dnsPort: Int = 9051
private var logListener: ArtiLogListener? = null
/**
* Set SOCKS proxy port.
* @param port Port number (default: 9050)
* @return this Builder for chaining
*/
fun setSocksPort(port: Int) = apply {
this.socksPort = port
}
/**
* Set DNS port.
* @param port Port number (default: 9051)
* @return this Builder for chaining
*/
fun setDnsPort(port: Int) = apply {
this.dnsPort = port
}
/**
* Set log listener for Arti log messages.
* @param listener Callback for log lines
* @return this Builder for chaining
*/
fun setLogListener(listener: ArtiLogListener) = apply {
this.logListener = listener
}
/**
* Build and return the configured ArtiProxy instance.
* @return Configured ArtiProxy (not yet started)
*/
fun build(): ArtiProxy {
return ArtiProxy(application, socksPort, dnsPort, logListener)
}
}
}
@@ -0,0 +1,54 @@
package org.torproject.arti
import info.guardianproject.arti.ArtiLogListener
/**
* JNI wrapper for custom-built Arti (Tor implementation in Rust)
*
* This class provides native bindings to libarti_android.so compiled from
* the latest Arti source with rustls (no OpenSSL dependency).
*
* Features:
* - Latest Arti v1.7.0 code
* - 16KB page size support (Google Play Nov 2025 ready)
* - Onion service client support
* - Pure Rust TLS (rustls)
*/
object ArtiNative {
init {
System.loadLibrary("arti_android")
}
/**
* Get Arti version string
* @return Version string from native library
*/
external fun getVersion(): String
/**
* Set log callback for Arti logs
* @param callback Callback object with onLogLine(String?) method
*/
external fun setLogCallback(callback: ArtiLogListener)
/**
* Initialize Arti runtime
* @param dataDir Directory for Arti state/cache
* @return 0 on success, error code otherwise
*/
external fun initialize(dataDir: String): Int
/**
* Start SOCKS proxy on specified port
* @param port Port number for SOCKS proxy (e.g., 9050)
* @return 0 on success, error code otherwise
*/
external fun startSocksProxy(port: Int): Int
/**
* Stop Arti and cleanup
* @return 0 on success, error code otherwise
*/
external fun stop(): Int
}
Binary file not shown.
Binary file not shown.
+37
View File
@@ -362,4 +362,41 @@
<string name="location_notes_empty_desc">كن أول من يضيف ملاحظة لهذا المكان.</string> <string name="location_notes_empty_desc">كن أول من يضيف ملاحظة لهذا المكان.</string>
<string name="dismiss">إغلاق</string> <string name="dismiss">إغلاق</string>
<string name="location_notes_input_placeholder">أضف ملاحظة لهذا المكان</string> <string name="location_notes_input_placeholder">أضف ملاحظة لهذا المكان</string>
<string name="mesh_service_notification_content">الشبكة تعمل — %1$d أقران</string>
<string name="verify_title">verify</string>
<string name="verify_my_qr_title">scan to verify me</string>
<string name="verify_scan_prompt_friend">scan someone elses qr</string>
<string name="verify_scan_someone">scan someone elses qr</string>
<string name="verify_show_my_qr">show my qr</string>
<string name="verify_remove">remove verification</string>
<string name="verify_qr_unavailable">qr unavailable</string>
<string name="verify_camera_permission">camera permission is needed to scan qr codes</string>
<string name="verify_request_camera">enable camera</string>
<string name="verify_paste_label">paste verification url</string>
<string name="verify_validate">validate</string>
<string name="verify_scanned">verification requested</string>
<string name="security_verification_title">security verification</string>
<string name="fingerprint_their">their fingerprint</string>
<string name="fingerprint_yours">your fingerprint</string>
<string name="fingerprint_pending">handshake pending</string>
<string name="fingerprint_no_peer">open a private chat to view fingerprints</string>
<string name="fingerprint_status_verified">encrypted &amp; verified</string>
<string name="fingerprint_status_encrypted">encrypted</string>
<string name="fingerprint_status_handshaking">handshaking</string>
<string name="fingerprint_status_failed">handshake failed</string>
<string name="fingerprint_status_uninitialized">not encrypted</string>
<string name="fingerprint_verified_label">verified</string>
<string name="fingerprint_verified_message">you have verified this persons identity.</string>
<string name="fingerprint_not_verified_label">not verified</string>
<string name="fingerprint_not_verified_message_fmt">compare these fingerprints with %1$s using a secure channel.</string>
<string name="fingerprint_mark_verified">mark as verified</string>
<string name="fingerprint_start_handshake">start handshake</string>
<string name="fingerprint_copy">copy</string>
<string name="verify_mutual_match_title">Mutual verification</string>
<string name="verify_mutual_match_body">You and %1$s verified each other</string>
<string name="verify_mutual_system_message">mutual verification with %1$s</string>
<string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
</resources> </resources>
+37
View File
@@ -349,4 +349,41 @@
<item quantity="other">%d জন</item> <item quantity="other">%d জন</item>
</plurals> </plurals>
<string name="mesh_service_notification_content">মেশ চলছে — %1$d পিয়ার</string>
<string name="verify_title">verify</string>
<string name="verify_my_qr_title">scan to verify me</string>
<string name="verify_scan_prompt_friend">scan someone elses qr</string>
<string name="verify_scan_someone">scan someone elses qr</string>
<string name="verify_show_my_qr">show my qr</string>
<string name="verify_remove">remove verification</string>
<string name="verify_qr_unavailable">qr unavailable</string>
<string name="verify_camera_permission">camera permission is needed to scan qr codes</string>
<string name="verify_request_camera">enable camera</string>
<string name="verify_paste_label">paste verification url</string>
<string name="verify_validate">validate</string>
<string name="verify_scanned">verification requested</string>
<string name="security_verification_title">security verification</string>
<string name="fingerprint_their">their fingerprint</string>
<string name="fingerprint_yours">your fingerprint</string>
<string name="fingerprint_pending">handshake pending</string>
<string name="fingerprint_no_peer">open a private chat to view fingerprints</string>
<string name="fingerprint_status_verified">encrypted &amp; verified</string>
<string name="fingerprint_status_encrypted">encrypted</string>
<string name="fingerprint_status_handshaking">handshaking</string>
<string name="fingerprint_status_failed">handshake failed</string>
<string name="fingerprint_status_uninitialized">not encrypted</string>
<string name="fingerprint_verified_label">verified</string>
<string name="fingerprint_verified_message">you have verified this persons identity.</string>
<string name="fingerprint_not_verified_label">not verified</string>
<string name="fingerprint_not_verified_message_fmt">compare these fingerprints with %1$s using a secure channel.</string>
<string name="fingerprint_mark_verified">mark as verified</string>
<string name="fingerprint_start_handshake">start handshake</string>
<string name="fingerprint_copy">copy</string>
<string name="verify_mutual_match_title">Mutual verification</string>
<string name="verify_mutual_match_body">You and %1$s verified each other</string>
<string name="verify_mutual_system_message">mutual verification with %1$s</string>
<string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
</resources> </resources>
+39 -2
View File
@@ -70,7 +70,7 @@
<string name="cd_remove_favorite">Aus Favoriten entfernen</string> <string name="cd_remove_favorite">Aus Favoriten entfernen</string>
<string name="cd_add_bookmark">Lesezeichen hinzufügen</string> <string name="cd_add_bookmark">Lesezeichen hinzufügen</string>
<!-- ChatKopf & Barrierefreiheit --> <!-- ChatKopf &amp; Barrierefreiheit -->
<string name="chat_back">zurück</string> <string name="chat_back">zurück</string>
<string name="chat_channel_prefix">Kanal: %1$s</string> <string name="chat_channel_prefix">Kanal: %1$s</string>
<string name="chat_leave">verlassen</string> <string name="chat_leave">verlassen</string>
@@ -170,7 +170,7 @@
<string name="cd_online_geohash_channels">OnlineGeohashKanäle</string> <string name="cd_online_geohash_channels">OnlineGeohashKanäle</string>
<string name="cd_end_to_end_encryption">EndezuEndeVerschlüsselung</string> <string name="cd_end_to_end_encryption">EndezuEndeVerschlüsselung</string>
<!-- Bilder & Dateien --> <!-- Bilder &amp; Dateien -->
<string name="image_page_of">Bild %1$d von %2$d</string> <string name="image_page_of">Bild %1$d von %2$d</string>
<string name="image_unavailable">Bild nicht verfügbar</string> <string name="image_unavailable">Bild nicht verfügbar</string>
<string name="image_saved_to_downloads">Bild in "Downloads" gespeichert</string> <string name="image_saved_to_downloads">Bild in "Downloads" gespeichert</string>
@@ -363,4 +363,41 @@
<string name="location_notes_empty_desc">sei der Erste, der hier eine Notiz hinterlässt.</string> <string name="location_notes_empty_desc">sei der Erste, der hier eine Notiz hinterlässt.</string>
<string name="dismiss">schließen</string> <string name="dismiss">schließen</string>
<string name="location_notes_input_placeholder">füge eine Notiz zu diesem Ort hinzu</string> <string name="location_notes_input_placeholder">füge eine Notiz zu diesem Ort hinzu</string>
<string name="mesh_service_notification_content">Mesh läuft — %1$d Peers</string>
<string name="verify_title">verifizieren</string>
<string name="verify_my_qr_title">scannen zur verifizierung</string>
<string name="verify_scan_prompt_friend">anderen qr scannen</string>
<string name="verify_scan_someone">anderen qr scannen</string>
<string name="verify_show_my_qr">mein qr zeigen</string>
<string name="verify_remove">verifizierung entfernen</string>
<string name="verify_qr_unavailable">qr nicht verfügbar</string>
<string name="verify_camera_permission">kamera-berechtigung nötig</string>
<string name="verify_request_camera">kamera aktivieren</string>
<string name="verify_paste_label">verifizierungs-url einfügen</string>
<string name="verify_validate">validieren</string>
<string name="verify_scanned">verifizierung angefordert</string>
<string name="security_verification_title">sicherheitsüberprüfung</string>
<string name="fingerprint_their">ihr fingerabdruck</string>
<string name="fingerprint_yours">dein fingerabdruck</string>
<string name="fingerprint_pending">handshake ausstehend</string>
<string name="fingerprint_no_peer">privatchat öffnen</string>
<string name="fingerprint_status_verified">verschlüsselt &amp; verifiziert</string>
<string name="fingerprint_status_encrypted">verschlüsselt</string>
<string name="fingerprint_status_handshaking">handshake</string>
<string name="fingerprint_status_failed">handshake fehlgeschlagen</string>
<string name="fingerprint_status_uninitialized">unverschlüsselt</string>
<string name="fingerprint_verified_label">verifiziert</string>
<string name="fingerprint_verified_message">du hast diese person verifiziert.</string>
<string name="fingerprint_not_verified_label">nicht verifiziert</string>
<string name="fingerprint_not_verified_message_fmt">vergleiche fingerabdrücke mit %1$s.</string>
<string name="fingerprint_mark_verified">als verifiziert markieren</string>
<string name="fingerprint_start_handshake">handshake starten</string>
<string name="fingerprint_copy">kopieren</string>
<string name="verify_mutual_match_title">Gegenseitige Verifizierung</string>
<string name="verify_mutual_match_body">Du und %1$s habt euch verifiziert</string>
<string name="verify_mutual_system_message">gegenseitige verifizierung mit %1$s</string>
<string name="verify_success_title">Verifiziert</string>
<string name="verify_success_body">Du hast %1$s verifiziert</string>
<string name="verify_success_system_message">verifiziert %1$s</string>
</resources> </resources>
+37
View File
@@ -362,4 +362,41 @@
<string name="location_notes_empty_desc">sé el primero en añadir una para este sitio.</string> <string name="location_notes_empty_desc">sé el primero en añadir una para este sitio.</string>
<string name="dismiss">cerrar</string> <string name="dismiss">cerrar</string>
<string name="location_notes_input_placeholder">agrega una nota para este lugar</string> <string name="location_notes_input_placeholder">agrega una nota para este lugar</string>
<string name="mesh_service_notification_content">Mesh en ejecución — %1$d pares</string>
<string name="verify_title">verificar</string>
<string name="verify_my_qr_title">escanear para verificarme</string>
<string name="verify_scan_prompt_friend">escanear qr de otro</string>
<string name="verify_scan_someone">escanear qr de otro</string>
<string name="verify_show_my_qr">mostrar mi qr</string>
<string name="verify_remove">eliminar verificación</string>
<string name="verify_qr_unavailable">qr no disponible</string>
<string name="verify_camera_permission">se necesita permiso de cámara</string>
<string name="verify_request_camera">activar cámara</string>
<string name="verify_paste_label">pegar url de verificación</string>
<string name="verify_validate">validar</string>
<string name="verify_scanned">verificación solicitada</string>
<string name="security_verification_title">verificación de seguridad</string>
<string name="fingerprint_their">su huella digital</string>
<string name="fingerprint_yours">tu huella digital</string>
<string name="fingerprint_pending">intercambio pendiente</string>
<string name="fingerprint_no_peer">abrir chat privado para ver</string>
<string name="fingerprint_status_verified">cifrado y verificado</string>
<string name="fingerprint_status_encrypted">cifrado</string>
<string name="fingerprint_status_handshaking">negociando</string>
<string name="fingerprint_status_failed">falló negociación</string>
<string name="fingerprint_status_uninitialized">no cifrado</string>
<string name="fingerprint_verified_label">verificado</string>
<string name="fingerprint_verified_message">has verificado esta identidad.</string>
<string name="fingerprint_not_verified_label">no verificado</string>
<string name="fingerprint_not_verified_message_fmt">compara huellas con %1$s por canal seguro.</string>
<string name="fingerprint_mark_verified">marcar verificado</string>
<string name="fingerprint_start_handshake">iniciar handshake</string>
<string name="fingerprint_copy">copiar</string>
<string name="verify_mutual_match_title">Verificación mutua</string>
<string name="verify_mutual_match_body">Tú y %1$s se verificaron</string>
<string name="verify_mutual_system_message">verificación mutua con %1$s</string>
<string name="verify_success_title">Verificado</string>
<string name="verify_success_body">Verificaste a %1$s</string>
<string name="verify_success_system_message">verificado %1$s</string>
</resources> </resources>
+37
View File
@@ -349,4 +349,41 @@
<item quantity="other">%d نفر</item> <item quantity="other">%d نفر</item>
</plurals> </plurals>
<string name="mesh_service_notification_content">مش در حال اجرا — %1$d همتا</string>
<string name="verify_title">verify</string>
<string name="verify_my_qr_title">scan to verify me</string>
<string name="verify_scan_prompt_friend">scan someone elses qr</string>
<string name="verify_scan_someone">scan someone elses qr</string>
<string name="verify_show_my_qr">show my qr</string>
<string name="verify_remove">remove verification</string>
<string name="verify_qr_unavailable">qr unavailable</string>
<string name="verify_camera_permission">camera permission is needed to scan qr codes</string>
<string name="verify_request_camera">enable camera</string>
<string name="verify_paste_label">paste verification url</string>
<string name="verify_validate">validate</string>
<string name="verify_scanned">verification requested</string>
<string name="security_verification_title">security verification</string>
<string name="fingerprint_their">their fingerprint</string>
<string name="fingerprint_yours">your fingerprint</string>
<string name="fingerprint_pending">handshake pending</string>
<string name="fingerprint_no_peer">open a private chat to view fingerprints</string>
<string name="fingerprint_status_verified">encrypted &amp; verified</string>
<string name="fingerprint_status_encrypted">encrypted</string>
<string name="fingerprint_status_handshaking">handshaking</string>
<string name="fingerprint_status_failed">handshake failed</string>
<string name="fingerprint_status_uninitialized">not encrypted</string>
<string name="fingerprint_verified_label">verified</string>
<string name="fingerprint_verified_message">you have verified this persons identity.</string>
<string name="fingerprint_not_verified_label">not verified</string>
<string name="fingerprint_not_verified_message_fmt">compare these fingerprints with %1$s using a secure channel.</string>
<string name="fingerprint_mark_verified">mark as verified</string>
<string name="fingerprint_start_handshake">start handshake</string>
<string name="fingerprint_copy">copy</string>
<string name="verify_mutual_match_title">Mutual verification</string>
<string name="verify_mutual_match_body">You and %1$s verified each other</string>
<string name="verify_mutual_system_message">mutual verification with %1$s</string>
<string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
</resources> </resources>
+37
View File
@@ -362,4 +362,41 @@
<item quantity="other">%d tao</item> <item quantity="other">%d tao</item>
</plurals> </plurals>
<string name="mesh_service_notification_content">Tumatakbo ang Mesh — %1$d na peer</string>
<string name="verify_title">verify</string>
<string name="verify_my_qr_title">scan to verify me</string>
<string name="verify_scan_prompt_friend">scan someone elses qr</string>
<string name="verify_scan_someone">scan someone elses qr</string>
<string name="verify_show_my_qr">show my qr</string>
<string name="verify_remove">remove verification</string>
<string name="verify_qr_unavailable">qr unavailable</string>
<string name="verify_camera_permission">camera permission is needed to scan qr codes</string>
<string name="verify_request_camera">enable camera</string>
<string name="verify_paste_label">paste verification url</string>
<string name="verify_validate">validate</string>
<string name="verify_scanned">verification requested</string>
<string name="security_verification_title">security verification</string>
<string name="fingerprint_their">their fingerprint</string>
<string name="fingerprint_yours">your fingerprint</string>
<string name="fingerprint_pending">handshake pending</string>
<string name="fingerprint_no_peer">open a private chat to view fingerprints</string>
<string name="fingerprint_status_verified">encrypted &amp; verified</string>
<string name="fingerprint_status_encrypted">encrypted</string>
<string name="fingerprint_status_handshaking">handshaking</string>
<string name="fingerprint_status_failed">handshake failed</string>
<string name="fingerprint_status_uninitialized">not encrypted</string>
<string name="fingerprint_verified_label">verified</string>
<string name="fingerprint_verified_message">you have verified this persons identity.</string>
<string name="fingerprint_not_verified_label">not verified</string>
<string name="fingerprint_not_verified_message_fmt">compare these fingerprints with %1$s using a secure channel.</string>
<string name="fingerprint_mark_verified">mark as verified</string>
<string name="fingerprint_start_handshake">start handshake</string>
<string name="fingerprint_copy">copy</string>
<string name="verify_mutual_match_title">Mutual verification</string>
<string name="verify_mutual_match_body">You and %1$s verified each other</string>
<string name="verify_mutual_system_message">mutual verification with %1$s</string>
<string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
</resources> </resources>
+39 -2
View File
@@ -70,7 +70,7 @@
<string name="cd_remove_favorite">Retirer des favoris</string> <string name="cd_remove_favorite">Retirer des favoris</string>
<string name="cd_add_bookmark">Ajouter un signet</string> <string name="cd_add_bookmark">Ajouter un signet</string>
<!-- Entête de chat & accessibilité --> <!-- Entête de chat &amp; accessibilité -->
<string name="chat_back">retour</string> <string name="chat_back">retour</string>
<string name="chat_channel_prefix">canal : %1$s</string> <string name="chat_channel_prefix">canal : %1$s</string>
<string name="chat_leave">quitter</string> <string name="chat_leave">quitter</string>
@@ -165,7 +165,7 @@
<string name="cd_online_geohash_channels">Canaux Geohash en ligne</string> <string name="cd_online_geohash_channels">Canaux Geohash en ligne</string>
<string name="cd_end_to_end_encryption">Chiffrement de bout en bout</string> <string name="cd_end_to_end_encryption">Chiffrement de bout en bout</string>
<!-- Images & fichiers --> <!-- Images &amp; fichiers -->
<string name="image_page_of">Image %1$d sur %2$d</string> <string name="image_page_of">Image %1$d sur %2$d</string>
<string name="image_unavailable">Image indisponible</string> <string name="image_unavailable">Image indisponible</string>
<string name="image_saved_to_downloads">Image enregistrée dans "Téléchargements"</string> <string name="image_saved_to_downloads">Image enregistrée dans "Téléchargements"</string>
@@ -376,4 +376,41 @@
<string name="location_notes_empty_desc">soyez le premier à en ajouter pour cet endroit.</string> <string name="location_notes_empty_desc">soyez le premier à en ajouter pour cet endroit.</string>
<string name="dismiss">fermer</string> <string name="dismiss">fermer</string>
<string name="location_notes_input_placeholder">ajoutez une note pour cet endroit</string> <string name="location_notes_input_placeholder">ajoutez une note pour cet endroit</string>
<string name="mesh_service_notification_content">Mesh actif — %1$d pairs</string>
<string name="verify_title">vérifier</string>
<string name="verify_my_qr_title">scanner pour me vérifier</string>
<string name="verify_scan_prompt_friend">scanner un autre qr</string>
<string name="verify_scan_someone">scanner un autre qr</string>
<string name="verify_show_my_qr">afficher mon qr</string>
<string name="verify_remove">supprimer la vérification</string>
<string name="verify_qr_unavailable">qr indisponible</string>
<string name="verify_camera_permission">permission caméra requise</string>
<string name="verify_request_camera">activer caméra</string>
<string name="verify_paste_label">coller url de vérification</string>
<string name="verify_validate">valider</string>
<string name="verify_scanned">vérification demandée</string>
<string name="security_verification_title">vérification de sécurité</string>
<string name="fingerprint_their">leur empreinte</string>
<string name="fingerprint_yours">votre empreinte</string>
<string name="fingerprint_pending">négociation en cours</string>
<string name="fingerprint_no_peer">ouvrir un chat privé</string>
<string name="fingerprint_status_verified">chiffré &amp; vérifié</string>
<string name="fingerprint_status_encrypted">chiffré</string>
<string name="fingerprint_status_handshaking">négociation</string>
<string name="fingerprint_status_failed">échec négociation</string>
<string name="fingerprint_status_uninitialized">non chiffré</string>
<string name="fingerprint_verified_label">vérifié</string>
<string name="fingerprint_verified_message">vous avez vérifié cette personne.</string>
<string name="fingerprint_not_verified_label">non vérifié</string>
<string name="fingerprint_not_verified_message_fmt">comparez avec %1$s via canal sûr.</string>
<string name="fingerprint_mark_verified">marquer vérifié</string>
<string name="fingerprint_start_handshake">lancer handshake</string>
<string name="fingerprint_copy">copier</string>
<string name="verify_mutual_match_title">Vérification mutuelle</string>
<string name="verify_mutual_match_body">Vous et %1$s êtes vérifiés</string>
<string name="verify_mutual_system_message">vérification mutuelle avec %1$s</string>
<string name="verify_success_title">Vérifié</string>
<string name="verify_success_body">Vous avez vérifié %1$s</string>
<string name="verify_success_system_message">vérifié %1$s</string>
</resources> </resources>
+37
View File
@@ -14,5 +14,42 @@
<string name="location_notes_empty_desc">היה הראשון להוסיף הערה למקום זה.</string> <string name="location_notes_empty_desc">היה הראשון להוסיף הערה למקום זה.</string>
<string name="dismiss">סגור</string> <string name="dismiss">סגור</string>
<string name="location_notes_input_placeholder">הוסף הערה למקום זה</string> <string name="location_notes_input_placeholder">הוסף הערה למקום זה</string>
<string name="mesh_service_notification_content">רשת Mesh פועלת — %1$d עמיתים</string>
<string name="verify_title">verify</string>
<string name="verify_my_qr_title">scan to verify me</string>
<string name="verify_scan_prompt_friend">scan someone elses qr</string>
<string name="verify_scan_someone">scan someone elses qr</string>
<string name="verify_show_my_qr">show my qr</string>
<string name="verify_remove">remove verification</string>
<string name="verify_qr_unavailable">qr unavailable</string>
<string name="verify_camera_permission">camera permission is needed to scan qr codes</string>
<string name="verify_request_camera">enable camera</string>
<string name="verify_paste_label">paste verification url</string>
<string name="verify_validate">validate</string>
<string name="verify_scanned">verification requested</string>
<string name="security_verification_title">security verification</string>
<string name="fingerprint_their">their fingerprint</string>
<string name="fingerprint_yours">your fingerprint</string>
<string name="fingerprint_pending">handshake pending</string>
<string name="fingerprint_no_peer">open a private chat to view fingerprints</string>
<string name="fingerprint_status_verified">encrypted &amp; verified</string>
<string name="fingerprint_status_encrypted">encrypted</string>
<string name="fingerprint_status_handshaking">handshaking</string>
<string name="fingerprint_status_failed">handshake failed</string>
<string name="fingerprint_status_uninitialized">not encrypted</string>
<string name="fingerprint_verified_label">verified</string>
<string name="fingerprint_verified_message">you have verified this persons identity.</string>
<string name="fingerprint_not_verified_label">not verified</string>
<string name="fingerprint_not_verified_message_fmt">compare these fingerprints with %1$s using a secure channel.</string>
<string name="fingerprint_mark_verified">mark as verified</string>
<string name="fingerprint_start_handshake">start handshake</string>
<string name="fingerprint_copy">copy</string>
<string name="verify_mutual_match_title">Mutual verification</string>
<string name="verify_mutual_match_body">You and %1$s verified each other</string>
<string name="verify_mutual_system_message">mutual verification with %1$s</string>
<string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
</resources> </resources>
+38 -1
View File
@@ -70,7 +70,7 @@
<string name="cd_remove_favorite">पसंदीदा से हटाएँ</string> <string name="cd_remove_favorite">पसंदीदा से हटाएँ</string>
<string name="cd_add_bookmark">बुकमार्क जोड़ें</string> <string name="cd_add_bookmark">बुकमार्क जोड़ें</string>
<!-- चैट हेडर & एक्सेसिबिलिटी --> <!-- चैट हेडर &amp; एक्सेसिबिलिटी -->
<string name="chat_back">वापस</string> <string name="chat_back">वापस</string>
<string name="chat_channel_prefix">चैनल: %1$s</string> <string name="chat_channel_prefix">चैनल: %1$s</string>
<string name="chat_leave">छोड़ें</string> <string name="chat_leave">छोड़ें</string>
@@ -362,4 +362,41 @@
<string name="location_notes_empty_desc">इस स्थान के लिए पहला नोट जोड़ें।</string> <string name="location_notes_empty_desc">इस स्थान के लिए पहला नोट जोड़ें।</string>
<string name="dismiss">बंद करें</string> <string name="dismiss">बंद करें</string>
<string name="location_notes_input_placeholder">इस स्थान के लिए एक नोट जोड़ें</string> <string name="location_notes_input_placeholder">इस स्थान के लिए एक नोट जोड़ें</string>
<string name="mesh_service_notification_content">मेश चल रहा है — %1$d पीयर्स</string>
<string name="verify_title">verify</string>
<string name="verify_my_qr_title">scan to verify me</string>
<string name="verify_scan_prompt_friend">scan someone elses qr</string>
<string name="verify_scan_someone">scan someone elses qr</string>
<string name="verify_show_my_qr">show my qr</string>
<string name="verify_remove">remove verification</string>
<string name="verify_qr_unavailable">qr unavailable</string>
<string name="verify_camera_permission">camera permission is needed to scan qr codes</string>
<string name="verify_request_camera">enable camera</string>
<string name="verify_paste_label">paste verification url</string>
<string name="verify_validate">validate</string>
<string name="verify_scanned">verification requested</string>
<string name="security_verification_title">security verification</string>
<string name="fingerprint_their">their fingerprint</string>
<string name="fingerprint_yours">your fingerprint</string>
<string name="fingerprint_pending">handshake pending</string>
<string name="fingerprint_no_peer">open a private chat to view fingerprints</string>
<string name="fingerprint_status_verified">encrypted &amp; verified</string>
<string name="fingerprint_status_encrypted">encrypted</string>
<string name="fingerprint_status_handshaking">handshaking</string>
<string name="fingerprint_status_failed">handshake failed</string>
<string name="fingerprint_status_uninitialized">not encrypted</string>
<string name="fingerprint_verified_label">verified</string>
<string name="fingerprint_verified_message">you have verified this persons identity.</string>
<string name="fingerprint_not_verified_label">not verified</string>
<string name="fingerprint_not_verified_message_fmt">compare these fingerprints with %1$s using a secure channel.</string>
<string name="fingerprint_mark_verified">mark as verified</string>
<string name="fingerprint_start_handshake">start handshake</string>
<string name="fingerprint_copy">copy</string>
<string name="verify_mutual_match_title">Mutual verification</string>
<string name="verify_mutual_match_body">You and %1$s verified each other</string>
<string name="verify_mutual_system_message">mutual verification with %1$s</string>
<string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
</resources> </resources>
+39 -2
View File
@@ -70,7 +70,7 @@
<string name="cd_remove_favorite">Hapus dari favorit</string> <string name="cd_remove_favorite">Hapus dari favorit</string>
<string name="cd_add_bookmark">Tambah bookmark</string> <string name="cd_add_bookmark">Tambah bookmark</string>
<!-- Header chat & aksesibilitas --> <!-- Header chat &amp; aksesibilitas -->
<string name="chat_back">kembali</string> <string name="chat_back">kembali</string>
<string name="chat_channel_prefix">Channel: %1$s</string> <string name="chat_channel_prefix">Channel: %1$s</string>
<string name="chat_leave">keluar</string> <string name="chat_leave">keluar</string>
@@ -170,7 +170,7 @@
<string name="cd_online_geohash_channels">Channel Geohash Online</string> <string name="cd_online_geohash_channels">Channel Geohash Online</string>
<string name="cd_end_to_end_encryption">Enkripsi end-to-end</string> <string name="cd_end_to_end_encryption">Enkripsi end-to-end</string>
<!-- Gambar & file --> <!-- Gambar &amp; file -->
<string name="image_page_of">Gambar %1$d dari %2$d</string> <string name="image_page_of">Gambar %1$d dari %2$d</string>
<string name="image_unavailable">Gambar tidak tersedia</string> <string name="image_unavailable">Gambar tidak tersedia</string>
<string name="image_saved_to_downloads">Gambar disimpan ke \"Downloads\"</string> <string name="image_saved_to_downloads">Gambar disimpan ke \"Downloads\"</string>
@@ -362,4 +362,41 @@
<string name="location_notes_empty_desc">jadilah yang pertama menambahkan catatan untuk tempat ini.</string> <string name="location_notes_empty_desc">jadilah yang pertama menambahkan catatan untuk tempat ini.</string>
<string name="dismiss">tutup</string> <string name="dismiss">tutup</string>
<string name="location_notes_input_placeholder">tambahkan catatan untuk tempat ini</string> <string name="location_notes_input_placeholder">tambahkan catatan untuk tempat ini</string>
<string name="mesh_service_notification_content">Mesh berjalan — %1$d peer</string>
<string name="verify_title">verify</string>
<string name="verify_my_qr_title">scan to verify me</string>
<string name="verify_scan_prompt_friend">scan someone elses qr</string>
<string name="verify_scan_someone">scan someone elses qr</string>
<string name="verify_show_my_qr">show my qr</string>
<string name="verify_remove">remove verification</string>
<string name="verify_qr_unavailable">qr unavailable</string>
<string name="verify_camera_permission">camera permission is needed to scan qr codes</string>
<string name="verify_request_camera">enable camera</string>
<string name="verify_paste_label">paste verification url</string>
<string name="verify_validate">validate</string>
<string name="verify_scanned">verification requested</string>
<string name="security_verification_title">security verification</string>
<string name="fingerprint_their">their fingerprint</string>
<string name="fingerprint_yours">your fingerprint</string>
<string name="fingerprint_pending">handshake pending</string>
<string name="fingerprint_no_peer">open a private chat to view fingerprints</string>
<string name="fingerprint_status_verified">encrypted &amp; verified</string>
<string name="fingerprint_status_encrypted">encrypted</string>
<string name="fingerprint_status_handshaking">handshaking</string>
<string name="fingerprint_status_failed">handshake failed</string>
<string name="fingerprint_status_uninitialized">not encrypted</string>
<string name="fingerprint_verified_label">verified</string>
<string name="fingerprint_verified_message">you have verified this persons identity.</string>
<string name="fingerprint_not_verified_label">not verified</string>
<string name="fingerprint_not_verified_message_fmt">compare these fingerprints with %1$s using a secure channel.</string>
<string name="fingerprint_mark_verified">mark as verified</string>
<string name="fingerprint_start_handshake">start handshake</string>
<string name="fingerprint_copy">copy</string>
<string name="verify_mutual_match_title">Mutual verification</string>
<string name="verify_mutual_match_body">You and %1$s verified each other</string>
<string name="verify_mutual_system_message">mutual verification with %1$s</string>
<string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
</resources> </resources>
+37
View File
@@ -396,4 +396,41 @@
<string name="location_notes_empty_desc">sii il primo ad aggiungerne una per questo posto.</string> <string name="location_notes_empty_desc">sii il primo ad aggiungerne una per questo posto.</string>
<string name="dismiss">chiudi</string> <string name="dismiss">chiudi</string>
<string name="location_notes_input_placeholder">aggiungi una nota per questo luogo</string> <string name="location_notes_input_placeholder">aggiungi una nota per questo luogo</string>
<string name="mesh_service_notification_content">Mesh in esecuzione — %1$d peer</string>
<string name="verify_title">verifica</string>
<string name="verify_my_qr_title">scansiona per verificarmi</string>
<string name="verify_scan_prompt_friend">scansiona qr altrui</string>
<string name="verify_scan_someone">scansiona qr altrui</string>
<string name="verify_show_my_qr">mostra mio qr</string>
<string name="verify_remove">rimuovi verifica</string>
<string name="verify_qr_unavailable">qr non disponibile</string>
<string name="verify_camera_permission">permesso fotocamera necessario</string>
<string name="verify_request_camera">abilita fotocamera</string>
<string name="verify_paste_label">incolla url verifica</string>
<string name="verify_validate">convalida</string>
<string name="verify_scanned">verifica richiesta</string>
<string name="security_verification_title">verifica sicurezza</string>
<string name="fingerprint_their">loro impronta</string>
<string name="fingerprint_yours">tua impronta</string>
<string name="fingerprint_pending">handshake in corso</string>
<string name="fingerprint_no_peer">apri chat privata</string>
<string name="fingerprint_status_verified">criptato &amp; verificato</string>
<string name="fingerprint_status_encrypted">criptato</string>
<string name="fingerprint_status_handshaking">handshake</string>
<string name="fingerprint_status_failed">handshake fallito</string>
<string name="fingerprint_status_uninitialized">non criptato</string>
<string name="fingerprint_verified_label">verificato</string>
<string name="fingerprint_verified_message">hai verificato questa persona.</string>
<string name="fingerprint_not_verified_label">non verificato</string>
<string name="fingerprint_not_verified_message_fmt">confronta con %1$s via canale sicuro.</string>
<string name="fingerprint_mark_verified">segna verificato</string>
<string name="fingerprint_start_handshake">avvia handshake</string>
<string name="fingerprint_copy">copia</string>
<string name="verify_mutual_match_title">Verifica reciproca</string>
<string name="verify_mutual_match_body">Tu e %1$s vi siete verificati</string>
<string name="verify_mutual_system_message">verifica reciproca con %1$s</string>
<string name="verify_success_title">Verificato</string>
<string name="verify_success_body">Hai verificato %1$s</string>
<string name="verify_success_system_message">verificato %1$s</string>
</resources> </resources>
+39 -2
View File
@@ -70,7 +70,7 @@
<string name="cd_remove_favorite">お気に入りから削除</string> <string name="cd_remove_favorite">お気に入りから削除</string>
<string name="cd_add_bookmark">ブックマークに追加</string> <string name="cd_add_bookmark">ブックマークに追加</string>
<!-- チャットヘッダー & アクセシビリティ --> <!-- チャットヘッダー &amp; アクセシビリティ -->
<string name="chat_back">戻る</string> <string name="chat_back">戻る</string>
<string name="chat_channel_prefix">チャンネル: %1$s</string> <string name="chat_channel_prefix">チャンネル: %1$s</string>
<string name="chat_leave">退出</string> <string name="chat_leave">退出</string>
@@ -170,7 +170,7 @@
<string name="cd_online_geohash_channels">オンライン Geohash チャンネル</string> <string name="cd_online_geohash_channels">オンライン Geohash チャンネル</string>
<string name="cd_end_to_end_encryption">エンドツーエンド暗号化</string> <string name="cd_end_to_end_encryption">エンドツーエンド暗号化</string>
<!-- 画像 & ファイル --> <!-- 画像 &amp; ファイル -->
<string name="image_page_of">画像 %1$d / %2$d</string> <string name="image_page_of">画像 %1$d / %2$d</string>
<string name="image_unavailable">画像は利用できません</string> <string name="image_unavailable">画像は利用できません</string>
<string name="image_saved_to_downloads">画像を「ダウンロード」に保存しました</string> <string name="image_saved_to_downloads">画像を「ダウンロード」に保存しました</string>
@@ -362,4 +362,41 @@
<string name="location_notes_empty_desc">この場所の最初のメモを追加しましょう。</string> <string name="location_notes_empty_desc">この場所の最初のメモを追加しましょう。</string>
<string name="dismiss">閉じる</string> <string name="dismiss">閉じる</string>
<string name="location_notes_input_placeholder">この場所へのメモを追加</string> <string name="location_notes_input_placeholder">この場所へのメモを追加</string>
<string name="mesh_service_notification_content">メッシュ実行中 — %1$d ピア</string>
<string name="verify_title">検証</string>
<string name="verify_my_qr_title">私を検証するためにスキャン</string>
<string name="verify_scan_prompt_friend">他人のQRをスキャン</string>
<string name="verify_scan_someone">他人のQRをスキャン</string>
<string name="verify_show_my_qr">QRコードを表示</string>
<string name="verify_remove">検証を削除</string>
<string name="verify_qr_unavailable">QR利用不可</string>
<string name="verify_camera_permission">QRスキャンにカメラ権限が必要です</string>
<string name="verify_request_camera">カメラを有効化</string>
<string name="verify_paste_label">検証URLを貼り付け</string>
<string name="verify_validate">検証する</string>
<string name="verify_scanned">検証をリクエストしました</string>
<string name="security_verification_title">セキュリティ検証</string>
<string name="fingerprint_their">相手の指紋</string>
<string name="fingerprint_yours">あなたの指紋</string>
<string name="fingerprint_pending">ハンドシェイク保留中</string>
<string name="fingerprint_no_peer">指紋を見るにはプライベートチャットを開いてください</string>
<string name="fingerprint_status_verified">暗号化&検証済み</string>
<string name="fingerprint_status_encrypted">暗号化済み</string>
<string name="fingerprint_status_handshaking">ハンドシェイク中</string>
<string name="fingerprint_status_failed">ハンドシェイク失敗</string>
<string name="fingerprint_status_uninitialized">未暗号化</string>
<string name="fingerprint_verified_label">検証済み</string>
<string name="fingerprint_verified_message">この人物の身元を検証しました。</string>
<string name="fingerprint_not_verified_label">未検証</string>
<string name="fingerprint_not_verified_message_fmt">安全な経路で %1$s と指紋を比較してください。</string>
<string name="fingerprint_mark_verified">検証済みにする</string>
<string name="fingerprint_start_handshake">ハンドシェイク開始</string>
<string name="fingerprint_copy">コピー</string>
<string name="verify_mutual_match_title">相互検証</string>
<string name="verify_mutual_match_body">あなたと %1$s は相互に検証しました</string>
<string name="verify_mutual_system_message">%1$s と相互検証しました</string>
<string name="verify_success_title">検証済み</string>
<string name="verify_success_body">%1$s を検証しました</string>
<string name="verify_success_system_message">%1$s を検証しました</string>
</resources> </resources>
+39 -2
View File
@@ -70,7 +70,7 @@
<string name="cd_remove_favorite">რჩეულებიდან წაშლა</string> <string name="cd_remove_favorite">რჩეულებიდან წაშლა</string>
<string name="cd_add_bookmark">სანიშნეს დამატება</string> <string name="cd_add_bookmark">სანიშნეს დამატება</string>
<!-- ჩათის სათაური & ხელმისაწვდომობა --> <!-- ჩათის სათაური &amp; ხელმისაწვდომობა -->
<string name="chat_back">უკან</string> <string name="chat_back">უკან</string>
<string name="chat_channel_prefix">არხი: %1$s</string> <string name="chat_channel_prefix">არხი: %1$s</string>
<string name="chat_leave">გასვლა</string> <string name="chat_leave">გასვლა</string>
@@ -170,7 +170,7 @@
<string name="cd_online_geohash_channels">ონლაინ geohash არხები</string> <string name="cd_online_geohash_channels">ონლაინ geohash არხები</string>
<string name="cd_end_to_end_encryption">ბოლო-მდე დაშიფვრა</string> <string name="cd_end_to_end_encryption">ბოლო-მდე დაშიფვრა</string>
<!-- სურათები & ფაილები --> <!-- სურათები &amp; ფაილები -->
<string name="image_page_of">სურათი %1$d %2$d-დან</string> <string name="image_page_of">სურათი %1$d %2$d-დან</string>
<string name="image_unavailable">სურათი მიუწვდომელია</string> <string name="image_unavailable">სურათი მიუწვდომელია</string>
<string name="image_saved_to_downloads">სურათი შენახულია "ჩამოტვირთვებში"</string> <string name="image_saved_to_downloads">სურათი შენახულია "ჩამოტვირთვებში"</string>
@@ -349,4 +349,41 @@
<item quantity="other">%d ადამიანი</item> <item quantity="other">%d ადამიანი</item>
</plurals> </plurals>
<string name="mesh_service_notification_content">Mesh გაშვებულია — %1$d პირები</string>
<string name="verify_title">verify</string>
<string name="verify_my_qr_title">scan to verify me</string>
<string name="verify_scan_prompt_friend">scan someone elses qr</string>
<string name="verify_scan_someone">scan someone elses qr</string>
<string name="verify_show_my_qr">show my qr</string>
<string name="verify_remove">remove verification</string>
<string name="verify_qr_unavailable">qr unavailable</string>
<string name="verify_camera_permission">camera permission is needed to scan qr codes</string>
<string name="verify_request_camera">enable camera</string>
<string name="verify_paste_label">paste verification url</string>
<string name="verify_validate">validate</string>
<string name="verify_scanned">verification requested</string>
<string name="security_verification_title">security verification</string>
<string name="fingerprint_their">their fingerprint</string>
<string name="fingerprint_yours">your fingerprint</string>
<string name="fingerprint_pending">handshake pending</string>
<string name="fingerprint_no_peer">open a private chat to view fingerprints</string>
<string name="fingerprint_status_verified">encrypted &amp; verified</string>
<string name="fingerprint_status_encrypted">encrypted</string>
<string name="fingerprint_status_handshaking">handshaking</string>
<string name="fingerprint_status_failed">handshake failed</string>
<string name="fingerprint_status_uninitialized">not encrypted</string>
<string name="fingerprint_verified_label">verified</string>
<string name="fingerprint_verified_message">you have verified this persons identity.</string>
<string name="fingerprint_not_verified_label">not verified</string>
<string name="fingerprint_not_verified_message_fmt">compare these fingerprints with %1$s using a secure channel.</string>
<string name="fingerprint_mark_verified">mark as verified</string>
<string name="fingerprint_start_handshake">start handshake</string>
<string name="fingerprint_copy">copy</string>
<string name="verify_mutual_match_title">Mutual verification</string>
<string name="verify_mutual_match_body">You and %1$s verified each other</string>
<string name="verify_mutual_system_message">mutual verification with %1$s</string>
<string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
</resources> </resources>
+39 -2
View File
@@ -70,7 +70,7 @@
<string name="cd_remove_favorite">즐겨찾기에서 제거</string> <string name="cd_remove_favorite">즐겨찾기에서 제거</string>
<string name="cd_add_bookmark">북마크 추가</string> <string name="cd_add_bookmark">북마크 추가</string>
<!-- 채팅 헤더 & 접근성 --> <!-- 채팅 헤더 &amp; 접근성 -->
<string name="chat_back">뒤로</string> <string name="chat_back">뒤로</string>
<string name="chat_channel_prefix">채널: %1$s</string> <string name="chat_channel_prefix">채널: %1$s</string>
<string name="chat_leave">나가기</string> <string name="chat_leave">나가기</string>
@@ -170,7 +170,7 @@
<string name="cd_online_geohash_channels">온라인 geohash 채널</string> <string name="cd_online_geohash_channels">온라인 geohash 채널</string>
<string name="cd_end_to_end_encryption">종단간 암호화</string> <string name="cd_end_to_end_encryption">종단간 암호화</string>
<!-- 이미지 & 파일 --> <!-- 이미지 &amp; 파일 -->
<string name="image_page_of">이미지 %1$d / %2$d</string> <string name="image_page_of">이미지 %1$d / %2$d</string>
<string name="image_unavailable">이미지를 사용할 수 없음</string> <string name="image_unavailable">이미지를 사용할 수 없음</string>
<string name="image_saved_to_downloads">이미지가 \"Downloads\"에 저장됨</string> <string name="image_saved_to_downloads">이미지가 \"Downloads\"에 저장됨</string>
@@ -362,4 +362,41 @@
<string name="location_notes_empty_desc">이 장소에 첫 번째 노트를 추가해 보세요.</string> <string name="location_notes_empty_desc">이 장소에 첫 번째 노트를 추가해 보세요.</string>
<string name="dismiss">닫기</string> <string name="dismiss">닫기</string>
<string name="location_notes_input_placeholder">이 장소에 노트 추가</string> <string name="location_notes_input_placeholder">이 장소에 노트 추가</string>
<string name="mesh_service_notification_content">메시 실행 중 — %1$d 피어</string>
<string name="verify_title">verify</string>
<string name="verify_my_qr_title">scan to verify me</string>
<string name="verify_scan_prompt_friend">scan someone elses qr</string>
<string name="verify_scan_someone">scan someone elses qr</string>
<string name="verify_show_my_qr">show my qr</string>
<string name="verify_remove">remove verification</string>
<string name="verify_qr_unavailable">qr unavailable</string>
<string name="verify_camera_permission">camera permission is needed to scan qr codes</string>
<string name="verify_request_camera">enable camera</string>
<string name="verify_paste_label">paste verification url</string>
<string name="verify_validate">validate</string>
<string name="verify_scanned">verification requested</string>
<string name="security_verification_title">security verification</string>
<string name="fingerprint_their">their fingerprint</string>
<string name="fingerprint_yours">your fingerprint</string>
<string name="fingerprint_pending">handshake pending</string>
<string name="fingerprint_no_peer">open a private chat to view fingerprints</string>
<string name="fingerprint_status_verified">encrypted &amp; verified</string>
<string name="fingerprint_status_encrypted">encrypted</string>
<string name="fingerprint_status_handshaking">handshaking</string>
<string name="fingerprint_status_failed">handshake failed</string>
<string name="fingerprint_status_uninitialized">not encrypted</string>
<string name="fingerprint_verified_label">verified</string>
<string name="fingerprint_verified_message">you have verified this persons identity.</string>
<string name="fingerprint_not_verified_label">not verified</string>
<string name="fingerprint_not_verified_message_fmt">compare these fingerprints with %1$s using a secure channel.</string>
<string name="fingerprint_mark_verified">mark as verified</string>
<string name="fingerprint_start_handshake">start handshake</string>
<string name="fingerprint_copy">copy</string>
<string name="verify_mutual_match_title">Mutual verification</string>
<string name="verify_mutual_match_body">You and %1$s verified each other</string>
<string name="verify_mutual_system_message">mutual verification with %1$s</string>
<string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
</resources> </resources>
+39 -2
View File
@@ -70,7 +70,7 @@
<string name="cd_remove_favorite">Hanesorana amin\'ny ankafizina</string> <string name="cd_remove_favorite">Hanesorana amin\'ny ankafizina</string>
<string name="cd_add_bookmark">Hanampy bookmark</string> <string name="cd_add_bookmark">Hanampy bookmark</string>
<!-- Lohan\'ny resaka & fahafaha-miditra --> <!-- Lohan\'ny resaka &amp; fahafaha-miditra -->
<string name="chat_back">miverina</string> <string name="chat_back">miverina</string>
<string name="chat_channel_prefix">fantsona: %1$s</string> <string name="chat_channel_prefix">fantsona: %1$s</string>
<string name="chat_leave">hiala</string> <string name="chat_leave">hiala</string>
@@ -170,7 +170,7 @@
<string name="cd_online_geohash_channels">Fantsona Geohash an-tserasera</string> <string name="cd_online_geohash_channels">Fantsona Geohash an-tserasera</string>
<string name="cd_end_to_end_encryption">Fandokoana Farany amin\'ny Farany</string> <string name="cd_end_to_end_encryption">Fandokoana Farany amin\'ny Farany</string>
<!-- Sary & rakitra --> <!-- Sary &amp; rakitra -->
<string name="image_page_of">Sary %1$d amin\'ny %2$d</string> <string name="image_page_of">Sary %1$d amin\'ny %2$d</string>
<string name="image_unavailable">Tsy misy sary</string> <string name="image_unavailable">Tsy misy sary</string>
<string name="image_saved_to_downloads">Sary voatahiry ao amin\'ny Downloads</string> <string name="image_saved_to_downloads">Sary voatahiry ao amin\'ny Downloads</string>
@@ -376,4 +376,41 @@
<item quantity="other">Olona %d</item> <item quantity="other">Olona %d</item>
</plurals> </plurals>
<string name="mesh_service_notification_content">Mandeha ny Mesh — %1$d peers</string>
<string name="verify_title">verify</string>
<string name="verify_my_qr_title">scan to verify me</string>
<string name="verify_scan_prompt_friend">scan someone elses qr</string>
<string name="verify_scan_someone">scan someone elses qr</string>
<string name="verify_show_my_qr">show my qr</string>
<string name="verify_remove">remove verification</string>
<string name="verify_qr_unavailable">qr unavailable</string>
<string name="verify_camera_permission">camera permission is needed to scan qr codes</string>
<string name="verify_request_camera">enable camera</string>
<string name="verify_paste_label">paste verification url</string>
<string name="verify_validate">validate</string>
<string name="verify_scanned">verification requested</string>
<string name="security_verification_title">security verification</string>
<string name="fingerprint_their">their fingerprint</string>
<string name="fingerprint_yours">your fingerprint</string>
<string name="fingerprint_pending">handshake pending</string>
<string name="fingerprint_no_peer">open a private chat to view fingerprints</string>
<string name="fingerprint_status_verified">encrypted &amp; verified</string>
<string name="fingerprint_status_encrypted">encrypted</string>
<string name="fingerprint_status_handshaking">handshaking</string>
<string name="fingerprint_status_failed">handshake failed</string>
<string name="fingerprint_status_uninitialized">not encrypted</string>
<string name="fingerprint_verified_label">verified</string>
<string name="fingerprint_verified_message">you have verified this persons identity.</string>
<string name="fingerprint_not_verified_label">not verified</string>
<string name="fingerprint_not_verified_message_fmt">compare these fingerprints with %1$s using a secure channel.</string>
<string name="fingerprint_mark_verified">mark as verified</string>
<string name="fingerprint_start_handshake">start handshake</string>
<string name="fingerprint_copy">copy</string>
<string name="verify_mutual_match_title">Mutual verification</string>
<string name="verify_mutual_match_body">You and %1$s verified each other</string>
<string name="verify_mutual_system_message">mutual verification with %1$s</string>
<string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
</resources> </resources>
+37
View File
@@ -1,5 +1,42 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- TODO: Malay translations --> <!-- TODO: Malay translations -->
<string name="mesh_service_notification_content">Mesh sedang berjalan — %1$d rakan</string>
<string name="verify_title">verify</string>
<string name="verify_my_qr_title">scan to verify me</string>
<string name="verify_scan_prompt_friend">scan someone elses qr</string>
<string name="verify_scan_someone">scan someone elses qr</string>
<string name="verify_show_my_qr">show my qr</string>
<string name="verify_remove">remove verification</string>
<string name="verify_qr_unavailable">qr unavailable</string>
<string name="verify_camera_permission">camera permission is needed to scan qr codes</string>
<string name="verify_request_camera">enable camera</string>
<string name="verify_paste_label">paste verification url</string>
<string name="verify_validate">validate</string>
<string name="verify_scanned">verification requested</string>
<string name="security_verification_title">security verification</string>
<string name="fingerprint_their">their fingerprint</string>
<string name="fingerprint_yours">your fingerprint</string>
<string name="fingerprint_pending">handshake pending</string>
<string name="fingerprint_no_peer">open a private chat to view fingerprints</string>
<string name="fingerprint_status_verified">encrypted &amp; verified</string>
<string name="fingerprint_status_encrypted">encrypted</string>
<string name="fingerprint_status_handshaking">handshaking</string>
<string name="fingerprint_status_failed">handshake failed</string>
<string name="fingerprint_status_uninitialized">not encrypted</string>
<string name="fingerprint_verified_label">verified</string>
<string name="fingerprint_verified_message">you have verified this persons identity.</string>
<string name="fingerprint_not_verified_label">not verified</string>
<string name="fingerprint_not_verified_message_fmt">compare these fingerprints with %1$s using a secure channel.</string>
<string name="fingerprint_mark_verified">mark as verified</string>
<string name="fingerprint_start_handshake">start handshake</string>
<string name="fingerprint_copy">copy</string>
<string name="verify_mutual_match_title">Mutual verification</string>
<string name="verify_mutual_match_body">You and %1$s verified each other</string>
<string name="verify_mutual_system_message">mutual verification with %1$s</string>
<string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
</resources> </resources>
+37
View File
@@ -362,4 +362,41 @@
<item quantity="other">%d जना</item> <item quantity="other">%d जना</item>
</plurals> </plurals>
<string name="mesh_service_notification_content">मेश चलिरहेको छ — %1$d पियर्स</string>
<string name="verify_title">verify</string>
<string name="verify_my_qr_title">scan to verify me</string>
<string name="verify_scan_prompt_friend">scan someone elses qr</string>
<string name="verify_scan_someone">scan someone elses qr</string>
<string name="verify_show_my_qr">show my qr</string>
<string name="verify_remove">remove verification</string>
<string name="verify_qr_unavailable">qr unavailable</string>
<string name="verify_camera_permission">camera permission is needed to scan qr codes</string>
<string name="verify_request_camera">enable camera</string>
<string name="verify_paste_label">paste verification url</string>
<string name="verify_validate">validate</string>
<string name="verify_scanned">verification requested</string>
<string name="security_verification_title">security verification</string>
<string name="fingerprint_their">their fingerprint</string>
<string name="fingerprint_yours">your fingerprint</string>
<string name="fingerprint_pending">handshake pending</string>
<string name="fingerprint_no_peer">open a private chat to view fingerprints</string>
<string name="fingerprint_status_verified">encrypted &amp; verified</string>
<string name="fingerprint_status_encrypted">encrypted</string>
<string name="fingerprint_status_handshaking">handshaking</string>
<string name="fingerprint_status_failed">handshake failed</string>
<string name="fingerprint_status_uninitialized">not encrypted</string>
<string name="fingerprint_verified_label">verified</string>
<string name="fingerprint_verified_message">you have verified this persons identity.</string>
<string name="fingerprint_not_verified_label">not verified</string>
<string name="fingerprint_not_verified_message_fmt">compare these fingerprints with %1$s using a secure channel.</string>
<string name="fingerprint_mark_verified">mark as verified</string>
<string name="fingerprint_start_handshake">start handshake</string>
<string name="fingerprint_copy">copy</string>
<string name="verify_mutual_match_title">Mutual verification</string>
<string name="verify_mutual_match_body">You and %1$s verified each other</string>
<string name="verify_mutual_system_message">mutual verification with %1$s</string>
<string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
</resources> </resources>
+40 -3
View File
@@ -70,7 +70,7 @@
<string name="cd_remove_favorite">Verwijderen uit favorieten</string> <string name="cd_remove_favorite">Verwijderen uit favorieten</string>
<string name="cd_add_bookmark">Bladwijzer toevoegen</string> <string name="cd_add_bookmark">Bladwijzer toevoegen</string>
<!-- Chatkop & toegankelijkheid --> <!-- Chatkop &amp; toegankelijkheid -->
<string name="chat_back">terug</string> <string name="chat_back">terug</string>
<string name="chat_channel_prefix">kanaal: %1$s</string> <string name="chat_channel_prefix">kanaal: %1$s</string>
<string name="chat_leave">verlaten</string> <string name="chat_leave">verlaten</string>
@@ -170,7 +170,7 @@
<string name="cd_online_geohash_channels">Online geohash-kanalen</string> <string name="cd_online_geohash_channels">Online geohash-kanalen</string>
<string name="cd_end_to_end_encryption">End-to-end-versleuteling</string> <string name="cd_end_to_end_encryption">End-to-end-versleuteling</string>
<!-- Afbeeldingen & bestanden --> <!-- Afbeeldingen &amp; bestanden -->
<string name="image_page_of">Afbeelding %1$d van %2$d</string> <string name="image_page_of">Afbeelding %1$d van %2$d</string>
<string name="image_unavailable">Afbeelding niet beschikbaar</string> <string name="image_unavailable">Afbeelding niet beschikbaar</string>
<string name="image_saved_to_downloads">Afbeelding opgeslagen in Downloads</string> <string name="image_saved_to_downloads">Afbeelding opgeslagen in Downloads</string>
@@ -338,7 +338,7 @@
<string name="join">Deelnemen</string> <string name="join">Deelnemen</string>
<string name="cancel">Annuleren</string> <string name="cancel">Annuleren</string>
<!-- Symbolen & speciale strings --> <!-- Symbolen &amp; speciale strings -->
<string name="at_symbol">@</string> <string name="at_symbol">@</string>
<string name="app_brand">bitchat/</string> <string name="app_brand">bitchat/</string>
<string name="channel_count_prefix"> · ⧉ </string> <string name="channel_count_prefix"> · ⧉ </string>
@@ -394,4 +394,41 @@
<string name="location_notes_empty_desc">wees de eerste die een notitie voor deze plek toevoegt.</string> <string name="location_notes_empty_desc">wees de eerste die een notitie voor deze plek toevoegt.</string>
<string name="dismiss">sluiten</string> <string name="dismiss">sluiten</string>
<string name="location_notes_input_placeholder">voeg een notitie toe voor deze plek</string> <string name="location_notes_input_placeholder">voeg een notitie toe voor deze plek</string>
<string name="mesh_service_notification_content">Mesh actief — %1$d peers</string>
<string name="verify_title">verify</string>
<string name="verify_my_qr_title">scan to verify me</string>
<string name="verify_scan_prompt_friend">scan someone elses qr</string>
<string name="verify_scan_someone">scan someone elses qr</string>
<string name="verify_show_my_qr">show my qr</string>
<string name="verify_remove">remove verification</string>
<string name="verify_qr_unavailable">qr unavailable</string>
<string name="verify_camera_permission">camera permission is needed to scan qr codes</string>
<string name="verify_request_camera">enable camera</string>
<string name="verify_paste_label">paste verification url</string>
<string name="verify_validate">validate</string>
<string name="verify_scanned">verification requested</string>
<string name="security_verification_title">security verification</string>
<string name="fingerprint_their">their fingerprint</string>
<string name="fingerprint_yours">your fingerprint</string>
<string name="fingerprint_pending">handshake pending</string>
<string name="fingerprint_no_peer">open a private chat to view fingerprints</string>
<string name="fingerprint_status_verified">encrypted &amp; verified</string>
<string name="fingerprint_status_encrypted">encrypted</string>
<string name="fingerprint_status_handshaking">handshaking</string>
<string name="fingerprint_status_failed">handshake failed</string>
<string name="fingerprint_status_uninitialized">not encrypted</string>
<string name="fingerprint_verified_label">verified</string>
<string name="fingerprint_verified_message">you have verified this persons identity.</string>
<string name="fingerprint_not_verified_label">not verified</string>
<string name="fingerprint_not_verified_message_fmt">compare these fingerprints with %1$s using a secure channel.</string>
<string name="fingerprint_mark_verified">mark as verified</string>
<string name="fingerprint_start_handshake">start handshake</string>
<string name="fingerprint_copy">copy</string>
<string name="verify_mutual_match_title">Mutual verification</string>
<string name="verify_mutual_match_body">You and %1$s verified each other</string>
<string name="verify_mutual_system_message">mutual verification with %1$s</string>
<string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
</resources> </resources>
+39 -2
View File
@@ -70,7 +70,7 @@
<string name="cd_remove_favorite">پسندیدہ توں ہٹاؤ</string> <string name="cd_remove_favorite">پسندیدہ توں ہٹاؤ</string>
<string name="cd_add_bookmark">بُک مارک پاؤ</string> <string name="cd_add_bookmark">بُک مارک پاؤ</string>
<!-- چیٹ ہیڈر & رسائی پذیری --> <!-- چیٹ ہیڈر &amp; رسائی پذیری -->
<string name="chat_back">واپس</string> <string name="chat_back">واپس</string>
<string name="chat_channel_prefix">چینل: %1$s</string> <string name="chat_channel_prefix">چینل: %1$s</string>
<string name="chat_leave">باہر آؤ</string> <string name="chat_leave">باہر آؤ</string>
@@ -170,7 +170,7 @@
<string name="cd_online_geohash_channels">آن لائن geohash چینل</string> <string name="cd_online_geohash_channels">آن لائن geohash چینل</string>
<string name="cd_end_to_end_encryption">اینڈ ٹو اینڈ انکرپشن</string> <string name="cd_end_to_end_encryption">اینڈ ٹو اینڈ انکرپشن</string>
<!-- تصویراں & فائلز --> <!-- تصویراں &amp; فائلز -->
<string name="image_page_of">تصویر %1$d وچوں %2$d</string> <string name="image_page_of">تصویر %1$d وچوں %2$d</string>
<string name="image_unavailable">تصویر دستیاب نئیں</string> <string name="image_unavailable">تصویر دستیاب نئیں</string>
<string name="image_saved_to_downloads">تصویر \"Downloads\" وچ سیو ہو گئی</string> <string name="image_saved_to_downloads">تصویر \"Downloads\" وچ سیو ہو گئی</string>
@@ -349,4 +349,41 @@
<item quantity="other">%d بندے</item> <item quantity="other">%d بندے</item>
</plurals> </plurals>
<string name="mesh_service_notification_content">میش چل رہا ہے — %1$d ساتھی</string>
<string name="verify_title">verify</string>
<string name="verify_my_qr_title">scan to verify me</string>
<string name="verify_scan_prompt_friend">scan someone elses qr</string>
<string name="verify_scan_someone">scan someone elses qr</string>
<string name="verify_show_my_qr">show my qr</string>
<string name="verify_remove">remove verification</string>
<string name="verify_qr_unavailable">qr unavailable</string>
<string name="verify_camera_permission">camera permission is needed to scan qr codes</string>
<string name="verify_request_camera">enable camera</string>
<string name="verify_paste_label">paste verification url</string>
<string name="verify_validate">validate</string>
<string name="verify_scanned">verification requested</string>
<string name="security_verification_title">security verification</string>
<string name="fingerprint_their">their fingerprint</string>
<string name="fingerprint_yours">your fingerprint</string>
<string name="fingerprint_pending">handshake pending</string>
<string name="fingerprint_no_peer">open a private chat to view fingerprints</string>
<string name="fingerprint_status_verified">encrypted &amp; verified</string>
<string name="fingerprint_status_encrypted">encrypted</string>
<string name="fingerprint_status_handshaking">handshaking</string>
<string name="fingerprint_status_failed">handshake failed</string>
<string name="fingerprint_status_uninitialized">not encrypted</string>
<string name="fingerprint_verified_label">verified</string>
<string name="fingerprint_verified_message">you have verified this persons identity.</string>
<string name="fingerprint_not_verified_label">not verified</string>
<string name="fingerprint_not_verified_message_fmt">compare these fingerprints with %1$s using a secure channel.</string>
<string name="fingerprint_mark_verified">mark as verified</string>
<string name="fingerprint_start_handshake">start handshake</string>
<string name="fingerprint_copy">copy</string>
<string name="verify_mutual_match_title">Mutual verification</string>
<string name="verify_mutual_match_body">You and %1$s verified each other</string>
<string name="verify_mutual_system_message">mutual verification with %1$s</string>
<string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
</resources> </resources>
+37
View File
@@ -14,5 +14,42 @@
<string name="location_notes_empty_desc">bądź pierwszy, który doda notatkę dla tego miejsca.</string> <string name="location_notes_empty_desc">bądź pierwszy, który doda notatkę dla tego miejsca.</string>
<string name="dismiss">zamknij</string> <string name="dismiss">zamknij</string>
<string name="location_notes_input_placeholder">dodaj notatkę dla tego miejsca</string> <string name="location_notes_input_placeholder">dodaj notatkę dla tego miejsca</string>
<string name="mesh_service_notification_content">Mesh działa — %1$d peerów</string>
<string name="verify_title">verify</string>
<string name="verify_my_qr_title">scan to verify me</string>
<string name="verify_scan_prompt_friend">scan someone elses qr</string>
<string name="verify_scan_someone">scan someone elses qr</string>
<string name="verify_show_my_qr">show my qr</string>
<string name="verify_remove">remove verification</string>
<string name="verify_qr_unavailable">qr unavailable</string>
<string name="verify_camera_permission">camera permission is needed to scan qr codes</string>
<string name="verify_request_camera">enable camera</string>
<string name="verify_paste_label">paste verification url</string>
<string name="verify_validate">validate</string>
<string name="verify_scanned">verification requested</string>
<string name="security_verification_title">security verification</string>
<string name="fingerprint_their">their fingerprint</string>
<string name="fingerprint_yours">your fingerprint</string>
<string name="fingerprint_pending">handshake pending</string>
<string name="fingerprint_no_peer">open a private chat to view fingerprints</string>
<string name="fingerprint_status_verified">encrypted &amp; verified</string>
<string name="fingerprint_status_encrypted">encrypted</string>
<string name="fingerprint_status_handshaking">handshaking</string>
<string name="fingerprint_status_failed">handshake failed</string>
<string name="fingerprint_status_uninitialized">not encrypted</string>
<string name="fingerprint_verified_label">verified</string>
<string name="fingerprint_verified_message">you have verified this persons identity.</string>
<string name="fingerprint_not_verified_label">not verified</string>
<string name="fingerprint_not_verified_message_fmt">compare these fingerprints with %1$s using a secure channel.</string>
<string name="fingerprint_mark_verified">mark as verified</string>
<string name="fingerprint_start_handshake">start handshake</string>
<string name="fingerprint_copy">copy</string>
<string name="verify_mutual_match_title">Mutual verification</string>
<string name="verify_mutual_match_body">You and %1$s verified each other</string>
<string name="verify_mutual_system_message">mutual verification with %1$s</string>
<string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
</resources> </resources>
+39 -2
View File
@@ -70,7 +70,7 @@
<string name="cd_remove_favorite">Remover dos favoritos</string> <string name="cd_remove_favorite">Remover dos favoritos</string>
<string name="cd_add_bookmark">Adicionar favorito</string> <string name="cd_add_bookmark">Adicionar favorito</string>
<!-- Cabeçalho do chat & acessibilidade --> <!-- Cabeçalho do chat &amp; acessibilidade -->
<string name="chat_back">voltar</string> <string name="chat_back">voltar</string>
<string name="chat_channel_prefix">Canal: %1$s</string> <string name="chat_channel_prefix">Canal: %1$s</string>
<string name="chat_leave">sair</string> <string name="chat_leave">sair</string>
@@ -170,7 +170,7 @@
<string name="cd_online_geohash_channels">Canais geohash online</string> <string name="cd_online_geohash_channels">Canais geohash online</string>
<string name="cd_end_to_end_encryption">Criptografia de ponta a ponta</string> <string name="cd_end_to_end_encryption">Criptografia de ponta a ponta</string>
<!-- Imagens & arquivos --> <!-- Imagens &amp; arquivos -->
<string name="image_page_of">Imagem %1$d de %2$d</string> <string name="image_page_of">Imagem %1$d de %2$d</string>
<string name="image_unavailable">Imagem indisponível</string> <string name="image_unavailable">Imagem indisponível</string>
<string name="image_saved_to_downloads">Imagem salva em "Downloads"</string> <string name="image_saved_to_downloads">Imagem salva em "Downloads"</string>
@@ -362,4 +362,41 @@
<string name="location_notes_empty_desc">seja o primeiro a adicionar uma para este local.</string> <string name="location_notes_empty_desc">seja o primeiro a adicionar uma para este local.</string>
<string name="dismiss">fechar</string> <string name="dismiss">fechar</string>
<string name="location_notes_input_placeholder">adicione uma nota para este local</string> <string name="location_notes_input_placeholder">adicione uma nota para este local</string>
<string name="mesh_service_notification_content">Mesh rodando — %1$d pares</string>
<string name="verify_title">verificar</string>
<string name="verify_my_qr_title">digitalize para verificar-me</string>
<string name="verify_scan_prompt_friend">digitalizar outro qr</string>
<string name="verify_scan_someone">digitalizar outro qr</string>
<string name="verify_show_my_qr">mostrar meu qr</string>
<string name="verify_remove">remover verificação</string>
<string name="verify_qr_unavailable">qr indisponível</string>
<string name="verify_camera_permission">permissão de câmera necessária</string>
<string name="verify_request_camera">ativar câmera</string>
<string name="verify_paste_label">colar url de verificação</string>
<string name="verify_validate">validar</string>
<string name="verify_scanned">verificação solicitada</string>
<string name="security_verification_title">verificação de segurança</string>
<string name="fingerprint_their">impressão digital deles</string>
<string name="fingerprint_yours">sua impressão digital</string>
<string name="fingerprint_pending">handshake pendente</string>
<string name="fingerprint_no_peer">abra chat privado para ver</string>
<string name="fingerprint_status_verified">criptografado e verificado</string>
<string name="fingerprint_status_encrypted">criptografado</string>
<string name="fingerprint_status_handshaking">handshake</string>
<string name="fingerprint_status_failed">falha no handshake</string>
<string name="fingerprint_status_uninitialized">não criptografado</string>
<string name="fingerprint_verified_label">verificado</string>
<string name="fingerprint_verified_message">você verificou esta pessoa.</string>
<string name="fingerprint_not_verified_label">não verificado</string>
<string name="fingerprint_not_verified_message_fmt">compare com %1$s em canal seguro.</string>
<string name="fingerprint_mark_verified">marcar verificado</string>
<string name="fingerprint_start_handshake">iniciar handshake</string>
<string name="fingerprint_copy">copiar</string>
<string name="verify_mutual_match_title">Verificação mútua</string>
<string name="verify_mutual_match_body">Você e %1$s verificaram-se</string>
<string name="verify_mutual_system_message">verificação mútua com %1$s</string>
<string name="verify_success_title">Verificado</string>
<string name="verify_success_body">Você verificou %1$s</string>
<string name="verify_success_system_message">verificou %1$s</string>
</resources> </resources>
+37
View File
@@ -362,4 +362,41 @@
<string name="location_notes_empty_desc">seja o primeiro a adicionar uma para este local.</string> <string name="location_notes_empty_desc">seja o primeiro a adicionar uma para este local.</string>
<string name="dismiss">fechar</string> <string name="dismiss">fechar</string>
<string name="location_notes_input_placeholder">adicione uma nota para este local</string> <string name="location_notes_input_placeholder">adicione uma nota para este local</string>
<string name="mesh_service_notification_content">Mesh em execução — %1$d pares</string>
<string name="verify_title">verificar</string>
<string name="verify_my_qr_title">digitalize para verificar-me</string>
<string name="verify_scan_prompt_friend">digitalizar outro qr</string>
<string name="verify_scan_someone">digitalizar outro qr</string>
<string name="verify_show_my_qr">mostrar meu qr</string>
<string name="verify_remove">remover verificação</string>
<string name="verify_qr_unavailable">qr indisponível</string>
<string name="verify_camera_permission">permissão de câmera necessária</string>
<string name="verify_request_camera">ativar câmera</string>
<string name="verify_paste_label">colar url de verificação</string>
<string name="verify_validate">validar</string>
<string name="verify_scanned">verificação solicitada</string>
<string name="security_verification_title">verificação de segurança</string>
<string name="fingerprint_their">impressão digital deles</string>
<string name="fingerprint_yours">sua impressão digital</string>
<string name="fingerprint_pending">handshake pendente</string>
<string name="fingerprint_no_peer">abra chat privado para ver</string>
<string name="fingerprint_status_verified">criptografado e verificado</string>
<string name="fingerprint_status_encrypted">criptografado</string>
<string name="fingerprint_status_handshaking">handshake</string>
<string name="fingerprint_status_failed">falha no handshake</string>
<string name="fingerprint_status_uninitialized">não criptografado</string>
<string name="fingerprint_verified_label">verificado</string>
<string name="fingerprint_verified_message">você verificou esta pessoa.</string>
<string name="fingerprint_not_verified_label">não verificado</string>
<string name="fingerprint_not_verified_message_fmt">compare com %1$s em canal seguro.</string>
<string name="fingerprint_mark_verified">marcar verificado</string>
<string name="fingerprint_start_handshake">iniciar handshake</string>
<string name="fingerprint_copy">copiar</string>
<string name="verify_mutual_match_title">Verificação mútua</string>
<string name="verify_mutual_match_body">Você e %1$s verificaram-se</string>
<string name="verify_mutual_system_message">verificação mútua com %1$s</string>
<string name="verify_success_title">Verificado</string>
<string name="verify_success_body">Você verificou %1$s</string>
<string name="verify_success_system_message">verificou %1$s</string>
</resources> </resources>

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