mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 03:45:21 +00:00
Merge branch 'main' into gossip-routing-2
This commit is contained in:
@@ -1,15 +1,17 @@
|
||||
name: Android CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ "main", "develop" ]
|
||||
pull_request:
|
||||
branches: [ "main", "develop" ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
verify:
|
||||
name: Test & Lint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -39,7 +41,10 @@ jobs:
|
||||
- name: Run unit tests
|
||||
run: ./gradlew testDebugUnitTest
|
||||
|
||||
- name: Upload Test Reports (xml+html)
|
||||
- name: Run lint
|
||||
run: ./gradlew lintDebug
|
||||
|
||||
- name: Upload test reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -48,47 +53,6 @@ jobs:
|
||||
**/build/test-results/
|
||||
**/build/reports/tests/
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: test-results
|
||||
path: |
|
||||
**/build/test-results/
|
||||
**/build/reports/tests/
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/gradle-build-action@v3
|
||||
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
|
||||
- name: Cache Gradle packages
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Run lint
|
||||
run: ./gradlew lintDebug
|
||||
|
||||
- name: Upload lint results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
@@ -97,9 +61,13 @@ jobs:
|
||||
path: '**/build/reports/lint-results-*.html'
|
||||
|
||||
build:
|
||||
name: Build ${{ matrix.variant }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test, lint]
|
||||
|
||||
needs: verify
|
||||
strategy:
|
||||
matrix:
|
||||
variant: [Debug, Release]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -126,20 +94,11 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Build debug APK
|
||||
run: ./gradlew assembleDebug
|
||||
- name: Build ${{ matrix.variant }} APK
|
||||
run: ./gradlew assemble${{ matrix.variant }}
|
||||
|
||||
- name: Build release APK
|
||||
run: ./gradlew assembleRelease
|
||||
|
||||
- name: Upload debug APK
|
||||
- name: Upload ${{ matrix.variant }} APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: debug-apk
|
||||
path: app/build/outputs/apk/debug/*.apk
|
||||
|
||||
- name: Upload release APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-apk
|
||||
path: app/build/outputs/apk/release/*.apk
|
||||
name: ${{ matrix.variant }}-apk
|
||||
path: app/build/outputs/apk/**/*.apk
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/gradle-build-action@v2
|
||||
with:
|
||||
@@ -38,32 +38,32 @@ jobs:
|
||||
- name: Grant execute permission for Gradlew
|
||||
run: chmod +x ./gradlew
|
||||
|
||||
- name: Build APK
|
||||
- name: Build Release APKs (with architecture splits)
|
||||
run: ./gradlew assembleRelease --no-daemon --stacktrace
|
||||
|
||||
|
||||
- name: List APK files
|
||||
run: |
|
||||
echo "APK files built:"
|
||||
find app/build/outputs/apk/release -name "*.apk" -type f
|
||||
find app/build/outputs/apk/release -name "*.apk" -type f -exec ls -lh {} \;
|
||||
|
||||
- name: Rename APK
|
||||
- name: Rename APKs for GitHub Release
|
||||
run: |
|
||||
mv app/build/outputs/apk/release/app-release-unsigned.apk app/build/outputs/apk/release/bitchat.apk
|
||||
|
||||
cd app/build/outputs/apk/release
|
||||
[ -f "app-arm64-v8a-release-unsigned.apk" ] && mv app-arm64-v8a-release-unsigned.apk bitchat-android-arm64.apk
|
||||
[ -f "app-x86_64-release-unsigned.apk" ] && mv app-x86_64-release-unsigned.apk bitchat-android-x86_64.apk
|
||||
[ -f "app-universal-release-unsigned.apk" ] && mv app-universal-release-unsigned.apk bitchat-android-universal.apk
|
||||
|
||||
- name: DEBUG
|
||||
run: |
|
||||
set -x
|
||||
|
||||
pwd
|
||||
|
||||
ls -all
|
||||
|
||||
cd app/build/outputs/
|
||||
ls -all
|
||||
tree
|
||||
|
||||
# Optional: Sign APK (requires secrets)
|
||||
# - name: Sign APK
|
||||
tree || ls -R
|
||||
|
||||
# Optional: Sign APKs (uncomment and configure secrets when ready)
|
||||
# - name: Sign APKs
|
||||
# uses: r0adkll/sign-android-release@v1
|
||||
# with:
|
||||
# releaseDirectory: app/build/outputs/apk/release
|
||||
@@ -72,10 +72,10 @@ jobs:
|
||||
# keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
||||
# keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
|
||||
- name: Upload APK as artifact
|
||||
- name: Upload APKs as artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bitchat-release-apk-${{ github.ref_name }}
|
||||
name: bitchat-android-release-${{ github.ref_name }}
|
||||
path: app/build/outputs/apk/release/*.apk
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
@@ -85,16 +85,23 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Download APK artifact
|
||||
- name: Download release artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: bitchat-release-apk-${{ github.ref_name }}
|
||||
path: .
|
||||
name: bitchat-android-release-${{ github.ref_name }}
|
||||
path: release
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: bitchat.apk
|
||||
files: |
|
||||
release/bitchat-android-arm64.apk
|
||||
release/bitchat-android-x86_64.apk
|
||||
release/bitchat-android-universal.apk
|
||||
name: Release ${{ github.ref_name }}
|
||||
body: |
|
||||
**bitchat-android-arm64.apk** - ARM64 (most phones)
|
||||
**bitchat-android-x86_64.apk** - x86_64 (Chromebooks, tablets)
|
||||
**bitchat-android-universal.apk** - All architectures (fallback)
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
+7
-1
@@ -43,7 +43,9 @@ dependency-reduced-pom.xml
|
||||
# Other
|
||||
*.log
|
||||
.cxx/
|
||||
*build/
|
||||
# Gradle/Android build directories (but not tools/arti-build/)
|
||||
**/build/
|
||||
!tools/arti-build/
|
||||
out/
|
||||
gen/
|
||||
*~
|
||||
@@ -57,3 +59,7 @@ google-services.json
|
||||
# Keystore files
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
# Arti build artifacts (cloned repo and Rust build cache)
|
||||
tools/arti-build/.arti-source/
|
||||
tools/arti-build/target/
|
||||
|
||||
@@ -102,6 +102,7 @@ The app requires the following permissions (automatically requested):
|
||||
|
||||
- **Bluetooth**: Core BLE functionality
|
||||
- **Location**: Required for BLE scanning on Android
|
||||
- **Network**: Expand your mesh through public internet relays
|
||||
- **Notifications**: Message alerts and background updates
|
||||
|
||||
### Hardware Requirements
|
||||
|
||||
+35
-4
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "com.bitchat.droid"
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||
versionCode = 26
|
||||
versionName = "1.5.1"
|
||||
versionCode = 30
|
||||
versionName = "1.6.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -30,6 +30,12 @@ android {
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
ndk {
|
||||
// Include x86_64 for emulator support during development
|
||||
abiFilters += listOf("arm64-v8a", "x86_64")
|
||||
}
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
@@ -39,6 +45,18 @@ android {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// APK splits for GitHub releases - creates arm64, x86_64, and universal APKs
|
||||
// AAB for Play Store handles architecture distribution automatically
|
||||
splits {
|
||||
abi {
|
||||
isEnable = true
|
||||
reset()
|
||||
include("arm64-v8a", "x86_64")
|
||||
isUniversalApk = true // For F-Droid and fallback
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
@@ -73,12 +91,22 @@ dependencies {
|
||||
|
||||
// Lifecycle
|
||||
implementation(libs.bundles.lifecycle)
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
|
||||
// Navigation
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
|
||||
// Permissions
|
||||
implementation(libs.accompanist.permissions)
|
||||
|
||||
// QR
|
||||
implementation(libs.zxing.core)
|
||||
implementation(libs.mlkit.barcode.scanning)
|
||||
|
||||
// CameraX
|
||||
implementation(libs.androidx.camera.camera2)
|
||||
implementation(libs.androidx.camera.lifecycle)
|
||||
implementation(libs.androidx.camera.compose)
|
||||
|
||||
// Cryptography
|
||||
implementation(libs.bundles.cryptography)
|
||||
@@ -95,8 +123,11 @@ dependencies {
|
||||
// WebSocket
|
||||
implementation(libs.okhttp)
|
||||
|
||||
// Arti (Tor in Rust) Android bridge - use published AAR with native libs
|
||||
implementation("info.guardianproject:arti-mobile-ex:1.2.3")
|
||||
// Arti (Tor in Rust) Android bridge - custom build from latest source
|
||||
// Built with rustls, 16KB page size support, and onio//un service client
|
||||
// Native libraries are in src/tor/jniLibs/ (extracted from arti-custom.aar)
|
||||
// Only included in tor flavor to reduce APK size for standard builds
|
||||
// Note: AAR is kept in libs/ for reference, but libraries loaded from jniLibs/
|
||||
|
||||
// Google Play Services Location
|
||||
implementation(libs.gms.location)
|
||||
|
||||
@@ -243,17 +243,6 @@
|
||||
column="35"/>
|
||||
</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
|
||||
id="RedundantLabel"
|
||||
message="Redundant label can be removed"
|
||||
|
||||
Vendored
+7
-4
@@ -17,9 +17,12 @@
|
||||
-keep class com.bitchat.android.nostr.** { *; }
|
||||
-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 org.torproject.jni.** { *; }
|
||||
-keepnames class org.torproject.jni.**
|
||||
-keep class org.torproject.arti.** { *; }
|
||||
-keepnames class org.torproject.arti.**
|
||||
-dontwarn info.guardianproject.arti.**
|
||||
-dontwarn org.torproject.jni.**
|
||||
-dontwarn org.torproject.arti.**
|
||||
|
||||
@@ -16,12 +16,26 @@
|
||||
<!-- Location permission required for BLE scanning -->
|
||||
<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_BACKGROUND_LOCATION" />
|
||||
|
||||
<!-- Notification permissions -->
|
||||
<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 -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<!-- Camera for QR verification -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<!-- Storage permissions for file sharing -->
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
|
||||
@@ -38,6 +52,11 @@
|
||||
<!-- Hardware features -->
|
||||
<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.camera" android:required="false" />
|
||||
|
||||
<permission
|
||||
android:name="com.bitchat.android.permission.FORCE_FINISH"
|
||||
android:protectionLevel="signature" />
|
||||
|
||||
<application
|
||||
android:name=".BitchatApplication"
|
||||
@@ -76,6 +95,41 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
</manifest>
|
||||
|
||||
@@ -1,277 +1,290 @@
|
||||
Relay URL,Latitude,Longitude
|
||||
nostr-relay.amethyst.name,39.0438,-77.4874
|
||||
bcast.seutoba.com.br,43.6532,-79.3832
|
||||
premium.primal.net,40.7357,-74.1724
|
||||
strfry.openhoofd.nl,51.9229,4.40833
|
||||
relay.mostro.network,40.8302,-74.1299
|
||||
ribo.us.nostria.app,41.5868,-93.625
|
||||
relayone.geektank.ai,18.2148,-63.0574
|
||||
nostr.mikoshi.de,50.1109,8.68213
|
||||
relay.lifpay.me,1.35208,103.82
|
||||
shu02.shugur.net,21.4902,39.2246
|
||||
nostr.hifish.org,47.4043,8.57398
|
||||
orangepiller.org,60.1699,24.9384
|
||||
nostr.myshosholoza.co.za,52.3676,4.90414
|
||||
relay.nostrcheck.me,43.6532,-79.3832
|
||||
relay.nostrhub.tech,49.0291,8.35696
|
||||
nostr.mehdibekhtaoui.com,49.4939,-1.54813
|
||||
nostrcheck.me,40.7357,-74.1724
|
||||
relay.arx-ccn.com,50.4754,12.3683
|
||||
relay2.ngengine.org,43.6532,-79.3832
|
||||
nostr.21crypto.ch,47.4988,8.72369
|
||||
nostr.girino.org,43.6532,-79.3832
|
||||
relay.nostriches.club,43.6532,-79.3832
|
||||
relay.angor.io,48.1046,11.6002
|
||||
nostr.data.haus,50.4754,12.3683
|
||||
nostr.zoracle.org,45.6018,-121.185
|
||||
relay2.angor.io,48.1046,11.6002
|
||||
librerelay.aaroniumii.com,43.6532,-79.3832
|
||||
nostr.tadryanom.me,40.7357,-74.1724
|
||||
nostr-relay.cbrx.io,43.6532,-79.3832
|
||||
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
||||
relay.aloftus.io,34.0881,-118.379
|
||||
relay.barine.co,43.6532,-79.3832
|
||||
nostr.red5d.dev,43.6532,-79.3832
|
||||
nos.lol,50.4754,12.3683
|
||||
ithurtswhenip.ee,51.223,6.78245
|
||||
strfry.bonsai.com,37.8715,-122.273
|
||||
relay01.lnfi.network,39.0997,-94.5786
|
||||
nostrcheck.tnsor.network,43.6532,-79.3832
|
||||
x.kojira.io,43.6532,-79.3832
|
||||
nostr.snowbla.de,60.1699,24.9384
|
||||
purplerelay.com,50.1109,8.68213
|
||||
wot.sovbit.host,64.1466,-21.9426
|
||||
shu04.shugur.net,25.2604,55.2989
|
||||
nr.yay.so,46.2126,6.1154
|
||||
nostr.smartflowsocial.com,43.6532,-79.3832
|
||||
shu05.shugur.net,48.8566,2.35222
|
||||
wot.dtonon.com,43.6532,-79.3832
|
||||
pyramid.fiatjaf.com,51.5072,-0.127586
|
||||
nostr.88mph.life,40.7357,-74.1724
|
||||
dev-nostr.bityacht.io,25.0797,121.234
|
||||
cyberspace.nostr1.com,40.7128,-74.006
|
||||
relay.ngengine.org,40.7357,-74.1724
|
||||
nostr.calitabby.net,39.9268,-75.0246
|
||||
relay.toastr.net,40.8054,-74.0241
|
||||
relay.cosmicbolt.net,37.3986,-121.964
|
||||
nostr.davidebtc.me,50.1109,8.68213
|
||||
espelho.girino.org,43.6532,-79.3832
|
||||
srtrelay.c-stellar.net,40.7357,-74.1724
|
||||
relay.nostriot.com,41.5695,-83.9786
|
||||
relay.bitcoinveneto.org,64.1466,-21.9426
|
||||
bitcoiner.social,39.1585,-94.5728
|
||||
purpura.cloud,40.7357,-74.1724
|
||||
nostr.blankfors.se,60.1699,24.9384
|
||||
relay.nostromo.social,49.4543,11.0746
|
||||
ribo.af.nostria.app,-26.2041,28.0473
|
||||
relay.olas.app,50.4754,12.3683
|
||||
nostr.carroarmato0.be,50.9928,3.26317
|
||||
nostr.oxtr.dev,50.4754,12.3683
|
||||
relay.upleb.uk,52.2297,21.0122
|
||||
nostr.stakey.net,52.3676,4.90414
|
||||
nostr2.girino.org,40.7357,-74.1724
|
||||
nostr.rikmeijer.nl,50.4754,12.3683
|
||||
relay5.bitransfer.org,40.7357,-74.1724
|
||||
relay.mattybs.lol,40.7357,-74.1724
|
||||
nostr.makibisskey.work,40.7357,-74.1724
|
||||
wot.basspistol.org,49.4521,11.0767
|
||||
relay.nostr.net,50.4754,12.3683
|
||||
a.nos.lol,50.4754,12.3683
|
||||
wot.dergigi.com,64.1476,-21.9392
|
||||
fanfares.nostr1.com,40.7057,-74.0136
|
||||
nostr.faultables.net,43.6532,-79.3832
|
||||
relay.dwadziesciajeden.pl,52.2297,21.0122
|
||||
adre.su,59.9311,30.3609
|
||||
wot.nostr.party,36.1627,-86.7816
|
||||
relay03.lnfi.network,39.0997,-94.5786
|
||||
relay.orangepill.ovh,49.1689,-0.358841
|
||||
gnostr.com,42.6978,23.3246
|
||||
relay.wellorder.net,45.5201,-122.99
|
||||
relay.21e6.cz,50.1682,14.0546
|
||||
relay.tagayasu.xyz,43.6715,-79.38
|
||||
relay.chatbett.de,28.0199,-82.5248
|
||||
wot.soundhsa.com,33.1384,-95.6011
|
||||
prl.plus,38.9072,-77.0369
|
||||
nostr.einundzwanzig.space,50.1109,8.68213
|
||||
nostream.breadslice.com,1.35208,103.82
|
||||
relay.chorus.community,50.1109,8.68213
|
||||
nostr.mom,50.4754,12.3683
|
||||
relay.lightning.pub,41.8959,-88.2169
|
||||
relay.hook.cafe,40.7357,-74.1724
|
||||
relay-testnet.k8s.layer3.news,37.3387,-121.885
|
||||
relay.fundstr.me,42.3601,-71.0589
|
||||
relay.stream.labs.h3.se,59.4016,17.9455
|
||||
relay.holzeis.me,43.6532,-79.3832
|
||||
articles.layer3.news,37.3387,-121.885
|
||||
nproxy.kristapsk.lv,60.1699,24.9384
|
||||
nostr.thebiglake.org,32.71,-96.6745
|
||||
relay.coinos.io,40.7357,-74.1724
|
||||
nostr.camalolo.com,24.1469,120.684
|
||||
theoutpost.life,64.1476,-21.9392
|
||||
relay.nostraddress.com,40.7357,-74.1724
|
||||
nostr.casa21.space,40.7357,-74.1724
|
||||
relay.agorist.space,52.3734,4.89406
|
||||
relay.nostr.wirednet.jp,34.706,135.493
|
||||
nostr.jerrynya.fun,31.2304,121.474
|
||||
relay.13room.space,43.6532,-79.3832
|
||||
offchain.pub,47.6743,-117.112
|
||||
relay.hasenpfeffr.com,39.0438,-77.4874
|
||||
nostr-verified.wellorder.net,45.5201,-122.99
|
||||
nostrelay.memory-art.xyz,43.6532,-79.3832
|
||||
freeben666.fr,43.1204,6.12857
|
||||
nostr.bilthon.dev,25.8128,-80.2377
|
||||
nostr.spicyz.io,40.7357,-74.1724
|
||||
bcast.girino.org,40.7357,-74.1724
|
||||
nostr.noones.com,50.1109,8.68213
|
||||
relay.electriclifestyle.com,26.2897,-80.1293
|
||||
alien.macneilmediagroup.com,43.6532,-79.3832
|
||||
v-relay.d02.vrtmrz.net,34.6937,135.502
|
||||
relay.thebluepulse.com,49.4521,11.0767
|
||||
relay.guggero.org,47.3769,8.54169
|
||||
relay.siamdev.cc,13.8434,100.363
|
||||
relay.varke.eu,52.6921,6.19372
|
||||
nostr.now,36.55,139.733
|
||||
relay.origin.land,35.6673,139.751
|
||||
yabu.me,35.6092,139.73
|
||||
nostr-02.yakihonne.com,1.32123,103.695
|
||||
nostr.tac.lol,47.4748,-122.273
|
||||
r.bitcoinhold.net,40.7357,-74.1724
|
||||
nostr.rtvslawenia.com,49.4543,11.0746
|
||||
relay.sigit.io,50.4754,12.3683
|
||||
nostr-rs-relay-ishosta.phamthanh.me,40.7357,-74.1724
|
||||
relay.bitcoinartclock.com,50.4754,12.3683
|
||||
relay.snort.social,40.7357,-74.1724
|
||||
relay.mwaters.net,50.9871,2.12554
|
||||
nostr-03.dorafactory.org,1.35208,103.82
|
||||
relay.damus.io,40.7357,-74.1724
|
||||
black.nostrcity.club,41.8781,-87.6298
|
||||
noxir.kpherox.dev,34.8587,135.509
|
||||
relay.javi.space,43.4633,11.8796
|
||||
fenrir-s.notoshi.win,43.6532,-79.3832
|
||||
relay02.lnfi.network,39.0997,-94.5786
|
||||
mhp258zrpiiwn.clorecloud.net,40.7357,-74.1724
|
||||
relay.libernet.app,43.6532,-79.3832
|
||||
nostr.coincards.com,53.5501,-113.469
|
||||
ynostr.yael.at,60.1699,24.9384
|
||||
slick.mjex.me,39.048,-77.4817
|
||||
nostr.agentcampfire.com,50.8933,6.05805
|
||||
nostr-pub.wellorder.net,45.5201,-122.99
|
||||
relay.artiostr.ch,43.6532,-79.3832
|
||||
relay.davidebtc.me,50.1109,8.68213
|
||||
shu01.shugur.net,21.4902,39.2246
|
||||
relay.nostr.band,60.1699,24.9384
|
||||
relay.evanverma.com,40.8302,-74.1299
|
||||
nostr.simplex.icu,50.8198,-1.08798
|
||||
wot.nostr.net,43.6532,-79.3832
|
||||
relay.nostr.vet,52.6467,4.7395
|
||||
r.lostr.net,52.3676,4.90414
|
||||
relay.getsafebox.app,43.6532,-79.3832
|
||||
relay.moinsen.com,50.4754,12.3683
|
||||
nostr.luisschwab.net,43.6532,-79.3832
|
||||
strfry.elswa-dev.online,48.8566,2.35222
|
||||
alienos.libretechsystems.xyz,55.4724,9.87335
|
||||
nostr.overmind.lol,40.7357,-74.1724
|
||||
nos.xmark.cc,50.6924,3.20113
|
||||
relay.degmods.com,50.4754,12.3683
|
||||
relay.seq1.net,40.7128,-74.006
|
||||
relay.uid.ovh,43.6532,-79.3832
|
||||
santo.iguanatech.net,40.8302,-74.1299
|
||||
nostr.spaceshell.xyz,43.6532,-79.3832
|
||||
nostr.hekster.org,37.3986,-121.964
|
||||
wheat.happytavern.co,43.6532,-79.3832
|
||||
nostr-02.czas.top,53.471,9.88208
|
||||
satsage.xyz,37.3986,-121.964
|
||||
no.str.cr,9.92857,-84.0528
|
||||
relay.puresignal.news,40.7357,-74.1724
|
||||
nostr-relay.nextblockvending.com,47.2343,-119.853
|
||||
keys.nostr1.com,40.7057,-74.0136
|
||||
relayone.soundhsa.com,33.1384,-95.6011
|
||||
nostr-dev.wellorder.net,45.5201,-122.99
|
||||
alien.macneilmediagroup.com,40.7357,-74.1724
|
||||
nostr.kalf.org,52.3676,4.90414
|
||||
nostrelites.org,41.8781,-87.6298
|
||||
temp.iris.to,43.6532,-79.3832
|
||||
relay.utxo.farm,35.6916,139.768
|
||||
freelay.sovbit.host,64.1476,-21.9392
|
||||
relay.nosto.re,51.8933,4.42083
|
||||
inbox.azzamo.net,52.2633,21.0283
|
||||
nostr.ovia.to,43.6532,-79.3832
|
||||
nostr.lostr.space,40.7357,-74.1724
|
||||
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
|
||||
relay.nostar.org,40.7357,-74.1724
|
||||
relay.mccormick.cx,52.3563,4.95714
|
||||
nostr.0x7e.xyz,47.4988,8.72369
|
||||
relay.0xchat.com,1.35208,103.82
|
||||
relay.thebluepulse.com,40.7357,-74.1724
|
||||
relay.letsfo.com,51.098,17.0321
|
||||
nostrelay.circum.space,52.3676,4.90414
|
||||
nostr-relay.psfoundation.info,39.0438,-77.4874
|
||||
strfry.shock.network,41.8959,-88.2169
|
||||
nostr-2.21crypto.ch,47.4988,8.72369
|
||||
relay1.nostrchat.io,60.1699,24.9384
|
||||
nostr.plantroon.com,50.1013,8.62643
|
||||
relay.artx.market,43.652,-79.3633
|
||||
nostr.chaima.info,51.223,6.78245
|
||||
wot.nostr.place,30.2672,-97.7431
|
||||
nostr.rblb.it,43.4633,11.8796
|
||||
relay.magiccity.live,25.8128,-80.2377
|
||||
soloco.nl,40.7357,-74.1724
|
||||
dizzyspells.nostr1.com,40.7128,-74.006
|
||||
relay.zone667.com,60.1699,24.9384
|
||||
nostr-01.uid.ovh,43.6532,-79.3832
|
||||
zap.watch,45.5029,-73.5723
|
||||
relay.wolfcoil.com,35.6092,139.73
|
||||
relay.nostrdice.com,-33.8688,151.209
|
||||
relay.goodmorningbitcoin.com,43.6532,-79.3832
|
||||
relay.btcforplebs.com,40.7357,-74.1724
|
||||
nostr.sathoarder.com,48.5734,7.75211
|
||||
nostr.azzamo.net,52.2633,21.0283
|
||||
wot.utxo.one,40.7128,-74.006
|
||||
relay.bitcoindistrict.org,43.6532,-79.3832
|
||||
relay.lumina.rocks,49.0291,8.35695
|
||||
relay.primal.net,43.6532,-79.3832
|
||||
nostr.notribe.net,40.8302,-74.1299
|
||||
relay.usefusion.ai,38.7134,-78.1591
|
||||
relay-dev.satlantis.io,40.8302,-74.1299
|
||||
nostr.vulpem.com,49.4543,11.0746
|
||||
orangesync.tech,50.1109,8.68213
|
||||
relay.fountain.fm,39.0997,-94.5786
|
||||
relay.cypherflow.ai,48.8566,2.35222
|
||||
nostr-relay.online,40.7357,-74.1724
|
||||
relay.internationalright-wing.org,-22.5022,-48.7114
|
||||
relay.nostr.place,32.7767,-96.797
|
||||
relay.npubhaus.com,40.7357,-74.1724
|
||||
relay.jeffg.fyi,43.6532,-79.3832
|
||||
nostr.night7.space,50.4754,12.3683
|
||||
nostr.4rs.nl,49.0291,8.35696
|
||||
nostr.liberty.fans,36.9104,-89.5875
|
||||
ribo.eu.nostria.app,52.3676,4.90414
|
||||
nostr.bitcoiner.social,39.1585,-94.5728
|
||||
relay.basspistol.org,46.2044,6.14316
|
||||
dev-relay.lnfi.network,39.0997,-94.5786
|
||||
nostr.openhoofd.nl,51.9229,4.40833
|
||||
nostr.huszonegy.world,47.4979,19.0402
|
||||
relay.2nix.de,60.1699,24.9384
|
||||
relay-freeharmonypeople.space,38.7223,-9.13934
|
||||
nostr-relay.online,43.6532,-79.3832
|
||||
shu05.shugur.net,48.8566,2.35222
|
||||
relay.evanverma.com,40.8302,-74.1299
|
||||
relay2.angor.io,48.1046,11.6002
|
||||
relay.arx-ccn.com,50.4754,12.3683
|
||||
relay.davidebtc.me,51.5072,-0.127586
|
||||
temp.iris.to,43.6532,-79.3832
|
||||
nostr.superfriends.online,43.6532,-79.3832
|
||||
vitor.nostr1.com,40.7057,-74.0136
|
||||
nostr.zenon.network,43.5009,-70.4428
|
||||
relay.etch.social,41.2619,-95.8608
|
||||
relay.fr13nd5.com,52.5233,13.3426
|
||||
relay.agora.social,50.7383,15.0648
|
||||
relay.credenso.cafe,43.3601,-80.3127
|
||||
relay.illuminodes.com,47.6061,-122.333
|
||||
relay.freeplace.nl,52.3676,4.90414
|
||||
relay-rpi.edufeed.org,49.4543,11.0746
|
||||
relay.bullishbounty.com,43.6532,-79.3832
|
||||
relay04.lnfi.network,39.0997,-94.5786
|
||||
nostr-01.yakihonne.com,1.32123,103.695
|
||||
nostr-relay.xbytez.io,50.6924,3.20113
|
||||
wot.sebastix.social,51.8933,4.42083
|
||||
strfry.felixzieger.de,50.1013,8.62643
|
||||
nostr.n7ekb.net,47.4941,-122.294
|
||||
khatru.nostrver.se,51.8933,4.42083
|
||||
relayrs.notoshi.win,40.7357,-74.1724
|
||||
relay.wavlake.com,41.2619,-95.8608
|
||||
relay.moinsen.com,50.4754,12.3683
|
||||
ithurtswhenip.ee,51.223,6.78245
|
||||
nostr.tadryanom.me,43.6532,-79.3832
|
||||
relay.nostr.wirednet.jp,34.706,135.493
|
||||
relay.0xchat.com,1.35208,103.82
|
||||
nostrcheck.tnsor.network,43.6532,-79.3832
|
||||
relay.ngengine.org,43.6532,-79.3832
|
||||
relay.mitchelltribe.com,39.0438,-77.4874
|
||||
wotr.relatr.xyz,53.3498,-6.26031
|
||||
relay.chorus.community,50.1109,8.68213
|
||||
orly.ft.hn,50.4754,12.3683
|
||||
nostr.stakey.net,52.3676,4.90414
|
||||
relay.malxte.de,52.52,13.405
|
||||
nostrelay.circum.space,52.3676,4.90414
|
||||
nostrcheck.me,43.6532,-79.3832
|
||||
wot.sudocarlos.com,51.5072,-0.127586
|
||||
nostr.sagaciousd.com,49.2827,-123.121
|
||||
relay.routstr.com,43.6532,-79.3832
|
||||
relay.siamdev.cc,13.8434,100.363
|
||||
wot.sovbit.host,64.1466,-21.9426
|
||||
strfry.bonsai.com,37.8715,-122.273
|
||||
freeben666.fr,43.7221,7.15296
|
||||
okn.czas.top,51.267,6.81738
|
||||
relay.nostar.org,43.6532,-79.3832
|
||||
relay.jeffg.fyi,43.6532,-79.3832
|
||||
nostr-02.uid.ovh,43.6532,-79.3832
|
||||
relay.fr13nd5.com,52.5233,13.3426
|
||||
srtrelay.c-stellar.net,43.6532,-79.3832
|
||||
relay.dwadziesciajeden.pl,52.2297,21.0122
|
||||
relay.sigit.io,50.4754,12.3683
|
||||
nostr.girino.org,43.6532,-79.3832
|
||||
bitsat.molonlabe.holdings,51.4012,-1.3147
|
||||
nostream.breadslice.com,1.35208,103.82
|
||||
nostr.rtvslawenia.com,49.4543,11.0746
|
||||
nostr.openhoofd.nl,51.9229,4.40833
|
||||
librerelay.aaroniumii.com,43.6532,-79.3832
|
||||
relay.etch.social,41.2619,-95.8608
|
||||
strfry.felixzieger.de,50.1013,8.62643
|
||||
relay.wavlake.com,41.2619,-95.8608
|
||||
relay.fundstr.me,42.3601,-71.0589
|
||||
slick.mjex.me,39.048,-77.4817
|
||||
relay.upleb.uk,51.9194,19.1451
|
||||
nos4smartnkind.tech,40.1872,44.5152
|
||||
discovery.eu.nostria.app,52.3676,4.90414
|
||||
nostr.tac.lol,47.4748,-122.273
|
||||
wot.nostr.net,43.6532,-79.3832
|
||||
nostr-dev.wellorder.net,45.5201,-122.99
|
||||
fanfares.nostr1.com,40.7128,-74.006
|
||||
relay.bitcoindistrict.org,43.6532,-79.3832
|
||||
nostr-relay.nextblockvending.com,47.2343,-119.853
|
||||
relay-rpi.edufeed.org,49.4521,11.0767
|
||||
relay.nsnip.io,60.1699,24.9384
|
||||
black.nostrcity.club,48.8575,2.35138
|
||||
nostr-02.czas.top,51.2277,6.77346
|
||||
schnorr.me,43.6532,-79.3832
|
||||
relay.fountain.fm,39.0997,-94.5786
|
||||
nostr-verified.wellorder.net,45.5201,-122.99
|
||||
relay.divine.video,43.6532,-79.3832
|
||||
relay.thibautduchene.fr,43.6532,-79.3832
|
||||
nostr.plantroon.com,50.1013,8.62643
|
||||
relay.hasenpfeffr.com,39.0438,-77.4874
|
||||
relay.chakany.systems,43.6532,-79.3832
|
||||
nostr-01.yakihonne.com,1.32123,103.695
|
||||
nostr.azzamo.net,52.2633,21.0283
|
||||
nostr.bond,50.1109,8.68213
|
||||
nostr.coincards.com,53.5501,-113.469
|
||||
relay.nostrhub.tech,49.0291,8.35696
|
||||
relay.cosmicbolt.net,37.3986,-121.964
|
||||
nostr.21crypto.ch,47.5356,8.73209
|
||||
relay-dev.satlantis.io,40.8302,-74.1299
|
||||
offchain.pub,47.6743,-117.112
|
||||
relay.orangepill.ovh,49.1689,-0.358841
|
||||
nostr-relay.gateway.in.th,15.2634,100.344
|
||||
nostr.now,36.55,139.733
|
||||
relay.usefusion.ai,38.7134,-78.1591
|
||||
nostr.lkjsxc.com,43.6532,-79.3832
|
||||
nproxy.kristapsk.lv,60.1699,24.9384
|
||||
relay.nostrverse.net,43.6532,-79.3832
|
||||
relay.zone667.com,60.1699,24.9384
|
||||
relay.origin.land,35.6673,139.751
|
||||
relay.damus.io,43.6532,-79.3832
|
||||
relay.bnos.space,1.35208,103.82
|
||||
relay.endfiat.money,43.6532,-79.3832
|
||||
relay.bullishbounty.com,43.6532,-79.3832
|
||||
wot.nostr.party,36.1627,-86.7816
|
||||
nostr.huszonegy.world,47.4979,19.0402
|
||||
theoutpost.life,64.1476,-21.9392
|
||||
nostr.noones.com,50.1109,8.68213
|
||||
relay.21e6.cz,50.7383,15.0648
|
||||
nostr-relay.xbytez.io,50.6924,3.20113
|
||||
wot.shaving.kiwi,43.6532,-79.3832
|
||||
relay.trustroots.org,43.6532,-79.3832
|
||||
wot.dtonon.com,43.6532,-79.3832
|
||||
nostr.camalolo.com,24.1469,120.684
|
||||
czas.xyz,48.8566,2.35222
|
||||
relayrs.notoshi.win,43.6532,-79.3832
|
||||
skeme.vanderwarker.family,40.8218,-74.45
|
||||
nostr.red5d.dev,43.6532,-79.3832
|
||||
relay.javi.space,43.4633,11.8796
|
||||
relay.nuts.cash,34.0362,-118.443
|
||||
nostr.commonshub.brussels,49.4543,11.0746
|
||||
relay.artx.market,43.652,-79.3633
|
||||
satsage.xyz,37.3986,-121.964
|
||||
relay.nostx.io,43.6532,-79.3832
|
||||
shu02.shugur.net,21.4902,39.2246
|
||||
strfry.ymir.cloud,34.0965,-117.585
|
||||
relay.nostr-check.me,43.6532,-79.3832
|
||||
relay04.lnfi.network,39.0997,-94.5786
|
||||
kotukonostr.onrender.com,37.7775,-122.397
|
||||
nostr.davidebtc.me,51.5072,-0.127586
|
||||
relay2.ngengine.org,43.6532,-79.3832
|
||||
ribo.us.nostria.app,41.5868,-93.625
|
||||
nostr.czas.top,50.1109,8.68213
|
||||
relay.nostrzh.org,43.6532,-79.3832
|
||||
relay.angor.io,48.1046,11.6002
|
||||
alienos.libretechsystems.xyz,55.4724,9.87335
|
||||
vault.iris.to,43.6532,-79.3832
|
||||
relay.mccormick.cx,52.3563,4.95714
|
||||
nostr.quali.chat,60.1699,24.9384
|
||||
wheat.happytavern.co,43.6532,-79.3832
|
||||
relay.olas.app,50.4754,12.3683
|
||||
nostr-relayrs.gateway.in.th,15.2634,100.344
|
||||
relay.snort.social,53.3498,-6.26031
|
||||
nostr-2.21crypto.ch,47.5356,8.73209
|
||||
pyramid.treegaze.com,43.6532,-79.3832
|
||||
relay.smies.me,33.7501,-84.3885
|
||||
wot.dergigi.com,64.1476,-21.9392
|
||||
nostr-relay.zimage.com,34.0549,-118.243
|
||||
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
||||
nostr.jerrynya.fun,31.2304,121.474
|
||||
purplerelay.com,50.1109,8.68213
|
||||
relay.comcomponent.com,34.7062,135.493
|
||||
relay.unitypay.cash,41.4513,-81.7021
|
||||
nostr.mikoshi.de,50.1109,8.68213
|
||||
prl.plus,42.6978,23.3246
|
||||
shu04.shugur.net,25.2604,55.2989
|
||||
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
|
||||
nostr.zoracle.org,45.6018,-121.185
|
||||
nostriches.club,43.6532,-79.3832
|
||||
nostr.notribe.net,40.8302,-74.1299
|
||||
nostr.vulpem.com,49.4543,11.0746
|
||||
bucket.coracle.social,37.7775,-122.397
|
||||
wot.yesnostr.net,50.9871,2.12554
|
||||
wot.nostr.place,32.7767,-96.797
|
||||
nostr.mehdibekhtaoui.com,49.4939,-1.54813
|
||||
relay.getsafebox.app,43.6532,-79.3832
|
||||
nostr2.girino.org,43.6532,-79.3832
|
||||
nostr.blankfors.se,60.1699,24.9384
|
||||
premium.primal.net,43.6532,-79.3832
|
||||
nostr.rikmeijer.nl,50.4754,12.3683
|
||||
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
|
||||
adre.su,59.9311,30.3609
|
||||
nostr.n7ekb.net,47.4941,-122.294
|
||||
nostr.hekster.org,37.3986,-121.964
|
||||
relay.jabato.space,52.52,13.405
|
||||
fenrir-s.notoshi.win,43.6532,-79.3832
|
||||
notemine.io,52.2026,20.9397
|
||||
r.bitcoinhold.net,43.6532,-79.3832
|
||||
nostr-pub.wellorder.net,45.5201,-122.99
|
||||
articles.layer3.news,37.3387,-121.885
|
||||
relays.diggoo.com,43.6532,-79.3832
|
||||
relayb.uid.ovh,43.6532,-79.3832
|
||||
nostr.thebiglake.org,32.71,-96.6745
|
||||
chat-relay.zap-work.com,43.6532,-79.3832
|
||||
nr.yay.so,46.2126,6.1154
|
||||
shu01.shugur.net,21.4902,39.2246
|
||||
khatru.nostrver.se,51.1792,5.89444
|
||||
relay.openfarmtools.org,60.1699,24.9384
|
||||
relay01.lnfi.network,39.0997,-94.5786
|
||||
nostrja-kari.heguro.com,43.6532,-79.3832
|
||||
nostr.snowbla.de,60.1699,24.9384
|
||||
relay.satlantis.io,32.8769,-80.0114
|
||||
relay.vrtmrz.net,43.6532,-79.3832
|
||||
purpura.cloud,43.6532,-79.3832
|
||||
orangepiller.org,60.1699,24.9384
|
||||
relay.coinos.io,43.6532,-79.3832
|
||||
relay.layer.systems,49.0291,8.35695
|
||||
relay.toastr.net,40.8054,-74.0241
|
||||
ynostr.yael.at,60.1699,24.9384
|
||||
ribo.eu.nostria.app,52.3676,4.90414
|
||||
relay.nostrcheck.me,43.6532,-79.3832
|
||||
relay.primal.net,43.6532,-79.3832
|
||||
relay-testnet.k8s.layer3.news,37.3387,-121.885
|
||||
relay.threenine.services,51.5524,-0.29686
|
||||
relay.nostr.net,43.6532,-79.3832
|
||||
nostr.middling.mydns.jp,35.8099,140.12
|
||||
soloco.nl,43.6532,-79.3832
|
||||
nostr.4rs.nl,49.0291,8.35696
|
||||
no.str.cr,9.92857,-84.0528
|
||||
espelho.girino.org,43.6532,-79.3832
|
||||
nostr.hifish.org,47.4043,8.57398
|
||||
wot.sebastix.social,51.1792,5.89444
|
||||
relay03.lnfi.network,39.0997,-94.5786
|
||||
relay.nosto.re,51.1792,5.89444
|
||||
relay.nostrdice.com,-33.8688,151.209
|
||||
nostr.agentcampfire.com,52.3676,4.90414
|
||||
nostr.night7.space,50.4754,12.3683
|
||||
nostr.nodesmap.com,59.3327,18.0656
|
||||
relayone.soundhsa.com,33.1384,-95.6011
|
||||
relay.illuminodes.com,47.6061,-122.333
|
||||
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
|
||||
nostr.robosats.org,64.1476,-21.9392
|
||||
relay.seq1.net,43.6532,-79.3832
|
||||
strfry.shock.network,39.0438,-77.4874
|
||||
nostr.ovia.to,43.6532,-79.3832
|
||||
relay.holzeis.me,43.6532,-79.3832
|
||||
bcast.seutoba.com.br,43.6532,-79.3832
|
||||
relay.libernet.app,43.6532,-79.3832
|
||||
nostr-01.uid.ovh,43.6532,-79.3832
|
||||
nostr.luisschwab.net,43.6532,-79.3832
|
||||
strfry.elswa-dev.online,50.1109,8.68213
|
||||
relay.nostriot.com,41.5695,-83.9786
|
||||
nostr.oxtr.dev,50.4754,12.3683
|
||||
nostr.mom,50.4754,12.3683
|
||||
nostr-03.dorafactory.org,1.35208,103.82
|
||||
relay.camelus.app,45.5201,-122.99
|
||||
strfry.openhoofd.nl,51.9229,4.40833
|
||||
nos.lol,50.4754,12.3683
|
||||
nostr.bitcoiner.social,39.1585,-94.5728
|
||||
relay.islandbitcoin.com,12.8498,77.6545
|
||||
trizone.dev,1.35208,103.82
|
||||
schnorr.me,40.7357,-74.1724
|
||||
relay.vrtmrz.net,40.7357,-74.1724
|
||||
relay.tagayasu.xyz,43.6715,-79.38
|
||||
dev-nostr.bityacht.io,25.0797,121.234
|
||||
relay.nostrhub.fr,48.1045,11.6004
|
||||
relay.satmaxt.xyz,43.6532,-79.3832
|
||||
yabu.me,35.6092,139.73
|
||||
relay.samt.st,40.8302,-74.1299
|
||||
relay02.lnfi.network,39.0997,-94.5786
|
||||
nostr.88mph.life,51.5072,-0.127586
|
||||
dev-relay.lnfi.network,39.0997,-94.5786
|
||||
relay.goodmorningbitcoin.com,43.6532,-79.3832
|
||||
nostrelay.memory-art.xyz,43.6532,-79.3832
|
||||
relay.degmods.com,50.4754,12.3683
|
||||
nostr-02.yakihonne.com,1.32123,103.695
|
||||
nostr.spicyz.io,43.6532,-79.3832
|
||||
relay.minibolt.info,43.6532,-79.3832
|
||||
bcast.girino.org,43.6532,-79.3832
|
||||
nostr.bilthon.dev,25.8128,-80.2377
|
||||
relay.bitcoinartclock.com,50.4754,12.3683
|
||||
cyberspace.nostr1.com,40.7128,-74.006
|
||||
nostr.data.haus,50.4754,12.3683
|
||||
relay.agora.social,50.7383,15.0648
|
||||
relay.wavefunc.live,34.0362,-118.443
|
||||
nostr.casa21.space,43.6532,-79.3832
|
||||
relay.mostro.network,40.8302,-74.1299
|
||||
relay.ditto.pub,43.6532,-79.3832
|
||||
nostr-relay.amethyst.name,39.0438,-77.4874
|
||||
relay.credenso.cafe,43.3601,-80.3127
|
||||
nostr.calitabby.net,39.9268,-75.0246
|
||||
relay.magiccity.live,25.8128,-80.2377
|
||||
nostr.chaima.info,51.223,6.78245
|
||||
wot.brightbolt.net,47.6735,-116.781
|
||||
nostr.sathoarder.com,48.5734,7.75211
|
||||
inbox.azzamo.net,52.2633,21.0283
|
||||
nostr.na.social,43.6532,-79.3832
|
||||
nostr.myshosholoza.co.za,52.3676,4.90414
|
||||
nostr.spaceshell.xyz,43.6532,-79.3832
|
||||
relay.wellorder.net,45.5201,-122.99
|
||||
nostr.0x7e.xyz,47.4988,8.72369
|
||||
nostr-relay.cbrx.io,43.6532,-79.3832
|
||||
relay.btcforplebs.com,43.6532,-79.3832
|
||||
bitcoiner.social,39.1585,-94.5728
|
||||
relaynostr.breadslice.com,43.6532,-79.3832
|
||||
nostr.overmind.lol,43.6532,-79.3832
|
||||
santo.iguanatech.net,40.8302,-74.1299
|
||||
relay.lightning.pub,39.0438,-77.4874
|
||||
nostr.rblb.it,43.7094,10.6582
|
||||
relay.bitcoinveneto.org,64.1466,-21.9426
|
||||
relay.wolfcoil.com,35.6092,139.73
|
||||
nostr-relay.corb.net,38.8353,-104.822
|
||||
relay.nostr.place,32.7767,-96.797
|
||||
wot.soundhsa.com,33.1384,-95.6011
|
||||
relay.npubhaus.com,43.6532,-79.3832
|
||||
nostr-relay.psfoundation.info,39.0438,-77.4874
|
||||
x.kojira.io,43.6532,-79.3832
|
||||
relay.cypherflow.ai,48.8566,2.35222
|
||||
|
||||
|
@@ -3,18 +3,21 @@ package com.bitchat.android
|
||||
import android.app.Application
|
||||
import com.bitchat.android.nostr.RelayDirectory
|
||||
import com.bitchat.android.ui.theme.ThemePreferenceManager
|
||||
import com.bitchat.android.net.TorManager
|
||||
import com.bitchat.android.net.ArtiTorManager
|
||||
|
||||
/**
|
||||
* Main application class for bitchat Android
|
||||
*/
|
||||
class BitchatApplication : Application() {
|
||||
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
|
||||
// Initialize Tor first so any early network goes over Tor
|
||||
try { TorManager.init(this) } catch (_: Exception) { }
|
||||
try {
|
||||
val torProvider = ArtiTorManager.getInstance()
|
||||
torProvider.init(this)
|
||||
} catch (_: Exception){}
|
||||
|
||||
// Initialize relay directory (loads assets/nostr_relays.csv)
|
||||
RelayDirectory.initialize(this)
|
||||
@@ -38,6 +41,12 @@ class BitchatApplication : Application() {
|
||||
// Initialize debug preference manager (persists debug toggles)
|
||||
try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { }
|
||||
|
||||
// Initialize mesh service preferences
|
||||
try { com.bitchat.android.service.MeshServicePreferences.init(this) } catch (_: Exception) { }
|
||||
|
||||
// Proactively start the foreground service to keep mesh alive
|
||||
try { com.bitchat.android.service.MeshForegroundService.start(this) } catch (_: Exception) { }
|
||||
|
||||
// TorManager already initialized above
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import com.bitchat.android.onboarding.BatteryOptimizationManager
|
||||
import com.bitchat.android.onboarding.BatteryOptimizationPreferenceManager
|
||||
import com.bitchat.android.onboarding.BatteryOptimizationScreen
|
||||
import com.bitchat.android.onboarding.BatteryOptimizationStatus
|
||||
import com.bitchat.android.onboarding.BackgroundLocationPermissionScreen
|
||||
import com.bitchat.android.onboarding.InitializationErrorScreen
|
||||
import com.bitchat.android.onboarding.InitializingScreen
|
||||
import com.bitchat.android.onboarding.LocationCheckScreen
|
||||
@@ -40,6 +41,7 @@ import com.bitchat.android.ui.ChatViewModel
|
||||
import com.bitchat.android.ui.OrientationAwareActivity
|
||||
import com.bitchat.android.ui.theme.BitchatTheme
|
||||
import com.bitchat.android.nostr.PoWPreferenceManager
|
||||
import com.bitchat.android.services.VerificationService
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -51,7 +53,7 @@ class MainActivity : OrientationAwareActivity() {
|
||||
private lateinit var locationStatusManager: LocationStatusManager
|
||||
private lateinit var batteryOptimizationManager: BatteryOptimizationManager
|
||||
|
||||
// Core mesh service - managed at app level
|
||||
// Core mesh service - provided by the foreground service holder
|
||||
private lateinit var meshService: BluetoothMeshService
|
||||
private val mainViewModel: MainViewModel by viewModels()
|
||||
private val chatViewModel: ChatViewModel by viewModels {
|
||||
@@ -63,16 +65,55 @@ class MainActivity : OrientationAwareActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private val forceFinishReceiver = object : android.content.BroadcastReceiver() {
|
||||
override fun onReceive(context: android.content.Context, intent: android.content.Intent) {
|
||||
if (intent.action == com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH) {
|
||||
android.util.Log.i("MainActivity", "Received force finish broadcast, closing UI")
|
||||
finishAffinity()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Register receiver for force finish signal from shutdown coordinator
|
||||
val filter = android.content.IntentFilter(com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH)
|
||||
if (android.os.Build.VERSION.SDK_INT >= 33) {
|
||||
registerReceiver(
|
||||
forceFinishReceiver,
|
||||
filter,
|
||||
com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH,
|
||||
null,
|
||||
android.content.Context.RECEIVER_NOT_EXPORTED
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
registerReceiver(
|
||||
forceFinishReceiver,
|
||||
filter,
|
||||
com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH,
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
// Check if this is a quit request from the notification
|
||||
if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) {
|
||||
android.util.Log.d("MainActivity", "Quit request received in onCreate, finishing activity")
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
com.bitchat.android.service.AppShutdownCoordinator.cancelPendingShutdown()
|
||||
|
||||
// Enable edge-to-edge display for modern Android look
|
||||
enableEdgeToEdge()
|
||||
|
||||
// Initialize permission management
|
||||
permissionManager = PermissionManager(this)
|
||||
// Initialize core mesh service first
|
||||
meshService = BluetoothMeshService(this)
|
||||
// Ensure foreground service is running and get mesh instance from holder
|
||||
try { com.bitchat.android.service.MeshForegroundService.start(applicationContext) } catch (_: Exception) { }
|
||||
meshService = com.bitchat.android.service.MeshServiceHolder.getOrCreate(applicationContext)
|
||||
bluetoothStatusManager = BluetoothStatusManager(
|
||||
activity = this,
|
||||
context = this,
|
||||
@@ -95,6 +136,9 @@ class MainActivity : OrientationAwareActivity() {
|
||||
activity = this,
|
||||
permissionManager = permissionManager,
|
||||
onOnboardingComplete = ::handleOnboardingComplete,
|
||||
onBackgroundLocationRequired = {
|
||||
mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION)
|
||||
},
|
||||
onOnboardingFailed = ::handleOnboardingFailed
|
||||
)
|
||||
|
||||
@@ -227,6 +271,21 @@ class MainActivity : OrientationAwareActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
OnboardingState.BACKGROUND_LOCATION_EXPLANATION -> {
|
||||
BackgroundLocationPermissionScreen(
|
||||
modifier = modifier,
|
||||
onContinue = {
|
||||
onboardingCoordinator.requestBackgroundLocation()
|
||||
},
|
||||
onRetry = {
|
||||
onboardingCoordinator.checkBackgroundLocationAndProceed()
|
||||
},
|
||||
onSkip = {
|
||||
onboardingCoordinator.skipBackgroundLocation()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
OnboardingState.CHECKING, OnboardingState.INITIALIZING, OnboardingState.COMPLETE -> {
|
||||
// Set up back navigation handling for the chat screen
|
||||
val backCallback = object : OnBackPressedCallback(true) {
|
||||
@@ -340,10 +399,17 @@ class MainActivity : OrientationAwareActivity() {
|
||||
if (permissionManager.isFirstTimeLaunch()) {
|
||||
Log.d("MainActivity", "First time launch, showing permission explanation")
|
||||
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
|
||||
} else if (permissionManager.areAllPermissionsGranted()) {
|
||||
Log.d("MainActivity", "Existing user with permissions, initializing app")
|
||||
mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING)
|
||||
initializeApp()
|
||||
} else if (permissionManager.areRequiredPermissionsGranted()) {
|
||||
Log.d("MainActivity", "Existing user with required permissions")
|
||||
if (permissionManager.needsBackgroundLocationPermission() &&
|
||||
!permissionManager.isBackgroundLocationGranted() &&
|
||||
!com.bitchat.android.onboarding.BackgroundLocationPreferenceManager.isSkipped(this@MainActivity)
|
||||
) {
|
||||
mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION)
|
||||
} else {
|
||||
mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING)
|
||||
initializeApp()
|
||||
}
|
||||
} else {
|
||||
Log.d("MainActivity", "Existing user missing permissions, showing explanation")
|
||||
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
|
||||
@@ -616,6 +682,7 @@ class MainActivity : OrientationAwareActivity() {
|
||||
|
||||
// Handle any notification intent
|
||||
handleNotificationIntent(intent)
|
||||
handleVerificationIntent(intent)
|
||||
|
||||
// Small delay to ensure mesh service is fully initialized
|
||||
delay(500)
|
||||
@@ -630,9 +697,21 @@ class MainActivity : OrientationAwareActivity() {
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
|
||||
// Check if this is a quit request from the notification
|
||||
if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) {
|
||||
android.util.Log.d("MainActivity", "Quit request received, finishing activity")
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
com.bitchat.android.service.AppShutdownCoordinator.cancelPendingShutdown()
|
||||
|
||||
// Handle notification intents when app is already running
|
||||
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
||||
handleNotificationIntent(intent)
|
||||
handleVerificationIntent(intent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -640,9 +719,8 @@ class MainActivity : OrientationAwareActivity() {
|
||||
super.onResume()
|
||||
// Check Bluetooth and Location status on resume and handle accordingly
|
||||
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
||||
// Set app foreground state
|
||||
meshService.connectionManager.setAppBackgroundState(false)
|
||||
chatViewModel.setAppBackgroundState(false)
|
||||
// Reattach mesh delegate to new ChatViewModel instance after Activity recreation
|
||||
try { meshService.delegate = chatViewModel } catch (_: Exception) { }
|
||||
|
||||
// Check if Bluetooth was disabled while app was backgrounded
|
||||
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
|
||||
@@ -665,13 +743,12 @@ class MainActivity : OrientationAwareActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
// Only set background state if app is fully initialized
|
||||
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
||||
// Set app background state
|
||||
meshService.connectionManager.setAppBackgroundState(true)
|
||||
chatViewModel.setAppBackgroundState(true)
|
||||
// Detach UI delegate so the foreground service can own DM notifications while UI is closed
|
||||
try { meshService.delegate = null } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -734,10 +811,23 @@ class MainActivity : OrientationAwareActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleVerificationIntent(intent: Intent) {
|
||||
val uri = intent.data ?: return
|
||||
if (uri.scheme != "bitchat" || uri.host != "verify") return
|
||||
|
||||
chatViewModel.showVerificationSheet()
|
||||
val qr = VerificationService.verifyScannedQR(uri.toString())
|
||||
if (qr != null) {
|
||||
chatViewModel.beginQRVerification(qr)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
try { unregisterReceiver(forceFinishReceiver) } catch (_: Exception) { }
|
||||
|
||||
// Cleanup location status manager
|
||||
try {
|
||||
locationStatusManager.cleanup()
|
||||
@@ -746,14 +836,6 @@ class MainActivity : OrientationAwareActivity() {
|
||||
Log.w("MainActivity", "Error cleaning up location status manager: ${e.message}")
|
||||
}
|
||||
|
||||
// Stop mesh services if app was fully initialized
|
||||
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
||||
try {
|
||||
meshService.stopServices()
|
||||
Log.d("MainActivity", "Mesh services stopped successfully")
|
||||
} catch (e: Exception) {
|
||||
Log.w("MainActivity", "Error stopping mesh services in onDestroy: ${e.message}")
|
||||
}
|
||||
}
|
||||
// Do not stop mesh here; ForegroundService owns lifecycle for background reliability
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
* It now uses the Noise protocol for secure transport encryption with proper session management.
|
||||
*/
|
||||
class EncryptionService(private val context: Context) {
|
||||
open class EncryptionService(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "EncryptionService"
|
||||
@@ -27,14 +27,14 @@ class EncryptionService(private val context: Context) {
|
||||
}
|
||||
|
||||
// Core Noise encryption service
|
||||
private val noiseService: NoiseEncryptionService = NoiseEncryptionService(context)
|
||||
private val noiseService: NoiseEncryptionService by lazy { NoiseEncryptionService(context) }
|
||||
|
||||
// Session tracking for established connections
|
||||
private val establishedSessions = ConcurrentHashMap<String, String>() // peerID -> fingerprint
|
||||
|
||||
// Ed25519 signing keys (separate from Noise static keys)
|
||||
private val ed25519PrivateKey: Ed25519PrivateKeyParameters
|
||||
private val ed25519PublicKey: Ed25519PublicKeyParameters
|
||||
private lateinit var ed25519PrivateKey: Ed25519PrivateKeyParameters
|
||||
private lateinit var ed25519PublicKey: Ed25519PublicKeyParameters
|
||||
|
||||
// Callbacks for UI state updates
|
||||
var onSessionEstablished: ((String) -> Unit)? = null // peerID
|
||||
@@ -42,6 +42,13 @@ class EncryptionService(private val context: Context) {
|
||||
var onHandshakeRequired: ((String) -> Unit)? = null // peerID
|
||||
|
||||
init {
|
||||
initialize()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialization logic moved to method to allow overriding in tests
|
||||
*/
|
||||
protected open fun initialize() {
|
||||
// Initialize or load Ed25519 signing keys
|
||||
val keyPair = loadOrCreateEd25519KeyPair()
|
||||
ed25519PrivateKey = keyPair.private as Ed25519PrivateKeyParameters
|
||||
@@ -356,7 +363,7 @@ class EncryptionService(private val context: Context) {
|
||||
/**
|
||||
* Verify Ed25519 signature against data using a public key
|
||||
*/
|
||||
fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean {
|
||||
open fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean {
|
||||
return try {
|
||||
val publicKey = Ed25519PublicKeyParameters(publicKeyBytes, 0)
|
||||
val verifier = Ed25519Signer()
|
||||
|
||||
@@ -5,13 +5,14 @@ import android.location.Geocoder
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
@@ -46,11 +47,11 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
|
||||
|
||||
private val membership = mutableSetOf<String>()
|
||||
|
||||
private val _bookmarks = MutableLiveData<List<String>>(emptyList())
|
||||
val bookmarks: LiveData<List<String>> = _bookmarks
|
||||
private val _bookmarks = MutableStateFlow<List<String>>(emptyList())
|
||||
val bookmarks: StateFlow<List<String>> = _bookmarks.asStateFlow()
|
||||
|
||||
private val _bookmarkNames = MutableLiveData<Map<String, String>>(emptyMap())
|
||||
val bookmarkNames: LiveData<Map<String, String>> = _bookmarkNames
|
||||
private val _bookmarkNames = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
val bookmarkNames: StateFlow<Map<String, String>> = _bookmarkNames.asStateFlow()
|
||||
|
||||
// For throttling / preventing duplicate geocode lookups
|
||||
private val resolving = mutableSetOf<String>()
|
||||
@@ -68,8 +69,8 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
|
||||
val gh = normalize(geohash)
|
||||
if (gh.isEmpty() || membership.contains(gh)) return
|
||||
membership.add(gh)
|
||||
val updated = listOf(gh) + (_bookmarks.value ?: emptyList())
|
||||
_bookmarks.postValue(updated)
|
||||
val updated = listOf(gh) + (_bookmarks.value)
|
||||
_bookmarks.value = updated
|
||||
persist(updated)
|
||||
// Resolve friendly name asynchronously
|
||||
resolveNameIfNeeded(gh)
|
||||
@@ -79,12 +80,12 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
|
||||
val gh = normalize(geohash)
|
||||
if (!membership.contains(gh)) return
|
||||
membership.remove(gh)
|
||||
val updated = (_bookmarks.value ?: emptyList()).filterNot { it == gh }
|
||||
_bookmarks.postValue(updated)
|
||||
val updated = (_bookmarks.value).filterNot { it == gh }
|
||||
_bookmarks.value = updated
|
||||
// Remove stored name to avoid stale cache growth
|
||||
val names = _bookmarkNames.value?.toMutableMap() ?: mutableMapOf()
|
||||
val names = _bookmarkNames.value.toMutableMap()
|
||||
if (names.remove(gh) != null) {
|
||||
_bookmarkNames.postValue(names)
|
||||
_bookmarkNames.value = names
|
||||
persistNames(names)
|
||||
}
|
||||
persist(updated)
|
||||
@@ -108,7 +109,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
|
||||
}
|
||||
}
|
||||
membership.clear(); membership.addAll(seen)
|
||||
_bookmarks.postValue(ordered)
|
||||
_bookmarks.value = ordered
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load bookmarks: ${e.message}")
|
||||
@@ -118,7 +119,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
|
||||
if (!namesJson.isNullOrEmpty()) {
|
||||
val mapType = object : TypeToken<Map<String, String>>() {}.type
|
||||
val dict = gson.fromJson<Map<String, String>>(namesJson, mapType)
|
||||
_bookmarkNames.postValue(dict)
|
||||
_bookmarkNames.value = dict
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load bookmark names: ${e.message}")
|
||||
@@ -127,14 +128,14 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
|
||||
|
||||
private fun persist() {
|
||||
try {
|
||||
val json = gson.toJson(_bookmarks.value ?: emptyList<String>())
|
||||
val json = gson.toJson(_bookmarks.value)
|
||||
prefs.edit().putString(STORE_KEY, json).apply()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun persistNames() {
|
||||
try {
|
||||
val json = gson.toJson(_bookmarkNames.value ?: emptyMap<String, String>())
|
||||
val json = gson.toJson(_bookmarkNames.value)
|
||||
prefs.edit().putString(NAMES_STORE_KEY, json).apply()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
@@ -144,8 +145,8 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
|
||||
fun clearAll() {
|
||||
try {
|
||||
membership.clear()
|
||||
_bookmarks.postValue(emptyList())
|
||||
_bookmarkNames.postValue(emptyMap())
|
||||
_bookmarks.value = emptyList()
|
||||
_bookmarkNames.value = emptyMap()
|
||||
prefs.edit()
|
||||
.remove(STORE_KEY)
|
||||
.remove(NAMES_STORE_KEY)
|
||||
@@ -209,9 +210,9 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
|
||||
}
|
||||
|
||||
if (!name.isNullOrEmpty()) {
|
||||
val current = _bookmarkNames.value?.toMutableMap() ?: mutableMapOf()
|
||||
val current = _bookmarkNames.value.toMutableMap()
|
||||
current[gh] = name
|
||||
_bookmarkNames.postValue(current)
|
||||
_bookmarkNames.value = current
|
||||
persistNames(current)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -10,12 +10,12 @@ import android.location.LocationManager
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.*
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonSyntaxException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Manages location permissions, one-shot location retrieval, and computing geohash channels.
|
||||
@@ -53,28 +53,26 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
private var dataManager: com.bitchat.android.ui.DataManager? = null
|
||||
|
||||
// Published state for UI bindings (matching iOS @Published properties)
|
||||
private val _permissionState = MutableLiveData(PermissionState.NOT_DETERMINED)
|
||||
val permissionState: LiveData<PermissionState> = _permissionState
|
||||
private val _permissionState = MutableStateFlow(PermissionState.NOT_DETERMINED)
|
||||
val permissionState: StateFlow<PermissionState> = _permissionState
|
||||
|
||||
private val _availableChannels = MutableLiveData<List<GeohashChannel>>(emptyList())
|
||||
val availableChannels: LiveData<List<GeohashChannel>> = _availableChannels
|
||||
private val _availableChannels = MutableStateFlow<List<GeohashChannel>>(emptyList())
|
||||
val availableChannels: StateFlow<List<GeohashChannel>> = _availableChannels
|
||||
|
||||
private val _selectedChannel = MutableLiveData<ChannelID>(ChannelID.Mesh)
|
||||
val selectedChannel: LiveData<ChannelID> = _selectedChannel
|
||||
private val _selectedChannel = MutableStateFlow<ChannelID>(ChannelID.Mesh)
|
||||
val selectedChannel: StateFlow<ChannelID> = _selectedChannel
|
||||
|
||||
private val _teleported = MutableLiveData(false)
|
||||
val teleported: LiveData<Boolean> = _teleported
|
||||
private val _teleported = MutableStateFlow(false)
|
||||
val teleported: StateFlow<Boolean> = _teleported
|
||||
|
||||
private val _locationNames = MutableLiveData<Map<GeohashChannelLevel, String>>(emptyMap())
|
||||
val locationNames: LiveData<Map<GeohashChannelLevel, String>> = _locationNames
|
||||
private val _locationNames = MutableStateFlow<Map<GeohashChannelLevel, String>>(emptyMap())
|
||||
val locationNames: StateFlow<Map<GeohashChannelLevel, String>> = _locationNames
|
||||
|
||||
// Add a new LiveData property to indicate when location is being fetched
|
||||
private val _isLoadingLocation = MutableLiveData(false)
|
||||
val isLoadingLocation: LiveData<Boolean> = _isLoadingLocation
|
||||
private val _isLoadingLocation = MutableStateFlow(false)
|
||||
val isLoadingLocation: StateFlow<Boolean> = _isLoadingLocation
|
||||
|
||||
// Add a new LiveData property to track if location services are enabled by user
|
||||
private val _locationServicesEnabled = MutableLiveData(false)
|
||||
val locationServicesEnabled: LiveData<Boolean> = _locationServicesEnabled
|
||||
private val _locationServicesEnabled = MutableStateFlow(false)
|
||||
val locationServicesEnabled: StateFlow<Boolean> = _locationServicesEnabled
|
||||
|
||||
init {
|
||||
updatePermissionState()
|
||||
@@ -102,15 +100,15 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
when (getCurrentPermissionStatus()) {
|
||||
PermissionState.NOT_DETERMINED -> {
|
||||
Log.d(TAG, "Permission not determined - user needs to grant in app settings")
|
||||
_permissionState.postValue(PermissionState.NOT_DETERMINED)
|
||||
_permissionState.value = PermissionState.NOT_DETERMINED
|
||||
}
|
||||
PermissionState.DENIED, PermissionState.RESTRICTED -> {
|
||||
Log.d(TAG, "Permission denied or restricted")
|
||||
_permissionState.postValue(PermissionState.DENIED)
|
||||
_permissionState.value = PermissionState.DENIED
|
||||
}
|
||||
PermissionState.AUTHORIZED -> {
|
||||
Log.d(TAG, "Permission authorized - requesting location")
|
||||
_permissionState.postValue(PermissionState.AUTHORIZED)
|
||||
_permissionState.value = PermissionState.AUTHORIZED
|
||||
requestOneShotLocation()
|
||||
}
|
||||
}
|
||||
@@ -126,36 +124,55 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin periodic one-shot location refreshes while a selector UI is visible
|
||||
* Begin real-time location updates while a selector UI is visible
|
||||
* Uses requestLocationUpdates for continuous updates, plus a one-shot to prime state immediately
|
||||
*/
|
||||
fun beginLiveRefresh(interval: Long = 5000L) {
|
||||
Log.d(TAG, "Beginning live refresh with interval ${interval}ms")
|
||||
|
||||
Log.d(TAG, "Beginning live refresh (continuous updates)")
|
||||
|
||||
if (_permissionState.value != PermissionState.AUTHORIZED) {
|
||||
Log.w(TAG, "Cannot start live refresh - permission not authorized")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (!isLocationServicesEnabled()) {
|
||||
Log.w(TAG, "Cannot start live refresh - location services disabled by user")
|
||||
return
|
||||
}
|
||||
|
||||
// Cancel existing timer
|
||||
// Cancel any existing timer-based refreshers
|
||||
refreshTimer?.cancel()
|
||||
|
||||
// Start new timer with coroutines
|
||||
refreshTimer = CoroutineScope(Dispatchers.IO).launch {
|
||||
while (isActive) {
|
||||
if (isLocationServicesEnabled()) {
|
||||
requestOneShotLocation()
|
||||
refreshTimer = null
|
||||
|
||||
// Register for continuous updates from available providers
|
||||
try {
|
||||
if (hasLocationPermission()) {
|
||||
val providers = listOf(
|
||||
LocationManager.GPS_PROVIDER,
|
||||
LocationManager.NETWORK_PROVIDER
|
||||
)
|
||||
|
||||
providers.forEach { provider ->
|
||||
if (locationManager.isProviderEnabled(provider)) {
|
||||
// 2s min time, 5m min distance for responsive yet battery-aware updates
|
||||
locationManager.requestLocationUpdates(
|
||||
provider,
|
||||
interval,
|
||||
5f,
|
||||
continuousLocationListener
|
||||
)
|
||||
Log.d(TAG, "Registered continuous updates for $provider")
|
||||
}
|
||||
}
|
||||
delay(interval)
|
||||
|
||||
// Prime state immediately with last known / current location
|
||||
requestOneShotLocation()
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "Missing location permission for continuous updates: ${e.message}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to register continuous updates: ${e.message}")
|
||||
}
|
||||
|
||||
// Kick off immediately
|
||||
requestOneShotLocation()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,6 +182,12 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
Log.d(TAG, "Ending live refresh")
|
||||
refreshTimer?.cancel()
|
||||
refreshTimer = null
|
||||
// Unregister continuous updates listener
|
||||
try {
|
||||
locationManager.removeUpdates(continuousLocationListener)
|
||||
} catch (_: SecurityException) {
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,7 +203,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
lastLocation?.let { location ->
|
||||
when (channel) {
|
||||
is ChannelID.Mesh -> {
|
||||
_teleported.postValue(false)
|
||||
_teleported.value = false
|
||||
}
|
||||
is ChannelID.Location -> {
|
||||
val currentGeohash = Geohash.encode(
|
||||
@@ -189,7 +212,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
precision = channel.channel.level.precision
|
||||
)
|
||||
val isTeleportedNow = currentGeohash != channel.channel.geohash
|
||||
_teleported.postValue(isTeleportedNow)
|
||||
_teleported.value = isTeleportedNow
|
||||
Log.d(TAG, "Teleported (immediate recompute): $isTeleportedNow (current: $currentGeohash, selected: ${channel.channel.geohash})")
|
||||
}
|
||||
}
|
||||
@@ -201,7 +224,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
*/
|
||||
fun setTeleported(teleported: Boolean) {
|
||||
Log.d(TAG, "Setting teleported status: $teleported")
|
||||
_teleported.postValue(teleported)
|
||||
_teleported.value = teleported
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,7 +232,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
*/
|
||||
fun enableLocationServices() {
|
||||
Log.d(TAG, "enableLocationServices() called by user")
|
||||
_locationServicesEnabled.postValue(true)
|
||||
_locationServicesEnabled.value = true
|
||||
saveLocationServicesState(true)
|
||||
|
||||
// If we have permission, start location operations
|
||||
@@ -223,15 +246,15 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
*/
|
||||
fun disableLocationServices() {
|
||||
Log.d(TAG, "disableLocationServices() called by user")
|
||||
_locationServicesEnabled.postValue(false)
|
||||
_locationServicesEnabled.value = false
|
||||
saveLocationServicesState(false)
|
||||
|
||||
// Stop any ongoing location operations
|
||||
endLiveRefresh()
|
||||
|
||||
// Clear available channels when location is disabled
|
||||
_availableChannels.postValue(emptyList())
|
||||
_locationNames.postValue(emptyMap())
|
||||
_availableChannels.value = emptyList()
|
||||
_locationNames.value = emptyMap()
|
||||
|
||||
// If user had a location channel selected, switch back to mesh
|
||||
if (_selectedChannel.value is ChannelID.Location) {
|
||||
@@ -243,7 +266,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
* Check if location services are enabled by the user
|
||||
*/
|
||||
fun isLocationServicesEnabled(): Boolean {
|
||||
return _locationServicesEnabled.value ?: false
|
||||
return _locationServicesEnabled.value
|
||||
}
|
||||
|
||||
// MARK: - Location Operations
|
||||
@@ -280,13 +303,13 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
if (lastKnownLocation != null) {
|
||||
Log.d(TAG, "Using last known location: ${lastKnownLocation.latitude}, ${lastKnownLocation.longitude}")
|
||||
lastLocation = lastKnownLocation
|
||||
_isLoadingLocation.postValue(false) // Make sure loading state is off
|
||||
_isLoadingLocation.value = false // Make sure loading state is off
|
||||
computeChannels(lastKnownLocation)
|
||||
reverseGeocodeIfNeeded(lastKnownLocation)
|
||||
} else {
|
||||
Log.d(TAG, "No last known location available")
|
||||
// Set loading state to true so UI can show a spinner
|
||||
_isLoadingLocation.postValue(true)
|
||||
_isLoadingLocation.value = true
|
||||
|
||||
// Request a fresh location only when we don't have a last known location
|
||||
Log.d(TAG, "Requesting fresh location...")
|
||||
@@ -294,21 +317,45 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "Security exception requesting location: ${e.message}")
|
||||
_isLoadingLocation.postValue(false) // Turn off loading state on error
|
||||
_isLoadingLocation.value = false // Turn off loading state on error
|
||||
updatePermissionState()
|
||||
}
|
||||
}
|
||||
|
||||
// Continuous location listener for real-time updates
|
||||
private val continuousLocationListener = object : LocationListener {
|
||||
override fun onLocationChanged(location: Location) {
|
||||
Log.d(TAG, "Real-time location: ${location.latitude}, ${location.longitude} acc=${location.accuracy}m")
|
||||
lastLocation = location
|
||||
_isLoadingLocation.value = false
|
||||
computeChannels(location)
|
||||
reverseGeocodeIfNeeded(location)
|
||||
}
|
||||
|
||||
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {
|
||||
// Deprecated but can still be called on older devices
|
||||
Log.v(TAG, "Provider status changed: $provider -> $status")
|
||||
}
|
||||
|
||||
override fun onProviderEnabled(provider: String) {
|
||||
Log.d(TAG, "Provider enabled: $provider")
|
||||
}
|
||||
|
||||
override fun onProviderDisabled(provider: String) {
|
||||
Log.d(TAG, "Provider disabled: $provider")
|
||||
}
|
||||
}
|
||||
|
||||
// One-time location listener to get a fresh location update
|
||||
private val oneShotLocationListener = object : LocationListener {
|
||||
override fun onLocationChanged(location: Location) {
|
||||
Log.d(TAG, "Fresh location received: ${location.latitude}, ${location.longitude}")
|
||||
Log.d(TAG, "One-shot location: ${location.latitude}, ${location.longitude}")
|
||||
lastLocation = location
|
||||
computeChannels(location)
|
||||
reverseGeocodeIfNeeded(location)
|
||||
|
||||
|
||||
// Update loading state to indicate we have a location now
|
||||
_isLoadingLocation.postValue(false)
|
||||
_isLoadingLocation.value = false
|
||||
|
||||
// Remove this listener after getting the update
|
||||
try {
|
||||
@@ -317,18 +364,30 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
Log.e(TAG, "Error removing location listener: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {
|
||||
// Required for compatibility with older platform versions
|
||||
}
|
||||
|
||||
override fun onProviderEnabled(provider: String) {
|
||||
// Required for compatibility with older platform versions
|
||||
}
|
||||
|
||||
override fun onProviderDisabled(provider: String) {
|
||||
// Required for compatibility with older platform versions
|
||||
}
|
||||
}
|
||||
|
||||
// Request a fresh location update using getCurrentLocation instead of continuous updates
|
||||
private fun requestFreshLocation() {
|
||||
if (!hasLocationPermission()) {
|
||||
_isLoadingLocation.postValue(false) // Turn off loading state if no permission
|
||||
_isLoadingLocation.value = false // Turn off loading state if no permission
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Set loading state to true to indicate we're actively trying to get a location
|
||||
_isLoadingLocation.postValue(true)
|
||||
_isLoadingLocation.value = true
|
||||
|
||||
// Try common providers in order of preference
|
||||
val providers = listOf(
|
||||
@@ -358,7 +417,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
Log.w(TAG, "Received null location from getCurrentLocation")
|
||||
}
|
||||
// Update loading state to indicate we have a location now
|
||||
_isLoadingLocation.postValue(false)
|
||||
_isLoadingLocation.value = false
|
||||
}
|
||||
)
|
||||
} else {
|
||||
@@ -378,14 +437,14 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
// If no provider was available, turn off loading state
|
||||
if (!providerFound) {
|
||||
Log.w(TAG, "No location providers available")
|
||||
_isLoadingLocation.postValue(false)
|
||||
_isLoadingLocation.value = false
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "Security exception requesting location: ${e.message}")
|
||||
_isLoadingLocation.postValue(false) // Turn off loading state on error
|
||||
_isLoadingLocation.value = false // Turn off loading state on error
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error requesting location: ${e.message}")
|
||||
_isLoadingLocation.postValue(false) // Turn off loading state on error
|
||||
_isLoadingLocation.value = false // Turn off loading state on error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,7 +467,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
private fun updatePermissionState() {
|
||||
val newState = getCurrentPermissionStatus()
|
||||
Log.d(TAG, "Permission state updated to: $newState")
|
||||
_permissionState.postValue(newState)
|
||||
_permissionState.value = newState
|
||||
}
|
||||
|
||||
private fun hasLocationPermission(): Boolean {
|
||||
@@ -433,13 +492,13 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
Log.v(TAG, "Generated ${level.displayName}: $geohash")
|
||||
}
|
||||
|
||||
_availableChannels.postValue(result)
|
||||
_availableChannels.value = result
|
||||
|
||||
// Recompute teleported status based on current location vs selected channel
|
||||
val selectedChannelValue = _selectedChannel.value
|
||||
when (selectedChannelValue) {
|
||||
is ChannelID.Mesh -> {
|
||||
_teleported.postValue(false)
|
||||
_teleported.value = false
|
||||
}
|
||||
is ChannelID.Location -> {
|
||||
val currentGeohash = Geohash.encode(
|
||||
@@ -448,12 +507,9 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
precision = selectedChannelValue.channel.level.precision
|
||||
)
|
||||
val isTeleported = currentGeohash != selectedChannelValue.channel.geohash
|
||||
_teleported.postValue(isTeleported)
|
||||
_teleported.value = isTeleported
|
||||
Log.d(TAG, "Teleported status: $isTeleported (current: $currentGeohash, selected: ${selectedChannelValue.channel.geohash})")
|
||||
}
|
||||
null -> {
|
||||
_teleported.postValue(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,7 +538,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
val names = namesByLevel(address)
|
||||
|
||||
Log.d(TAG, "Reverse geocoding result: $names")
|
||||
_locationNames.postValue(names)
|
||||
_locationNames.value = names
|
||||
} else {
|
||||
Log.w(TAG, "No reverse geocoding results")
|
||||
}
|
||||
@@ -601,26 +657,26 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
}
|
||||
|
||||
if (channel != null) {
|
||||
_selectedChannel.postValue(channel)
|
||||
_selectedChannel.value = channel
|
||||
Log.d(TAG, "Restored persisted channel: ${channel.displayName}")
|
||||
} else {
|
||||
Log.d(TAG, "Could not restore persisted channel, defaulting to Mesh")
|
||||
_selectedChannel.postValue(ChannelID.Mesh)
|
||||
_selectedChannel.value = ChannelID.Mesh
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Invalid channel data format in persistence")
|
||||
_selectedChannel.postValue(ChannelID.Mesh)
|
||||
_selectedChannel.value = ChannelID.Mesh
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "No persisted channel found, defaulting to Mesh")
|
||||
_selectedChannel.postValue(ChannelID.Mesh)
|
||||
_selectedChannel.value = ChannelID.Mesh
|
||||
}
|
||||
} catch (e: JsonSyntaxException) {
|
||||
Log.e(TAG, "Failed to parse persisted channel data: ${e.message}")
|
||||
_selectedChannel.postValue(ChannelID.Mesh)
|
||||
_selectedChannel.value = ChannelID.Mesh
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load persisted channel: ${e.message}")
|
||||
_selectedChannel.postValue(ChannelID.Mesh)
|
||||
_selectedChannel.value = ChannelID.Mesh
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,7 +685,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
*/
|
||||
fun clearPersistedChannel() {
|
||||
dataManager?.clearLastGeohashChannel()
|
||||
_selectedChannel.postValue(ChannelID.Mesh)
|
||||
_selectedChannel.value = ChannelID.Mesh
|
||||
Log.d(TAG, "Cleared persisted channel selection")
|
||||
}
|
||||
|
||||
@@ -653,11 +709,11 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
private fun loadLocationServicesState() {
|
||||
try {
|
||||
val enabled = dataManager?.isLocationServicesEnabled() ?: false
|
||||
_locationServicesEnabled.postValue(enabled)
|
||||
_locationServicesEnabled.value = enabled
|
||||
Log.d(TAG, "Loaded location services state: $enabled")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load location services state: ${e.message}")
|
||||
_locationServicesEnabled.postValue(false)
|
||||
_locationServicesEnabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -668,16 +724,9 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
Log.d(TAG, "Cleaning up LocationChannelManager")
|
||||
endLiveRefresh()
|
||||
|
||||
// For older Android versions, remove any remaining location listener to prevent memory leaks
|
||||
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.R) {
|
||||
try {
|
||||
locationManager.removeUpdates(oneShotLocationListener)
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "Error removing location listener during cleanup: ${e.message}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error during cleanup: ${e.message}")
|
||||
}
|
||||
}
|
||||
// Remove listeners to prevent memory leaks
|
||||
try { locationManager.removeUpdates(oneShotLocationListener) } catch (_: Exception) {}
|
||||
try { locationManager.removeUpdates(continuousLocationListener) } catch (_: Exception) {}
|
||||
// For Android 11+, getCurrentLocation doesn't need explicit cleanup as it's a one-time operation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import java.security.MessageDigest
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.bitchat.android.util.hexEncodedString
|
||||
import androidx.core.content.edit
|
||||
|
||||
/**
|
||||
* Manages persistent identity storage and peer ID rotation - 100% compatible with iOS implementation
|
||||
@@ -24,9 +27,15 @@ class SecureIdentityStateManager(private val context: Context) {
|
||||
private const val KEY_STATIC_PUBLIC_KEY = "static_public_key"
|
||||
private const val KEY_SIGNING_PRIVATE_KEY = "signing_private_key"
|
||||
private const val KEY_SIGNING_PUBLIC_KEY = "signing_public_key"
|
||||
private const val KEY_VERIFIED_FINGERPRINTS = "verified_fingerprints"
|
||||
private const val KEY_CACHED_PEER_FINGERPRINTS = "cached_peer_fingerprints"
|
||||
private const val KEY_CACHED_PEER_NOISE_KEYS = "cached_peer_noise_keys"
|
||||
private const val KEY_CACHED_NOISE_FINGERPRINTS = "cached_noise_fingerprints"
|
||||
private const val KEY_CACHED_FINGERPRINT_NICKNAMES = "cached_fingerprint_nicknames"
|
||||
}
|
||||
|
||||
private val prefs: SharedPreferences
|
||||
private val lock = Any()
|
||||
|
||||
init {
|
||||
// Create master key for encryption
|
||||
@@ -168,7 +177,7 @@ class SecureIdentityStateManager(private val context: Context) {
|
||||
fun generateFingerprint(publicKeyData: ByteArray): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val hash = digest.digest(publicKeyData)
|
||||
return hash.joinToString("") { "%02x".format(it) }
|
||||
return hash.hexEncodedString()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,6 +187,112 @@ class SecureIdentityStateManager(private val context: Context) {
|
||||
// SHA-256 fingerprint should be 64 hex characters
|
||||
return fingerprint.matches(Regex("^[a-fA-F0-9]{64}$"))
|
||||
}
|
||||
|
||||
// MARK: - Verified Fingerprints
|
||||
|
||||
fun getVerifiedFingerprints(): Set<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)
|
||||
// Android now derives peer ID from the persisted Noise identity fingerprint.
|
||||
|
||||
@@ -84,6 +84,10 @@ class BluetoothConnectionManager(
|
||||
|
||||
// Public property for address-peer mapping
|
||||
val addressPeerMap get() = connectionTracker.addressPeerMap
|
||||
|
||||
// Expose first-announce helpers to higher layers
|
||||
fun noteAnnounceReceived(address: String) { connectionTracker.noteAnnounceReceived(address) }
|
||||
fun hasSeenFirstAnnounce(address: String): Boolean = connectionTracker.hasSeenFirstAnnounce(address)
|
||||
|
||||
init {
|
||||
powerManager.delegate = this
|
||||
@@ -145,6 +149,7 @@ class BluetoothConnectionManager(
|
||||
|
||||
try {
|
||||
isActive = true
|
||||
Log.d(TAG, "ConnectionManager activated (permissions and adapter OK)")
|
||||
|
||||
// set the adapter's name to our 8-character peerID for iOS privacy, TODO: Make this configurable
|
||||
// try {
|
||||
@@ -175,6 +180,7 @@ class BluetoothConnectionManager(
|
||||
this@BluetoothConnectionManager.isActive = false
|
||||
return@launch
|
||||
}
|
||||
Log.d(TAG, "GATT Server started")
|
||||
} else {
|
||||
Log.i(TAG, "GATT Server disabled by debug settings; not starting")
|
||||
}
|
||||
@@ -185,6 +191,7 @@ class BluetoothConnectionManager(
|
||||
this@BluetoothConnectionManager.isActive = false
|
||||
return@launch
|
||||
}
|
||||
Log.d(TAG, "GATT Client started")
|
||||
} else {
|
||||
Log.i(TAG, "GATT Client disabled by debug settings; not starting")
|
||||
}
|
||||
@@ -210,6 +217,7 @@ class BluetoothConnectionManager(
|
||||
isActive = false
|
||||
|
||||
connectionScope.launch {
|
||||
Log.d(TAG, "Stopping client/server and power components...")
|
||||
// Stop component managers
|
||||
clientManager.stop()
|
||||
serverManager.stop()
|
||||
@@ -226,14 +234,19 @@ class BluetoothConnectionManager(
|
||||
Log.i(TAG, "All Bluetooth services stopped")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set app background state for power optimization
|
||||
*/
|
||||
fun setAppBackgroundState(inBackground: Boolean) {
|
||||
powerManager.setAppBackgroundState(inBackground)
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether this instance can be safely reused for a future start.
|
||||
* Returns false if its coroutine scope has been cancelled.
|
||||
*/
|
||||
fun isReusable(): Boolean {
|
||||
val active = connectionScope.isActive
|
||||
if (!active) {
|
||||
Log.d(TAG, "BluetoothConnectionManager isReusable=false (scope cancelled)")
|
||||
}
|
||||
return active
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast packet to connected devices with connection limit enforcement
|
||||
* Automatically fragments large packets to fit within BLE MTU limits
|
||||
|
||||
@@ -30,6 +30,8 @@ class BluetoothConnectionTracker(
|
||||
private val connectedDevices = ConcurrentHashMap<String, DeviceConnection>()
|
||||
private val subscribedDevices = CopyOnWriteArrayList<BluetoothDevice>()
|
||||
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)
|
||||
private val scanRSSI = ConcurrentHashMap<String, Int>()
|
||||
@@ -91,6 +93,8 @@ class BluetoothConnectionTracker(
|
||||
Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}")
|
||||
connectedDevices[deviceAddress] = deviceConn
|
||||
pendingConnections.remove(deviceAddress)
|
||||
// Mark as awaiting first ANNOUNCE on this connection
|
||||
firstAnnounceSeen[deviceAddress] = false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,7 +198,8 @@ class BluetoothConnectionTracker(
|
||||
}
|
||||
|
||||
// Update connection attempt atomically
|
||||
val attempts = (currentAttempt?.attempts ?: 0) + 1
|
||||
// If the previous attempt window expired, reset backoff to 1; otherwise increment
|
||||
val attempts = if (currentAttempt?.isExpired() == true) 1 else (currentAttempt?.attempts ?: 0) + 1
|
||||
pendingConnections[deviceAddress] = ConnectionAttempt(attempts)
|
||||
Log.d(TAG, "Tracker: Added pending connection for $deviceAddress (attempts: $attempts)")
|
||||
return true
|
||||
@@ -279,7 +284,7 @@ class BluetoothConnectionTracker(
|
||||
subscribedDevices.removeAll { it.address == deviceAddress }
|
||||
addressPeerMap.remove(deviceAddress)
|
||||
}
|
||||
pendingConnections.remove(deviceAddress)
|
||||
firstAnnounceSeen.remove(deviceAddress)
|
||||
Log.d(TAG, "Cleaned up device connection for $deviceAddress")
|
||||
}
|
||||
|
||||
@@ -313,6 +318,21 @@ class BluetoothConnectionTracker(
|
||||
addressPeerMap.clear()
|
||||
pendingConnections.clear()
|
||||
scanRSSI.clear()
|
||||
firstAnnounceSeen.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark that we have received the first ANNOUNCE over this device connection.
|
||||
*/
|
||||
fun noteAnnounceReceived(deviceAddress: String) {
|
||||
firstAnnounceSeen[deviceAddress] = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the first ANNOUNCE has been seen for a device connection.
|
||||
*/
|
||||
fun hasSeenFirstAnnounce(deviceAddress: String): Boolean {
|
||||
return firstAnnounceSeen[deviceAddress] == true
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,12 +7,15 @@ import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.protocol.MessagePadding
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.model.IdentityAnnouncement
|
||||
import com.bitchat.android.model.NoisePayload
|
||||
import com.bitchat.android.model.NoisePayloadType
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.protocol.SpecialRecipients
|
||||
import com.bitchat.android.model.RequestSyncPacket
|
||||
import com.bitchat.android.sync.GossipSyncManager
|
||||
import com.bitchat.android.util.toHexString
|
||||
import com.bitchat.android.services.VerificationService
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.*
|
||||
import kotlin.math.sign
|
||||
@@ -52,6 +55,12 @@ class BluetoothMeshService(private val context: Context) {
|
||||
internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access
|
||||
private val packetProcessor = PacketProcessor(myPeerID)
|
||||
private lateinit var gossipSyncManager: GossipSyncManager
|
||||
// Service-level notification manager for background (no-UI) DMs
|
||||
private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager(
|
||||
context.applicationContext,
|
||||
androidx.core.app.NotificationManagerCompat.from(context.applicationContext),
|
||||
com.bitchat.android.util.NotificationIntervalManager()
|
||||
)
|
||||
|
||||
// Service state management
|
||||
private var isActive = false
|
||||
@@ -61,8 +70,12 @@ class BluetoothMeshService(private val context: Context) {
|
||||
|
||||
// Coroutines
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
// Tracks whether this instance has been terminated via stopServices()
|
||||
private var terminated = false
|
||||
|
||||
init {
|
||||
Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID")
|
||||
VerificationService.configure(encryptionService)
|
||||
setupDelegates()
|
||||
messageHandler.packetProcessor = packetProcessor
|
||||
//startPeriodicDebugLogging()
|
||||
@@ -98,6 +111,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
return signPacketBeforeBroadcast(packet)
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Delegates set up; GossipSyncManager initialized")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,6 +119,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
*/
|
||||
private fun startPeriodicDebugLogging() {
|
||||
serviceScope.launch {
|
||||
Log.d(TAG, "Starting periodic debug logging loop")
|
||||
while (isActive) {
|
||||
try {
|
||||
delay(10000) // 10 seconds
|
||||
@@ -116,6 +131,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
Log.e(TAG, "Error in periodic debug logging: ${e.message}")
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Periodic debug logging loop ended (isActive=$isActive)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +140,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
*/
|
||||
private fun sendPeriodicBroadcastAnnounce() {
|
||||
serviceScope.launch {
|
||||
Log.d(TAG, "Starting periodic announce loop")
|
||||
while (isActive) {
|
||||
try {
|
||||
delay(30000) // 30 seconds
|
||||
@@ -132,6 +149,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
Log.e(TAG, "Error in periodic broadcast announce: ${e.message}")
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Periodic announce loop ended (isActive=$isActive)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +157,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
* Setup delegate connections between components
|
||||
*/
|
||||
private fun setupDelegates() {
|
||||
Log.d(TAG, "Setting up component delegates")
|
||||
// Provide nickname resolver to BLE broadcaster for detailed logs
|
||||
try {
|
||||
connectionManager.setNicknameResolver { pid -> peerManager.getPeerNickname(pid) }
|
||||
@@ -146,6 +165,9 @@ class BluetoothMeshService(private val context: Context) {
|
||||
// PeerManager delegates to main mesh service delegate
|
||||
peerManager.delegate = object : PeerManagerDelegate {
|
||||
override fun onPeerListUpdated(peerIDs: List<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)
|
||||
}
|
||||
override fun onPeerRemoved(peerID: String) {
|
||||
@@ -165,6 +187,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) {
|
||||
// Send announcement and cached messages after key exchange
|
||||
serviceScope.launch {
|
||||
Log.d(TAG, "Key exchange completed with $peerID; sending follow-ups")
|
||||
delay(100)
|
||||
sendAnnouncementToPeer(peerID)
|
||||
|
||||
@@ -352,7 +375,36 @@ class BluetoothMeshService(private val context: Context) {
|
||||
|
||||
// Callbacks
|
||||
override fun onMessageReceived(message: BitchatMessage) {
|
||||
// Always reflect into process-wide store so UI can hydrate after recreation
|
||||
try {
|
||||
when {
|
||||
message.isPrivate -> {
|
||||
val peer = message.senderPeerID ?: ""
|
||||
if (peer.isNotEmpty()) com.bitchat.android.services.AppStateStore.addPrivateMessage(peer, message)
|
||||
}
|
||||
message.channel != null -> {
|
||||
com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel!!, message)
|
||||
}
|
||||
else -> {
|
||||
com.bitchat.android.services.AppStateStore.addPublicMessage(message)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
// And forward to UI delegate if attached
|
||||
delegate?.didReceiveMessage(message)
|
||||
|
||||
// If no UI delegate attached (app closed), show DM notification via service manager
|
||||
if (delegate == null && message.isPrivate) {
|
||||
try {
|
||||
val senderPeerID = message.senderPeerID
|
||||
if (senderPeerID != null) {
|
||||
val nick = try { peerManager.getPeerNickname(senderPeerID) } catch (_: Exception) { null } ?: senderPeerID
|
||||
val preview = com.bitchat.android.ui.NotificationTextUtils.buildPrivateMessagePreview(message)
|
||||
serviceNotificationManager.setAppBackgroundState(true)
|
||||
serviceNotificationManager.showPrivateMessageNotification(senderPeerID, nick, preview)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onChannelLeave(channel: String, fromPeer: String) {
|
||||
@@ -366,6 +418,14 @@ class BluetoothMeshService(private val context: Context) {
|
||||
override fun onReadReceiptReceived(messageID: String, peerID: String) {
|
||||
delegate?.didReceiveReadReceipt(messageID, peerID)
|
||||
}
|
||||
|
||||
override fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
delegate?.didReceiveVerifyChallenge(peerID, payload, timestampMs)
|
||||
}
|
||||
|
||||
override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
|
||||
}
|
||||
}
|
||||
|
||||
// PacketProcessor delegates
|
||||
@@ -408,25 +468,17 @@ class BluetoothMeshService(private val context: Context) {
|
||||
val deviceAddress = routed.relayAddress
|
||||
val pid = routed.peerID
|
||||
if (deviceAddress != null && pid != null) {
|
||||
// Only set mapping if not already mapped
|
||||
if (!connectionManager.addressPeerMap.containsKey(deviceAddress)) {
|
||||
// First ANNOUNCE over a device connection defines a direct neighbor.
|
||||
if (!connectionManager.hasSeenFirstAnnounce(deviceAddress)) {
|
||||
// Bind or rebind this device address to the announcing peer
|
||||
connectionManager.addressPeerMap[deviceAddress] = pid
|
||||
Log.d(TAG, "Mapped device $deviceAddress to peer $pid on ANNOUNCE")
|
||||
connectionManager.noteAnnounceReceived(deviceAddress)
|
||||
Log.d(TAG, "Mapped device $deviceAddress to peer $pid on FIRST-ANNOUNCE for this connection")
|
||||
|
||||
// Mark this peer as directly connected for UI
|
||||
try {
|
||||
peerManager.getPeerInfo(pid)?.let {
|
||||
// Set direct connection flag
|
||||
// (This will also trigger a peer list update)
|
||||
peerManager.setDirectConnection(pid, true)
|
||||
// Also push reactive directness state to UI (best-effort)
|
||||
try {
|
||||
// Note: UI observes via didUpdatePeerList, but we can also update ChatState on a timer
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
// Mark as directly connected (upgrades from routed if needed)
|
||||
try { peerManager.setDirectConnection(pid, true) } catch (_: Exception) { }
|
||||
|
||||
// Schedule initial sync for this new directly connected peer only
|
||||
// Initial sync for this newly direct peer
|
||||
try { gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
@@ -489,12 +541,23 @@ class BluetoothMeshService(private val context: Context) {
|
||||
// BluetoothConnectionManager delegates
|
||||
connectionManager.delegate = object : BluetoothConnectionManagerDelegate {
|
||||
override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) {
|
||||
// Log incoming for debug graphs (do not double-count anywhere else)
|
||||
try {
|
||||
val nick = getPeerNicknames()[peerID]
|
||||
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming(
|
||||
packetType = packet.type.toString(),
|
||||
fromPeerID = peerID,
|
||||
fromNickname = nick,
|
||||
fromDeviceAddress = device?.address
|
||||
)
|
||||
} catch (_: Exception) { }
|
||||
packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address))
|
||||
}
|
||||
|
||||
override fun onDeviceConnected(device: android.bluetooth.BluetoothDevice) {
|
||||
// Send initial announcements after services are ready
|
||||
serviceScope.launch {
|
||||
Log.d(TAG, "Device connected: ${device.address}; scheduling announce")
|
||||
delay(200)
|
||||
sendBroadcastAnnounce()
|
||||
}
|
||||
@@ -509,6 +572,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
}
|
||||
|
||||
override fun onDeviceDisconnected(device: android.bluetooth.BluetoothDevice) {
|
||||
Log.d(TAG, "Device disconnected: ${device.address}")
|
||||
val addr = device.address
|
||||
// Remove mapping and, if that was the last direct path for the peer, clear direct flag
|
||||
val peer = connectionManager.addressPeerMap[addr]
|
||||
@@ -547,6 +611,11 @@ class BluetoothMeshService(private val context: Context) {
|
||||
Log.w(TAG, "Mesh service already active, ignoring duplicate start request")
|
||||
return
|
||||
}
|
||||
if (terminated) {
|
||||
// This instance's scope was cancelled previously; refuse to start to avoid using dead scopes.
|
||||
Log.e(TAG, "Mesh service instance was terminated; create a new instance instead of restarting")
|
||||
return
|
||||
}
|
||||
|
||||
Log.i(TAG, "Starting Bluetooth mesh service with peer ID: $myPeerID")
|
||||
|
||||
@@ -558,6 +627,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
Log.d(TAG, "Started periodic broadcast announcements (every 30 seconds)")
|
||||
// Start periodic syncs
|
||||
gossipSyncManager.start()
|
||||
Log.d(TAG, "GossipSyncManager started")
|
||||
} else {
|
||||
Log.e(TAG, "Failed to start Bluetooth services")
|
||||
}
|
||||
@@ -579,11 +649,14 @@ class BluetoothMeshService(private val context: Context) {
|
||||
sendLeaveAnnouncement()
|
||||
|
||||
serviceScope.launch {
|
||||
Log.d(TAG, "Stopping subcomponents and cancelling scope...")
|
||||
delay(200) // Give leave message time to send
|
||||
|
||||
// Stop all components
|
||||
gossipSyncManager.stop()
|
||||
Log.d(TAG, "GossipSyncManager stopped")
|
||||
connectionManager.stopServices()
|
||||
Log.d(TAG, "BluetoothConnectionManager stop requested")
|
||||
peerManager.shutdown()
|
||||
fragmentManager.shutdown()
|
||||
securityManager.shutdown()
|
||||
@@ -591,9 +664,24 @@ class BluetoothMeshService(private val context: Context) {
|
||||
messageHandler.shutdown()
|
||||
packetProcessor.shutdown()
|
||||
|
||||
// Mark this instance as terminated and cancel its scope so it won't be reused
|
||||
terminated = true
|
||||
serviceScope.cancel()
|
||||
Log.i(TAG, "BluetoothMeshService terminated and scope cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this instance can be safely reused. Returns false after stopServices() or if
|
||||
* any critical internal scope has been cancelled.
|
||||
*/
|
||||
fun isReusable(): Boolean {
|
||||
val reusable = !terminated && serviceScope.isActive && connectionManager.isReusable()
|
||||
if (!reusable) {
|
||||
Log.d(TAG, "isReusable=false (terminated=$terminated, scopeActive=${serviceScope.isActive}, connReusable=${connectionManager.isReusable()})")
|
||||
}
|
||||
return reusable
|
||||
}
|
||||
|
||||
/**
|
||||
* Send public message
|
||||
@@ -813,7 +901,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) {
|
||||
serviceScope.launch {
|
||||
Log.d(TAG, "📖 Sending read receipt for message $messageID to $recipientPeerID")
|
||||
|
||||
|
||||
// Route geohash read receipts via MessageRouter instead of here
|
||||
val geo = runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance() }.getOrNull()
|
||||
val isGeoAlias = try {
|
||||
@@ -824,8 +912,15 @@ class BluetoothMeshService(private val context: Context) {
|
||||
geo.sendReadReceipt(com.bitchat.android.model.ReadReceipt(messageID), recipientPeerID)
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Avoid duplicate read receipts: check persistent store first
|
||||
val seenStore = try { com.bitchat.android.services.SeenMessageStore.getInstance(context.applicationContext) } catch (_: Exception) { null }
|
||||
if (seenStore?.hasRead(messageID) == true) {
|
||||
Log.d(TAG, "Skipping read receipt for $messageID - already marked read")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Create read receipt payload using NoisePayloadType exactly like iOS
|
||||
val readReceiptPayload = com.bitchat.android.model.NoisePayload(
|
||||
type = com.bitchat.android.model.NoisePayloadType.READ_RECEIPT,
|
||||
@@ -851,12 +946,59 @@ class BluetoothMeshService(private val context: Context) {
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
||||
Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID")
|
||||
|
||||
|
||||
// Persist as read after successful send
|
||||
try { seenStore?.markRead(messageID) } catch (_: Exception) { }
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send read receipt to $recipientPeerID: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: QR Verification over Noise
|
||||
|
||||
fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
|
||||
val tlv = VerificationService.buildVerifyChallenge(noiseKeyHex, nonceA)
|
||||
val payload = NoisePayload(
|
||||
type = NoisePayloadType.VERIFY_CHALLENGE,
|
||||
data = tlv
|
||||
)
|
||||
sendNoisePayloadToPeer(payload, peerID, "verify challenge")
|
||||
}
|
||||
|
||||
fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
|
||||
val tlv = VerificationService.buildVerifyResponse(noiseKeyHex, nonceA) ?: return
|
||||
val payload = NoisePayload(
|
||||
type = NoisePayloadType.VERIFY_RESPONSE,
|
||||
data = tlv
|
||||
)
|
||||
sendNoisePayloadToPeer(payload, peerID, "verify response")
|
||||
}
|
||||
|
||||
private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String, label: String) {
|
||||
serviceScope.launch {
|
||||
try {
|
||||
val encrypted = encryptionService.encrypt(payload.encode(), recipientPeerID)
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = hexStringToByteArray(recipientPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = encrypted,
|
||||
signature = null,
|
||||
ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
|
||||
)
|
||||
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
||||
Log.d(TAG, "📤 Sent $label to $recipientPeerID (${payload.data.size} bytes)")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send $label to $recipientPeerID: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send broadcast announce with TLV-encoded identity announcement - exactly like iOS
|
||||
@@ -864,7 +1006,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
fun sendBroadcastAnnounce() {
|
||||
Log.d(TAG, "Sending broadcast announce")
|
||||
serviceScope.launch {
|
||||
val nickname = delegate?.getNickname() ?: myPeerID
|
||||
val nickname = try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { myPeerID }
|
||||
|
||||
// Get the static public key for the announcement
|
||||
val staticKey = encryptionService.getStaticPublicKey()
|
||||
@@ -927,7 +1069,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
fun sendAnnouncementToPeer(peerID: String) {
|
||||
if (peerManager.hasAnnouncedToPeer(peerID)) return
|
||||
|
||||
val nickname = delegate?.getNickname() ?: myPeerID
|
||||
val nickname = try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { myPeerID }
|
||||
|
||||
// Get the static public key for the announcement
|
||||
val staticKey = encryptionService.getStaticPublicKey()
|
||||
@@ -1003,12 +1145,11 @@ class BluetoothMeshService(private val context: Context) {
|
||||
* Send leave announcement
|
||||
*/
|
||||
private fun sendLeaveAnnouncement() {
|
||||
val nickname = delegate?.getNickname() ?: myPeerID
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.LEAVE.value,
|
||||
ttl = MAX_TTL,
|
||||
senderID = myPeerID,
|
||||
payload = nickname.toByteArray()
|
||||
payload = byteArrayOf()
|
||||
)
|
||||
|
||||
// Sign the packet before broadcasting
|
||||
@@ -1055,6 +1196,13 @@ class BluetoothMeshService(private val context: Context) {
|
||||
return peerManager.getFingerprintForPeer(peerID)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current active peer count (for status/notifications)
|
||||
*/
|
||||
fun getActivePeerCount(): Int {
|
||||
return try { peerManager.getActivePeerCount() } catch (_: Exception) { 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get peer info for verification purposes
|
||||
*/
|
||||
@@ -1081,6 +1229,10 @@ class BluetoothMeshService(private val context: Context) {
|
||||
fun getIdentityFingerprint(): String {
|
||||
return encryptionService.getIdentityFingerprint()
|
||||
}
|
||||
|
||||
fun getStaticNoisePublicKey(): ByteArray? {
|
||||
return encryptionService.getStaticPublicKey()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if encryption icon should be shown for a peer
|
||||
@@ -1253,6 +1405,8 @@ interface BluetoothMeshDelegate {
|
||||
fun didReceiveChannelLeave(channel: String, fromPeer: String)
|
||||
fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String)
|
||||
fun didReceiveReadReceipt(messageID: String, recipientPeerID: String)
|
||||
fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long)
|
||||
fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long)
|
||||
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
|
||||
fun getNickname(): String?
|
||||
fun isFavorite(peerID: String): Boolean
|
||||
|
||||
@@ -73,9 +73,17 @@ class BluetoothPacketBroadcaster(
|
||||
try {
|
||||
val fromNick = incomingPeer?.let { nicknameResolver?.invoke(it) }
|
||||
val toNick = toPeer?.let { nicknameResolver?.invoke(it) }
|
||||
val isRelay = (incomingAddr != null || incomingPeer != null)
|
||||
|
||||
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logPacketRelayDetailed(
|
||||
val manager = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance()
|
||||
// Always log outgoing for the actual transmission target
|
||||
manager.logOutgoing(
|
||||
packetType = typeName,
|
||||
toPeerID = toPeer,
|
||||
toNickname = toNick,
|
||||
toDeviceAddress = toDeviceAddress,
|
||||
previousHopPeerID = incomingPeer
|
||||
)
|
||||
// Keep the verbose relay message for human readability
|
||||
manager.logPacketRelayDetailed(
|
||||
packetType = typeName,
|
||||
senderPeerID = senderPeerID,
|
||||
senderNickname = senderNick,
|
||||
@@ -86,7 +94,7 @@ class BluetoothPacketBroadcaster(
|
||||
toNickname = toNick,
|
||||
toDeviceAddress = toDeviceAddress,
|
||||
ttl = ttl,
|
||||
isRelay = isRelay
|
||||
isRelay = true
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
// Silently ignore debug logging failures
|
||||
|
||||
@@ -33,9 +33,9 @@ class BluetoothPermissionManager(private val context: Context) {
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION
|
||||
))
|
||||
|
||||
|
||||
return permissions.all {
|
||||
ActivityCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +157,14 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
// Simplified: Call delegate with messageID and peerID directly
|
||||
delegate?.onReadReceiptReceived(messageID, peerID)
|
||||
}
|
||||
com.bitchat.android.model.NoisePayloadType.VERIFY_CHALLENGE -> {
|
||||
Log.d(TAG, "🔐 Verify challenge received from $peerID (${noisePayload.data.size} bytes)")
|
||||
delegate?.onVerifyChallengeReceived(peerID, noisePayload.data, packet.timestamp.toLong())
|
||||
}
|
||||
com.bitchat.android.model.NoisePayloadType.VERIFY_RESPONSE -> {
|
||||
Log.d(TAG, "🔐 Verify response received from $peerID (${noisePayload.data.size} bytes)")
|
||||
delegate?.onVerifyResponseReceived(peerID, noisePayload.data, packet.timestamp.toLong())
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
@@ -618,4 +626,6 @@ interface MessageHandlerDelegate {
|
||||
fun onChannelLeave(channel: String, fromPeer: String)
|
||||
fun onDeliveryAckReceived(messageID: String, peerID: String)
|
||||
fun onReadReceiptReceived(messageID: String, peerID: String)
|
||||
fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long)
|
||||
fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long)
|
||||
}
|
||||
|
||||
@@ -299,8 +299,7 @@ class PeerManager {
|
||||
*/
|
||||
fun isPeerActive(peerID: String): Boolean {
|
||||
val info = peers[peerID] ?: return false
|
||||
val now = System.currentTimeMillis()
|
||||
return (now - info.lastSeen) <= stalePeerTimeoutMs && info.isConnected
|
||||
return info.isConnected
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,8 +327,7 @@ class PeerManager {
|
||||
* Get list of active peer IDs
|
||||
*/
|
||||
fun getActivePeerIDs(): List<String> {
|
||||
val now = System.currentTimeMillis()
|
||||
return peers.filterValues { (now - it.lastSeen) <= stalePeerTimeoutMs && it.isConnected }
|
||||
return peers.filterValues { it.isConnected }
|
||||
.keys
|
||||
.toList()
|
||||
.sorted()
|
||||
|
||||
@@ -7,7 +7,13 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.BatteryManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import kotlinx.coroutines.*
|
||||
import kotlin.math.max
|
||||
|
||||
@@ -15,7 +21,7 @@ import kotlin.math.max
|
||||
* Power-aware Bluetooth management for bitchat
|
||||
* Adjusts scanning, advertising, and connection behavior based on battery state
|
||||
*/
|
||||
class PowerManager(private val context: Context) {
|
||||
class PowerManager(private val context: Context) : LifecycleEventObserver {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PowerManager"
|
||||
@@ -49,7 +55,7 @@ class PowerManager(private val context: Context) {
|
||||
private var currentMode = PowerMode.BALANCED
|
||||
private var isCharging = false
|
||||
private var batteryLevel = 100
|
||||
private var isAppInBackground = false
|
||||
private var isAppInBackground = true
|
||||
|
||||
private val powerScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var dutyCycleJob: Job? = null
|
||||
@@ -87,6 +93,16 @@ class PowerManager(private val context: Context) {
|
||||
|
||||
init {
|
||||
registerBatteryReceiver()
|
||||
|
||||
// Register for process lifecycle events on the main thread
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
try {
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to register lifecycle observer: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
updatePowerMode()
|
||||
}
|
||||
|
||||
@@ -99,13 +115,30 @@ class PowerManager(private val context: Context) {
|
||||
Log.i(TAG, "Stopping power management")
|
||||
powerScope.cancel()
|
||||
unregisterBatteryReceiver()
|
||||
|
||||
// Unregister lifecycle observer
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
try {
|
||||
ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to remove lifecycle observer: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAppBackgroundState(inBackground: Boolean) {
|
||||
if (isAppInBackground != inBackground) {
|
||||
isAppInBackground = inBackground
|
||||
Log.d(TAG, "App background state changed: $inBackground")
|
||||
updatePowerMode()
|
||||
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_START -> {
|
||||
Log.d(TAG, "Process lifecycle: ON_START (App coming to foreground)")
|
||||
isAppInBackground = false
|
||||
updatePowerMode()
|
||||
}
|
||||
Lifecycle.Event.ON_STOP -> {
|
||||
Log.d(TAG, "Process lifecycle: ON_STOP (App going to background)")
|
||||
isAppInBackground = true
|
||||
updatePowerMode()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +166,7 @@ class PowerManager(private val context: Context) {
|
||||
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
|
||||
|
||||
PowerMode.ULTRA_LOW_POWER -> builder
|
||||
.setScanMode(ScanSettings.SCAN_MODE_OPPORTUNISTIC)
|
||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
|
||||
.setMatchMode(ScanSettings.MATCH_MODE_STICKY)
|
||||
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
|
||||
}
|
||||
@@ -224,26 +257,29 @@ class PowerManager(private val context: Context) {
|
||||
}
|
||||
|
||||
private fun updatePowerMode() {
|
||||
val newMode = when {
|
||||
// Always use performance mode when charging (unless in background too long)
|
||||
// Determine the base mode from battery/charging state only
|
||||
val baseMode = when {
|
||||
// Charging in foreground may use performance
|
||||
isCharging && !isAppInBackground -> PowerMode.PERFORMANCE
|
||||
|
||||
// Critical battery - use ultra low power
|
||||
|
||||
// Critical battery - force ultra low power regardless of foreground/background
|
||||
batteryLevel <= CRITICAL_BATTERY -> PowerMode.ULTRA_LOW_POWER
|
||||
|
||||
// Low battery - use power saver
|
||||
|
||||
// Low battery - prefer power saver
|
||||
batteryLevel <= LOW_BATTERY -> PowerMode.POWER_SAVER
|
||||
|
||||
// Background app with medium battery - use power saver
|
||||
isAppInBackground && batteryLevel <= MEDIUM_BATTERY -> PowerMode.POWER_SAVER
|
||||
|
||||
// Background app with good battery - use balanced
|
||||
isAppInBackground -> PowerMode.BALANCED
|
||||
|
||||
// Foreground with good battery - use balanced
|
||||
|
||||
// Otherwise balanced
|
||||
else -> PowerMode.BALANCED
|
||||
}
|
||||
|
||||
|
||||
// If app is in background (including when running as a foreground service),
|
||||
// cap the power mode to at least POWER_SAVER. Preserve ULTRA_LOW_POWER.
|
||||
val newMode = if (isAppInBackground) {
|
||||
if (baseMode == PowerMode.ULTRA_LOW_POWER) PowerMode.ULTRA_LOW_POWER else PowerMode.POWER_SAVER
|
||||
} else {
|
||||
baseMode
|
||||
}
|
||||
|
||||
if (newMode != currentMode) {
|
||||
val oldMode = currentMode
|
||||
currentMode = newMode
|
||||
|
||||
@@ -50,35 +50,36 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate packet payload
|
||||
if (packet.payload.isEmpty()) {
|
||||
Log.d(TAG, "Dropping packet with empty payload")
|
||||
return false
|
||||
}
|
||||
|
||||
// Replay attack protection (same 5-minute window as iOS)
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val packetTime = packet.timestamp.toLong()
|
||||
val timeDiff = kotlin.math.abs(currentTime - packetTime)
|
||||
|
||||
// if (timeDiff > MESSAGE_TIMEOUT) {
|
||||
// Log.d(TAG, "Dropping old packet from $peerID, time diff: ${timeDiff/1000}s")
|
||||
// return false
|
||||
// }
|
||||
val messageType = MessageType.fromValue(packet.type)
|
||||
|
||||
// Duplicate detection
|
||||
val messageID = generateMessageID(packet, peerID)
|
||||
if (processedMessages.contains(messageID)) {
|
||||
Log.d(TAG, "Dropping duplicate packet: $messageID")
|
||||
return false
|
||||
}
|
||||
|
||||
if (processedMessages.contains(messageID)) {
|
||||
// Check for ANNOUNCE exception: allow if it looks like a direct neighbor (max TTL)
|
||||
// This ensures we catch the "first announce" on a new connection for binding,
|
||||
// while still dropping looped/relayed duplicates.
|
||||
val isFreshAnnounce = messageType == MessageType.ANNOUNCE &&
|
||||
packet.ttl >= com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
|
||||
|
||||
if (!isFreshAnnounce) {
|
||||
Log.d(TAG, "Dropping duplicate packet: $messageID")
|
||||
return false
|
||||
}
|
||||
Log.d(TAG, "Allowing duplicate ANNOUNCE from direct neighbor: $messageID")
|
||||
}
|
||||
|
||||
// Add to processed messages
|
||||
processedMessages.add(messageID)
|
||||
messageTimestamps[messageID] = currentTime
|
||||
|
||||
// NEW: Signature verification logging (not rejecting yet)
|
||||
verifyPacketSignatureWithLogging(packet, peerID)
|
||||
// Enforce mandatory signature verification
|
||||
if (!verifyPacketSignature(packet, peerID)) {
|
||||
Log.w(TAG, "Dropping packet from $peerID due to signature verification failure")
|
||||
return false
|
||||
}
|
||||
|
||||
Log.d(TAG, "Packet validation passed for $peerID, messageID: $messageID")
|
||||
return true
|
||||
@@ -231,34 +232,58 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify packet signature using peer's signing public key and log the result
|
||||
* Verify packet signature using peer's signing public key
|
||||
* Returns true only if signature is present and valid
|
||||
*/
|
||||
private fun verifyPacketSignatureWithLogging(packet: BitchatPacket, peerID: String) {
|
||||
private fun verifyPacketSignature(packet: BitchatPacket, peerID: String): Boolean {
|
||||
try {
|
||||
// Check if packet has a signature
|
||||
// only verify ANNOUNCE, MESSAGE, and FILE_TRANSFER
|
||||
if (MessageType.fromValue(packet.type) !in setOf(
|
||||
MessageType.ANNOUNCE,
|
||||
MessageType.MESSAGE,
|
||||
MessageType.FILE_TRANSFER
|
||||
)) {
|
||||
return true
|
||||
}
|
||||
// 1. Mandatory Signature Check
|
||||
if (packet.signature == null) {
|
||||
Log.d(TAG, "📝 Signature check for $peerID: NO_SIGNATURE (packet type ${packet.type})")
|
||||
return
|
||||
Log.w(TAG, "❌ Signature check for $peerID: NO_SIGNATURE (packet type ${packet.type})")
|
||||
return false
|
||||
}
|
||||
|
||||
// Try to get peer's signing public key from peer info
|
||||
val peerInfo = delegate?.getPeerInfo(peerID)
|
||||
val signingPublicKey = peerInfo?.signingPublicKey
|
||||
// 2. Get Signing Public Key
|
||||
var signingPublicKey: ByteArray? = null
|
||||
|
||||
if (MessageType.fromValue(packet.type) == MessageType.ANNOUNCE) {
|
||||
// Special Case: ANNOUNCE packets carry their own signing key
|
||||
try {
|
||||
val announcement = com.bitchat.android.model.IdentityAnnouncement.decode(packet.payload)
|
||||
signingPublicKey = announcement?.signingPublicKey
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to decode announcement for key extraction: ${e.message}")
|
||||
}
|
||||
} else {
|
||||
// Standard Case: Get key from known peer info
|
||||
val peerInfo = delegate?.getPeerInfo(peerID)
|
||||
signingPublicKey = peerInfo?.signingPublicKey
|
||||
}
|
||||
|
||||
if (signingPublicKey == null) {
|
||||
Log.d(TAG, "📝 Signature check for $peerID: NO_SIGNING_KEY (packet type ${packet.type})")
|
||||
return
|
||||
// If we don't have a key (and it's not an announce), we can't verify.
|
||||
// For security, we must reject packets from unknown peers unless it's an announce.
|
||||
Log.w(TAG, "❌ Signature check for $peerID: NO_SIGNING_KEY_AVAILABLE (packet type ${packet.type})")
|
||||
return false
|
||||
}
|
||||
|
||||
// Get the canonical packet data for signature verification (without signature)
|
||||
// 3. Get Canonical Data
|
||||
val packetDataForSigning = packet.toBinaryDataForSigning()
|
||||
if (packetDataForSigning == null) {
|
||||
Log.w(TAG, "📝 Signature check for $peerID: ENCODING_ERROR (packet type ${packet.type})")
|
||||
return
|
||||
Log.w(TAG, "❌ Signature check for $peerID: ENCODING_ERROR (packet type ${packet.type})")
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify the signature using the peer's signing public key
|
||||
val signature = packet.signature!! // We already checked for null above
|
||||
// 4. Verify Signature
|
||||
val signature = packet.signature!!
|
||||
val isSignatureValid = encryptionService.verifyEd25519Signature(
|
||||
signature,
|
||||
packetDataForSigning,
|
||||
@@ -266,13 +291,16 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
)
|
||||
|
||||
if (isSignatureValid) {
|
||||
Log.d(TAG, "📝 Signature check for $peerID: ✅ VALID (packet type ${packet.type})")
|
||||
// Log.v(TAG, "✅ Signature verified for $peerID (type ${packet.type})")
|
||||
return true
|
||||
} else {
|
||||
Log.w(TAG, "📝 Signature check for $peerID: ❌ INVALID (packet type ${packet.type})")
|
||||
Log.w(TAG, "❌ Signature INVALID for $peerID (type ${packet.type})")
|
||||
return false
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "📝 Signature check for $peerID: ERROR - ${e.message} (packet type ${packet.type})")
|
||||
Log.e(TAG, "❌ Signature verification error for $peerID: ${e.message}")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ enum class NoisePayloadType(val value: UByte) {
|
||||
PRIVATE_MESSAGE(0x01u), // Private chat message with TLV encoding
|
||||
READ_RECEIPT(0x02u), // Message was read
|
||||
DELIVERED(0x03u), // Message was delivered
|
||||
VERIFY_CHALLENGE(0x10u), // Verification challenge
|
||||
VERIFY_RESPONSE(0x11u), // Verification response
|
||||
FILE_TRANSFER(0x20u);
|
||||
|
||||
|
||||
|
||||
+268
-124
@@ -2,6 +2,7 @@ package com.bitchat.android.net
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import com.bitchat.android.util.AppConstants
|
||||
import info.guardianproject.arti.ArtiLogListener
|
||||
import info.guardianproject.arti.ArtiProxy
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -11,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -24,54 +26,97 @@ import java.util.concurrent.atomic.AtomicReference
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* Manages embedded Tor lifecycle & provides SOCKS proxy address.
|
||||
* Uses Arti (Tor in Rust) for improved security and reliability.
|
||||
* Tor provider implementation using custom-built Arti (Tor-in-Rust).
|
||||
*
|
||||
* This singleton provides Tor anonymity features using a custom Arti build
|
||||
* compiled with 16KB page size support for Google Play compliance.
|
||||
*
|
||||
* Based on the original TorManager implementation.
|
||||
*/
|
||||
object TorManager {
|
||||
private const val TAG = "TorManager"
|
||||
private const val DEFAULT_SOCKS_PORT = com.bitchat.android.util.AppConstants.Tor.DEFAULT_SOCKS_PORT
|
||||
private const val RESTART_DELAY_MS = com.bitchat.android.util.AppConstants.Tor.RESTART_DELAY_MS // 2 seconds between stop/start
|
||||
private const val INACTIVITY_TIMEOUT_MS = com.bitchat.android.util.AppConstants.Tor.INACTIVITY_TIMEOUT_MS // 5 seconds of no activity before restart
|
||||
private const val MAX_RETRY_ATTEMPTS = com.bitchat.android.util.AppConstants.Tor.MAX_RETRY_ATTEMPTS
|
||||
private const val STOP_TIMEOUT_MS = com.bitchat.android.util.AppConstants.Tor.STOP_TIMEOUT_MS
|
||||
class ArtiTorManager private constructor() {
|
||||
enum class TorState {
|
||||
OFF,
|
||||
STARTING,
|
||||
BOOTSTRAPPING,
|
||||
RUNNING,
|
||||
STOPPING,
|
||||
ERROR
|
||||
}
|
||||
|
||||
data class TorStatus(
|
||||
val mode: TorMode = TorMode.OFF,
|
||||
val running: Boolean = false,
|
||||
val bootstrapPercent: Int = 0,
|
||||
val lastLogLine: String = "",
|
||||
val state: TorState = TorState.OFF
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ArtiTorManager"
|
||||
private const val DEFAULT_SOCKS_PORT = AppConstants.Tor.DEFAULT_SOCKS_PORT
|
||||
private const val RESTART_DELAY_MS = AppConstants.Tor.RESTART_DELAY_MS
|
||||
private const val INACTIVITY_TIMEOUT_MS = AppConstants.Tor.INACTIVITY_TIMEOUT_MS
|
||||
private const val MAX_RETRY_ATTEMPTS = AppConstants.Tor.MAX_RETRY_ATTEMPTS
|
||||
private const val STOP_TIMEOUT_MS = AppConstants.Tor.STOP_TIMEOUT_MS
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: ArtiTorManager? = null
|
||||
|
||||
fun getInstance(): ArtiTorManager {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: ArtiTorManager().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
@Volatile private var initialized = false
|
||||
@Volatile private var socksAddr: InetSocketAddress? = null
|
||||
private val artiProxyRef = AtomicReference<ArtiProxy?>(null)
|
||||
@Volatile private var lastMode: TorMode = TorMode.OFF
|
||||
@Volatile
|
||||
private var initialized = false
|
||||
@Volatile
|
||||
private var socksAddr: InetSocketAddress? = null
|
||||
@Volatile
|
||||
private var artiProxy: ArtiProxy? = null
|
||||
@Volatile
|
||||
private var lastMode: TorMode = TorMode.OFF
|
||||
private val applyMutex = Mutex()
|
||||
@Volatile private var desiredMode: TorMode = TorMode.OFF
|
||||
@Volatile private var currentSocksPort: Int = DEFAULT_SOCKS_PORT
|
||||
@Volatile private var lastLogTime = AtomicLong(0L)
|
||||
@Volatile private var retryAttempts = 0
|
||||
@Volatile private var bindRetryAttempts = 0
|
||||
@Volatile
|
||||
private var desiredMode: TorMode = TorMode.OFF
|
||||
@Volatile
|
||||
private var currentSocksPort: Int = DEFAULT_SOCKS_PORT
|
||||
@Volatile
|
||||
private var lastLogTime = AtomicLong(0L)
|
||||
@Volatile
|
||||
private var retryAttempts = 0
|
||||
@Volatile
|
||||
private var bindRetryAttempts = 0
|
||||
private var inactivityJob: Job? = null
|
||||
private var retryJob: Job? = null
|
||||
private var currentApplication: Application? = null
|
||||
|
||||
private enum class LifecycleState { STOPPED, STARTING, RUNNING, STOPPING }
|
||||
@Volatile private var lifecycleState: LifecycleState = LifecycleState.STOPPED
|
||||
|
||||
enum class TorState { OFF, STARTING, BOOTSTRAPPING, RUNNING, STOPPING, ERROR }
|
||||
@Volatile
|
||||
private var lifecycleState: LifecycleState = LifecycleState.STOPPED
|
||||
|
||||
data class TorStatus(
|
||||
val mode: TorMode = TorMode.OFF,
|
||||
val running: Boolean = false,
|
||||
val bootstrapPercent: Int = 0, // kept for backwards compatibility with UI; 0 or 100 only
|
||||
val lastLogLine: String = "",
|
||||
val state: TorState = TorState.OFF
|
||||
private val _statusFlow = MutableStateFlow(
|
||||
TorStatus(
|
||||
mode = TorMode.OFF,
|
||||
running = false,
|
||||
bootstrapPercent = 0,
|
||||
lastLogLine = "",
|
||||
state = TorState.OFF
|
||||
)
|
||||
)
|
||||
|
||||
private val _status = MutableStateFlow(TorStatus())
|
||||
val statusFlow: StateFlow<TorStatus> = _status.asStateFlow()
|
||||
val statusFlow: StateFlow<TorStatus> = _statusFlow.asStateFlow()
|
||||
|
||||
private val stateChangeDeferred = AtomicReference<CompletableDeferred<TorState>?>(null)
|
||||
|
||||
fun isProxyEnabled(): Boolean {
|
||||
val s = _status.value
|
||||
return s.mode != TorMode.OFF && s.running && s.bootstrapPercent >= 100 && socksAddr != null && s.state == TorState.RUNNING
|
||||
val s = _statusFlow.value
|
||||
return s.mode != TorMode.OFF && s.running && s.bootstrapPercent >= 100 &&
|
||||
socksAddr != null && s.state == TorState.RUNNING
|
||||
}
|
||||
|
||||
fun init(application: Application) {
|
||||
@@ -82,7 +127,21 @@ object TorManager {
|
||||
currentApplication = application
|
||||
TorPreferenceManager.init(application)
|
||||
|
||||
// Apply saved mode at startup. If ON, set planned SOCKS immediately to avoid any leak.
|
||||
val logListener = ArtiLogListener { logLine ->
|
||||
val text = logLine ?: return@ArtiLogListener
|
||||
val s = text
|
||||
Log.i(TAG, "arti: $s")
|
||||
lastLogTime.set(System.currentTimeMillis())
|
||||
_statusFlow.update { it.copy(lastLogLine = s) }
|
||||
handleArtiLogLine(s)
|
||||
}
|
||||
|
||||
artiProxy = ArtiProxy.Builder(application)
|
||||
.setSocksPort(currentSocksPort)
|
||||
.setDnsPort(currentSocksPort + 1)
|
||||
.setLogListener(logListener)
|
||||
.build()
|
||||
|
||||
val savedMode = TorPreferenceManager.get(application)
|
||||
if (savedMode == TorMode.ON) {
|
||||
if (currentSocksPort < DEFAULT_SOCKS_PORT) {
|
||||
@@ -90,13 +149,15 @@ object TorManager {
|
||||
}
|
||||
desiredMode = savedMode
|
||||
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
|
||||
try { OkHttpProvider.reset() } catch (_: Throwable) { }
|
||||
try {
|
||||
OkHttpProvider.reset()
|
||||
} catch (_: Throwable) {
|
||||
} // Only reset OkHttp during init
|
||||
}
|
||||
appScope.launch {
|
||||
applyMode(application, savedMode)
|
||||
}
|
||||
|
||||
// Observe changes
|
||||
appScope.launch {
|
||||
TorPreferenceManager.modeFlow.collect { mode ->
|
||||
applyMode(application, mode)
|
||||
@@ -112,53 +173,63 @@ object TorManager {
|
||||
try {
|
||||
desiredMode = mode
|
||||
lastMode = mode
|
||||
val s = _status.value
|
||||
if (mode == s.mode && mode != TorMode.OFF && (lifecycleState == LifecycleState.STARTING || lifecycleState == LifecycleState.RUNNING)) {
|
||||
Log.i(TAG, "applyMode: already in progress/running mode=$mode, state=$lifecycleState; skip")
|
||||
val s = _statusFlow.value
|
||||
if (mode == s.mode && mode != TorMode.OFF &&
|
||||
(lifecycleState == LifecycleState.STARTING || lifecycleState == LifecycleState.RUNNING)
|
||||
) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"applyMode: already in progress/running mode=$mode, state=$lifecycleState; skip"
|
||||
)
|
||||
return
|
||||
}
|
||||
when (mode) {
|
||||
TorMode.OFF -> {
|
||||
Log.i(TAG, "applyMode: OFF -> stopping tor")
|
||||
lifecycleState = LifecycleState.STOPPING
|
||||
_status.value = _status.value.copy(mode = TorMode.OFF, running = false, bootstrapPercent = 0, state = TorState.STOPPING)
|
||||
stopArti() // non-suspending immediate request
|
||||
// Best-effort wait for STOPPED before we declare OFF
|
||||
_statusFlow.value = _statusFlow.value.copy(
|
||||
mode = TorMode.OFF,
|
||||
running = false,
|
||||
bootstrapPercent = 0,
|
||||
state = TorState.STOPPING
|
||||
)
|
||||
stopArti()
|
||||
waitForStateTransition(target = TorState.OFF, timeoutMs = STOP_TIMEOUT_MS)
|
||||
socksAddr = null
|
||||
_status.value = _status.value.copy(mode = TorMode.OFF, running = false, bootstrapPercent = 0, state = TorState.OFF)
|
||||
_statusFlow.value = _statusFlow.value.copy(
|
||||
mode = TorMode.OFF,
|
||||
running = false,
|
||||
bootstrapPercent = 0,
|
||||
state = TorState.OFF
|
||||
)
|
||||
currentSocksPort = DEFAULT_SOCKS_PORT
|
||||
bindRetryAttempts = 0
|
||||
lifecycleState = LifecycleState.STOPPED
|
||||
// Rebuild clients WITHOUT proxy and reconnect relays
|
||||
try {
|
||||
OkHttpProvider.reset()
|
||||
com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections()
|
||||
} catch (_: Throwable) { }
|
||||
resetNetworkConnections()
|
||||
}
|
||||
|
||||
TorMode.ON -> {
|
||||
Log.i(TAG, "applyMode: ON -> starting arti")
|
||||
// Reset port to default unless we're already using a higher port
|
||||
if (currentSocksPort < DEFAULT_SOCKS_PORT) {
|
||||
currentSocksPort = DEFAULT_SOCKS_PORT
|
||||
}
|
||||
bindRetryAttempts = 0
|
||||
lifecycleState = LifecycleState.STARTING
|
||||
_status.value = _status.value.copy(mode = TorMode.ON, running = false, bootstrapPercent = 0, state = TorState.STARTING)
|
||||
// Immediately set the planned SOCKS address so all traffic is forced through it,
|
||||
// even before Tor is fully bootstrapped. This prevents any direct connections.
|
||||
_statusFlow.value = _statusFlow.value.copy(
|
||||
mode = TorMode.ON,
|
||||
running = false,
|
||||
bootstrapPercent = 0,
|
||||
state = TorState.STARTING
|
||||
)
|
||||
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
|
||||
try { OkHttpProvider.reset() } catch (_: Throwable) { }
|
||||
try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { }
|
||||
resetNetworkConnections()
|
||||
startArti(application, useDelay = false)
|
||||
// Defer enabling proxy until bootstrap completes
|
||||
appScope.launch {
|
||||
waitUntilBootstrapped()
|
||||
if (_status.value.running && desiredMode == TorMode.ON) {
|
||||
if (_statusFlow.value.running && desiredMode == TorMode.ON) {
|
||||
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
|
||||
Log.i(TAG, "Tor ON: proxy set to ${socksAddr}")
|
||||
OkHttpProvider.reset()
|
||||
try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { }
|
||||
resetNetworkConnections()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,84 +242,95 @@ object TorManager {
|
||||
|
||||
private suspend fun startArti(application: Application, useDelay: Boolean = false) {
|
||||
try {
|
||||
// Ensure any previous instance is fully stopped before starting a new one
|
||||
stopArtiAndWait()
|
||||
|
||||
Log.i(TAG, "Starting Arti on port $currentSocksPort…")
|
||||
if (useDelay) {
|
||||
delay(RESTART_DELAY_MS)
|
||||
}
|
||||
|
||||
val logListener = ArtiLogListener { logLine ->
|
||||
val text = logLine ?: return@ArtiLogListener
|
||||
val s = text.toString()
|
||||
Log.i(TAG, "arti: $s")
|
||||
lastLogTime.set(System.currentTimeMillis())
|
||||
_status.value = _status.value.copy(lastLogLine = s)
|
||||
handleArtiLogLine(s)
|
||||
val proxy = artiProxy ?: run {
|
||||
Log.e(TAG, "ArtiProxy not initialized! This should not happen.")
|
||||
_statusFlow.update { it.copy(state = TorState.ERROR) }
|
||||
return
|
||||
}
|
||||
|
||||
val proxy = ArtiProxy.Builder(application)
|
||||
.setSocksPort(currentSocksPort)
|
||||
.setDnsPort(currentSocksPort + 1)
|
||||
.setLogListener(logListener)
|
||||
.build()
|
||||
|
||||
artiProxyRef.set(proxy)
|
||||
proxy.start()
|
||||
lastLogTime.set(System.currentTimeMillis())
|
||||
|
||||
_status.value = _status.value.copy(running = true, bootstrapPercent = 0, state = TorState.STARTING)
|
||||
_statusFlow.update {
|
||||
it.copy(
|
||||
running = true,
|
||||
bootstrapPercent = 0,
|
||||
state = TorState.STARTING
|
||||
)
|
||||
}
|
||||
lifecycleState = LifecycleState.RUNNING
|
||||
startInactivityMonitoring()
|
||||
|
||||
// Removed onion service startup (BLE-only file transfer in this branch)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error starting Arti on port $currentSocksPort: ${e.message}")
|
||||
_status.value = _status.value.copy(state = TorState.ERROR)
|
||||
|
||||
// Check if this is a bind error
|
||||
_statusFlow.update { it.copy(state = TorState.ERROR) }
|
||||
|
||||
val isBindError = isBindError(e)
|
||||
if (isBindError && bindRetryAttempts < MAX_RETRY_ATTEMPTS) {
|
||||
bindRetryAttempts++
|
||||
currentSocksPort++
|
||||
Log.w(TAG, "Port bind failed (attempt $bindRetryAttempts/$MAX_RETRY_ATTEMPTS), retrying with port $currentSocksPort")
|
||||
// Update planned SOCKS address immediately so all new connections target the new port
|
||||
Log.w(
|
||||
TAG,
|
||||
"Port bind failed (attempt $bindRetryAttempts/$MAX_RETRY_ATTEMPTS), retrying with port $currentSocksPort"
|
||||
)
|
||||
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
|
||||
try { OkHttpProvider.reset() } catch (_: Throwable) { }
|
||||
try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { }
|
||||
// Immediate retry with incremented port, no exponential backoff for bind errors
|
||||
resetNetworkConnections()
|
||||
startArti(application, useDelay = false)
|
||||
} else if (isBindError) {
|
||||
Log.e(TAG, "Max bind retry attempts reached ($MAX_RETRY_ATTEMPTS), giving up")
|
||||
lifecycleState = LifecycleState.STOPPED
|
||||
_status.value = _status.value.copy(running = false, bootstrapPercent = 0, state = TorState.ERROR)
|
||||
_statusFlow.update {
|
||||
it.copy(
|
||||
running = false,
|
||||
bootstrapPercent = 0,
|
||||
state = TorState.ERROR
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// For non-bind errors, use the existing retry mechanism
|
||||
scheduleRetry(application)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the exception indicates a port binding failure
|
||||
*/
|
||||
|
||||
private fun isBindError(exception: Exception): Boolean {
|
||||
val message = exception.message?.lowercase() ?: ""
|
||||
return message.contains("bind") ||
|
||||
message.contains("address already in use") ||
|
||||
message.contains("port") && message.contains("use") ||
|
||||
message.contains("permission denied") && message.contains("port") ||
|
||||
message.contains("could not bind")
|
||||
message.contains("address already in use") ||
|
||||
message.contains("port") && message.contains("use") ||
|
||||
message.contains("permission denied") && message.contains("port") ||
|
||||
message.contains("could not bind")
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset network connections after Tor state changes.
|
||||
* Rebuilds OkHttp clients and reconnects Nostr relays.
|
||||
*/
|
||||
private fun resetNetworkConnections() {
|
||||
try {
|
||||
OkHttpProvider.reset()
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
try {
|
||||
com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections()
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopArtiInternal() {
|
||||
try {
|
||||
val proxy = artiProxyRef.getAndSet(null)
|
||||
val proxy = artiProxy
|
||||
if (proxy != null) {
|
||||
Log.i(TAG, "Stopping Arti…")
|
||||
try { proxy.stop() } catch (_: Throwable) {}
|
||||
try {
|
||||
proxy.stop()
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
stopInactivityMonitoring()
|
||||
stopRetryMonitoring()
|
||||
@@ -260,15 +342,16 @@ object TorManager {
|
||||
private fun stopArti() {
|
||||
stopArtiInternal()
|
||||
socksAddr = null
|
||||
_status.value = _status.value.copy(running = false, bootstrapPercent = 0, state = TorState.STOPPING)
|
||||
_statusFlow.value = _statusFlow.value.copy(
|
||||
running = false,
|
||||
bootstrapPercent = 0,
|
||||
state = TorState.STOPPING
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun stopArtiAndWait(timeoutMs: Long = STOP_TIMEOUT_MS) {
|
||||
// Request stop
|
||||
stopArtiInternal()
|
||||
// Wait for confirmation via logs (Stopped) or timeout
|
||||
waitForStateTransition(target = TorState.OFF, timeoutMs = timeoutMs)
|
||||
// Small grace period before relaunch to let file locks clear
|
||||
delay(200)
|
||||
}
|
||||
|
||||
@@ -276,7 +359,7 @@ object TorManager {
|
||||
Log.i(TAG, "Restarting Arti (keeping SOCKS proxy enabled)...")
|
||||
stopArtiAndWait()
|
||||
delay(RESTART_DELAY_MS)
|
||||
startArti(application, useDelay = false) // Already delayed above
|
||||
startArti(application, useDelay = false)
|
||||
}
|
||||
|
||||
private fun startInactivityMonitoring() {
|
||||
@@ -287,13 +370,16 @@ object TorManager {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val lastActivity = lastLogTime.get()
|
||||
val timeSinceLastActivity = currentTime - lastActivity
|
||||
|
||||
|
||||
if (timeSinceLastActivity > INACTIVITY_TIMEOUT_MS) {
|
||||
val currentMode = _status.value.mode
|
||||
val currentMode = _statusFlow.value.mode
|
||||
if (currentMode == TorMode.ON) {
|
||||
val bootstrapPercent = _status.value.bootstrapPercent
|
||||
val bootstrapPercent = _statusFlow.value.bootstrapPercent
|
||||
if (bootstrapPercent < 100) {
|
||||
Log.w(TAG, "Inactivity detected (${timeSinceLastActivity}ms), restarting Arti")
|
||||
Log.w(
|
||||
TAG,
|
||||
"Inactivity detected (${timeSinceLastActivity}ms), restarting Arti"
|
||||
)
|
||||
currentApplication?.let { app ->
|
||||
appScope.launch {
|
||||
restartArti(app)
|
||||
@@ -316,11 +402,11 @@ object TorManager {
|
||||
retryJob?.cancel()
|
||||
if (retryAttempts < MAX_RETRY_ATTEMPTS) {
|
||||
retryAttempts++
|
||||
val delayMs = (1000L * (1 shl retryAttempts)).coerceAtMost(30000L) // Exponential backoff, max 30s
|
||||
val delayMs = (1000L * (1 shl retryAttempts)).coerceAtMost(30000L)
|
||||
Log.w(TAG, "Scheduling Arti retry attempt $retryAttempts in ${delayMs}ms")
|
||||
retryJob = appScope.launch {
|
||||
delay(delayMs)
|
||||
val currentMode = _status.value.mode
|
||||
val currentMode = _statusFlow.value.mode
|
||||
if (currentMode == TorMode.ON) {
|
||||
Log.i(TAG, "Retrying Arti start (attempt $retryAttempts)")
|
||||
restartArti(application)
|
||||
@@ -337,50 +423,110 @@ object TorManager {
|
||||
}
|
||||
|
||||
private suspend fun waitUntilBootstrapped() {
|
||||
val current = _status.value
|
||||
val current = _statusFlow.value
|
||||
if (!current.running) return
|
||||
if (current.bootstrapPercent >= 100 && current.state == TorState.RUNNING) return
|
||||
// Suspend until we observe RUNNING at least once
|
||||
while (true) {
|
||||
val s = statusFlow.first { (it.bootstrapPercent >= 100 && it.state == TorState.RUNNING) || !it.running || it.state == TorState.ERROR }
|
||||
val s = statusFlow.first {
|
||||
(it.bootstrapPercent >= 100 && it.state == TorState.RUNNING) ||
|
||||
!it.running ||
|
||||
it.state == TorState.ERROR
|
||||
}
|
||||
if (!s.running || s.state == TorState.ERROR) return
|
||||
if (s.bootstrapPercent >= 100 && s.state == TorState.RUNNING) return
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleArtiLogLine(s: String) {
|
||||
val currentState = _statusFlow.value.state
|
||||
val currentLifecycle = lifecycleState
|
||||
|
||||
when {
|
||||
s.contains("AMEx: state changed to Initialized", ignoreCase = true) -> {
|
||||
_status.value = _status.value.copy(state = TorState.STARTING)
|
||||
if (currentLifecycle != LifecycleState.STARTING && currentLifecycle != LifecycleState.RUNNING) {
|
||||
Log.w(TAG, "Ignoring stale 'Initialized' log (lifecycle: $currentLifecycle)")
|
||||
return
|
||||
}
|
||||
_statusFlow.update { it.copy(state = TorState.STARTING) }
|
||||
completeWaitersIf(TorState.STARTING)
|
||||
}
|
||||
|
||||
s.contains("AMEx: state changed to Starting", ignoreCase = true) -> {
|
||||
_status.value = _status.value.copy(state = TorState.STARTING)
|
||||
if (currentLifecycle != LifecycleState.STARTING && currentLifecycle != LifecycleState.RUNNING) {
|
||||
Log.w(TAG, "Ignoring stale 'Starting' log (lifecycle: $currentLifecycle)")
|
||||
return
|
||||
}
|
||||
_statusFlow.update { it.copy(state = TorState.STARTING) }
|
||||
completeWaitersIf(TorState.STARTING)
|
||||
}
|
||||
s.contains("Sufficiently bootstrapped; system SOCKS now functional", ignoreCase = true) -> {
|
||||
_status.value = _status.value.copy(bootstrapPercent = 75, state = TorState.BOOTSTRAPPING)
|
||||
|
||||
s.contains(
|
||||
"Sufficiently bootstrapped; system SOCKS now functional",
|
||||
ignoreCase = true
|
||||
) -> {
|
||||
if (currentLifecycle != LifecycleState.RUNNING) {
|
||||
Log.w(TAG, "Ignoring bootstrap log (lifecycle: $currentLifecycle)")
|
||||
return
|
||||
}
|
||||
_statusFlow.update {
|
||||
it.copy(
|
||||
bootstrapPercent = 75,
|
||||
state = TorState.BOOTSTRAPPING
|
||||
)
|
||||
}
|
||||
retryAttempts = 0
|
||||
bindRetryAttempts = 0
|
||||
startInactivityMonitoring()
|
||||
}
|
||||
//s.contains("AMEx: state changed to Running", ignoreCase = true) -> {
|
||||
|
||||
s.contains("We have found that guard [scrubbed] is usable.", ignoreCase = true) -> {
|
||||
// If we already saw Sufficiently bootstrapped, mark as RUNNING and ready.
|
||||
val bp = if (_status.value.bootstrapPercent >= 100) 100 else 100 // treat Running as ready
|
||||
_status.value = _status.value.copy(state = TorState.RUNNING, bootstrapPercent = bp, running = true)
|
||||
if (currentLifecycle != LifecycleState.RUNNING) {
|
||||
Log.w(TAG, "Ignoring guard discovery log (lifecycle: $currentLifecycle)")
|
||||
return
|
||||
}
|
||||
_statusFlow.update {
|
||||
it.copy(
|
||||
state = TorState.RUNNING,
|
||||
bootstrapPercent = 100,
|
||||
running = true
|
||||
)
|
||||
}
|
||||
completeWaitersIf(TorState.RUNNING)
|
||||
}
|
||||
|
||||
s.contains("AMEx: state changed to Stopping", ignoreCase = true) -> {
|
||||
_status.value = _status.value.copy(state = TorState.STOPPING, running = false)
|
||||
if (currentLifecycle != LifecycleState.STOPPING) {
|
||||
Log.w(TAG, "Ignoring stale 'Stopping' log (lifecycle: $currentLifecycle)")
|
||||
return
|
||||
}
|
||||
_statusFlow.update {
|
||||
it.copy(
|
||||
state = TorState.STOPPING,
|
||||
running = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
s.contains("AMEx: state changed to Stopped", ignoreCase = true) -> {
|
||||
_status.value = _status.value.copy(state = TorState.OFF, running = false, bootstrapPercent = 0)
|
||||
if (currentLifecycle != LifecycleState.STOPPING && currentLifecycle != LifecycleState.STOPPED) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Ignoring stale 'Stopped' log (lifecycle: $currentLifecycle, preventing state corruption)"
|
||||
)
|
||||
return
|
||||
}
|
||||
_statusFlow.update {
|
||||
it.copy(
|
||||
state = TorState.OFF,
|
||||
running = false,
|
||||
bootstrapPercent = 0
|
||||
)
|
||||
}
|
||||
completeWaitersIf(TorState.OFF)
|
||||
}
|
||||
|
||||
s.contains("Another process has the lock on our state files", ignoreCase = true) -> {
|
||||
// Signal error; we'll likely need to wait longer before restart
|
||||
_status.value = _status.value.copy(state = TorState.ERROR)
|
||||
_statusFlow.update { it.copy(state = TorState.ERROR) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -395,13 +541,11 @@ object TorManager {
|
||||
val def = CompletableDeferred<TorState>()
|
||||
stateChangeDeferred.getAndSet(def)?.cancel()
|
||||
return withTimeoutOrNull(timeoutMs) {
|
||||
// Fast-path: if we're already there
|
||||
val cur = _status.value.state
|
||||
val cur = _statusFlow.value.state
|
||||
if (cur == target) return@withTimeoutOrNull cur
|
||||
def.await()
|
||||
}
|
||||
}
|
||||
|
||||
// Visible for instrumentation tests to validate installation
|
||||
fun installResourcesForTest(application: Application): Boolean { return true }
|
||||
fun isTorAvailable(): Boolean = true
|
||||
}
|
||||
@@ -42,8 +42,9 @@ object OkHttpProvider {
|
||||
|
||||
private fun baseBuilderForCurrentProxy(): OkHttpClient.Builder {
|
||||
val builder = OkHttpClient.Builder()
|
||||
val socks: InetSocketAddress? = TorManager.currentSocksAddress()
|
||||
// If a SOCKS address is defined, always use it. TorManager sets this as soon as Tor mode is ON,
|
||||
val torProvider = ArtiTorManager.getInstance()
|
||||
val socks: InetSocketAddress? = torProvider.currentSocksAddress()
|
||||
// If a SOCKS address is defined, always use it. TorProvider sets this as soon as Tor mode is ON,
|
||||
// even before bootstrap, to prevent any direct connections from occurring.
|
||||
if (socks != null) {
|
||||
val proxy = Proxy(Proxy.Type.SOCKS, socks)
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.bitchat.android.nostr
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.bitchat.android.ui.ChatState
|
||||
import com.bitchat.android.ui.GeoPerson
|
||||
import java.util.Date
|
||||
@@ -112,7 +111,7 @@ class GeohashRepository(
|
||||
val geohash = currentGeohash
|
||||
if (geohash == null) {
|
||||
// Use postValue for thread safety - this can be called from background threads
|
||||
state.postGeohashPeople(emptyList())
|
||||
state.setGeohashPeople(emptyList())
|
||||
return
|
||||
}
|
||||
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
|
||||
@@ -143,7 +142,7 @@ class GeohashRepository(
|
||||
)
|
||||
}.sortedByDescending { it.lastSeen }
|
||||
// Use postValue for thread safety - this can be called from background threads
|
||||
state.postGeohashPeople(people)
|
||||
state.setGeohashPeople(people)
|
||||
}
|
||||
|
||||
fun updateReactiveParticipantCounts() {
|
||||
@@ -155,7 +154,7 @@ class GeohashRepository(
|
||||
counts[gh] = active
|
||||
}
|
||||
// Use postValue for thread safety - this can be called from background threads
|
||||
state.postGeohashParticipantCounts(counts)
|
||||
state.setGeohashParticipantCounts(counts)
|
||||
}
|
||||
|
||||
fun putNostrKeyMapping(tempKeyOrPeer: String, pubkeyHex: String) {
|
||||
|
||||
@@ -2,13 +2,14 @@ package com.bitchat.android.nostr
|
||||
|
||||
import android.util.Log
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Manages location notes (kind=1 text notes with geohash tags)
|
||||
* iOS-compatible implementation with LiveData for Android UI binding
|
||||
* iOS-compatible implementation with StateFlow for Android UI binding
|
||||
*/
|
||||
@MainThread
|
||||
class LocationNotesManager private constructor() {
|
||||
@@ -63,21 +64,21 @@ class LocationNotesManager private constructor() {
|
||||
NO_RELAYS
|
||||
}
|
||||
|
||||
// Published state (LiveData for Android)
|
||||
private val _notes = MutableLiveData<List<Note>>(emptyList())
|
||||
val notes: LiveData<List<Note>> = _notes
|
||||
// Published state (StateFlow for Android)
|
||||
private val _notes = MutableStateFlow<List<Note>>(emptyList())
|
||||
val notes: StateFlow<List<Note>> = _notes.asStateFlow()
|
||||
|
||||
private val _geohash = MutableLiveData<String?>(null)
|
||||
val geohash: LiveData<String?> = _geohash
|
||||
private val _geohash = MutableStateFlow<String?>(null)
|
||||
val geohash: StateFlow<String?> = _geohash.asStateFlow()
|
||||
|
||||
private val _initialLoadComplete = MutableLiveData(false)
|
||||
val initialLoadComplete: LiveData<Boolean> = _initialLoadComplete
|
||||
private val _initialLoadComplete = MutableStateFlow(false)
|
||||
val initialLoadComplete: StateFlow<Boolean> = _initialLoadComplete.asStateFlow()
|
||||
|
||||
private val _state = MutableLiveData(State.IDLE)
|
||||
val state: LiveData<State> = _state
|
||||
private val _state = MutableStateFlow(State.IDLE)
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
private val _errorMessage = MutableLiveData<String?>(null)
|
||||
val errorMessage: LiveData<String?> = _errorMessage
|
||||
private val _errorMessage = MutableStateFlow<String?>(null)
|
||||
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
|
||||
|
||||
// Private state
|
||||
private var subscriptionIDs: MutableMap<String, String> = mutableMapOf()
|
||||
|
||||
@@ -2,9 +2,10 @@ package com.bitchat.android.nostr
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* High-level Nostr client that manages identity, connections, and messaging
|
||||
@@ -30,11 +31,11 @@ class NostrClient private constructor(private val context: Context) {
|
||||
private var currentIdentity: NostrIdentity? = null
|
||||
|
||||
// Client state
|
||||
private val _isInitialized = MutableLiveData<Boolean>()
|
||||
val isInitialized: LiveData<Boolean> = _isInitialized
|
||||
private val _isInitialized = MutableStateFlow(false)
|
||||
val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
|
||||
|
||||
private val _currentNpub = MutableLiveData<String>()
|
||||
val currentNpub: LiveData<String> = _currentNpub
|
||||
private val _currentNpub = MutableStateFlow<String?>(null)
|
||||
val currentNpub: StateFlow<String?> = _currentNpub.asStateFlow()
|
||||
|
||||
// Message processing
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
@@ -53,21 +54,21 @@ class NostrClient private constructor(private val context: Context) {
|
||||
currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context)
|
||||
|
||||
if (currentIdentity != null) {
|
||||
_currentNpub.postValue(currentIdentity!!.npub)
|
||||
_currentNpub.value = currentIdentity!!.npub
|
||||
Log.i(TAG, "✅ Nostr identity loaded: ${currentIdentity!!.getShortNpub()}")
|
||||
|
||||
// Connect to relays
|
||||
relayManager.connect()
|
||||
|
||||
_isInitialized.postValue(true)
|
||||
_isInitialized.value = true
|
||||
Log.i(TAG, "✅ Nostr client initialized successfully")
|
||||
} else {
|
||||
Log.e(TAG, "❌ Failed to load/create Nostr identity")
|
||||
_isInitialized.postValue(false)
|
||||
_isInitialized.value = false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to initialize Nostr client: ${e.message}")
|
||||
_isInitialized.postValue(false)
|
||||
_isInitialized.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,7 +79,7 @@ class NostrClient private constructor(private val context: Context) {
|
||||
fun shutdown() {
|
||||
Log.d(TAG, "Shutting down Nostr client")
|
||||
relayManager.disconnect()
|
||||
_isInitialized.postValue(false)
|
||||
_isInitialized.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,12 +228,12 @@ class NostrClient private constructor(private val context: Context) {
|
||||
/**
|
||||
* Get relay connection status
|
||||
*/
|
||||
val relayConnectionStatus: LiveData<Boolean> = relayManager.isConnected
|
||||
val relayConnectionStatus: StateFlow<Boolean> = relayManager.isConnected
|
||||
|
||||
/**
|
||||
* Get relay information
|
||||
*/
|
||||
val relayInfo: LiveData<List<NostrRelayManager.Relay>> = relayManager.relays
|
||||
val relayInfo: StateFlow<List<NostrRelayManager.Relay>> = relayManager.relays
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
|
||||
@@ -2,8 +2,12 @@ package com.bitchat.android.nostr
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.model.NoisePayload
|
||||
import com.bitchat.android.model.NoisePayloadType
|
||||
import com.bitchat.android.model.PrivateMessagePacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.services.SeenMessageStore
|
||||
import com.bitchat.android.ui.ChatState
|
||||
@@ -71,7 +75,7 @@ class NostrDirectMessageHandler(
|
||||
|
||||
if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch
|
||||
|
||||
val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return@launch
|
||||
val noisePayload = NoisePayload.decode(packet.payload) ?: return@launch
|
||||
val messageTimestamp = Date(giftWrap.createdAt * 1000L)
|
||||
val convKey = "nostr_${senderPubkey.take(16)}"
|
||||
repo.putNostrKeyMapping(convKey, senderPubkey)
|
||||
@@ -104,7 +108,7 @@ class NostrDirectMessageHandler(
|
||||
}
|
||||
|
||||
private suspend fun processNoisePayload(
|
||||
payload: com.bitchat.android.model.NoisePayload,
|
||||
payload: NoisePayload,
|
||||
convKey: String,
|
||||
senderNickname: String,
|
||||
timestamp: Date,
|
||||
@@ -112,8 +116,8 @@ class NostrDirectMessageHandler(
|
||||
recipientIdentity: NostrIdentity
|
||||
) {
|
||||
when (payload.type) {
|
||||
com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE -> {
|
||||
val pm = com.bitchat.android.model.PrivateMessagePacket.decode(payload.data) ?: return
|
||||
NoisePayloadType.PRIVATE_MESSAGE -> {
|
||||
val pm = PrivateMessagePacket.decode(payload.data) ?: return
|
||||
val existingMessages = state.getPrivateChatsValue()[convKey] ?: emptyList()
|
||||
if (existingMessages.any { it.id == pm.messageID }) return
|
||||
|
||||
@@ -148,21 +152,21 @@ class NostrDirectMessageHandler(
|
||||
seenStore.markRead(pm.messageID)
|
||||
}
|
||||
}
|
||||
com.bitchat.android.model.NoisePayloadType.DELIVERED -> {
|
||||
NoisePayloadType.DELIVERED -> {
|
||||
val messageId = String(payload.data, Charsets.UTF_8)
|
||||
withContext(Dispatchers.Main) {
|
||||
meshDelegateHandler.didReceiveDeliveryAck(messageId, convKey)
|
||||
}
|
||||
}
|
||||
com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> {
|
||||
NoisePayloadType.READ_RECEIPT -> {
|
||||
val messageId = String(payload.data, Charsets.UTF_8)
|
||||
withContext(Dispatchers.Main) {
|
||||
meshDelegateHandler.didReceiveReadReceipt(messageId, convKey)
|
||||
}
|
||||
}
|
||||
com.bitchat.android.model.NoisePayloadType.FILE_TRANSFER -> {
|
||||
NoisePayloadType.FILE_TRANSFER -> {
|
||||
// Properly handle encrypted file transfer
|
||||
val file = com.bitchat.android.model.BitchatFilePacket.decode(payload.data)
|
||||
val file = BitchatFilePacket.decode(payload.data)
|
||||
if (file != null) {
|
||||
val uniqueMsgId = java.util.UUID.randomUUID().toString().uppercase()
|
||||
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(application, file)
|
||||
@@ -185,6 +189,8 @@ class NostrDirectMessageHandler(
|
||||
Log.w(TAG, "⚠️ Failed to decode Nostr file transfer from $convKey")
|
||||
}
|
||||
}
|
||||
NoisePayloadType.VERIFY_CHALLENGE,
|
||||
NoisePayloadType.VERIFY_RESPONSE -> Unit // Ignore verification payloads in Nostr direct messages
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonParser
|
||||
import kotlinx.coroutines.*
|
||||
@@ -72,11 +73,11 @@ class NostrRelayManager private constructor() {
|
||||
)
|
||||
|
||||
// Published state
|
||||
private val _relays = MutableLiveData<List<Relay>>()
|
||||
val relays: LiveData<List<Relay>> = _relays
|
||||
private val _relays = MutableStateFlow<List<Relay>>(emptyList())
|
||||
val relays: StateFlow<List<Relay>> = _relays.asStateFlow()
|
||||
|
||||
private val _isConnected = MutableLiveData<Boolean>()
|
||||
val isConnected: LiveData<Boolean> = _isConnected
|
||||
private val _isConnected = MutableStateFlow<Boolean>(false)
|
||||
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||
|
||||
// Internal state
|
||||
private val relaysList = mutableListOf<Relay>()
|
||||
@@ -226,14 +227,14 @@ class NostrRelayManager private constructor() {
|
||||
"wss://nostr21.com"
|
||||
)
|
||||
relaysList.addAll(defaultRelayUrls.map { Relay(it) })
|
||||
_relays.postValue(relaysList.toList())
|
||||
_relays.value = relaysList.toList()
|
||||
updateConnectionStatus()
|
||||
Log.d(TAG, "✅ NostrRelayManager initialized with ${relaysList.size} default relays")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to initialize NostrRelayManager: ${e.message}", e)
|
||||
// Initialize with empty list as fallback
|
||||
_relays.postValue(emptyList())
|
||||
_isConnected.postValue(false)
|
||||
_relays.value = emptyList()
|
||||
_isConnected.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -797,12 +798,12 @@ class NostrRelayManager private constructor() {
|
||||
}
|
||||
|
||||
private fun updateRelaysList() {
|
||||
_relays.postValue(relaysList.toList())
|
||||
_relays.value = relaysList.toList()
|
||||
}
|
||||
|
||||
private fun updateConnectionStatus() {
|
||||
val connected = relaysList.any { it.isConnected }
|
||||
_isConnected.postValue(connected)
|
||||
_isConnected.value = connected
|
||||
}
|
||||
|
||||
private fun generateSubscriptionId(): String {
|
||||
|
||||
+251
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
+21
@@ -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 permissionManager: PermissionManager,
|
||||
private val onOnboardingComplete: () -> Unit,
|
||||
private val onBackgroundLocationRequired: () -> Unit,
|
||||
private val onOnboardingFailed: (String) -> Unit
|
||||
) {
|
||||
|
||||
@@ -27,9 +28,11 @@ class OnboardingCoordinator(
|
||||
}
|
||||
|
||||
private var permissionLauncher: ActivityResultLauncher<Array<String>>? = null
|
||||
private var backgroundLocationLauncher: ActivityResultLauncher<String>? = null
|
||||
|
||||
init {
|
||||
setupPermissionLauncher()
|
||||
setupBackgroundLocationLauncher()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,6 +46,14 @@ class OnboardingCoordinator(
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupBackgroundLocationLauncher() {
|
||||
backgroundLocationLauncher = activity.registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
handleBackgroundLocationResult(granted)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the onboarding process
|
||||
*/
|
||||
@@ -50,9 +61,14 @@ class OnboardingCoordinator(
|
||||
Log.d(TAG, "Starting onboarding process")
|
||||
permissionManager.logPermissionStatus()
|
||||
|
||||
if (permissionManager.areAllPermissionsGranted()) {
|
||||
Log.d(TAG, "All permissions already granted, completing onboarding")
|
||||
completeOnboarding()
|
||||
if (permissionManager.areRequiredPermissionsGranted()) {
|
||||
if (shouldRequestBackgroundLocation()) {
|
||||
Log.d(TAG, "Foreground permissions granted; background location recommended")
|
||||
onBackgroundLocationRequired()
|
||||
} else {
|
||||
Log.d(TAG, "Required permissions already granted, completing onboarding")
|
||||
completeOnboarding()
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Missing permissions, need to start explanation flow")
|
||||
// The explanation screen will be shown by the calling activity
|
||||
@@ -76,7 +92,11 @@ class OnboardingCoordinator(
|
||||
val missingPermissions = (missingRequired + optionalToRequest).distinct()
|
||||
|
||||
if (missingPermissions.isEmpty()) {
|
||||
completeOnboarding()
|
||||
if (shouldRequestBackgroundLocation()) {
|
||||
onBackgroundLocationRequired()
|
||||
} else {
|
||||
completeOnboarding()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -98,13 +118,19 @@ class OnboardingCoordinator(
|
||||
val criticalGranted = criticalPermissions.all { permissions[it] == true }
|
||||
|
||||
when {
|
||||
allGranted -> {
|
||||
Log.d(TAG, "All permissions granted successfully")
|
||||
completeOnboarding()
|
||||
}
|
||||
criticalGranted -> {
|
||||
Log.d(TAG, "Critical permissions granted, can proceed with limited functionality")
|
||||
showPartialPermissionWarning(permissions)
|
||||
if (shouldRequestBackgroundLocation()) {
|
||||
Log.d(TAG, "Foreground permissions granted; requesting background location next")
|
||||
onBackgroundLocationRequired()
|
||||
return
|
||||
}
|
||||
if (allGranted) {
|
||||
Log.d(TAG, "All permissions granted successfully")
|
||||
completeOnboarding()
|
||||
} else {
|
||||
Log.d(TAG, "Critical permissions granted, can proceed with limited functionality")
|
||||
showPartialPermissionWarning(permissions)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Log.d(TAG, "Critical permissions denied")
|
||||
@@ -113,15 +139,50 @@ class OnboardingCoordinator(
|
||||
}
|
||||
}
|
||||
|
||||
fun requestBackgroundLocation() {
|
||||
val permission = permissionManager.getBackgroundLocationPermission()
|
||||
if (permission == null) {
|
||||
completeOnboarding()
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "Requesting background location permission")
|
||||
backgroundLocationLauncher?.launch(permission)
|
||||
}
|
||||
|
||||
private fun handleBackgroundLocationResult(granted: Boolean) {
|
||||
if (granted) {
|
||||
Log.d(TAG, "Background location permission granted")
|
||||
} else {
|
||||
Log.w(TAG, "Background location permission denied; continuing without it")
|
||||
}
|
||||
completeOnboarding()
|
||||
}
|
||||
|
||||
fun skipBackgroundLocation() {
|
||||
Log.d(TAG, "User skipped background location permission")
|
||||
BackgroundLocationPreferenceManager.setSkipped(activity, true)
|
||||
completeOnboarding()
|
||||
}
|
||||
|
||||
fun checkBackgroundLocationAndProceed() {
|
||||
if (!shouldRequestBackgroundLocation()) {
|
||||
completeOnboarding()
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldRequestBackgroundLocation(): Boolean {
|
||||
return permissionManager.needsBackgroundLocationPermission() &&
|
||||
!permissionManager.isBackgroundLocationGranted() &&
|
||||
!BackgroundLocationPreferenceManager.isSkipped(activity)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of critical permissions that are absolutely required
|
||||
*/
|
||||
private fun getCriticalPermissions(): List<String> {
|
||||
// For bitchat, Bluetooth and location permissions are critical
|
||||
// Notifications are nice-to-have but not critical
|
||||
return permissionManager.getRequiredPermissions().filter { permission ->
|
||||
!permission.contains("POST_NOTIFICATIONS")
|
||||
}
|
||||
// Notifications are nice-to-have but not critical and are not included in getRequiredPermissions()
|
||||
return permissionManager.getRequiredPermissions()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,6 +269,7 @@ class OnboardingCoordinator(
|
||||
private fun getPermissionDisplayName(permission: String): String {
|
||||
return when {
|
||||
permission.contains("BLUETOOTH") -> "Bluetooth/Nearby Devices"
|
||||
permission.contains("BACKGROUND") -> "Background Location"
|
||||
permission.contains("LOCATION") -> "Location (for Bluetooth scanning)"
|
||||
permission.contains("NOTIFICATION") -> "Notifications"
|
||||
else -> permission.substringAfterLast(".")
|
||||
|
||||
@@ -6,8 +6,9 @@ enum class OnboardingState {
|
||||
LOCATION_CHECK,
|
||||
BATTERY_OPTIMIZATION_CHECK,
|
||||
PERMISSION_EXPLANATION,
|
||||
BACKGROUND_LOCATION_EXPLANATION,
|
||||
PERMISSION_REQUESTING,
|
||||
INITIALIZING,
|
||||
COMPLETE,
|
||||
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.Security
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -209,29 +207,6 @@ private fun PermissionCategoryCard(
|
||||
color = colorScheme.onBackground.copy(alpha = 0.8f)
|
||||
)
|
||||
|
||||
if (category.type == PermissionType.PRECISE_LOCATION) {
|
||||
// Extra emphasis for location permission
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = stringResource(R.string.cd_warning),
|
||||
tint = Color(0xFFFF9800),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.location_tracking_warning),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Color(0xFFFF9800)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,6 +215,7 @@ private fun getPermissionIcon(permissionType: PermissionType): ImageVector {
|
||||
return when (permissionType) {
|
||||
PermissionType.NEARBY_DEVICES -> Icons.Filled.Bluetooth
|
||||
PermissionType.PRECISE_LOCATION -> Icons.Filled.LocationOn
|
||||
PermissionType.BACKGROUND_LOCATION -> Icons.Filled.LocationOn
|
||||
PermissionType.MICROPHONE -> Icons.Filled.Mic
|
||||
PermissionType.NOTIFICATIONS -> Icons.Filled.Notifications
|
||||
PermissionType.BATTERY_OPTIMIZATION -> Icons.Filled.Power
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.bitchat.android.R
|
||||
|
||||
/**
|
||||
* Centralized permission management for bitchat app
|
||||
@@ -40,7 +41,8 @@ class PermissionManager(private val context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all permissions required by the app
|
||||
* Get required permissions that can be requested together.
|
||||
* Background location is handled separately to ensure correct request order.
|
||||
* Note: Notification permission is optional and not included here,
|
||||
* so the app works without notification access.
|
||||
*/
|
||||
@@ -72,6 +74,27 @@ class PermissionManager(private val context: Context) {
|
||||
return permissions
|
||||
}
|
||||
|
||||
/**
|
||||
* Background location permission is required on Android 10+ for background BLE scanning.
|
||||
* Must be requested after foreground location permissions are granted.
|
||||
*/
|
||||
fun needsBackgroundLocationPermission(): Boolean {
|
||||
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
||||
}
|
||||
|
||||
fun getBackgroundLocationPermission(): String? {
|
||||
return if (needsBackgroundLocationPermission()) {
|
||||
Manifest.permission.ACCESS_BACKGROUND_LOCATION
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun isBackgroundLocationGranted(): Boolean {
|
||||
val permission = getBackgroundLocationPermission() ?: return true
|
||||
return isPermissionGranted(permission)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get optional permissions that improve the experience but aren't required.
|
||||
* Currently includes POST_NOTIFICATIONS on Android 13+.
|
||||
@@ -93,9 +116,13 @@ class PermissionManager(private val context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all required permissions are granted
|
||||
* Check if all required permissions are granted (background location is optional).
|
||||
*/
|
||||
fun areAllPermissionsGranted(): Boolean {
|
||||
return areRequiredPermissionsGranted()
|
||||
}
|
||||
|
||||
fun areRequiredPermissionsGranted(): Boolean {
|
||||
return getRequiredPermissions().all { isPermissionGranted(it) }
|
||||
}
|
||||
|
||||
@@ -131,6 +158,11 @@ class PermissionManager(private val context: Context) {
|
||||
return getRequiredPermissions().filter { !isPermissionGranted(it) }
|
||||
}
|
||||
|
||||
fun getMissingBackgroundLocationPermission(): List<String> {
|
||||
val permission = getBackgroundLocationPermission() ?: return emptyList()
|
||||
return if (isPermissionGranted(permission)) emptyList() else listOf(permission)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get categorized permission information for display
|
||||
*/
|
||||
@@ -177,6 +209,19 @@ class PermissionManager(private val context: Context) {
|
||||
)
|
||||
)
|
||||
|
||||
if (needsBackgroundLocationPermission()) {
|
||||
val backgroundPermission = listOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
|
||||
categories.add(
|
||||
PermissionCategory(
|
||||
type = PermissionType.BACKGROUND_LOCATION,
|
||||
description = context.getString(R.string.perm_background_location_desc),
|
||||
permissions = backgroundPermission,
|
||||
isGranted = backgroundPermission.all { isPermissionGranted(it) },
|
||||
systemDescription = context.getString(R.string.perm_background_location_system)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Notifications category (if applicable)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
categories.add(
|
||||
@@ -216,7 +261,7 @@ class PermissionManager(private val context: Context) {
|
||||
appendLine("Permission Diagnostics:")
|
||||
appendLine("Android SDK: ${Build.VERSION.SDK_INT}")
|
||||
appendLine("First time launch: ${isFirstTimeLaunch()}")
|
||||
appendLine("All permissions granted: ${areAllPermissionsGranted()}")
|
||||
appendLine("Required permissions granted: ${areAllPermissionsGranted()}")
|
||||
appendLine()
|
||||
|
||||
getCategorizedPermissions().forEach { category ->
|
||||
@@ -228,7 +273,7 @@ class PermissionManager(private val context: Context) {
|
||||
appendLine()
|
||||
}
|
||||
|
||||
val missing = getMissingPermissions()
|
||||
val missing = getMissingPermissions() + getMissingBackgroundLocationPermission()
|
||||
if (missing.isNotEmpty()) {
|
||||
appendLine("Missing permissions:")
|
||||
missing.forEach { permission ->
|
||||
@@ -260,6 +305,7 @@ data class PermissionCategory(
|
||||
enum class PermissionType(val nameValue: String) {
|
||||
NEARBY_DEVICES("Nearby Devices"),
|
||||
PRECISE_LOCATION("Precise Location"),
|
||||
BACKGROUND_LOCATION("Background Location"),
|
||||
MICROPHONE("Microphone"),
|
||||
NOTIFICATIONS("Notifications"),
|
||||
BATTERY_OPTIMIZATION("Battery Optimization"),
|
||||
|
||||
@@ -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.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -28,9 +27,9 @@ import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.core.ui.utils.singleOrTripleClickable
|
||||
import com.bitchat.android.geohash.LocationChannelManager.PermissionState
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
|
||||
/**
|
||||
* Header components for ChatScreen
|
||||
@@ -59,7 +58,8 @@ fun isFavoriteReactive(
|
||||
fun TorStatusDot(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState()
|
||||
val torProvider = remember { com.bitchat.android.net.ArtiTorManager.getInstance() }
|
||||
val torStatus by torProvider.statusFlow.collectAsState()
|
||||
|
||||
if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) {
|
||||
val dotColor = when {
|
||||
@@ -248,10 +248,10 @@ fun ChatHeaderContent(
|
||||
when {
|
||||
selectedPrivatePeer != null -> {
|
||||
// Private chat header - Fully reactive state tracking
|
||||
val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet())
|
||||
val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap())
|
||||
val peerSessionStates by viewModel.peerSessionStates.observeAsState(emptyMap())
|
||||
val peerNicknames by viewModel.peerNicknames.observeAsState(emptyMap())
|
||||
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
|
||||
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
|
||||
val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle()
|
||||
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
|
||||
|
||||
// Reactive favorite computation - no more static lookups!
|
||||
val isFavorite = isFavoriteReactive(
|
||||
@@ -264,8 +264,8 @@ fun ChatHeaderContent(
|
||||
Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, isFav=$isFavorite, sessionState=$sessionState")
|
||||
|
||||
// Pass geohash context and people for NIP-17 chat title formatting
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
||||
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList())
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||
|
||||
PrivateChatHeader(
|
||||
peerID = selectedPrivatePeer,
|
||||
@@ -318,6 +318,10 @@ private fun PrivateChatHeader(
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isNostrDM = peerID.startsWith("nostr_") || peerID.startsWith("nostr:")
|
||||
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
|
||||
val isVerified = remember(peerID, verifiedFingerprints) {
|
||||
viewModel.isPeerVerified(peerID, verifiedFingerprints)
|
||||
}
|
||||
// Determine mutual favorite state for this peer (supports mesh ephemeral 16-hex via favorites lookup)
|
||||
val isMutualFavorite = remember(peerID, peerNicknames) {
|
||||
try {
|
||||
@@ -413,17 +417,33 @@ private fun PrivateChatHeader(
|
||||
|
||||
// Show a globe when chatting via Nostr alias, or when mesh session not established but mutual favorite exists
|
||||
val showGlobe = isNostrDM || (sessionState != "established" && isMutualFavorite)
|
||||
val securityModifier = if (!isNostrDM) {
|
||||
Modifier.clickable { viewModel.showSecurityVerificationSheet() }
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
if (showGlobe) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Public,
|
||||
contentDescription = stringResource(R.string.cd_nostr_reachable),
|
||||
modifier = Modifier.size(14.dp),
|
||||
modifier = Modifier.size(14.dp).then(securityModifier),
|
||||
tint = Color(0xFF9B59B6) // Purple like iOS
|
||||
)
|
||||
} else {
|
||||
NoiseSessionIcon(
|
||||
sessionState = sessionState,
|
||||
modifier = Modifier.size(14.dp)
|
||||
modifier = Modifier.size(14.dp).then(securityModifier)
|
||||
)
|
||||
}
|
||||
|
||||
if (isVerified) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Verified,
|
||||
contentDescription = stringResource(R.string.verify_title),
|
||||
modifier = Modifier.size(14.dp).then(securityModifier),
|
||||
tint = Color(0xFF32D74B)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -523,18 +543,18 @@ private fun MainHeader(
|
||||
viewModel: ChatViewModel
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList())
|
||||
val joinedChannels by viewModel.joinedChannels.observeAsState(emptySet())
|
||||
val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap())
|
||||
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
|
||||
val isConnected by viewModel.isConnected.observeAsState(false)
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
||||
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList())
|
||||
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
|
||||
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
|
||||
val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
|
||||
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
||||
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||
|
||||
// Bookmarks store for current geohash toggle (iOS parity)
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val bookmarksStore = remember { com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(context) }
|
||||
val bookmarks by bookmarksStore.bookmarks.observeAsState(emptyList())
|
||||
val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle()
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -653,8 +673,8 @@ private fun LocationChannelsButton(
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
// Get current channel selection from location manager
|
||||
val selectedChannel by viewModel.selectedLocationChannel.observeAsState()
|
||||
val teleported by viewModel.isTeleported.observeAsState(false)
|
||||
val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val teleported by viewModel.isTeleported.collectAsStateWithLifecycle()
|
||||
|
||||
val (badgeText, badgeColor) = when (selectedChannel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Mesh -> {
|
||||
|
||||
@@ -10,7 +10,6 @@ import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -25,6 +24,7 @@ import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.ui.media.FullScreenImageViewer
|
||||
|
||||
@@ -41,22 +41,24 @@ import com.bitchat.android.ui.media.FullScreenImageViewer
|
||||
@Composable
|
||||
fun ChatScreen(viewModel: ChatViewModel) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val messages by viewModel.messages.observeAsState(emptyList())
|
||||
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList())
|
||||
val nickname by viewModel.nickname.observeAsState("")
|
||||
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState()
|
||||
val currentChannel by viewModel.currentChannel.observeAsState()
|
||||
val joinedChannels by viewModel.joinedChannels.observeAsState(emptySet())
|
||||
val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap())
|
||||
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
|
||||
val privateChats by viewModel.privateChats.observeAsState(emptyMap())
|
||||
val channelMessages by viewModel.channelMessages.observeAsState(emptyMap())
|
||||
val showSidebar by viewModel.showSidebar.observeAsState(false)
|
||||
val showCommandSuggestions by viewModel.showCommandSuggestions.observeAsState(false)
|
||||
val commandSuggestions by viewModel.commandSuggestions.observeAsState(emptyList())
|
||||
val showMentionSuggestions by viewModel.showMentionSuggestions.observeAsState(false)
|
||||
val mentionSuggestions by viewModel.mentionSuggestions.observeAsState(emptyList())
|
||||
val showAppInfo by viewModel.showAppInfo.observeAsState(false)
|
||||
val messages by viewModel.messages.collectAsStateWithLifecycle()
|
||||
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
|
||||
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
|
||||
val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
|
||||
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
|
||||
val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
|
||||
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
||||
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
|
||||
val channelMessages by viewModel.channelMessages.collectAsStateWithLifecycle()
|
||||
val showSidebar by viewModel.showSidebar.collectAsStateWithLifecycle()
|
||||
val showCommandSuggestions by viewModel.showCommandSuggestions.collectAsStateWithLifecycle()
|
||||
val commandSuggestions by viewModel.commandSuggestions.collectAsStateWithLifecycle()
|
||||
val showMentionSuggestions by viewModel.showMentionSuggestions.collectAsStateWithLifecycle()
|
||||
val mentionSuggestions by viewModel.mentionSuggestions.collectAsStateWithLifecycle()
|
||||
val showAppInfo by viewModel.showAppInfo.collectAsStateWithLifecycle()
|
||||
val showVerificationSheet by viewModel.showVerificationSheet.collectAsStateWithLifecycle()
|
||||
val showSecurityVerificationSheet by viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle()
|
||||
|
||||
var messageText by remember { mutableStateOf(TextFieldValue("")) }
|
||||
var showPasswordPrompt by remember { mutableStateOf(false) }
|
||||
@@ -78,11 +80,11 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
showPasswordDialog = showPasswordPrompt
|
||||
}
|
||||
|
||||
val isConnected by viewModel.isConnected.observeAsState(false)
|
||||
val passwordPromptChannel by viewModel.passwordPromptChannel.observeAsState(null)
|
||||
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
|
||||
val passwordPromptChannel by viewModel.passwordPromptChannel.collectAsStateWithLifecycle()
|
||||
|
||||
// Get location channel info for timeline switching
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
|
||||
// Determine what messages to show based on current context (unified timelines)
|
||||
val displayMessages = when {
|
||||
@@ -327,6 +329,10 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
SidebarOverlay(
|
||||
viewModel = viewModel,
|
||||
onDismiss = { viewModel.hideSidebar() },
|
||||
onShowVerification = {
|
||||
viewModel.showVerificationSheet(fromSidebar = true)
|
||||
viewModel.hideSidebar()
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
@@ -373,7 +379,11 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
},
|
||||
selectedUserForSheet = selectedUserForSheet,
|
||||
selectedMessageForSheet = selectedMessageForSheet,
|
||||
viewModel = viewModel
|
||||
viewModel = viewModel,
|
||||
showVerificationSheet = showVerificationSheet,
|
||||
onVerificationSheetDismiss = viewModel::hideVerificationSheet,
|
||||
showSecurityVerificationSheet = showSecurityVerificationSheet,
|
||||
onSecurityVerificationSheetDismiss = viewModel::hideSecurityVerificationSheet
|
||||
)
|
||||
}
|
||||
|
||||
@@ -513,7 +523,11 @@ private fun ChatDialogs(
|
||||
onUserSheetDismiss: () -> Unit,
|
||||
selectedUserForSheet: String,
|
||||
selectedMessageForSheet: BitchatMessage?,
|
||||
viewModel: ChatViewModel
|
||||
viewModel: ChatViewModel,
|
||||
showVerificationSheet: Boolean,
|
||||
onVerificationSheetDismiss: () -> Unit,
|
||||
showSecurityVerificationSheet: Boolean,
|
||||
onSecurityVerificationSheetDismiss: () -> Unit
|
||||
) {
|
||||
// Password dialog
|
||||
PasswordPromptDialog(
|
||||
@@ -567,4 +581,20 @@ private fun ChatDialogs(
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
|
||||
if (showVerificationSheet) {
|
||||
VerificationSheet(
|
||||
isPresented = showVerificationSheet,
|
||||
onDismiss = onVerificationSheetDismiss,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
|
||||
if (showSecurityVerificationSheet) {
|
||||
SecurityVerificationSheet(
|
||||
isPresented = showSecurityVerificationSheet,
|
||||
onDismiss = onSecurityVerificationSheetDismiss,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/**
|
||||
* Centralized state definitions and data classes for the chat system
|
||||
@@ -21,171 +28,168 @@ data class CommandSuggestion(
|
||||
/**
|
||||
* Contains all the observable state for the chat system
|
||||
*/
|
||||
class ChatState {
|
||||
class ChatState(
|
||||
scope: CoroutineScope
|
||||
) {
|
||||
|
||||
// Core messages and peer state
|
||||
private val _messages = MutableLiveData<List<BitchatMessage>>(emptyList())
|
||||
val messages: LiveData<List<BitchatMessage>> = _messages
|
||||
private val _messages = MutableStateFlow<List<BitchatMessage>>(emptyList())
|
||||
val messages: StateFlow<List<BitchatMessage>> = _messages.asStateFlow()
|
||||
|
||||
private val _connectedPeers = MutableLiveData<List<String>>(emptyList())
|
||||
val connectedPeers: LiveData<List<String>> = _connectedPeers
|
||||
private val _connectedPeers = MutableStateFlow<List<String>>(emptyList())
|
||||
val connectedPeers: StateFlow<List<String>> = _connectedPeers.asStateFlow()
|
||||
|
||||
private val _nickname = MutableLiveData<String>()
|
||||
val nickname: LiveData<String> = _nickname
|
||||
private val _nickname = MutableStateFlow<String>("")
|
||||
val nickname: StateFlow<String> = _nickname.asStateFlow()
|
||||
|
||||
private val _isConnected = MutableLiveData<Boolean>(false)
|
||||
val isConnected: LiveData<Boolean> = _isConnected
|
||||
private val _isConnected = MutableStateFlow<Boolean>(false)
|
||||
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||
|
||||
// Private chats
|
||||
private val _privateChats = MutableLiveData<Map<String, List<BitchatMessage>>>(emptyMap())
|
||||
val privateChats: LiveData<Map<String, List<BitchatMessage>>> = _privateChats
|
||||
private val _privateChats = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
|
||||
val privateChats: StateFlow<Map<String, List<BitchatMessage>>> = _privateChats.asStateFlow()
|
||||
|
||||
private val _selectedPrivateChatPeer = MutableLiveData<String?>(null)
|
||||
val selectedPrivateChatPeer: LiveData<String?> = _selectedPrivateChatPeer
|
||||
private val _selectedPrivateChatPeer = MutableStateFlow<String?>(null)
|
||||
val selectedPrivateChatPeer: StateFlow<String?> = _selectedPrivateChatPeer.asStateFlow()
|
||||
|
||||
private val _unreadPrivateMessages = MutableLiveData<Set<String>>(emptySet())
|
||||
val unreadPrivateMessages: LiveData<Set<String>> = _unreadPrivateMessages
|
||||
private val _unreadPrivateMessages = MutableStateFlow<Set<String>>(emptySet())
|
||||
val unreadPrivateMessages: StateFlow<Set<String>> = _unreadPrivateMessages.asStateFlow()
|
||||
|
||||
// Channels
|
||||
private val _joinedChannels = MutableLiveData<Set<String>>(emptySet())
|
||||
val joinedChannels: LiveData<Set<String>> = _joinedChannels
|
||||
private val _joinedChannels = MutableStateFlow<Set<String>>(emptySet())
|
||||
val joinedChannels: StateFlow<Set<String>> = _joinedChannels.asStateFlow()
|
||||
|
||||
private val _currentChannel = MutableLiveData<String?>(null)
|
||||
val currentChannel: LiveData<String?> = _currentChannel
|
||||
private val _currentChannel = MutableStateFlow<String?>(null)
|
||||
val currentChannel: StateFlow<String?> = _currentChannel.asStateFlow()
|
||||
|
||||
private val _channelMessages = MutableLiveData<Map<String, List<BitchatMessage>>>(emptyMap())
|
||||
val channelMessages: LiveData<Map<String, List<BitchatMessage>>> = _channelMessages
|
||||
private val _channelMessages = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
|
||||
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = _channelMessages.asStateFlow()
|
||||
|
||||
private val _unreadChannelMessages = MutableLiveData<Map<String, Int>>(emptyMap())
|
||||
val unreadChannelMessages: LiveData<Map<String, Int>> = _unreadChannelMessages
|
||||
private val _unreadChannelMessages = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||
val unreadChannelMessages: StateFlow<Map<String, Int>> = _unreadChannelMessages.asStateFlow()
|
||||
|
||||
private val _passwordProtectedChannels = MutableLiveData<Set<String>>(emptySet())
|
||||
val passwordProtectedChannels: LiveData<Set<String>> = _passwordProtectedChannels
|
||||
private val _passwordProtectedChannels = MutableStateFlow<Set<String>>(emptySet())
|
||||
val passwordProtectedChannels: StateFlow<Set<String>> = _passwordProtectedChannels.asStateFlow()
|
||||
|
||||
private val _showPasswordPrompt = MutableLiveData<Boolean>(false)
|
||||
val showPasswordPrompt: LiveData<Boolean> = _showPasswordPrompt
|
||||
private val _showPasswordPrompt = MutableStateFlow<Boolean>(false)
|
||||
val showPasswordPrompt: StateFlow<Boolean> = _showPasswordPrompt.asStateFlow()
|
||||
|
||||
private val _passwordPromptChannel = MutableLiveData<String?>(null)
|
||||
val passwordPromptChannel: LiveData<String?> = _passwordPromptChannel
|
||||
private val _passwordPromptChannel = MutableStateFlow<String?>(null)
|
||||
val passwordPromptChannel: StateFlow<String?> = _passwordPromptChannel.asStateFlow()
|
||||
|
||||
// Sidebar state
|
||||
private val _showSidebar = MutableLiveData(false)
|
||||
val showSidebar: LiveData<Boolean> = _showSidebar
|
||||
private val _showSidebar = MutableStateFlow(false)
|
||||
val showSidebar: StateFlow<Boolean> = _showSidebar.asStateFlow()
|
||||
|
||||
// Command autocomplete
|
||||
private val _showCommandSuggestions = MutableLiveData(false)
|
||||
val showCommandSuggestions: LiveData<Boolean> = _showCommandSuggestions
|
||||
private val _showCommandSuggestions = MutableStateFlow(false)
|
||||
val showCommandSuggestions: StateFlow<Boolean> = _showCommandSuggestions.asStateFlow()
|
||||
|
||||
private val _commandSuggestions = MutableLiveData<List<CommandSuggestion>>(emptyList())
|
||||
val commandSuggestions: LiveData<List<CommandSuggestion>> = _commandSuggestions
|
||||
private val _commandSuggestions = MutableStateFlow<List<CommandSuggestion>>(emptyList())
|
||||
val commandSuggestions: StateFlow<List<CommandSuggestion>> = _commandSuggestions.asStateFlow()
|
||||
|
||||
// Mention autocomplete
|
||||
private val _showMentionSuggestions = MutableLiveData(false)
|
||||
val showMentionSuggestions: LiveData<Boolean> = _showMentionSuggestions
|
||||
private val _showMentionSuggestions = MutableStateFlow(false)
|
||||
val showMentionSuggestions: StateFlow<Boolean> = _showMentionSuggestions.asStateFlow()
|
||||
|
||||
private val _mentionSuggestions = MutableLiveData<List<String>>(emptyList())
|
||||
val mentionSuggestions: LiveData<List<String>> = _mentionSuggestions
|
||||
private val _mentionSuggestions = MutableStateFlow<List<String>>(emptyList())
|
||||
val mentionSuggestions: StateFlow<List<String>> = _mentionSuggestions.asStateFlow()
|
||||
|
||||
// Favorites
|
||||
private val _favoritePeers = MutableLiveData<Set<String>>(emptySet())
|
||||
val favoritePeers: LiveData<Set<String>> = _favoritePeers
|
||||
private val _favoritePeers = MutableStateFlow<Set<String>>(emptySet())
|
||||
val favoritePeers: StateFlow<Set<String>> = _favoritePeers.asStateFlow()
|
||||
|
||||
// Noise session states for peers (for reactive UI updates)
|
||||
private val _peerSessionStates = MutableLiveData<Map<String, String>>(emptyMap())
|
||||
val peerSessionStates: LiveData<Map<String, String>> = _peerSessionStates
|
||||
private val _peerSessionStates = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
val peerSessionStates: StateFlow<Map<String, String>> = _peerSessionStates.asStateFlow()
|
||||
|
||||
// Peer fingerprint state for reactive favorites (for reactive UI updates)
|
||||
private val _peerFingerprints = MutableLiveData<Map<String, String>>(emptyMap())
|
||||
val peerFingerprints: LiveData<Map<String, String>> = _peerFingerprints
|
||||
private val _peerFingerprints = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
val peerFingerprints: StateFlow<Map<String, String>> = _peerFingerprints.asStateFlow()
|
||||
|
||||
private val _peerNicknames = MutableLiveData<Map<String, String>>(emptyMap())
|
||||
val peerNicknames: LiveData<Map<String, String>> = _peerNicknames
|
||||
private val _peerNicknames = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
val peerNicknames: StateFlow<Map<String, String>> = _peerNicknames.asStateFlow()
|
||||
|
||||
private val _peerRSSI = MutableLiveData<Map<String, Int>>(emptyMap())
|
||||
val peerRSSI: LiveData<Map<String, Int>> = _peerRSSI
|
||||
private val _peerRSSI = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||
val peerRSSI: StateFlow<Map<String, Int>> = _peerRSSI.asStateFlow()
|
||||
|
||||
// Direct connection status per peer (for live UI updates)
|
||||
private val _peerDirect = MutableLiveData<Map<String, Boolean>>(emptyMap())
|
||||
val peerDirect: LiveData<Map<String, Boolean>> = _peerDirect
|
||||
private val _peerDirect = MutableStateFlow<Map<String, Boolean>>(emptyMap())
|
||||
val peerDirect: StateFlow<Map<String, Boolean>> = _peerDirect.asStateFlow()
|
||||
|
||||
// peerIDToPublicKeyFingerprint REMOVED - fingerprints now handled centrally in PeerManager
|
||||
|
||||
// Navigation state
|
||||
private val _showAppInfo = MutableLiveData<Boolean>(false)
|
||||
val showAppInfo: LiveData<Boolean> = _showAppInfo
|
||||
private val _showAppInfo = MutableStateFlow<Boolean>(false)
|
||||
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)
|
||||
private val _selectedLocationChannel = MutableLiveData<com.bitchat.android.geohash.ChannelID?>(com.bitchat.android.geohash.ChannelID.Mesh)
|
||||
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = _selectedLocationChannel
|
||||
private val _selectedLocationChannel = MutableStateFlow<com.bitchat.android.geohash.ChannelID?>(com.bitchat.android.geohash.ChannelID.Mesh)
|
||||
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = _selectedLocationChannel.asStateFlow()
|
||||
|
||||
private val _isTeleported = MutableLiveData<Boolean>(false)
|
||||
val isTeleported: LiveData<Boolean> = _isTeleported
|
||||
private val _isTeleported = MutableStateFlow<Boolean>(false)
|
||||
val isTeleported: StateFlow<Boolean> = _isTeleported.asStateFlow()
|
||||
|
||||
// Geohash people state (iOS-compatible)
|
||||
private val _geohashPeople = MutableLiveData<List<GeoPerson>>(emptyList())
|
||||
val geohashPeople: LiveData<List<GeoPerson>> = _geohashPeople
|
||||
// For background thread updates by repositories/handlers in their own scopes
|
||||
val geohashPeopleMutable: MutableLiveData<List<GeoPerson>> get() = _geohashPeople
|
||||
private val _geohashPeople = MutableStateFlow<List<GeoPerson>>(emptyList())
|
||||
val geohashPeople: StateFlow<List<GeoPerson>> = _geohashPeople.asStateFlow()
|
||||
|
||||
|
||||
private val _teleportedGeo = MutableLiveData<Set<String>>(emptySet())
|
||||
val teleportedGeo: LiveData<Set<String>> = _teleportedGeo
|
||||
private val _teleportedGeo = MutableStateFlow<Set<String>>(emptySet())
|
||||
val teleportedGeo: StateFlow<Set<String>> = _teleportedGeo.asStateFlow()
|
||||
|
||||
// Geohash participant counts reactive state (for real-time location channel counts)
|
||||
private val _geohashParticipantCounts = MutableLiveData<Map<String, Int>>(emptyMap())
|
||||
val geohashParticipantCounts: LiveData<Map<String, Int>> = _geohashParticipantCounts
|
||||
private val _geohashParticipantCounts = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||
val geohashParticipantCounts: StateFlow<Map<String, Int>> = _geohashParticipantCounts.asStateFlow()
|
||||
|
||||
// Unread state computed properties
|
||||
val hasUnreadChannels: MediatorLiveData<Boolean> = MediatorLiveData<Boolean>()
|
||||
val hasUnreadPrivateMessages: MediatorLiveData<Boolean> = MediatorLiveData<Boolean>()
|
||||
|
||||
val hasUnreadChannels: StateFlow<Boolean> = _unreadChannelMessages
|
||||
.map { unreadMap -> unreadMap.values.any { it > 0 } }
|
||||
.stateIn(
|
||||
scope = scope,
|
||||
started = WhileSubscribed(5_000),
|
||||
initialValue = false
|
||||
)
|
||||
|
||||
init {
|
||||
// Initialize unread state mediators
|
||||
hasUnreadChannels.addSource(_unreadChannelMessages) { unreadMap ->
|
||||
hasUnreadChannels.value = unreadMap.values.any { it > 0 }
|
||||
}
|
||||
|
||||
hasUnreadPrivateMessages.addSource(_unreadPrivateMessages) { unreadSet ->
|
||||
hasUnreadPrivateMessages.value = unreadSet.isNotEmpty()
|
||||
}
|
||||
}
|
||||
val hasUnreadPrivateMessages: StateFlow<Boolean> = _unreadPrivateMessages
|
||||
.map { unreadSet -> unreadSet.isNotEmpty() }
|
||||
.stateIn(
|
||||
scope = scope,
|
||||
started = WhileSubscribed(5_000),
|
||||
initialValue = false
|
||||
)
|
||||
|
||||
// Getters for internal state access
|
||||
fun getMessagesValue() = _messages.value ?: emptyList()
|
||||
fun getConnectedPeersValue() = _connectedPeers.value ?: emptyList()
|
||||
fun getMessagesValue() = _messages.value
|
||||
fun getConnectedPeersValue() = _connectedPeers.value
|
||||
fun getNicknameValue() = _nickname.value
|
||||
fun getPrivateChatsValue() = _privateChats.value ?: emptyMap()
|
||||
fun getPrivateChatsValue() = _privateChats.value
|
||||
fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value
|
||||
fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value ?: emptySet()
|
||||
fun getJoinedChannelsValue() = _joinedChannels.value ?: emptySet()
|
||||
// Thread-safe posting helpers for background updates
|
||||
fun postGeohashPeople(people: List<GeoPerson>) {
|
||||
_geohashPeople.postValue(people)
|
||||
}
|
||||
|
||||
fun postGeohashParticipantCounts(counts: Map<String, Int>) {
|
||||
_geohashParticipantCounts.postValue(counts)
|
||||
}
|
||||
|
||||
|
||||
fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value
|
||||
fun getJoinedChannelsValue() = _joinedChannels.value
|
||||
fun getCurrentChannelValue() = _currentChannel.value
|
||||
fun getChannelMessagesValue() = _channelMessages.value ?: emptyMap()
|
||||
fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value ?: emptyMap()
|
||||
fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value ?: emptySet()
|
||||
fun getShowPasswordPromptValue() = _showPasswordPrompt.value ?: false
|
||||
fun getChannelMessagesValue() = _channelMessages.value
|
||||
fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value
|
||||
fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value
|
||||
fun getShowPasswordPromptValue() = _showPasswordPrompt.value
|
||||
fun getPasswordPromptChannelValue() = _passwordPromptChannel.value
|
||||
fun getShowSidebarValue() = _showSidebar.value ?: false
|
||||
fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value ?: false
|
||||
fun getCommandSuggestionsValue() = _commandSuggestions.value ?: emptyList()
|
||||
fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value ?: false
|
||||
fun getMentionSuggestionsValue() = _mentionSuggestions.value ?: emptyList()
|
||||
fun getFavoritePeersValue() = _favoritePeers.value ?: emptySet()
|
||||
fun getPeerSessionStatesValue() = _peerSessionStates.value ?: emptyMap()
|
||||
fun getPeerFingerprintsValue() = _peerFingerprints.value ?: emptyMap()
|
||||
fun getShowAppInfoValue() = _showAppInfo.value ?: false
|
||||
fun getGeohashPeopleValue() = _geohashPeople.value ?: emptyList()
|
||||
fun getTeleportedGeoValue() = _teleportedGeo.value ?: emptySet()
|
||||
fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value ?: emptyMap()
|
||||
fun getShowSidebarValue() = _showSidebar.value
|
||||
fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value
|
||||
fun getCommandSuggestionsValue() = _commandSuggestions.value
|
||||
fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value
|
||||
fun getMentionSuggestionsValue() = _mentionSuggestions.value
|
||||
fun getFavoritePeersValue() = _favoritePeers.value
|
||||
fun getPeerSessionStatesValue() = _peerSessionStates.value
|
||||
fun getPeerFingerprintsValue() = _peerFingerprints.value
|
||||
fun getShowAppInfoValue() = _showAppInfo.value
|
||||
fun getGeohashPeopleValue() = _geohashPeople.value
|
||||
fun getTeleportedGeoValue() = _teleportedGeo.value
|
||||
fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value
|
||||
|
||||
// Setters for state updates
|
||||
fun setMessages(messages: List<BitchatMessage>) {
|
||||
@@ -197,7 +201,7 @@ class ChatState {
|
||||
}
|
||||
|
||||
fun postTeleportedGeo(teleported: Set<String>) {
|
||||
_teleportedGeo.postValue(teleported)
|
||||
_teleportedGeo.value = teleported
|
||||
}
|
||||
|
||||
fun setNickname(nickname: String) {
|
||||
@@ -269,7 +273,7 @@ class ChatState {
|
||||
}
|
||||
|
||||
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", "Current value: $currentValue")
|
||||
Log.d("ChatState", "Values equal: ${currentValue == favorites}")
|
||||
@@ -278,8 +282,7 @@ class ChatState {
|
||||
// Always set the value - even if equal, this ensures observers are triggered
|
||||
_favoritePeers.value = favorites
|
||||
|
||||
Log.d("ChatState", "LiveData value after set: ${_favoritePeers.value}")
|
||||
Log.d("ChatState", "LiveData has active observers: ${_favoritePeers.hasActiveObservers()}")
|
||||
Log.d("ChatState", "StateFlow value after set: ${_favoritePeers.value}")
|
||||
}
|
||||
|
||||
fun setPeerSessionStates(states: Map<String, String>) {
|
||||
@@ -305,6 +308,14 @@ class ChatState {
|
||||
fun setShowAppInfo(show: Boolean) {
|
||||
_showAppInfo.value = show
|
||||
}
|
||||
|
||||
fun setShowVerificationSheet(show: Boolean) {
|
||||
_showVerificationSheet.value = show
|
||||
}
|
||||
|
||||
fun setShowSecurityVerificationSheet(show: Boolean) {
|
||||
_showSecurityVerificationSheet.value = show
|
||||
}
|
||||
|
||||
fun setSelectedLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) {
|
||||
_selectedLocationChannel.value = channel
|
||||
|
||||
@@ -4,12 +4,16 @@ import android.app.Application
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitchat.android.favorites.FavoritesPersistenceService
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import com.bitchat.android.mesh.BluetoothMeshDelegate
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import com.bitchat.android.nostr.NostrIdentityBridge
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
|
||||
|
||||
@@ -19,6 +23,13 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
import kotlin.random.Random
|
||||
import com.bitchat.android.services.VerificationService
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.bitchat.android.noise.NoiseSession
|
||||
import com.bitchat.android.nostr.GeohashAliasRegistry
|
||||
import com.bitchat.android.util.dataFromHexString
|
||||
import com.bitchat.android.util.hexEncodedString
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Refactored ChatViewModel - Main coordinator for bitchat functionality
|
||||
@@ -46,8 +57,24 @@ class ChatViewModel(
|
||||
mediaSendingManager.sendImageNote(toPeerIDOrNull, channelOrNull, filePath)
|
||||
}
|
||||
|
||||
fun getCurrentNpub(): String? {
|
||||
return try {
|
||||
NostrIdentityBridge
|
||||
.getCurrentNostrIdentity(getApplication())
|
||||
?.npub
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun buildMyQRString(nickname: String, npub: String?): String {
|
||||
return VerificationService.buildMyQRString(nickname, npub) ?: ""
|
||||
}
|
||||
|
||||
// MARK: - State management
|
||||
private val state = ChatState()
|
||||
private val state = ChatState(
|
||||
scope = viewModelScope,
|
||||
)
|
||||
|
||||
// Transfer progress tracking
|
||||
private val transferMessageMap = mutableMapOf<String, String>()
|
||||
@@ -55,6 +82,7 @@ class ChatViewModel(
|
||||
|
||||
// Specialized managers
|
||||
private val dataManager = DataManager(application.applicationContext)
|
||||
private val identityManager by lazy { SecureIdentityStateManager(getApplication()) }
|
||||
private val messageManager = MessageManager(state)
|
||||
private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope)
|
||||
|
||||
@@ -73,6 +101,17 @@ class ChatViewModel(
|
||||
NotificationIntervalManager()
|
||||
)
|
||||
|
||||
private val verificationHandler = VerificationHandler(
|
||||
context = application.applicationContext,
|
||||
scope = viewModelScope,
|
||||
meshService = meshService,
|
||||
identityManager = identityManager,
|
||||
state = state,
|
||||
notificationManager = notificationManager,
|
||||
messageManager = messageManager
|
||||
)
|
||||
val verifiedFingerprints = verificationHandler.verifiedFingerprints
|
||||
|
||||
// Media file sending manager
|
||||
private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager, meshService)
|
||||
|
||||
@@ -103,44 +142,81 @@ class ChatViewModel(
|
||||
|
||||
|
||||
|
||||
// Expose state through LiveData (maintaining the same interface)
|
||||
val messages: LiveData<List<BitchatMessage>> = state.messages
|
||||
val connectedPeers: LiveData<List<String>> = state.connectedPeers
|
||||
val nickname: LiveData<String> = state.nickname
|
||||
val isConnected: LiveData<Boolean> = state.isConnected
|
||||
val privateChats: LiveData<Map<String, List<BitchatMessage>>> = state.privateChats
|
||||
val selectedPrivateChatPeer: LiveData<String?> = state.selectedPrivateChatPeer
|
||||
val unreadPrivateMessages: LiveData<Set<String>> = state.unreadPrivateMessages
|
||||
val joinedChannels: LiveData<Set<String>> = state.joinedChannels
|
||||
val currentChannel: LiveData<String?> = state.currentChannel
|
||||
val channelMessages: LiveData<Map<String, List<BitchatMessage>>> = state.channelMessages
|
||||
val unreadChannelMessages: LiveData<Map<String, Int>> = state.unreadChannelMessages
|
||||
val passwordProtectedChannels: LiveData<Set<String>> = state.passwordProtectedChannels
|
||||
val showPasswordPrompt: LiveData<Boolean> = state.showPasswordPrompt
|
||||
val passwordPromptChannel: LiveData<String?> = state.passwordPromptChannel
|
||||
val showSidebar: LiveData<Boolean> = state.showSidebar
|
||||
|
||||
val messages: StateFlow<List<BitchatMessage>> = state.messages
|
||||
val connectedPeers: StateFlow<List<String>> = state.connectedPeers
|
||||
val nickname: StateFlow<String> = state.nickname
|
||||
val isConnected: StateFlow<Boolean> = state.isConnected
|
||||
val privateChats: StateFlow<Map<String, List<BitchatMessage>>> = state.privateChats
|
||||
val selectedPrivateChatPeer: StateFlow<String?> = state.selectedPrivateChatPeer
|
||||
val unreadPrivateMessages: StateFlow<Set<String>> = state.unreadPrivateMessages
|
||||
val joinedChannels: StateFlow<Set<String>> = state.joinedChannels
|
||||
val currentChannel: StateFlow<String?> = state.currentChannel
|
||||
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = state.channelMessages
|
||||
val unreadChannelMessages: StateFlow<Map<String, Int>> = state.unreadChannelMessages
|
||||
val passwordProtectedChannels: StateFlow<Set<String>> = state.passwordProtectedChannels
|
||||
val showPasswordPrompt: StateFlow<Boolean> = state.showPasswordPrompt
|
||||
val passwordPromptChannel: StateFlow<String?> = state.passwordPromptChannel
|
||||
val showSidebar: StateFlow<Boolean> = state.showSidebar
|
||||
val hasUnreadChannels = state.hasUnreadChannels
|
||||
val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages
|
||||
val showCommandSuggestions: LiveData<Boolean> = state.showCommandSuggestions
|
||||
val commandSuggestions: LiveData<List<CommandSuggestion>> = state.commandSuggestions
|
||||
val showMentionSuggestions: LiveData<Boolean> = state.showMentionSuggestions
|
||||
val mentionSuggestions: LiveData<List<String>> = state.mentionSuggestions
|
||||
val favoritePeers: LiveData<Set<String>> = state.favoritePeers
|
||||
val peerSessionStates: LiveData<Map<String, String>> = state.peerSessionStates
|
||||
val peerFingerprints: LiveData<Map<String, String>> = state.peerFingerprints
|
||||
val peerNicknames: LiveData<Map<String, String>> = state.peerNicknames
|
||||
val peerRSSI: LiveData<Map<String, Int>> = state.peerRSSI
|
||||
val peerDirect: LiveData<Map<String, Boolean>> = state.peerDirect
|
||||
val showAppInfo: LiveData<Boolean> = state.showAppInfo
|
||||
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
|
||||
val isTeleported: LiveData<Boolean> = state.isTeleported
|
||||
val geohashPeople: LiveData<List<GeoPerson>> = state.geohashPeople
|
||||
val teleportedGeo: LiveData<Set<String>> = state.teleportedGeo
|
||||
val geohashParticipantCounts: LiveData<Map<String, Int>> = state.geohashParticipantCounts
|
||||
val showCommandSuggestions: StateFlow<Boolean> = state.showCommandSuggestions
|
||||
val commandSuggestions: StateFlow<List<CommandSuggestion>> = state.commandSuggestions
|
||||
val showMentionSuggestions: StateFlow<Boolean> = state.showMentionSuggestions
|
||||
val mentionSuggestions: StateFlow<List<String>> = state.mentionSuggestions
|
||||
val favoritePeers: StateFlow<Set<String>> = state.favoritePeers
|
||||
val peerSessionStates: StateFlow<Map<String, String>> = state.peerSessionStates
|
||||
val peerFingerprints: StateFlow<Map<String, String>> = state.peerFingerprints
|
||||
val peerNicknames: StateFlow<Map<String, String>> = state.peerNicknames
|
||||
val peerRSSI: StateFlow<Map<String, Int>> = state.peerRSSI
|
||||
val peerDirect: StateFlow<Map<String, Boolean>> = state.peerDirect
|
||||
val showAppInfo: StateFlow<Boolean> = state.showAppInfo
|
||||
val showVerificationSheet: StateFlow<Boolean> = state.showVerificationSheet
|
||||
val showSecurityVerificationSheet: StateFlow<Boolean> = state.showSecurityVerificationSheet
|
||||
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
|
||||
val isTeleported: StateFlow<Boolean> = state.isTeleported
|
||||
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
|
||||
val teleportedGeo: StateFlow<Set<String>> = state.teleportedGeo
|
||||
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
|
||||
|
||||
init {
|
||||
// Note: Mesh service delegate is now set by MainActivity
|
||||
loadAndInitialize()
|
||||
// Hydrate UI state from process-wide AppStateStore to survive Activity recreation
|
||||
viewModelScope.launch {
|
||||
try { com.bitchat.android.services.AppStateStore.peers.collect { peers ->
|
||||
state.setConnectedPeers(peers)
|
||||
state.setIsConnected(peers.isNotEmpty())
|
||||
} } catch (_: Exception) { }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try { com.bitchat.android.services.AppStateStore.publicMessages.collect { msgs ->
|
||||
// Source of truth is AppStateStore; replace to avoid duplicate keys in LazyColumn
|
||||
state.setMessages(msgs)
|
||||
} } catch (_: Exception) { }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try { com.bitchat.android.services.AppStateStore.privateMessages.collect { byPeer ->
|
||||
// Replace with store snapshot
|
||||
state.setPrivateChats(byPeer)
|
||||
// Recompute unread set using SeenMessageStore for robustness across Activity recreation
|
||||
try {
|
||||
val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication())
|
||||
val myNick = state.getNicknameValue() ?: meshService.myPeerID
|
||||
val unread = mutableSetOf<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
|
||||
viewModelScope.launch {
|
||||
com.bitchat.android.mesh.TransferProgressManager.events.collect { evt ->
|
||||
@@ -210,6 +286,9 @@ class ChatViewModel(
|
||||
// Initialize favorites persistence service
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication())
|
||||
|
||||
// Load verified fingerprints from secure storage
|
||||
verificationHandler.loadVerifiedFingerprints()
|
||||
|
||||
|
||||
// Ensure NostrTransport knows our mesh peer ID for embedded packets
|
||||
try {
|
||||
@@ -565,7 +644,7 @@ class ChatViewModel(
|
||||
|
||||
private fun logCurrentFavoriteState() {
|
||||
Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===")
|
||||
Log.i("ChatViewModel", "LiveData favorite peers: ${favoritePeers.value}")
|
||||
Log.i("ChatViewModel", "StateFlow favorite peers: ${favoritePeers.value}")
|
||||
Log.i("ChatViewModel", "DataManager favorite peers: ${dataManager.favoritePeers}")
|
||||
Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}")
|
||||
Log.i("ChatViewModel", "==============================")
|
||||
@@ -609,6 +688,18 @@ class ChatViewModel(
|
||||
// Update fingerprint mappings from centralized manager
|
||||
val fingerprints = privateChatManager.getAllPeerFingerprints()
|
||||
state.setPeerFingerprints(fingerprints)
|
||||
fingerprints.forEach { (peerID, fingerprint) ->
|
||||
identityManager.cachePeerFingerprint(peerID, fingerprint)
|
||||
val info = try { meshService.getPeerInfo(peerID) } catch (_: Exception) { null }
|
||||
val noiseKeyHex = info?.noisePublicKey?.hexEncodedString()
|
||||
if (noiseKeyHex != null) {
|
||||
identityManager.cachePeerNoiseKey(peerID, noiseKeyHex)
|
||||
identityManager.cacheNoiseFingerprint(noiseKeyHex, fingerprint)
|
||||
}
|
||||
info?.nickname?.takeIf { it.isNotBlank() }?.let { nickname ->
|
||||
identityManager.cacheFingerprintNickname(fingerprint, nickname)
|
||||
}
|
||||
}
|
||||
|
||||
val nicknames = meshService.getPeerNicknames()
|
||||
state.setPeerNicknames(nicknames)
|
||||
@@ -623,6 +714,34 @@ class ChatViewModel(
|
||||
}
|
||||
state.setPeerDirect(directMap)
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Flush any pending QR verification once a Noise session is established
|
||||
currentPeers.forEach { peerID ->
|
||||
if (meshService.getSessionState(peerID) is NoiseSession.NoiseSessionState.Established) {
|
||||
verificationHandler.sendPendingVerificationIfNeeded(peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - QR Verification
|
||||
|
||||
fun isPeerVerified(peerID: String, verifiedFingerprints: Set<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
|
||||
@@ -631,41 +750,71 @@ class ChatViewModel(
|
||||
return meshService.getDebugStatus()
|
||||
}
|
||||
|
||||
// Note: Mesh service restart is now handled by MainActivity
|
||||
// This function is no longer needed
|
||||
|
||||
fun setAppBackgroundState(inBackground: Boolean) {
|
||||
// Forward to notification manager for notification logic
|
||||
notificationManager.setAppBackgroundState(inBackground)
|
||||
}
|
||||
|
||||
fun setCurrentPrivateChatPeer(peerID: String?) {
|
||||
// Update notification manager with current private chat peer
|
||||
notificationManager.setCurrentPrivateChatPeer(peerID)
|
||||
}
|
||||
|
||||
fun setCurrentGeohash(geohash: String?) {
|
||||
// Update notification manager with current geohash for notification logic
|
||||
notificationManager.setCurrentGeohash(geohash)
|
||||
}
|
||||
|
||||
fun clearNotificationsForSender(peerID: String) {
|
||||
// Clear notifications when user opens a chat
|
||||
notificationManager.clearNotificationsForSender(peerID)
|
||||
}
|
||||
|
||||
fun clearNotificationsForGeohash(geohash: String) {
|
||||
// Clear notifications when user opens a geohash chat
|
||||
notificationManager.clearNotificationsForGeohash(geohash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear mesh mention notifications when user opens mesh chat
|
||||
*/
|
||||
fun clearMeshMentionNotifications() {
|
||||
notificationManager.clearMeshMentionNotifications()
|
||||
}
|
||||
|
||||
private var reopenSidebarAfterVerification = false
|
||||
|
||||
fun showVerificationSheet(fromSidebar: Boolean = false) {
|
||||
if (fromSidebar) {
|
||||
reopenSidebarAfterVerification = true
|
||||
}
|
||||
state.setShowVerificationSheet(true)
|
||||
}
|
||||
|
||||
fun hideVerificationSheet() {
|
||||
state.setShowVerificationSheet(false)
|
||||
if (reopenSidebarAfterVerification) {
|
||||
reopenSidebarAfterVerification = false
|
||||
state.setShowSidebar(true)
|
||||
}
|
||||
}
|
||||
|
||||
fun showSecurityVerificationSheet() {
|
||||
state.setShowSecurityVerificationSheet(true)
|
||||
}
|
||||
|
||||
fun hideSecurityVerificationSheet() {
|
||||
state.setShowSecurityVerificationSheet(false)
|
||||
}
|
||||
|
||||
fun getPeerFingerprintForDisplay(peerID: String): String? {
|
||||
return verificationHandler.getPeerFingerprintForDisplay(peerID)
|
||||
}
|
||||
|
||||
fun getMyFingerprint(): String {
|
||||
return verificationHandler.getMyFingerprint()
|
||||
}
|
||||
|
||||
fun resolvePeerDisplayNameForFingerprint(peerID: String): String {
|
||||
return verificationHandler.resolvePeerDisplayNameForFingerprint(peerID)
|
||||
}
|
||||
|
||||
fun verifyFingerprintValue(fingerprint: String) {
|
||||
verificationHandler.verifyFingerprintValue(fingerprint)
|
||||
}
|
||||
|
||||
fun unverifyFingerprintValue(fingerprint: String) {
|
||||
verificationHandler.unverifyFingerprintValue(fingerprint)
|
||||
}
|
||||
|
||||
// MARK: - Command Autocomplete (delegated)
|
||||
|
||||
fun updateCommandSuggestions(input: String) {
|
||||
@@ -707,6 +856,14 @@ class ChatViewModel(
|
||||
override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) {
|
||||
meshDelegateHandler.didReceiveReadReceipt(messageID, recipientPeerID)
|
||||
}
|
||||
|
||||
override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
verificationHandler.didReceiveVerifyChallenge(peerID, payload)
|
||||
}
|
||||
|
||||
override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
verificationHandler.didReceiveVerifyResponse(peerID, payload)
|
||||
}
|
||||
|
||||
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
|
||||
return meshDelegateHandler.decryptChannelMessage(encryptedContent, channel)
|
||||
@@ -790,7 +947,7 @@ class ChatViewModel(
|
||||
|
||||
// Clear secure identity state (if used)
|
||||
try {
|
||||
val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication())
|
||||
val identityManager = SecureIdentityStateManager(getApplication())
|
||||
identityManager.clearIdentityData()
|
||||
// Also clear secure values used by FavoritesPersistenceService (favorites + peerID index)
|
||||
try {
|
||||
@@ -803,7 +960,7 @@ class ChatViewModel(
|
||||
|
||||
// Clear FavoritesPersistenceService persistent relationships
|
||||
try {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.clearAllFavorites()
|
||||
FavoritesPersistenceService.shared.clearAllFavorites()
|
||||
Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships")
|
||||
} catch (_: Exception) { }
|
||||
|
||||
@@ -932,5 +1089,6 @@ class ChatViewModel(
|
||||
*/
|
||||
fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
|
||||
return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.compose.material.icons.outlined.Explore
|
||||
import androidx.compose.material.icons.outlined.LocationOn
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -21,6 +20,7 @@ import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import java.util.*
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.R
|
||||
|
||||
/**
|
||||
@@ -46,11 +46,11 @@ fun GeohashPeopleList(
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
// Observe geohash people from ChatViewModel
|
||||
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList())
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
||||
val isTeleported by viewModel.isTeleported.observeAsState(false)
|
||||
val nickname by viewModel.nickname.observeAsState("")
|
||||
val unreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
|
||||
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val isTeleported by viewModel.isTeleported.collectAsStateWithLifecycle()
|
||||
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||
val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
||||
|
||||
Column {
|
||||
// Header matching iOS style
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.bitchat.android.ui
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitchat.android.nostr.GeohashMessageHandler
|
||||
import com.bitchat.android.nostr.GeohashRepository
|
||||
@@ -15,6 +14,7 @@ import com.bitchat.android.nostr.NostrSubscriptionManager
|
||||
import com.bitchat.android.nostr.PoWPreferenceManager
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
|
||||
@@ -55,9 +55,9 @@ class GeohashViewModel(
|
||||
private var geoTimer: Job? = null
|
||||
private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null
|
||||
|
||||
val geohashPeople: LiveData<List<GeoPerson>> = state.geohashPeople
|
||||
val geohashParticipantCounts: LiveData<Map<String, Int>> = state.geohashParticipantCounts
|
||||
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
|
||||
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
|
||||
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
|
||||
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
|
||||
|
||||
fun initialize() {
|
||||
subscriptionManager.connect()
|
||||
@@ -73,12 +73,16 @@ class GeohashViewModel(
|
||||
}
|
||||
try {
|
||||
locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication())
|
||||
locationChannelManager?.selectedChannel?.observeForever { channel ->
|
||||
state.setSelectedLocationChannel(channel)
|
||||
switchLocationChannel(channel)
|
||||
viewModelScope.launch {
|
||||
locationChannelManager?.selectedChannel?.collect { channel ->
|
||||
state.setSelectedLocationChannel(channel)
|
||||
switchLocationChannel(channel)
|
||||
}
|
||||
}
|
||||
locationChannelManager?.teleported?.observeForever { teleported ->
|
||||
state.setIsTeleported(teleported)
|
||||
viewModelScope.launch {
|
||||
locationChannelManager?.teleported?.collect { teleported ->
|
||||
state.setIsTeleported(teleported)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to initialize location channel state: ${e.message}")
|
||||
@@ -120,7 +124,7 @@ class GeohashViewModel(
|
||||
}
|
||||
try {
|
||||
val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication())
|
||||
val teleported = state.isTeleported.value ?: false
|
||||
val teleported = state.isTeleported.value
|
||||
val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported)
|
||||
val relayManager = NostrRelayManager.getInstance(getApplication())
|
||||
relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5)
|
||||
@@ -231,7 +235,7 @@ class GeohashViewModel(
|
||||
try {
|
||||
val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication())
|
||||
repo.updateParticipant(channel.channel.geohash, identity.publicKeyHex, Date())
|
||||
val teleported = state.isTeleported.value ?: false
|
||||
val teleported = state.isTeleported.value
|
||||
if (teleported) repo.markTeleported(identity.publicKeyHex)
|
||||
} catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") }
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import androidx.compose.material.icons.filled.PinDrop
|
||||
import androidx.compose.material.icons.outlined.BookmarkBorder
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
@@ -38,7 +37,9 @@ import com.bitchat.android.geohash.LocationChannelManager
|
||||
import com.bitchat.android.geohash.GeohashBookmarksStore
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.core.ui.component.button.CloseButton
|
||||
|
||||
/**
|
||||
* Location Channels Sheet for selecting geohash-based location channels
|
||||
@@ -57,18 +58,18 @@ fun LocationChannelsSheet(
|
||||
val bookmarksStore = remember { GeohashBookmarksStore.getInstance(context) }
|
||||
|
||||
// Observe location manager state
|
||||
val permissionState by locationManager.permissionState.observeAsState()
|
||||
val availableChannels by locationManager.availableChannels.observeAsState(emptyList())
|
||||
val selectedChannel by locationManager.selectedChannel.observeAsState()
|
||||
val locationNames by locationManager.locationNames.observeAsState(emptyMap())
|
||||
val locationServicesEnabled by locationManager.locationServicesEnabled.observeAsState(false)
|
||||
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
|
||||
val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
|
||||
val selectedChannel by locationManager.selectedChannel.collectAsStateWithLifecycle()
|
||||
val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
|
||||
val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle()
|
||||
|
||||
// Observe bookmarks state
|
||||
val bookmarks by bookmarksStore.bookmarks.observeAsState(emptyList())
|
||||
val bookmarkNames by bookmarksStore.bookmarkNames.observeAsState(emptyMap())
|
||||
val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle()
|
||||
val bookmarkNames by bookmarksStore.bookmarkNames.collectAsStateWithLifecycle()
|
||||
|
||||
// Observe reactive participant counts
|
||||
val geohashParticipantCounts by viewModel.geohashParticipantCounts.observeAsState(emptyMap())
|
||||
val geohashParticipantCounts by viewModel.geohashParticipantCounts.collectAsStateWithLifecycle()
|
||||
|
||||
// UI state
|
||||
var customGeohash by remember { mutableStateOf("") }
|
||||
@@ -551,18 +552,12 @@ fun LocationChannelsSheet(
|
||||
.height(56.dp)
|
||||
.background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha))
|
||||
) {
|
||||
TextButton(
|
||||
CloseButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
modifier = modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.close_plain),
|
||||
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -35,10 +35,10 @@ fun LocationNotesButton(
|
||||
val context = LocalContext.current
|
||||
|
||||
// Get channel and permission state
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val locationManager = remember { LocationChannelManager.getInstance(context) }
|
||||
val permissionState by locationManager.permissionState.observeAsState()
|
||||
val locationServicesEnabled by locationManager.locationServicesEnabled.observeAsState(false)
|
||||
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
|
||||
val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle(false)
|
||||
|
||||
// Check both permission AND location services enabled
|
||||
val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED
|
||||
@@ -46,7 +46,7 @@ fun LocationNotesButton(
|
||||
|
||||
// Get notes count from LocationNotesManager
|
||||
val notesManager = remember { LocationNotesManager.getInstance() }
|
||||
val notes by notesManager.notes.observeAsState(emptyList())
|
||||
val notes by notesManager.notes.collectAsStateWithLifecycle()
|
||||
val notesCount = notes.size
|
||||
|
||||
// Only show in mesh mode when location is authorized (iOS pattern)
|
||||
|
||||
@@ -12,7 +12,6 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowUpward
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -25,6 +24,7 @@ import com.bitchat.android.R
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.geohash.GeohashChannelLevel
|
||||
import com.bitchat.android.geohash.LocationChannelManager
|
||||
import com.bitchat.android.nostr.LocationNotesManager
|
||||
@@ -57,16 +57,16 @@ fun LocationNotesSheet(
|
||||
val locationManager = remember { LocationChannelManager.getInstance(context) }
|
||||
|
||||
// State
|
||||
val notes by notesManager.notes.observeAsState(emptyList())
|
||||
val state by notesManager.state.observeAsState(LocationNotesManager.State.IDLE)
|
||||
val errorMessage by notesManager.errorMessage.observeAsState()
|
||||
val initialLoadComplete by notesManager.initialLoadComplete.observeAsState(false)
|
||||
val notes by notesManager.notes.collectAsStateWithLifecycle()
|
||||
val state by notesManager.state.collectAsStateWithLifecycle(LocationNotesManager.State.IDLE)
|
||||
val errorMessage by notesManager.errorMessage.collectAsStateWithLifecycle()
|
||||
val initialLoadComplete by notesManager.initialLoadComplete.collectAsStateWithLifecycle(false)
|
||||
|
||||
// SIMPLIFIED: Get count directly from notes list (no separate counter needed)
|
||||
val count = notes.size
|
||||
|
||||
// Get location name (building or block) - matches iOS locationNames lookup
|
||||
val locationNames by locationManager.locationNames.observeAsState(emptyMap())
|
||||
val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
|
||||
val displayLocationName = locationNames[GeohashChannelLevel.BUILDING]?.takeIf { it.isNotEmpty() }
|
||||
?: locationNames[GeohashChannelLevel.BLOCK]?.takeIf { it.isNotEmpty() }
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.geohash.GeohashChannelLevel
|
||||
import com.bitchat.android.geohash.LocationChannelManager
|
||||
|
||||
@@ -26,15 +26,15 @@ fun LocationNotesSheetPresenter(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val locationManager = remember { LocationChannelManager.getInstance(context) }
|
||||
val availableChannels by locationManager.availableChannels.observeAsState(emptyList())
|
||||
val nickname by viewModel.nickname.observeAsState("")
|
||||
val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
|
||||
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||
|
||||
// iOS pattern: notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash
|
||||
val buildingGeohash = availableChannels.firstOrNull { it.level == GeohashChannelLevel.BUILDING }?.geohash
|
||||
|
||||
if (buildingGeohash != null) {
|
||||
// Get location name from locationManager
|
||||
val locationNames by locationManager.locationNames.observeAsState(emptyMap())
|
||||
val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
|
||||
val locationName = locationNames[GeohashChannelLevel.BUILDING]
|
||||
?: locationNames[GeohashChannelLevel.BLOCK]
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -26,7 +27,7 @@ private enum class CharacterAnimationState {
|
||||
*/
|
||||
@Composable
|
||||
fun shouldAnimateMessage(messageId: String): Boolean {
|
||||
val miningMessages by PoWMiningTracker.miningMessages.collectAsState()
|
||||
val miningMessages by PoWMiningTracker.miningMessages.collectAsStateWithLifecycle()
|
||||
return miningMessages.contains(messageId)
|
||||
}
|
||||
|
||||
|
||||
@@ -46,10 +46,10 @@ class MeshDelegateHandler(
|
||||
if (message.isPrivate) {
|
||||
// Private message
|
||||
privateChatManager.handleIncomingPrivateMessage(message)
|
||||
|
||||
// Reactive read receipts: Send immediately if user is currently viewing this chat
|
||||
|
||||
// Reactive read receipts: if chat is focused, send immediately for this message
|
||||
message.senderPeerID?.let { senderPeerID ->
|
||||
sendReadReceiptIfFocused(senderPeerID)
|
||||
sendReadReceiptIfFocused(message)
|
||||
}
|
||||
|
||||
// Show notification with enhanced information - now includes senderPeerID
|
||||
@@ -64,15 +64,26 @@ class MeshDelegateHandler(
|
||||
)
|
||||
}
|
||||
} else if (message.channel != null) {
|
||||
// Channel message
|
||||
// Channel message: AppStateStore is the source of truth for list; only manage unread
|
||||
if (state.getJoinedChannelsValue().contains(message.channel)) {
|
||||
channelManager.addChannelMessage(message.channel, message, message.senderPeerID)
|
||||
val channel = message.channel
|
||||
val viewingClassic = state.getCurrentChannelValue() == channel
|
||||
val viewingGeohash = try {
|
||||
if (channel.startsWith("geo:")) {
|
||||
val geo = channel.removePrefix("geo:")
|
||||
val selected = state.selectedLocationChannel.value
|
||||
selected is com.bitchat.android.geohash.ChannelID.Location && selected.channel.geohash.equals(geo, ignoreCase = true)
|
||||
} else false
|
||||
} catch (_: Exception) { false }
|
||||
if (!viewingClassic && !viewingGeohash) {
|
||||
val currentUnread = state.getUnreadChannelMessagesValue().toMutableMap()
|
||||
currentUnread[channel] = (currentUnread[channel] ?: 0) + 1
|
||||
state.setUnreadChannelMessages(currentUnread)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Public mesh message - always store to preserve message history
|
||||
messageManager.addMessage(message)
|
||||
|
||||
// Check for mentions in mesh chat
|
||||
// Public mesh message: AppStateStore is the source of truth; avoid double-adding to UI state
|
||||
// Still run mention detection/notifications
|
||||
checkAndTriggerMeshMentionNotification(message)
|
||||
}
|
||||
|
||||
@@ -205,6 +216,14 @@ class MeshDelegateHandler(
|
||||
messageManager.updateMessageDeliveryStatus(messageID, DeliveryStatus.Read(recipientPeerID, Date()))
|
||||
}
|
||||
}
|
||||
|
||||
override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
// Handled by ChatViewModel for verification flow
|
||||
}
|
||||
|
||||
override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
// Handled by ChatViewModel for verification flow
|
||||
}
|
||||
|
||||
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
|
||||
return channelManager.decryptChannelMessage(encryptedContent, channel)
|
||||
@@ -263,21 +282,31 @@ class MeshDelegateHandler(
|
||||
* Uses same logic as notification system - send read receipt if user is currently
|
||||
* viewing the private chat with this sender AND app is in foreground.
|
||||
*/
|
||||
private fun sendReadReceiptIfFocused(senderPeerID: String) {
|
||||
private fun sendReadReceiptIfFocused(message: BitchatMessage) {
|
||||
// Get notification manager's focus state (mirror the notification logic)
|
||||
val isAppInBackground = notificationManager.getAppBackgroundState()
|
||||
val currentPrivateChatPeer = notificationManager.getCurrentPrivateChatPeer()
|
||||
|
||||
// Send read receipt if user is currently focused on this specific chat
|
||||
val shouldSendReadReceipt = !isAppInBackground && currentPrivateChatPeer == senderPeerID
|
||||
val senderPeerID = message.senderPeerID
|
||||
val shouldSendReadReceipt = !isAppInBackground && senderPeerID != null && currentPrivateChatPeer == senderPeerID
|
||||
|
||||
if (shouldSendReadReceipt) {
|
||||
android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID")
|
||||
privateChatManager.sendReadReceiptsForPeer(senderPeerID, getMeshService())
|
||||
} else {
|
||||
android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)")
|
||||
if (shouldSendReadReceipt) {
|
||||
android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID (message=${message.id})")
|
||||
val nickname = state.getNicknameValue() ?: "unknown"
|
||||
// Send directly for this message to avoid relying on unread queues
|
||||
getMeshService().sendReadReceipt(message.id, senderPeerID!!, nickname)
|
||||
// Ensure unread badge is cleared for this peer immediately
|
||||
try {
|
||||
val current = state.getUnreadPrivateMessagesValue().toMutableSet()
|
||||
if (current.remove(senderPeerID)) {
|
||||
state.setUnreadPrivateMessages(current)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
} else {
|
||||
android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ class MessageManager(private val state: ChatState) {
|
||||
val currentMessages = state.getMessagesValue().toMutableList()
|
||||
currentMessages.add(message)
|
||||
state.setMessages(currentMessages)
|
||||
// Reflect into process-wide store so snapshot replacements don't drop local outgoing messages
|
||||
try { com.bitchat.android.services.AppStateStore.addPublicMessage(message) } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
// Log a system message into the main chat (visible to user)
|
||||
@@ -52,6 +54,8 @@ class MessageManager(private val state: ChatState) {
|
||||
channelMessageList.add(message)
|
||||
currentChannelMessages[channel] = channelMessageList
|
||||
state.setChannelMessages(currentChannelMessages)
|
||||
// Reflect into process-wide store
|
||||
try { com.bitchat.android.services.AppStateStore.addChannelMessage(channel, message) } catch (_: Exception) { }
|
||||
|
||||
// Update unread count if not currently viewing this channel
|
||||
// Consider both classic channels (state.currentChannel) and geohash location channel selection
|
||||
@@ -105,6 +109,8 @@ class MessageManager(private val state: ChatState) {
|
||||
chatMessages.add(message)
|
||||
currentPrivateChats[peerID] = chatMessages
|
||||
state.setPrivateChats(currentPrivateChats)
|
||||
// Reflect into process-wide store
|
||||
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(peerID, message) } catch (_: Exception) { }
|
||||
|
||||
// Mark as unread if not currently viewing this chat
|
||||
if (state.getSelectedPrivateChatPeerValue() != peerID && message.sender != state.getNicknameValue()) {
|
||||
@@ -124,6 +130,8 @@ class MessageManager(private val state: ChatState) {
|
||||
chatMessages.add(message)
|
||||
currentPrivateChats[peerID] = chatMessages
|
||||
state.setPrivateChats(currentPrivateChats)
|
||||
// Reflect into process-wide store
|
||||
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(peerID, message) } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
fun clearPrivateMessages(peerID: String) {
|
||||
@@ -206,6 +214,21 @@ class MessageManager(private val state: ChatState) {
|
||||
|
||||
// MARK: - Delivery Status Updates
|
||||
|
||||
private fun statusPriority(status: DeliveryStatus?): Int = when (status) {
|
||||
null -> 0
|
||||
is DeliveryStatus.Sending -> 1
|
||||
is DeliveryStatus.Sent -> 2
|
||||
is DeliveryStatus.PartiallyDelivered -> 3
|
||||
is DeliveryStatus.Delivered -> 4
|
||||
is DeliveryStatus.Read -> 5
|
||||
is DeliveryStatus.Failed -> 0 // treat as lowest for UI check marks ordering
|
||||
}
|
||||
|
||||
private fun chooseStatus(old: DeliveryStatus?, new: DeliveryStatus): DeliveryStatus? {
|
||||
// Never downgrade (e.g., Read -> Delivered). Keep the higher priority.
|
||||
return if (statusPriority(new) >= statusPriority(old)) new else old
|
||||
}
|
||||
|
||||
fun updateMessageDeliveryStatus(messageID: String, status: DeliveryStatus) {
|
||||
// Update in private chats
|
||||
val updatedPrivateChats = state.getPrivateChatsValue().toMutableMap()
|
||||
@@ -215,22 +238,32 @@ class MessageManager(private val state: ChatState) {
|
||||
val updatedMessages = messages.toMutableList()
|
||||
val messageIndex = updatedMessages.indexOfFirst { it.id == messageID }
|
||||
if (messageIndex >= 0) {
|
||||
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = status)
|
||||
updatedPrivateChats[peerID] = updatedMessages
|
||||
updated = true
|
||||
val current = updatedMessages[messageIndex].deliveryStatus
|
||||
val finalStatus = chooseStatus(current, status)
|
||||
if (finalStatus !== current) {
|
||||
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = finalStatus)
|
||||
updatedPrivateChats[peerID] = updatedMessages
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
state.setPrivateChats(updatedPrivateChats)
|
||||
// Keep process-wide store in sync to prevent snapshot overwrites resetting status
|
||||
try { com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(messageID, status) } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
// Update in main messages
|
||||
val updatedMessages = state.getMessagesValue().toMutableList()
|
||||
val messageIndex = updatedMessages.indexOfFirst { it.id == messageID }
|
||||
if (messageIndex >= 0) {
|
||||
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = status)
|
||||
state.setMessages(updatedMessages)
|
||||
val current = updatedMessages[messageIndex].deliveryStatus
|
||||
val finalStatus = chooseStatus(current, status)
|
||||
if (finalStatus !== current) {
|
||||
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = finalStatus)
|
||||
state.setMessages(updatedMessages)
|
||||
}
|
||||
}
|
||||
|
||||
// Update in channel messages
|
||||
@@ -239,8 +272,12 @@ class MessageManager(private val state: ChatState) {
|
||||
val channelMessagesList = messages.toMutableList()
|
||||
val channelMessageIndex = channelMessagesList.indexOfFirst { it.id == messageID }
|
||||
if (channelMessageIndex >= 0) {
|
||||
channelMessagesList[channelMessageIndex] = channelMessagesList[channelMessageIndex].copy(deliveryStatus = status)
|
||||
updatedChannelMessages[channel] = channelMessagesList
|
||||
val current = channelMessagesList[channelMessageIndex].deliveryStatus
|
||||
val finalStatus = chooseStatus(current, status)
|
||||
if (finalStatus !== current) {
|
||||
channelMessagesList[channelMessageIndex] = channelMessagesList[channelMessageIndex].copy(deliveryStatus = finalStatus)
|
||||
updatedChannelMessages[channel] = channelMessagesList
|
||||
}
|
||||
}
|
||||
}
|
||||
state.setChannelMessages(updatedChannelMessages)
|
||||
|
||||
@@ -280,6 +280,37 @@ class NotificationManager(
|
||||
Log.d(TAG, "Displayed notification for $contentTitle with ID $notificationId")
|
||||
}
|
||||
|
||||
fun showVerificationNotification(title: String, body: String, peerID: String? = null) {
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
if (peerID != null) {
|
||||
putExtra(EXTRA_OPEN_PRIVATE_CHAT, true)
|
||||
putExtra(EXTRA_PEER_ID, peerID)
|
||||
putExtra(EXTRA_SENDER_NICKNAME, body)
|
||||
}
|
||||
}
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
(System.currentTimeMillis() and 0x7FFFFFFF).toInt(),
|
||||
intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setShowWhen(true)
|
||||
.setWhen(System.currentTimeMillis())
|
||||
|
||||
notificationManager.notify((System.currentTimeMillis() and 0x7FFFFFFF).toInt(), builder.build())
|
||||
}
|
||||
|
||||
private fun showNotificationForActivePeers(peersSize: Int) {
|
||||
// Create intent to open the app
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.nostr.NostrProofOfWork
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.nostr.PoWPreferenceManager
|
||||
|
||||
@@ -27,9 +28,9 @@ fun PoWStatusIndicator(
|
||||
modifier: Modifier = Modifier,
|
||||
style: PoWIndicatorStyle = PoWIndicatorStyle.COMPACT
|
||||
) {
|
||||
val powEnabled by PoWPreferenceManager.powEnabled.collectAsState()
|
||||
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState()
|
||||
val isMining by PoWPreferenceManager.isMining.collectAsState()
|
||||
val powEnabled by PoWPreferenceManager.powEnabled.collectAsStateWithLifecycle()
|
||||
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsStateWithLifecycle()
|
||||
val isMining by PoWPreferenceManager.isMining.collectAsStateWithLifecycle()
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
|
||||
|
||||
@@ -292,28 +292,30 @@ class PrivateChatManager(
|
||||
}
|
||||
|
||||
fun handleIncomingPrivateMessage(message: BitchatMessage, suppressUnread: Boolean) {
|
||||
message.senderPeerID?.let { senderPeerID ->
|
||||
val senderPeerID = message.senderPeerID
|
||||
if (senderPeerID != null) {
|
||||
// Mesh-origin private message: AppStateStore updates the list; avoid double-add here.
|
||||
if (!isPeerBlocked(senderPeerID)) {
|
||||
// Add to private messages
|
||||
if (suppressUnread) {
|
||||
messageManager.addPrivateMessageNoUnread(senderPeerID, message)
|
||||
} else {
|
||||
messageManager.addPrivateMessage(senderPeerID, message)
|
||||
}
|
||||
|
||||
// Track as unread for read receipt purposes
|
||||
var unreadCount = 0
|
||||
if (!suppressUnread) {
|
||||
// Ensure chat exists
|
||||
messageManager.initializePrivateChat(senderPeerID)
|
||||
// Track as unread for read receipt purposes if not focused
|
||||
if (!suppressUnread && state.getSelectedPrivateChatPeerValue() != senderPeerID) {
|
||||
val unreadList = unreadReceivedMessages.getOrPut(senderPeerID) { mutableListOf() }
|
||||
unreadList.add(message)
|
||||
unreadCount = unreadList.size
|
||||
Log.d(TAG, "Queued unread from $senderPeerID (count=${unreadList.size})")
|
||||
val currentUnread = state.getUnreadPrivateMessagesValue().toMutableSet()
|
||||
currentUnread.add(senderPeerID)
|
||||
state.setUnreadPrivateMessages(currentUnread)
|
||||
}
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"Added received message ${message.id} from $senderPeerID to unread list (${unreadCount} unread)"
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Non-mesh path (e.g., Nostr): add to UI state using existing logic
|
||||
val inferredPeer = state.getSelectedPrivateChatPeerValue() ?: return
|
||||
if (suppressUnread) {
|
||||
messageManager.addPrivateMessageNoUnread(inferredPeer, message)
|
||||
} else {
|
||||
messageManager.addPrivateMessage(inferredPeer, message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,27 +324,33 @@ class PrivateChatManager(
|
||||
* Called when the user focuses on a private chat
|
||||
*/
|
||||
fun sendReadReceiptsForPeer(peerID: String, meshService: BluetoothMeshService) {
|
||||
val unreadList = unreadReceivedMessages[peerID]
|
||||
if (unreadList.isNullOrEmpty()) {
|
||||
Log.d(TAG, "No unread messages to send read receipts for peer $peerID")
|
||||
return
|
||||
// Collect candidate messages: all incoming messages from this peer in the conversation
|
||||
val chats = try { state.getPrivateChatsValue() } catch (_: Exception) { emptyMap<String, List<BitchatMessage>>() }
|
||||
val messages = chats[peerID].orEmpty()
|
||||
|
||||
if (messages.isEmpty()) {
|
||||
Log.d(TAG, "No messages found for peer $peerID to send read receipts")
|
||||
}
|
||||
|
||||
Log.d(TAG, "Sending read receipts for ${unreadList.size} unread messages from $peerID")
|
||||
|
||||
// Send read receipt for each unread message - now using direct method call
|
||||
unreadList.forEach { message ->
|
||||
try {
|
||||
val myNickname = state.getNicknameValue() ?: "unknown"
|
||||
meshService.sendReadReceipt(message.id, peerID, myNickname)
|
||||
Log.d(TAG, "Sent read receipt for message ${message.id} to $peerID")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to send read receipt for message ${message.id}: ${e.message}")
|
||||
val myNickname = state.getNicknameValue() ?: "unknown"
|
||||
var sentCount = 0
|
||||
messages.forEach { msg ->
|
||||
// Only for incoming messages from this peer
|
||||
if (msg.senderPeerID == peerID) {
|
||||
try {
|
||||
meshService.sendReadReceipt(msg.id, peerID, myNickname)
|
||||
sentCount += 1
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to send read receipt for message ${msg.id}: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the unread list since we've sent read receipts
|
||||
// Clear any locally tracked unread queue for this peer
|
||||
unreadReceivedMessages.remove(peerID)
|
||||
// Also clear UI unread marker for this peer now that chat is focused/read
|
||||
try { messageManager.clearPrivateUnreadMessages(peerID) } catch (_: Exception) { }
|
||||
Log.d(TAG, "Sent $sentCount read receipts for peer $peerID (from conversation messages)")
|
||||
}
|
||||
|
||||
fun cleanupDisconnectedPeer(peerID: String) {
|
||||
|
||||
@@ -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.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -22,7 +21,9 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import com.bitchat.android.util.hexEncodedString
|
||||
|
||||
|
||||
/**
|
||||
@@ -34,19 +35,20 @@ import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
fun SidebarOverlay(
|
||||
viewModel: ChatViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
onShowVerification: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList())
|
||||
val joinedChannels by viewModel.joinedChannels.observeAsState(emptyList())
|
||||
val currentChannel by viewModel.currentChannel.observeAsState()
|
||||
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState()
|
||||
val nickname by viewModel.nickname.observeAsState("")
|
||||
val unreadChannelMessages by viewModel.unreadChannelMessages.observeAsState(emptyMap())
|
||||
val peerNicknames by viewModel.peerNicknames.observeAsState(emptyMap())
|
||||
val peerRSSI by viewModel.peerRSSI.observeAsState(emptyMap())
|
||||
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
|
||||
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
|
||||
val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
|
||||
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
|
||||
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||
val unreadChannelMessages by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
|
||||
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
|
||||
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
@@ -110,7 +112,7 @@ fun SidebarOverlay(
|
||||
|
||||
// People section - switch between mesh and geohash lists (iOS-compatible)
|
||||
item {
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsState()
|
||||
|
||||
when (selectedLocationChannel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||
@@ -131,6 +133,7 @@ fun SidebarOverlay(
|
||||
colorScheme = colorScheme,
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
viewModel = viewModel,
|
||||
onShowVerification = onShowVerification,
|
||||
onPrivateChatStart = { peerID ->
|
||||
viewModel.startPrivateChat(peerID)
|
||||
onDismiss()
|
||||
@@ -257,8 +260,11 @@ fun PeopleSection(
|
||||
colorScheme: ColorScheme,
|
||||
selectedPrivatePeer: String?,
|
||||
viewModel: ChatViewModel,
|
||||
onShowVerification: () -> Unit,
|
||||
onPrivateChatStart: (String) -> Unit
|
||||
) {
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -279,6 +285,17 @@ fun PeopleSection(
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (selectedLocationChannel !is com.bitchat.android.geohash.ChannelID.Location) {
|
||||
IconButton(onClick = onShowVerification, modifier = Modifier.size(24.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.QrCode,
|
||||
contentDescription = stringResource(R.string.verify_title),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.8f),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (connectedPeers.isEmpty()) {
|
||||
@@ -291,10 +308,11 @@ fun PeopleSection(
|
||||
}
|
||||
|
||||
// Observe reactive state for favorites and fingerprints
|
||||
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
|
||||
val privateChats by viewModel.privateChats.observeAsState(emptyMap())
|
||||
val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet())
|
||||
val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap())
|
||||
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
||||
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
|
||||
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
|
||||
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
|
||||
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
|
||||
|
||||
// Reactive favorite computation for all peers
|
||||
val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) {
|
||||
@@ -308,10 +326,16 @@ fun PeopleSection(
|
||||
// Build mapping of connected peerID -> noise key hex to unify with offline favorites
|
||||
val noiseHexByPeerID: Map<String, String> = connectedPeers.associateWith { pid ->
|
||||
try {
|
||||
viewModel.meshService.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
|
||||
viewModel.meshService.getPeerInfo(pid)?.noisePublicKey?.hexEncodedString()
|
||||
} catch (_: Exception) { null }
|
||||
}.filterValues { it != null }.mapValues { it.value!! }
|
||||
|
||||
val peerVerifiedStates = remember(verifiedFingerprints, peerFingerprints, connectedPeers) {
|
||||
connectedPeers.associateWith { peerID ->
|
||||
viewModel.isPeerVerified(peerID, verifiedFingerprints)
|
||||
}
|
||||
}
|
||||
|
||||
Log.d("SidebarComponents", "Recomposing with ${favoritePeers.size} favorites, peer states: $peerFavoriteStates")
|
||||
|
||||
// Smart sorting: unread DMs first, then by most recent DM, then favorites, then alphabetical
|
||||
@@ -342,7 +366,7 @@ fun PeopleSection(
|
||||
// Offline favorites (exclude ones mapped to connected)
|
||||
val offlineFavorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites()
|
||||
offlineFavorites.forEach { fav ->
|
||||
val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
|
||||
val favPeerID = fav.peerNoisePublicKey.hexEncodedString()
|
||||
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
|
||||
if (!isMappedToConnected) {
|
||||
val dn = peerNicknames[favPeerID] ?: fav.peerNickname
|
||||
@@ -368,6 +392,7 @@ fun PeopleSection(
|
||||
|
||||
sortedPeers.forEach { peerID ->
|
||||
val isFavorite = peerFavoriteStates[peerID] ?: false
|
||||
val isVerified = peerVerifiedStates[peerID] ?: false
|
||||
// fingerprint and favorite relationship resolution not needed here; UI will show Nostr globe for appended offline favorites below
|
||||
|
||||
val noiseHex = noiseHexByPeerID[peerID]
|
||||
@@ -384,7 +409,7 @@ fun PeopleSection(
|
||||
val (bName, _) = com.bitchat.android.ui.splitSuffix(displayName)
|
||||
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
||||
|
||||
val directMap by viewModel.peerDirect.observeAsState(emptyMap())
|
||||
val directMap by viewModel.peerDirect.collectAsStateWithLifecycle()
|
||||
val isDirectLive = directMap[peerID] ?: try { viewModel.meshService.getPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false }
|
||||
PeerItem(
|
||||
peerID = peerID,
|
||||
@@ -392,6 +417,7 @@ fun PeopleSection(
|
||||
isDirect = isDirectLive,
|
||||
isSelected = peerID == selectedPrivatePeer,
|
||||
isFavorite = isFavorite,
|
||||
isVerified = isVerified,
|
||||
hasUnreadDM = combinedHasUnread,
|
||||
colorScheme = colorScheme,
|
||||
viewModel = viewModel,
|
||||
@@ -408,7 +434,7 @@ fun PeopleSection(
|
||||
|
||||
// Append offline favorites we actively favorite (and not currently connected)
|
||||
offlineFavorites.forEach { fav ->
|
||||
val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
|
||||
val favPeerID = fav.peerNoisePublicKey.hexEncodedString()
|
||||
// If any connected peer maps to this noise key, skip showing the offline entry
|
||||
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
|
||||
if (isMappedToConnected) return@forEach
|
||||
@@ -419,7 +445,7 @@ fun PeopleSection(
|
||||
if (npubOrHex != null) {
|
||||
val hex = if (npubOrHex.startsWith("npub")) {
|
||||
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npubOrHex)
|
||||
if (hrp == "npub") data.joinToString("") { "%02x".format(it) } else null
|
||||
if (hrp == "npub") data.hexEncodedString() else null
|
||||
} else {
|
||||
npubOrHex.lowercase()
|
||||
}
|
||||
@@ -435,6 +461,7 @@ fun PeopleSection(
|
||||
val dn = peerNicknames[favPeerID] ?: fav.peerNickname
|
||||
val (bName, _) = com.bitchat.android.ui.splitSuffix(dn)
|
||||
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
||||
val isVerified = viewModel.isNoisePublicKeyVerified(fav.peerNoisePublicKey, verifiedFingerprints)
|
||||
|
||||
// Compute unreadCount from either noise conversation or Nostr conversation
|
||||
val unreadCount = (
|
||||
@@ -449,6 +476,7 @@ fun PeopleSection(
|
||||
isDirect = false,
|
||||
isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer,
|
||||
isFavorite = true,
|
||||
isVerified = isVerified,
|
||||
hasUnreadDM = hasUnread,
|
||||
colorScheme = colorScheme,
|
||||
viewModel = viewModel,
|
||||
@@ -516,6 +544,7 @@ private fun PeerItem(
|
||||
isDirect: Boolean,
|
||||
isSelected: Boolean,
|
||||
isFavorite: Boolean,
|
||||
isVerified: Boolean,
|
||||
hasUnreadDM: Boolean,
|
||||
colorScheme: ColorScheme,
|
||||
viewModel: ChatViewModel,
|
||||
@@ -616,6 +645,16 @@ private fun PeerItem(
|
||||
color = baseColor.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
|
||||
if (isVerified) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Verified,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = Color(0xFF32D74B)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Favorite star with proper filled/outlined states
|
||||
|
||||
@@ -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)
|
||||
private const val KEY_GCS_MAX_BYTES = "gcs_max_filter_bytes"
|
||||
private const val KEY_GCS_FPR = "gcs_filter_fpr_percent"
|
||||
// Removed: persistent notification toggle is now governed by MeshServicePreferences.isBackgroundEnabled
|
||||
|
||||
private lateinit var prefs: SharedPreferences
|
||||
|
||||
@@ -100,4 +101,6 @@ object DebugPreferenceManager {
|
||||
fun setGcsFprPercent(value: Double) {
|
||||
if (ready()) prefs.edit().putLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(value)).apply()
|
||||
}
|
||||
|
||||
// No longer storing persistent notification in debug prefs.
|
||||
}
|
||||
|
||||
@@ -36,6 +36,11 @@ class DebugSettingsManager private constructor() {
|
||||
private val _packetRelayEnabled = MutableStateFlow(true)
|
||||
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)
|
||||
private val _maxConnectionsOverall = MutableStateFlow(8)
|
||||
val maxConnectionsOverall: StateFlow<Int> = _maxConnectionsOverall.asStateFlow()
|
||||
@@ -75,12 +80,63 @@ class DebugSettingsManager private constructor() {
|
||||
|
||||
// Timestamps to compute rolling window stats
|
||||
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
|
||||
private val debugMessageQueue = ConcurrentLinkedQueue<DebugMessage>()
|
||||
private val scanResultsQueue = ConcurrentLinkedQueue<DebugScanResult>()
|
||||
|
||||
private fun updateRelayStatsFromTimestamps() {
|
||||
if (!_debugSheetVisible.value) return
|
||||
val now = System.currentTimeMillis()
|
||||
// prune older than 15m
|
||||
while (true) {
|
||||
@@ -89,18 +145,84 @@ class DebugSettingsManager private constructor() {
|
||||
relayTimestamps.poll()
|
||||
} else break
|
||||
}
|
||||
// prune per-device and per-peer and compute 1s/60s rates
|
||||
fun pruneAndCount1s(map: MutableMap<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 last10s = relayTimestamps.count { now - it <= 10_000L }
|
||||
val last1m = relayTimestamps.count { now - it <= 60_000L }
|
||||
val last15m = relayTimestamps.size
|
||||
val total = _relayStats.value.totalRelaysCount + 1
|
||||
// And incoming/outgoing per-second counters
|
||||
val last1sIncoming = incomingTimestamps.count { now - it <= 1_000L }
|
||||
val last1sOutgoing = outgoingTimestamps.count { now - it <= 1_000L }
|
||||
val last10sIncoming = incomingTimestamps.count { now - it <= 10_000L }
|
||||
val last10sOutgoing = outgoingTimestamps.count { now - it <= 10_000L }
|
||||
val last1mIncoming = incomingTimestamps.count { now - it <= 60_000L }
|
||||
val last1mOutgoing = outgoingTimestamps.count { now - it <= 60_000L }
|
||||
val last15mIncoming = incomingTimestamps.size
|
||||
val last15mOutgoing = outgoingTimestamps.size
|
||||
val totalIncoming = _relayStats.value.totalIncomingCount
|
||||
val totalOutgoing = _relayStats.value.totalOutgoingCount
|
||||
_relayStats.value = PacketRelayStats(
|
||||
totalRelaysCount = total,
|
||||
totalRelaysCount = totalIncoming + totalOutgoing,
|
||||
lastSecondRelays = last1s,
|
||||
last10SecondRelays = last10s,
|
||||
lastMinuteRelays = last1m,
|
||||
last15MinuteRelays = last15m,
|
||||
lastResetTime = _relayStats.value.lastResetTime
|
||||
lastResetTime = _relayStats.value.lastResetTime,
|
||||
lastSecondIncoming = last1sIncoming,
|
||||
lastSecondOutgoing = last1sOutgoing,
|
||||
last10SecondIncoming = last10sIncoming,
|
||||
last10SecondOutgoing = last10sOutgoing,
|
||||
lastMinuteIncoming = last1mIncoming,
|
||||
lastMinuteOutgoing = last1mOutgoing,
|
||||
last15MinuteIncoming = last15mIncoming,
|
||||
last15MinuteOutgoing = last15mOutgoing,
|
||||
totalIncomingCount = totalIncoming,
|
||||
totalOutgoingCount = totalOutgoing
|
||||
)
|
||||
}
|
||||
|
||||
@@ -336,11 +458,61 @@ class DebugSettingsManager private constructor() {
|
||||
}
|
||||
}
|
||||
|
||||
// Update rolling statistics only for relays
|
||||
if (isRelay) {
|
||||
relayTimestamps.offer(System.currentTimeMillis())
|
||||
updateRelayStatsFromTimestamps()
|
||||
// Do not update counters here; this path is for readable logs only.
|
||||
}
|
||||
|
||||
// Explicit incoming/outgoing logging to avoid double counting
|
||||
fun logIncoming(packetType: String, fromPeerID: String?, fromNickname: String?, fromDeviceAddress: String?) {
|
||||
if (verboseLoggingEnabled.value) {
|
||||
val who = fromNickname ?: fromPeerID ?: "unknown"
|
||||
addDebugMessage(DebugMessage.PacketEvent("📥 Incoming $packetType from $who (${fromPeerID ?: "?"}, ${fromDeviceAddress ?: "?"})"))
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
val visible = _debugSheetVisible.value
|
||||
if (visible) incomingTimestamps.offer(now)
|
||||
fromDeviceAddress?.let {
|
||||
perDeviceIncoming.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now)
|
||||
deviceIncomingTotalsMap[it] = (deviceIncomingTotalsMap[it] ?: 0L) + 1L
|
||||
_perDeviceIncomingTotalsFlow.value = deviceIncomingTotalsMap.toMap()
|
||||
}
|
||||
fromPeerID?.let {
|
||||
perPeerIncoming.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now)
|
||||
peerIncomingTotalsMap[it] = (peerIncomingTotalsMap[it] ?: 0L) + 1L
|
||||
_perPeerIncomingTotalsFlow.value = peerIncomingTotalsMap.toMap()
|
||||
}
|
||||
// bump totals
|
||||
val cur = _relayStats.value
|
||||
_relayStats.value = cur.copy(
|
||||
totalIncomingCount = cur.totalIncomingCount + 1,
|
||||
totalRelaysCount = cur.totalRelaysCount + 1
|
||||
)
|
||||
if (visible) updateRelayStatsFromTimestamps()
|
||||
}
|
||||
|
||||
fun logOutgoing(packetType: String, toPeerID: String?, toNickname: String?, toDeviceAddress: String?, previousHopPeerID: String? = null) {
|
||||
if (verboseLoggingEnabled.value) {
|
||||
val who = toNickname ?: toPeerID ?: "unknown"
|
||||
addDebugMessage(DebugMessage.PacketEvent("📤 Outgoing $packetType to $who (${toPeerID ?: "?"}, ${toDeviceAddress ?: "?"})"))
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
val visible = _debugSheetVisible.value
|
||||
if (visible) outgoingTimestamps.offer(now)
|
||||
toDeviceAddress?.let {
|
||||
perDeviceOutgoing.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now)
|
||||
deviceOutgoingTotalsMap[it] = (deviceOutgoingTotalsMap[it] ?: 0L) + 1L
|
||||
_perDeviceOutgoingTotalsFlow.value = deviceOutgoingTotalsMap.toMap()
|
||||
}
|
||||
(toPeerID ?: previousHopPeerID)?.let {
|
||||
perPeerOutgoing.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now)
|
||||
peerOutgoingTotalsMap[it] = (peerOutgoingTotalsMap[it] ?: 0L) + 1L
|
||||
_perPeerOutgoingTotalsFlow.value = peerOutgoingTotalsMap.toMap()
|
||||
}
|
||||
val cur = _relayStats.value
|
||||
_relayStats.value = cur.copy(
|
||||
totalOutgoingCount = cur.totalOutgoingCount + 1,
|
||||
totalRelaysCount = cur.totalRelaysCount + 1
|
||||
)
|
||||
if (visible) updateRelayStatsFromTimestamps()
|
||||
}
|
||||
|
||||
// MARK: - Clear Data
|
||||
@@ -407,5 +579,15 @@ data class PacketRelayStats(
|
||||
val last10SecondRelays: Int = 0,
|
||||
val lastMinuteRelays: Int = 0,
|
||||
val last15MinuteRelays: Int = 0,
|
||||
val lastResetTime: Date = Date()
|
||||
val lastResetTime: Date = Date(),
|
||||
val lastSecondIncoming: Int = 0,
|
||||
val lastSecondOutgoing: Int = 0,
|
||||
val last10SecondIncoming: Int = 0,
|
||||
val last10SecondOutgoing: Int = 0,
|
||||
val lastMinuteIncoming: Int = 0,
|
||||
val lastMinuteOutgoing: Int = 0,
|
||||
val last15MinuteIncoming: Int = 0,
|
||||
val last15MinuteOutgoing: Int = 0,
|
||||
val totalIncomingCount: Long = 0,
|
||||
val totalOutgoingCount: Long = 0
|
||||
)
|
||||
|
||||
@@ -3,9 +3,11 @@ package com.bitchat.android.ui.debug
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bluetooth
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
@@ -15,6 +17,7 @@ import androidx.compose.material.icons.filled.PowerSettingsNew
|
||||
import androidx.compose.material.icons.filled.SettingsEthernet
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -31,6 +34,9 @@ import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.R
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.bitchat.android.service.MeshServicePreferences
|
||||
import com.bitchat.android.service.MeshForegroundService
|
||||
|
||||
@Composable
|
||||
fun MeshTopologySection() {
|
||||
@@ -113,7 +119,9 @@ fun MeshTopologySection() {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private enum class GraphMode { OVERALL, PER_DEVICE, PER_PEER }
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun DebugSettingsSheet(
|
||||
isPresented: Boolean,
|
||||
@@ -138,6 +146,8 @@ fun DebugSettingsSheet(
|
||||
val seenCapacity by manager.seenPacketCapacity.collectAsState()
|
||||
val gcsMaxBytes by manager.gcsMaxBytes.collectAsState()
|
||||
val gcsFpr by manager.gcsFprPercent.collectAsState()
|
||||
val context = LocalContext.current
|
||||
// Persistent notification is now controlled solely by MeshServicePreferences.isBackgroundEnabled
|
||||
|
||||
// Push live connected devices from mesh service whenever sheet is visible
|
||||
LaunchedEffect(isPresented) {
|
||||
@@ -174,6 +184,11 @@ fun DebugSettingsSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState
|
||||
) {
|
||||
// Mark debug sheet visible/invisible to gate heavy work
|
||||
LaunchedEffect(Unit) { DebugSettingsManager.getInstance().setDebugSheetVisible(true) }
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { DebugSettingsManager.getInstance().setDebugSheetVisible(false) }
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -292,135 +307,260 @@ fun DebugSettingsSheet(
|
||||
item {
|
||||
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
// Persistent notification is controlled by About sheet (MeshServicePreferences.isBackgroundEnabled)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, tint = Color(0xFFFF9500))
|
||||
Text(stringResource(R.string.debug_packet_relay), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Switch(checked = packetRelayEnabled, onCheckedChange = { manager.setPacketRelayEnabled(it) })
|
||||
}
|
||||
Text(stringResource(R.string.debug_since_start_fmt, relayStats.totalRelaysCount), fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
Text(stringResource(R.string.debug_relays_window_fmt, relayStats.last10SecondRelays, relayStats.lastMinuteRelays, relayStats.last15MinuteRelays), fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
// Realtime graph: per-second relays, full-width canvas, bottom-up bars, fast decay
|
||||
var series by remember { mutableStateOf(List(60) { 0f }) }
|
||||
LaunchedEffect(isPresented) {
|
||||
// Removed aggregate labels; we will show per-direction compact labels below titles
|
||||
// Toggle: overall vs per-connection vs per-peer
|
||||
var graphMode by rememberSaveable { mutableStateOf(GraphMode.OVERALL) }
|
||||
val perDeviceIncoming by manager.perDeviceIncomingLastSecond.collectAsState()
|
||||
val perPeerIncoming by manager.perPeerIncomingLastSecond.collectAsState()
|
||||
val perDeviceOutgoing by manager.perDeviceOutgoingLastSecond.collectAsState()
|
||||
val perPeerOutgoing by manager.perPeerOutgoingLastSecond.collectAsState()
|
||||
val perDeviceIncoming1m by manager.perDeviceIncomingLastMinute.collectAsState()
|
||||
val perDeviceOutgoing1m by manager.perDeviceOutgoingLastMinute.collectAsState()
|
||||
val perPeerIncoming1m by manager.perPeerIncomingLastMinute.collectAsState()
|
||||
val perPeerOutgoing1m by manager.perPeerOutgoingLastMinute.collectAsState()
|
||||
val perDeviceIncomingTotal by manager.perDeviceIncomingTotal.collectAsState()
|
||||
val perDeviceOutgoingTotal by manager.perDeviceOutgoingTotal.collectAsState()
|
||||
val perPeerIncomingTotal by manager.perPeerIncomingTotal.collectAsState()
|
||||
val perPeerOutgoingTotal by manager.perPeerOutgoingTotal.collectAsState()
|
||||
val nicknameMap = remember { mutableStateOf<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) {
|
||||
val s = relayStats.lastSecondRelays.toFloat()
|
||||
val last = series.lastOrNull() ?: 0f
|
||||
// Faster decay and smoothing
|
||||
val v = last * 0.5f + s * 0.5f
|
||||
series = (series + v).takeLast(60)
|
||||
kotlinx.coroutines.delay(400)
|
||||
try { devicePeerMap.value = meshService.getDeviceAddressToPeerMapping() } catch (_: Exception) { }
|
||||
kotlinx.coroutines.delay(1000)
|
||||
}
|
||||
}
|
||||
val maxValRaw = series.maxOrNull() ?: 0f
|
||||
val maxVal = if (maxValRaw > 0f) maxValRaw else 0f
|
||||
val leftGutter = 40.dp
|
||||
Box(Modifier.fillMaxWidth().height(56.dp)) {
|
||||
// Graph canvas
|
||||
androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) {
|
||||
val axisPx = leftGutter.toPx() // reserved left gutter for labels
|
||||
val barCount = series.size
|
||||
val availW = (size.width - axisPx).coerceAtLeast(1f)
|
||||
val w = availW / barCount
|
||||
val h = size.height
|
||||
// Baseline at bottom (y = 0)
|
||||
drawLine(
|
||||
color = Color(0x33888888),
|
||||
start = androidx.compose.ui.geometry.Offset(axisPx, h - 1f),
|
||||
end = androidx.compose.ui.geometry.Offset(size.width, h - 1f),
|
||||
strokeWidth = 1f
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// Mode selector
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(
|
||||
selected = graphMode == GraphMode.OVERALL,
|
||||
onClick = { graphMode = GraphMode.OVERALL },
|
||||
label = { Text("Overall") }
|
||||
)
|
||||
// Bars from bottom-up; skip zeros entirely
|
||||
series.forEachIndexed { i, value ->
|
||||
if (value > 0f && maxVal > 0f) {
|
||||
val ratio = (value / maxVal).coerceIn(0f, 1f)
|
||||
val barHeight = (h * ratio).coerceAtLeast(0f)
|
||||
if (barHeight > 0.5f) {
|
||||
drawRect(
|
||||
color = Color(0xFF00C851),
|
||||
topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = h - barHeight),
|
||||
size = androidx.compose.ui.geometry.Size(w, barHeight)
|
||||
)
|
||||
}
|
||||
}
|
||||
FilterChip(
|
||||
selected = graphMode == GraphMode.PER_DEVICE,
|
||||
onClick = { graphMode = GraphMode.PER_DEVICE },
|
||||
label = { Text("Per Device") },
|
||||
leadingIcon = { Icon(Icons.Filled.Devices, contentDescription = null) }
|
||||
)
|
||||
FilterChip(
|
||||
selected = graphMode == GraphMode.PER_PEER,
|
||||
onClick = { graphMode = GraphMode.PER_PEER },
|
||||
label = { Text("Per Peer") },
|
||||
leadingIcon = { Icon(Icons.Filled.SettingsEthernet, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
|
||||
// Time series state
|
||||
var overallSeriesIncoming by rememberSaveable { mutableStateOf(List(60) { 0f }) }
|
||||
var overallSeriesOutgoing by rememberSaveable { mutableStateOf(List(60) { 0f }) }
|
||||
var stackedKeysIncoming by rememberSaveable { mutableStateOf(listOf<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
|
||||
fun MeshTopologySection() {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val graphService = remember { MeshGraphService.getInstance() }
|
||||
val snapshot by graphService.graphState.collectAsState()
|
||||
|
||||
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF8E8E93))
|
||||
Text("mesh topology", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
val nodes = snapshot.nodes
|
||||
val edges = snapshot.edges
|
||||
val empty = nodes.isEmpty()
|
||||
if (empty) {
|
||||
Text("no gossip yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
|
||||
} else {
|
||||
androidx.compose.foundation.Canvas(Modifier.fillMaxWidth().height(220.dp).background(colorScheme.surface.copy(alpha = 0.4f))) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val cx = w / 2f
|
||||
val cy = h / 2f
|
||||
val radius = (minOf(w, h) * 0.36f)
|
||||
val n = nodes.size
|
||||
if (n == 1) {
|
||||
// Single node centered
|
||||
drawCircle(color = Color(0xFF00C851), radius = 12f, center = androidx.compose.ui.geometry.Offset(cx, cy))
|
||||
} else {
|
||||
// Circular layout
|
||||
val positions = nodes.mapIndexed { i, node ->
|
||||
val angle = (2 * Math.PI * i.toDouble()) / n
|
||||
val x = cx + (radius * Math.cos(angle)).toFloat()
|
||||
val y = cy + (radius * Math.sin(angle)).toFloat()
|
||||
node.peerID to androidx.compose.ui.geometry.Offset(x, y)
|
||||
}.toMap()
|
||||
|
||||
// Draw edges
|
||||
edges.forEach { e ->
|
||||
val p1 = positions[e.a]
|
||||
val p2 = positions[e.b]
|
||||
if (p1 != null && p2 != null) {
|
||||
drawLine(color = Color(0xFF4A90E2), start = p1, end = p2, strokeWidth = 2f)
|
||||
LaunchedEffect(isPresented, graphMode) {
|
||||
while (isPresented) {
|
||||
when (graphMode) {
|
||||
GraphMode.OVERALL -> {
|
||||
val sIn = relayStats.lastSecondIncoming.toFloat()
|
||||
val sOut = relayStats.lastSecondOutgoing.toFloat()
|
||||
overallSeriesIncoming = (overallSeriesIncoming + sIn).takeLast(60)
|
||||
overallSeriesOutgoing = (overallSeriesOutgoing + sOut).takeLast(60)
|
||||
}
|
||||
GraphMode.PER_DEVICE -> {
|
||||
val snapshotIn = perDeviceIncoming
|
||||
val snapshotOut = perDeviceOutgoing
|
||||
fun advance(base: Map<String, List<Float>>, snap: Map<String, Int>): Map<String, List<Float>> {
|
||||
val next = mutableMapOf<String, List<Float>>()
|
||||
val union = (base.keys + snap.keys).toSet()
|
||||
union.forEach { k ->
|
||||
val prev = base[k] ?: List(60) { 0f }
|
||||
val s = (snap[k] ?: 0).toFloat()
|
||||
next[k] = (prev + s).takeLast(60)
|
||||
}
|
||||
return next
|
||||
}
|
||||
// Advance and prune fully-stale series (all-zero in visible window)
|
||||
stackedSeriesIncoming = advance(stackedSeriesIncoming, snapshotIn).filterValues { series -> series.any { it != 0f } }
|
||||
stackedSeriesOutgoing = advance(stackedSeriesOutgoing, snapshotOut).filterValues { series -> series.any { it != 0f } }
|
||||
stackedKeysIncoming = stackedSeriesIncoming.keys.sorted()
|
||||
stackedKeysOutgoing = stackedSeriesOutgoing.keys.sorted()
|
||||
}
|
||||
GraphMode.PER_PEER -> {
|
||||
val snapshotIn = perPeerIncoming
|
||||
val snapshotOut = perPeerOutgoing
|
||||
fun advance(base: Map<String, List<Float>>, snap: Map<String, Int>): Map<String, List<Float>> {
|
||||
val next = mutableMapOf<String, List<Float>>()
|
||||
val union = (base.keys + snap.keys).toSet()
|
||||
union.forEach { k ->
|
||||
val prev = base[k] ?: List(60) { 0f }
|
||||
val s = (snap[k] ?: 0).toFloat()
|
||||
next[k] = (prev + s).takeLast(60)
|
||||
}
|
||||
return next
|
||||
}
|
||||
stackedSeriesIncoming = advance(stackedSeriesIncoming, snapshotIn).filterValues { series -> series.any { it != 0f } }
|
||||
stackedSeriesOutgoing = advance(stackedSeriesOutgoing, snapshotOut).filterValues { series -> series.any { it != 0f } }
|
||||
stackedKeysIncoming = stackedSeriesIncoming.keys.sorted()
|
||||
stackedKeysOutgoing = stackedSeriesOutgoing.keys.sorted()
|
||||
}
|
||||
}
|
||||
kotlinx.coroutines.delay(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions moved to top-level composable below to avoid scope issues
|
||||
|
||||
// Draw nodes
|
||||
nodes.forEach { node ->
|
||||
val pos = positions[node.peerID] ?: androidx.compose.ui.geometry.Offset(cx, cy)
|
||||
drawCircle(color = Color(0xFF00C851), radius = 10f, center = pos)
|
||||
// Render two blocks: Incoming and Outgoing
|
||||
Text("Incoming", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
Text(
|
||||
"${relayStats.lastSecondIncoming}/s • ${relayStats.lastMinuteIncoming}/m • ${relayStats.last15MinuteIncoming}/15m • total ${relayStats.totalIncomingCount}",
|
||||
fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
DrawGraphBlock(
|
||||
title = "Incoming",
|
||||
stackedKeys = stackedKeysIncoming,
|
||||
stackedSeries = stackedSeriesIncoming,
|
||||
overallSeries = if (graphMode == GraphMode.OVERALL) overallSeriesIncoming else null,
|
||||
graphMode = graphMode,
|
||||
highlightedKey = highlightedKey,
|
||||
onToggleHighlight = { key -> highlightedKey = if (highlightedKey == key) null else key },
|
||||
ensureColors = { keys -> ensureColors(keys) },
|
||||
colorForKey = { k -> colorForKey[k] ?: stableColorFor(k) },
|
||||
legendTitleFor = { key ->
|
||||
when (graphMode) {
|
||||
GraphMode.PER_PEER -> {
|
||||
val nick = nicknameMap.value[key]
|
||||
val prefix = key.take(6)
|
||||
if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix
|
||||
}
|
||||
GraphMode.PER_DEVICE -> {
|
||||
val device = key
|
||||
val pid = connectedDevices.firstOrNull { it.deviceAddress == device }?.peerID
|
||||
?: devicePeerMap.value[device]
|
||||
if (pid != null) {
|
||||
val nick = nicknameMap.value[pid]
|
||||
val prefix = pid.take(6)
|
||||
"$device (${if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix})"
|
||||
} else device
|
||||
}
|
||||
else -> key
|
||||
}
|
||||
},
|
||||
legendMetricsFor = { key ->
|
||||
when (graphMode) {
|
||||
GraphMode.PER_PEER -> {
|
||||
val s = perPeerIncoming[key] ?: 0
|
||||
val m = perPeerIncoming1m[key] ?: 0
|
||||
val t = (perPeerIncomingTotal[key] ?: 0L)
|
||||
"${s}/s • ${m}/m • total ${t}"
|
||||
}
|
||||
GraphMode.PER_DEVICE -> {
|
||||
val s = perDeviceIncoming[key] ?: 0
|
||||
val m = perDeviceIncoming1m[key] ?: 0
|
||||
val t = (perDeviceIncomingTotal[key] ?: 0L)
|
||||
"${s}/s • ${m}/m • total ${t}"
|
||||
}
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
)
|
||||
if (graphMode != GraphMode.OVERALL && stackedKeysIncoming.isNotEmpty()) { /* legend printed inside DrawGraphBlock */ }
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Outgoing", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
Text(
|
||||
"${relayStats.lastSecondOutgoing}/s • ${relayStats.lastMinuteOutgoing}/m • ${relayStats.last15MinuteOutgoing}/15m • total ${relayStats.totalOutgoingCount}",
|
||||
fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
DrawGraphBlock(
|
||||
title = "Outgoing",
|
||||
stackedKeys = stackedKeysOutgoing,
|
||||
stackedSeries = stackedSeriesOutgoing,
|
||||
overallSeries = if (graphMode == GraphMode.OVERALL) overallSeriesOutgoing else null,
|
||||
graphMode = graphMode,
|
||||
highlightedKey = highlightedKey,
|
||||
onToggleHighlight = { key -> highlightedKey = if (highlightedKey == key) null else key },
|
||||
ensureColors = { keys -> ensureColors(keys) },
|
||||
colorForKey = { k -> colorForKey[k] ?: stableColorFor(k) },
|
||||
legendTitleFor = { key ->
|
||||
when (graphMode) {
|
||||
GraphMode.PER_PEER -> {
|
||||
val nick = nicknameMap.value[key]
|
||||
val prefix = key.take(6)
|
||||
if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix
|
||||
}
|
||||
GraphMode.PER_DEVICE -> {
|
||||
val device = key
|
||||
val pid = connectedDevices.firstOrNull { it.deviceAddress == device }?.peerID
|
||||
?: devicePeerMap.value[device]
|
||||
if (pid != null) {
|
||||
val nick = nicknameMap.value[pid]
|
||||
val prefix = pid.take(6)
|
||||
"$device (${if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix})"
|
||||
} else device
|
||||
}
|
||||
else -> key
|
||||
}
|
||||
},
|
||||
legendMetricsFor = { key ->
|
||||
when (graphMode) {
|
||||
GraphMode.PER_PEER -> {
|
||||
val s = perPeerOutgoing[key] ?: 0
|
||||
val m = perPeerOutgoing1m[key] ?: 0
|
||||
val t = (perPeerOutgoingTotal[key] ?: 0L)
|
||||
"${s}/s • ${m}/m • total ${t}"
|
||||
}
|
||||
GraphMode.PER_DEVICE -> {
|
||||
val s = perDeviceOutgoing[key] ?: 0
|
||||
val m = perDeviceOutgoing1m[key] ?: 0
|
||||
val t = (perDeviceOutgoingTotal[key] ?: 0L)
|
||||
"${s}/s • ${m}/m • total ${t}"
|
||||
}
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
)
|
||||
if (graphMode != GraphMode.OVERALL && stackedKeysOutgoing.isNotEmpty()) { /* legend printed inside DrawGraphBlock */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Label list for clarity under the canvas
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 140.dp)) {
|
||||
items(nodes) { node ->
|
||||
val label = "${node.peerID.take(8)} • ${node.nickname ?: "unknown"}"
|
||||
Text(label, fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.85f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Connected devices
|
||||
item {
|
||||
@@ -443,9 +583,6 @@ fun MeshTopologySection() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// Connected devices
|
||||
item {
|
||||
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
||||
@@ -532,3 +669,146 @@ fun MeshTopologySection() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DrawGraphBlock(
|
||||
title: String,
|
||||
stackedKeys: List<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
|
||||
}
|
||||
|
||||
object Verification {
|
||||
const val QR_MAX_AGE_SECONDS: Long = 300L // 5 minutes
|
||||
}
|
||||
|
||||
object Protocol {
|
||||
const val COMPRESSION_THRESHOLD_BYTES: Int = 100
|
||||
}
|
||||
@@ -76,12 +80,12 @@ object AppConstants {
|
||||
const val SCAN_ON_DURATION_NORMAL_MS: Long = 8_000L
|
||||
const val SCAN_OFF_DURATION_NORMAL_MS: Long = 2_000L
|
||||
const val SCAN_ON_DURATION_POWER_SAVE_MS: Long = 2_000L
|
||||
const val SCAN_OFF_DURATION_POWER_SAVE_MS: Long = 8_000L
|
||||
const val SCAN_OFF_DURATION_POWER_SAVE_MS: Long = 28_000L
|
||||
const val SCAN_ON_DURATION_ULTRA_LOW_MS: Long = 1_000L
|
||||
const val SCAN_OFF_DURATION_ULTRA_LOW_MS: Long = 10_000L
|
||||
const val SCAN_OFF_DURATION_ULTRA_LOW_MS: Long = 29_000L
|
||||
const val MAX_CONNECTIONS_NORMAL: Int = 8
|
||||
const val MAX_CONNECTIONS_POWER_SAVE: Int = 4
|
||||
const val MAX_CONNECTIONS_ULTRA_LOW: Int = 2
|
||||
const val MAX_CONNECTIONS_POWER_SAVE: Int = 8
|
||||
const val MAX_CONNECTIONS_ULTRA_LOW: Int = 4
|
||||
}
|
||||
|
||||
object Nostr {
|
||||
@@ -115,6 +119,8 @@ object AppConstants {
|
||||
const val MESSAGE_DEDUP_TIMEOUT_MS: Long = 30_000L
|
||||
const val SYSTEM_EVENT_DEDUP_TIMEOUT_MS: Long = 5_000L
|
||||
const val ACTIVE_PEERS_NOTIFICATION_INTERVAL_MS: Long = 300_000L
|
||||
const val ACTION_FORCE_FINISH: String = "com.bitchat.android.ACTION_FORCE_FINISH"
|
||||
const val PERMISSION_FORCE_FINISH: String = "com.bitchat.android.permission.FORCE_FINISH"
|
||||
}
|
||||
|
||||
object Media {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -362,4 +362,41 @@
|
||||
<string name="location_notes_empty_desc">كن أول من يضيف ملاحظة لهذا المكان.</string>
|
||||
<string name="dismiss">إغلاق</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 & 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>
|
||||
|
||||
@@ -349,4 +349,41 @@
|
||||
<item quantity="other">%d জন</item>
|
||||
</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 & 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>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<string name="cd_remove_favorite">Aus Favoriten entfernen</string>
|
||||
<string name="cd_add_bookmark">Lesezeichen hinzufügen</string>
|
||||
|
||||
<!-- Chat‑Kopf & Barrierefreiheit -->
|
||||
<!-- Chat‑Kopf & Barrierefreiheit -->
|
||||
<string name="chat_back">zurück</string>
|
||||
<string name="chat_channel_prefix">Kanal: %1$s</string>
|
||||
<string name="chat_leave">verlassen</string>
|
||||
@@ -170,7 +170,7 @@
|
||||
<string name="cd_online_geohash_channels">Online‑Geohash‑Kanäle</string>
|
||||
<string name="cd_end_to_end_encryption">Ende‑zu‑Ende‑Verschlüsselung</string>
|
||||
|
||||
<!-- Bilder & Dateien -->
|
||||
<!-- Bilder & Dateien -->
|
||||
<string name="image_page_of">Bild %1$d von %2$d</string>
|
||||
<string name="image_unavailable">Bild nicht verfügbar</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="dismiss">schließen</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 & 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>
|
||||
|
||||
@@ -362,4 +362,41 @@
|
||||
<string name="location_notes_empty_desc">sé el primero en añadir una para este sitio.</string>
|
||||
<string name="dismiss">cerrar</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>
|
||||
|
||||
@@ -349,4 +349,41 @@
|
||||
<item quantity="other">%d نفر</item>
|
||||
</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 & 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>
|
||||
|
||||
@@ -362,4 +362,41 @@
|
||||
<item quantity="other">%d tao</item>
|
||||
</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 & 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>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<string name="cd_remove_favorite">Retirer des favoris</string>
|
||||
<string name="cd_add_bookmark">Ajouter un signet</string>
|
||||
|
||||
<!-- En‑tête de chat & accessibilité -->
|
||||
<!-- En‑tête de chat & accessibilité -->
|
||||
<string name="chat_back">retour</string>
|
||||
<string name="chat_channel_prefix">canal : %1$s</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_end_to_end_encryption">Chiffrement de bout en bout</string>
|
||||
|
||||
<!-- Images & fichiers -->
|
||||
<!-- Images & fichiers -->
|
||||
<string name="image_page_of">Image %1$d sur %2$d</string>
|
||||
<string name="image_unavailable">Image indisponible</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="dismiss">fermer</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é & 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>
|
||||
|
||||
@@ -14,5 +14,42 @@
|
||||
<string name="location_notes_empty_desc">היה הראשון להוסיף הערה למקום זה.</string>
|
||||
<string name="dismiss">סגור</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 & 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>
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<string name="cd_remove_favorite">पसंदीदा से हटाएँ</string>
|
||||
<string name="cd_add_bookmark">बुकमार्क जोड़ें</string>
|
||||
|
||||
<!-- चैट हेडर & एक्सेसिबिलिटी -->
|
||||
<!-- चैट हेडर & एक्सेसिबिलिटी -->
|
||||
<string name="chat_back">वापस</string>
|
||||
<string name="chat_channel_prefix">चैनल: %1$s</string>
|
||||
<string name="chat_leave">छोड़ें</string>
|
||||
@@ -362,4 +362,41 @@
|
||||
<string name="location_notes_empty_desc">इस स्थान के लिए पहला नोट जोड़ें।</string>
|
||||
<string name="dismiss">बंद करें</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 & 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>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<string name="cd_remove_favorite">Hapus dari favorit</string>
|
||||
<string name="cd_add_bookmark">Tambah bookmark</string>
|
||||
|
||||
<!-- Header chat & aksesibilitas -->
|
||||
<!-- Header chat & aksesibilitas -->
|
||||
<string name="chat_back">kembali</string>
|
||||
<string name="chat_channel_prefix">Channel: %1$s</string>
|
||||
<string name="chat_leave">keluar</string>
|
||||
@@ -170,7 +170,7 @@
|
||||
<string name="cd_online_geohash_channels">Channel Geohash Online</string>
|
||||
<string name="cd_end_to_end_encryption">Enkripsi end-to-end</string>
|
||||
|
||||
<!-- Gambar & file -->
|
||||
<!-- Gambar & file -->
|
||||
<string name="image_page_of">Gambar %1$d dari %2$d</string>
|
||||
<string name="image_unavailable">Gambar tidak tersedia</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="dismiss">tutup</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 & 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>
|
||||
|
||||
@@ -396,4 +396,41 @@
|
||||
<string name="location_notes_empty_desc">sii il primo ad aggiungerne una per questo posto.</string>
|
||||
<string name="dismiss">chiudi</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 & 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>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<string name="cd_remove_favorite">お気に入りから削除</string>
|
||||
<string name="cd_add_bookmark">ブックマークに追加</string>
|
||||
|
||||
<!-- チャットヘッダー & アクセシビリティ -->
|
||||
<!-- チャットヘッダー & アクセシビリティ -->
|
||||
<string name="chat_back">戻る</string>
|
||||
<string name="chat_channel_prefix">チャンネル: %1$s</string>
|
||||
<string name="chat_leave">退出</string>
|
||||
@@ -170,7 +170,7 @@
|
||||
<string name="cd_online_geohash_channels">オンライン Geohash チャンネル</string>
|
||||
<string name="cd_end_to_end_encryption">エンドツーエンド暗号化</string>
|
||||
|
||||
<!-- 画像 & ファイル -->
|
||||
<!-- 画像 & ファイル -->
|
||||
<string name="image_page_of">画像 %1$d / %2$d</string>
|
||||
<string name="image_unavailable">画像は利用できません</string>
|
||||
<string name="image_saved_to_downloads">画像を「ダウンロード」に保存しました</string>
|
||||
@@ -362,4 +362,41 @@
|
||||
<string name="location_notes_empty_desc">この場所の最初のメモを追加しましょう。</string>
|
||||
<string name="dismiss">閉じる</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>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<string name="cd_remove_favorite">რჩეულებიდან წაშლა</string>
|
||||
<string name="cd_add_bookmark">სანიშნეს დამატება</string>
|
||||
|
||||
<!-- ჩათის სათაური & ხელმისაწვდომობა -->
|
||||
<!-- ჩათის სათაური & ხელმისაწვდომობა -->
|
||||
<string name="chat_back">უკან</string>
|
||||
<string name="chat_channel_prefix">არხი: %1$s</string>
|
||||
<string name="chat_leave">გასვლა</string>
|
||||
@@ -170,7 +170,7 @@
|
||||
<string name="cd_online_geohash_channels">ონლაინ geohash არხები</string>
|
||||
<string name="cd_end_to_end_encryption">ბოლო-მდე დაშიფვრა</string>
|
||||
|
||||
<!-- სურათები & ფაილები -->
|
||||
<!-- სურათები & ფაილები -->
|
||||
<string name="image_page_of">სურათი %1$d %2$d-დან</string>
|
||||
<string name="image_unavailable">სურათი მიუწვდომელია</string>
|
||||
<string name="image_saved_to_downloads">სურათი შენახულია "ჩამოტვირთვებში"</string>
|
||||
@@ -349,4 +349,41 @@
|
||||
<item quantity="other">%d ადამიანი</item>
|
||||
</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 & 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>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<string name="cd_remove_favorite">즐겨찾기에서 제거</string>
|
||||
<string name="cd_add_bookmark">북마크 추가</string>
|
||||
|
||||
<!-- 채팅 헤더 & 접근성 -->
|
||||
<!-- 채팅 헤더 & 접근성 -->
|
||||
<string name="chat_back">뒤로</string>
|
||||
<string name="chat_channel_prefix">채널: %1$s</string>
|
||||
<string name="chat_leave">나가기</string>
|
||||
@@ -170,7 +170,7 @@
|
||||
<string name="cd_online_geohash_channels">온라인 geohash 채널</string>
|
||||
<string name="cd_end_to_end_encryption">종단간 암호화</string>
|
||||
|
||||
<!-- 이미지 & 파일 -->
|
||||
<!-- 이미지 & 파일 -->
|
||||
<string name="image_page_of">이미지 %1$d / %2$d</string>
|
||||
<string name="image_unavailable">이미지를 사용할 수 없음</string>
|
||||
<string name="image_saved_to_downloads">이미지가 \"Downloads\"에 저장됨</string>
|
||||
@@ -362,4 +362,41 @@
|
||||
<string name="location_notes_empty_desc">이 장소에 첫 번째 노트를 추가해 보세요.</string>
|
||||
<string name="dismiss">닫기</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 & 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>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<string name="cd_remove_favorite">Hanesorana amin\'ny ankafizina</string>
|
||||
<string name="cd_add_bookmark">Hanampy bookmark</string>
|
||||
|
||||
<!-- Lohan\'ny resaka & fahafaha-miditra -->
|
||||
<!-- Lohan\'ny resaka & fahafaha-miditra -->
|
||||
<string name="chat_back">miverina</string>
|
||||
<string name="chat_channel_prefix">fantsona: %1$s</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_end_to_end_encryption">Fandokoana Farany amin\'ny Farany</string>
|
||||
|
||||
<!-- Sary & rakitra -->
|
||||
<!-- Sary & rakitra -->
|
||||
<string name="image_page_of">Sary %1$d amin\'ny %2$d</string>
|
||||
<string name="image_unavailable">Tsy misy sary</string>
|
||||
<string name="image_saved_to_downloads">Sary voatahiry ao amin\'ny Downloads</string>
|
||||
@@ -376,4 +376,41 @@
|
||||
<item quantity="other">Olona %d</item>
|
||||
</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 & 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>
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- 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 & 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>
|
||||
|
||||
|
||||
@@ -362,4 +362,41 @@
|
||||
<item quantity="other">%d जना</item>
|
||||
</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 & 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>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<string name="cd_remove_favorite">Verwijderen uit favorieten</string>
|
||||
<string name="cd_add_bookmark">Bladwijzer toevoegen</string>
|
||||
|
||||
<!-- Chatkop & toegankelijkheid -->
|
||||
<!-- Chatkop & toegankelijkheid -->
|
||||
<string name="chat_back">terug</string>
|
||||
<string name="chat_channel_prefix">kanaal: %1$s</string>
|
||||
<string name="chat_leave">verlaten</string>
|
||||
@@ -170,7 +170,7 @@
|
||||
<string name="cd_online_geohash_channels">Online geohash-kanalen</string>
|
||||
<string name="cd_end_to_end_encryption">End-to-end-versleuteling</string>
|
||||
|
||||
<!-- Afbeeldingen & bestanden -->
|
||||
<!-- Afbeeldingen & bestanden -->
|
||||
<string name="image_page_of">Afbeelding %1$d van %2$d</string>
|
||||
<string name="image_unavailable">Afbeelding niet beschikbaar</string>
|
||||
<string name="image_saved_to_downloads">Afbeelding opgeslagen in Downloads</string>
|
||||
@@ -338,7 +338,7 @@
|
||||
<string name="join">Deelnemen</string>
|
||||
<string name="cancel">Annuleren</string>
|
||||
|
||||
<!-- Symbolen & speciale strings -->
|
||||
<!-- Symbolen & speciale strings -->
|
||||
<string name="at_symbol">@</string>
|
||||
<string name="app_brand">bitchat/</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="dismiss">sluiten</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 & 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>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<string name="cd_remove_favorite">پسندیدہ توں ہٹاؤ</string>
|
||||
<string name="cd_add_bookmark">بُک مارک پاؤ</string>
|
||||
|
||||
<!-- چیٹ ہیڈر & رسائی پذیری -->
|
||||
<!-- چیٹ ہیڈر & رسائی پذیری -->
|
||||
<string name="chat_back">واپس</string>
|
||||
<string name="chat_channel_prefix">چینل: %1$s</string>
|
||||
<string name="chat_leave">باہر آؤ</string>
|
||||
@@ -170,7 +170,7 @@
|
||||
<string name="cd_online_geohash_channels">آن لائن geohash چینل</string>
|
||||
<string name="cd_end_to_end_encryption">اینڈ ٹو اینڈ انکرپشن</string>
|
||||
|
||||
<!-- تصویراں & فائلز -->
|
||||
<!-- تصویراں & فائلز -->
|
||||
<string name="image_page_of">تصویر %1$d وچوں %2$d</string>
|
||||
<string name="image_unavailable">تصویر دستیاب نئیں</string>
|
||||
<string name="image_saved_to_downloads">تصویر \"Downloads\" وچ سیو ہو گئی</string>
|
||||
@@ -349,4 +349,41 @@
|
||||
<item quantity="other">%d بندے</item>
|
||||
</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 & 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>
|
||||
|
||||
@@ -14,5 +14,42 @@
|
||||
<string name="location_notes_empty_desc">bądź pierwszy, który doda notatkę dla tego miejsca.</string>
|
||||
<string name="dismiss">zamknij</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 & 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>
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<string name="cd_remove_favorite">Remover dos favoritos</string>
|
||||
<string name="cd_add_bookmark">Adicionar favorito</string>
|
||||
|
||||
<!-- Cabeçalho do chat & acessibilidade -->
|
||||
<!-- Cabeçalho do chat & acessibilidade -->
|
||||
<string name="chat_back">voltar</string>
|
||||
<string name="chat_channel_prefix">Canal: %1$s</string>
|
||||
<string name="chat_leave">sair</string>
|
||||
@@ -170,7 +170,7 @@
|
||||
<string name="cd_online_geohash_channels">Canais geohash online</string>
|
||||
<string name="cd_end_to_end_encryption">Criptografia de ponta a ponta</string>
|
||||
|
||||
<!-- Imagens & arquivos -->
|
||||
<!-- Imagens & arquivos -->
|
||||
<string name="image_page_of">Imagem %1$d de %2$d</string>
|
||||
<string name="image_unavailable">Imagem indisponível</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="dismiss">fechar</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>
|
||||
|
||||
@@ -362,4 +362,41 @@
|
||||
<string name="location_notes_empty_desc">seja o primeiro a adicionar uma para este local.</string>
|
||||
<string name="dismiss">fechar</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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user