mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 22:25:26 +00:00
Merge remote-tracking branch 'upstream/main'
This commit is contained in:
@@ -7,9 +7,10 @@ on:
|
|||||||
branches: [ "main", "develop" ]
|
branches: [ "main", "develop" ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
verify:
|
||||||
|
name: Test & Lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -39,7 +40,10 @@ jobs:
|
|||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: ./gradlew testDebugUnitTest
|
run: ./gradlew testDebugUnitTest
|
||||||
|
|
||||||
- name: Upload Test Reports (xml+html)
|
- name: Run lint
|
||||||
|
run: ./gradlew lintDebug
|
||||||
|
|
||||||
|
- name: Upload test reports
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -48,47 +52,6 @@ jobs:
|
|||||||
**/build/test-results/
|
**/build/test-results/
|
||||||
**/build/reports/tests/
|
**/build/reports/tests/
|
||||||
|
|
||||||
- name: Upload test results
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
name: test-results
|
|
||||||
path: |
|
|
||||||
**/build/test-results/
|
|
||||||
**/build/reports/tests/
|
|
||||||
|
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up JDK 17
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
java-version: '17'
|
|
||||||
distribution: 'temurin'
|
|
||||||
|
|
||||||
- name: Setup Gradle
|
|
||||||
uses: gradle/gradle-build-action@v3
|
|
||||||
|
|
||||||
- name: Grant execute permission for gradlew
|
|
||||||
run: chmod +x gradlew
|
|
||||||
|
|
||||||
- name: Cache Gradle packages
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.gradle/caches
|
|
||||||
~/.gradle/wrapper
|
|
||||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-gradle-
|
|
||||||
|
|
||||||
- name: Run lint
|
|
||||||
run: ./gradlew lintDebug
|
|
||||||
|
|
||||||
- name: Upload lint results
|
- name: Upload lint results
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
@@ -97,9 +60,13 @@ jobs:
|
|||||||
path: '**/build/reports/lint-results-*.html'
|
path: '**/build/reports/lint-results-*.html'
|
||||||
|
|
||||||
build:
|
build:
|
||||||
|
name: Build ${{ matrix.variant }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [test, lint]
|
needs: verify
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
variant: [Debug, Release]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -126,20 +93,11 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-gradle-
|
${{ runner.os }}-gradle-
|
||||||
|
|
||||||
- name: Build debug APK
|
- name: Build ${{ matrix.variant }} APK
|
||||||
run: ./gradlew assembleDebug
|
run: ./gradlew assemble${{ matrix.variant }}
|
||||||
|
|
||||||
- name: Build release APK
|
- name: Upload ${{ matrix.variant }} APK
|
||||||
run: ./gradlew assembleRelease
|
|
||||||
|
|
||||||
- name: Upload debug APK
|
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: debug-apk
|
name: ${{ matrix.variant }}-apk
|
||||||
path: app/build/outputs/apk/debug/*.apk
|
path: app/build/outputs/apk/**/*.apk
|
||||||
|
|
||||||
- name: Upload release APK
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: release-apk
|
|
||||||
path: app/build/outputs/apk/release/*.apk
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: 17
|
java-version: 17
|
||||||
|
|
||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/gradle-build-action@v2
|
uses: gradle/gradle-build-action@v2
|
||||||
with:
|
with:
|
||||||
@@ -38,30 +38,27 @@ jobs:
|
|||||||
- name: Grant execute permission for Gradlew
|
- name: Grant execute permission for Gradlew
|
||||||
run: chmod +x ./gradlew
|
run: chmod +x ./gradlew
|
||||||
|
|
||||||
- name: Build APK
|
- name: Build Release APK (with Tor)
|
||||||
run: ./gradlew assembleRelease --no-daemon --stacktrace
|
run: ./gradlew assembleRelease --no-daemon --stacktrace
|
||||||
|
|
||||||
- name: List APK files
|
- name: List APK files
|
||||||
run: |
|
run: |
|
||||||
echo "APK files built:"
|
echo "APK files built:"
|
||||||
find app/build/outputs/apk/release -name "*.apk" -type f
|
find app/build/outputs/apk -name "*.apk" -type f
|
||||||
|
|
||||||
- name: Rename APK
|
- name: Rename APK
|
||||||
run: |
|
run: |
|
||||||
mv app/build/outputs/apk/release/app-release-unsigned.apk app/build/outputs/apk/release/bitchat.apk
|
mv app/build/outputs/apk/release/app-release-unsigned.apk app/build/outputs/apk/release/bitchat-android.apk
|
||||||
|
|
||||||
- name: DEBUG
|
- name: DEBUG
|
||||||
run: |
|
run: |
|
||||||
set -x
|
set -x
|
||||||
|
|
||||||
pwd
|
pwd
|
||||||
|
|
||||||
ls -all
|
ls -all
|
||||||
|
|
||||||
cd app/build/outputs/
|
cd app/build/outputs/
|
||||||
ls -all
|
ls -all
|
||||||
tree
|
tree || ls -R
|
||||||
|
|
||||||
# Optional: Sign APK (requires secrets)
|
# Optional: Sign APK (requires secrets)
|
||||||
# - name: Sign APK
|
# - name: Sign APK
|
||||||
# uses: r0adkll/sign-android-release@v1
|
# uses: r0adkll/sign-android-release@v1
|
||||||
@@ -75,7 +72,7 @@ jobs:
|
|||||||
- name: Upload APK as artifact
|
- name: Upload APK as artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: bitchat-release-apk-${{ github.ref_name }}
|
name: bitchat-android-apk-${{ github.ref_name }}
|
||||||
path: app/build/outputs/apk/release/*.apk
|
path: app/build/outputs/apk/release/*.apk
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
@@ -88,13 +85,22 @@ jobs:
|
|||||||
- name: Download APK artifact
|
- name: Download APK artifact
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: bitchat-release-apk-${{ github.ref_name }}
|
name: bitchat-android-apk-${{ github.ref_name }}
|
||||||
path: .
|
path: release
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
files: bitchat.apk
|
files: |
|
||||||
|
release/bitchat-android.apk
|
||||||
name: Release ${{ github.ref_name }}
|
name: Release ${{ github.ref_name }}
|
||||||
|
body: |
|
||||||
|
## bitchat Android Release
|
||||||
|
|
||||||
|
**bitchat-android.apk** (~15MB)
|
||||||
|
- Secure P2P messaging over Bluetooth mesh and Nostr
|
||||||
|
- Built-in Tor support (custom Arti build with 16KB page size)
|
||||||
|
- Compatible with Google Play requirements (Nov 2025+)
|
||||||
|
- Cross-platform compatible with iOS version
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
+7
-1
@@ -43,7 +43,9 @@ dependency-reduced-pom.xml
|
|||||||
# Other
|
# Other
|
||||||
*.log
|
*.log
|
||||||
.cxx/
|
.cxx/
|
||||||
*build/
|
# Gradle/Android build directories (but not tools/arti-build/)
|
||||||
|
**/build/
|
||||||
|
!tools/arti-build/
|
||||||
out/
|
out/
|
||||||
gen/
|
gen/
|
||||||
*~
|
*~
|
||||||
@@ -57,3 +59,7 @@ google-services.json
|
|||||||
# Keystore files
|
# Keystore files
|
||||||
*.jks
|
*.jks
|
||||||
*.keystore
|
*.keystore
|
||||||
|
|
||||||
|
# Arti build artifacts (cloned repo and Rust build cache)
|
||||||
|
tools/arti-build/.arti-source/
|
||||||
|
tools/arti-build/target/
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ The app requires the following permissions (automatically requested):
|
|||||||
|
|
||||||
- **Bluetooth**: Core BLE functionality
|
- **Bluetooth**: Core BLE functionality
|
||||||
- **Location**: Required for BLE scanning on Android
|
- **Location**: Required for BLE scanning on Android
|
||||||
|
- **Network**: Expand your mesh through public internet relays
|
||||||
- **Notifications**: Message alerts and background updates
|
- **Notifications**: Message alerts and background updates
|
||||||
|
|
||||||
### Hardware Requirements
|
### Hardware Requirements
|
||||||
|
|||||||
+18
-4
@@ -13,8 +13,8 @@ android {
|
|||||||
applicationId = "com.bitchat.droid"
|
applicationId = "com.bitchat.droid"
|
||||||
minSdk = libs.versions.minSdk.get().toInt()
|
minSdk = libs.versions.minSdk.get().toInt()
|
||||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||||
versionCode = 26
|
versionCode = 27
|
||||||
versionName = "1.5.1"
|
versionName = "1.6.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
@@ -30,6 +30,12 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
ndk {
|
||||||
|
// Include x86_64 for emulator support during development
|
||||||
|
abiFilters += listOf("arm64-v8a", "x86_64")
|
||||||
|
}
|
||||||
|
}
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
@@ -37,6 +43,11 @@ android {
|
|||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
)
|
)
|
||||||
|
ndk {
|
||||||
|
// ARM64-only to minimize APK size (~5.8MB savings)
|
||||||
|
// Excludes x86_64 as emulator not needed for production builds
|
||||||
|
abiFilters += listOf("arm64-v8a")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
@@ -95,8 +106,11 @@ dependencies {
|
|||||||
// WebSocket
|
// WebSocket
|
||||||
implementation(libs.okhttp)
|
implementation(libs.okhttp)
|
||||||
|
|
||||||
// Arti (Tor in Rust) Android bridge - use published AAR with native libs
|
// Arti (Tor in Rust) Android bridge - custom build from latest source
|
||||||
implementation("info.guardianproject:arti-mobile-ex:1.2.3")
|
// Built with rustls, 16KB page size support, and onio//un service client
|
||||||
|
// Native libraries are in src/tor/jniLibs/ (extracted from arti-custom.aar)
|
||||||
|
// Only included in tor flavor to reduce APK size for standard builds
|
||||||
|
// Note: AAR is kept in libs/ for reference, but libraries loaded from jniLibs/
|
||||||
|
|
||||||
// Google Play Services Location
|
// Google Play Services Location
|
||||||
implementation(libs.gms.location)
|
implementation(libs.gms.location)
|
||||||
|
|||||||
Vendored
+7
-4
@@ -17,9 +17,12 @@
|
|||||||
-keep class com.bitchat.android.nostr.** { *; }
|
-keep class com.bitchat.android.nostr.** { *; }
|
||||||
-keep class com.bitchat.android.identity.** { *; }
|
-keep class com.bitchat.android.identity.** { *; }
|
||||||
|
|
||||||
# Arti (Tor) ProGuard rules
|
# Keep Tor implementation (always included)
|
||||||
|
-keep class com.bitchat.android.net.RealTorProvider { *; }
|
||||||
|
|
||||||
|
# Arti (Custom Tor implementation in Rust) ProGuard rules
|
||||||
-keep class info.guardianproject.arti.** { *; }
|
-keep class info.guardianproject.arti.** { *; }
|
||||||
-keep class org.torproject.jni.** { *; }
|
-keep class org.torproject.arti.** { *; }
|
||||||
-keepnames class org.torproject.jni.**
|
-keepnames class org.torproject.arti.**
|
||||||
-dontwarn info.guardianproject.arti.**
|
-dontwarn info.guardianproject.arti.**
|
||||||
-dontwarn org.torproject.jni.**
|
-dontwarn org.torproject.arti.**
|
||||||
|
|||||||
@@ -19,6 +19,13 @@
|
|||||||
|
|
||||||
<!-- Notification permissions -->
|
<!-- Notification permissions -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<!-- 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" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
|
||||||
<!-- Microphone for voice notes -->
|
<!-- Microphone for voice notes -->
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
@@ -77,5 +84,34 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- Persistent foreground service to run the mesh in background -->
|
||||||
|
<service
|
||||||
|
android:name=".service.MeshForegroundService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="connectedDevice|dataSync"
|
||||||
|
tools:ignore="DataExtractionRules">
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<!-- Listen for in-app broadcast when POST_NOTIFICATIONS is granted -->
|
||||||
|
<receiver
|
||||||
|
android:name=".service.NotificationPermissionChangedReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.bitchat.android.action.NOTIFICATION_PERMISSION_GRANTED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Auto-start mesh service after boot if enabled -->
|
||||||
|
<receiver
|
||||||
|
android:name=".service.BootCompletedReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -3,18 +3,21 @@ package com.bitchat.android
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import com.bitchat.android.nostr.RelayDirectory
|
import com.bitchat.android.nostr.RelayDirectory
|
||||||
import com.bitchat.android.ui.theme.ThemePreferenceManager
|
import com.bitchat.android.ui.theme.ThemePreferenceManager
|
||||||
import com.bitchat.android.net.TorManager
|
import com.bitchat.android.net.ArtiTorManager
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main application class for bitchat Android
|
* Main application class for bitchat Android
|
||||||
*/
|
*/
|
||||||
class BitchatApplication : Application() {
|
class BitchatApplication : Application() {
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
// Initialize Tor first so any early network goes over Tor
|
// Initialize Tor first so any early network goes over Tor
|
||||||
try { TorManager.init(this) } catch (_: Exception) { }
|
try {
|
||||||
|
val torProvider = ArtiTorManager.getInstance()
|
||||||
|
torProvider.init(this)
|
||||||
|
} catch (_: Exception){}
|
||||||
|
|
||||||
// Initialize relay directory (loads assets/nostr_relays.csv)
|
// Initialize relay directory (loads assets/nostr_relays.csv)
|
||||||
RelayDirectory.initialize(this)
|
RelayDirectory.initialize(this)
|
||||||
@@ -38,6 +41,12 @@ class BitchatApplication : Application() {
|
|||||||
// Initialize debug preference manager (persists debug toggles)
|
// Initialize debug preference manager (persists debug toggles)
|
||||||
try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { }
|
try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { }
|
||||||
|
|
||||||
|
// Initialize mesh service preferences
|
||||||
|
try { com.bitchat.android.service.MeshServicePreferences.init(this) } catch (_: Exception) { }
|
||||||
|
|
||||||
|
// Proactively start the foreground service to keep mesh alive
|
||||||
|
try { com.bitchat.android.service.MeshForegroundService.start(this) } catch (_: Exception) { }
|
||||||
|
|
||||||
// TorManager already initialized above
|
// TorManager already initialized above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class MainActivity : OrientationAwareActivity() {
|
|||||||
private lateinit var locationStatusManager: LocationStatusManager
|
private lateinit var locationStatusManager: LocationStatusManager
|
||||||
private lateinit var batteryOptimizationManager: BatteryOptimizationManager
|
private lateinit var batteryOptimizationManager: BatteryOptimizationManager
|
||||||
|
|
||||||
// Core mesh service - managed at app level
|
// Core mesh service - provided by the foreground service holder
|
||||||
private lateinit var meshService: BluetoothMeshService
|
private lateinit var meshService: BluetoothMeshService
|
||||||
private val mainViewModel: MainViewModel by viewModels()
|
private val mainViewModel: MainViewModel by viewModels()
|
||||||
private val chatViewModel: ChatViewModel by viewModels {
|
private val chatViewModel: ChatViewModel by viewModels {
|
||||||
@@ -66,13 +66,21 @@ class MainActivity : OrientationAwareActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
// Enable edge-to-edge display for modern Android look
|
// Enable edge-to-edge display for modern Android look
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
// Initialize permission management
|
// Initialize permission management
|
||||||
permissionManager = PermissionManager(this)
|
permissionManager = PermissionManager(this)
|
||||||
// Initialize core mesh service first
|
// Ensure foreground service is running and get mesh instance from holder
|
||||||
meshService = BluetoothMeshService(this)
|
try { com.bitchat.android.service.MeshForegroundService.start(applicationContext) } catch (_: Exception) { }
|
||||||
|
meshService = com.bitchat.android.service.MeshServiceHolder.getOrCreate(applicationContext)
|
||||||
bluetoothStatusManager = BluetoothStatusManager(
|
bluetoothStatusManager = BluetoothStatusManager(
|
||||||
activity = this,
|
activity = this,
|
||||||
context = this,
|
context = this,
|
||||||
@@ -630,6 +638,15 @@ class MainActivity : OrientationAwareActivity() {
|
|||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
|
||||||
|
// Check if this is a quit request from the notification
|
||||||
|
if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) {
|
||||||
|
android.util.Log.d("MainActivity", "Quit request received, finishing activity")
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Handle notification intents when app is already running
|
// Handle notification intents when app is already running
|
||||||
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
||||||
handleNotificationIntent(intent)
|
handleNotificationIntent(intent)
|
||||||
@@ -640,6 +657,8 @@ class MainActivity : OrientationAwareActivity() {
|
|||||||
super.onResume()
|
super.onResume()
|
||||||
// Check Bluetooth and Location status on resume and handle accordingly
|
// Check Bluetooth and Location status on resume and handle accordingly
|
||||||
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
||||||
|
// Reattach mesh delegate to new ChatViewModel instance after Activity recreation
|
||||||
|
try { meshService.delegate = chatViewModel } catch (_: Exception) { }
|
||||||
// Set app foreground state
|
// Set app foreground state
|
||||||
meshService.connectionManager.setAppBackgroundState(false)
|
meshService.connectionManager.setAppBackgroundState(false)
|
||||||
chatViewModel.setAppBackgroundState(false)
|
chatViewModel.setAppBackgroundState(false)
|
||||||
@@ -665,13 +684,15 @@ class MainActivity : OrientationAwareActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
super.onPause()
|
super.onPause()
|
||||||
// Only set background state if app is fully initialized
|
// Only set background state if app is fully initialized
|
||||||
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
||||||
// Set app background state
|
// Set app background state
|
||||||
meshService.connectionManager.setAppBackgroundState(true)
|
meshService.connectionManager.setAppBackgroundState(true)
|
||||||
chatViewModel.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) { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -746,14 +767,6 @@ class MainActivity : OrientationAwareActivity() {
|
|||||||
Log.w("MainActivity", "Error cleaning up location status manager: ${e.message}")
|
Log.w("MainActivity", "Error cleaning up location status manager: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop mesh services if app was fully initialized
|
// Do not stop mesh here; ForegroundService owns lifecycle for background reliability
|
||||||
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
|
||||||
try {
|
|
||||||
meshService.stopServices()
|
|
||||||
Log.d("MainActivity", "Mesh services stopped successfully")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w("MainActivity", "Error stopping mesh services in onDestroy: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.bitchat.android.core.ui.component.button
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CloseButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier.Companion
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier
|
||||||
|
.size(32.dp),
|
||||||
|
colors = IconButtonDefaults.iconButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
|
||||||
|
containerColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.1f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Close",
|
||||||
|
modifier = Modifier.Companion.size(18.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,13 +5,14 @@ import android.location.Geocoder
|
|||||||
import android.location.Location
|
import android.location.Location
|
||||||
import android.location.LocationManager
|
import android.location.LocationManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.google.gson.reflect.TypeToken
|
import com.google.gson.reflect.TypeToken
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,11 +47,11 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
|
|||||||
|
|
||||||
private val membership = mutableSetOf<String>()
|
private val membership = mutableSetOf<String>()
|
||||||
|
|
||||||
private val _bookmarks = MutableLiveData<List<String>>(emptyList())
|
private val _bookmarks = MutableStateFlow<List<String>>(emptyList())
|
||||||
val bookmarks: LiveData<List<String>> = _bookmarks
|
val bookmarks: StateFlow<List<String>> = _bookmarks.asStateFlow()
|
||||||
|
|
||||||
private val _bookmarkNames = MutableLiveData<Map<String, String>>(emptyMap())
|
private val _bookmarkNames = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||||
val bookmarkNames: LiveData<Map<String, String>> = _bookmarkNames
|
val bookmarkNames: StateFlow<Map<String, String>> = _bookmarkNames.asStateFlow()
|
||||||
|
|
||||||
// For throttling / preventing duplicate geocode lookups
|
// For throttling / preventing duplicate geocode lookups
|
||||||
private val resolving = mutableSetOf<String>()
|
private val resolving = mutableSetOf<String>()
|
||||||
@@ -68,8 +69,8 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
|
|||||||
val gh = normalize(geohash)
|
val gh = normalize(geohash)
|
||||||
if (gh.isEmpty() || membership.contains(gh)) return
|
if (gh.isEmpty() || membership.contains(gh)) return
|
||||||
membership.add(gh)
|
membership.add(gh)
|
||||||
val updated = listOf(gh) + (_bookmarks.value ?: emptyList())
|
val updated = listOf(gh) + (_bookmarks.value)
|
||||||
_bookmarks.postValue(updated)
|
_bookmarks.value = updated
|
||||||
persist(updated)
|
persist(updated)
|
||||||
// Resolve friendly name asynchronously
|
// Resolve friendly name asynchronously
|
||||||
resolveNameIfNeeded(gh)
|
resolveNameIfNeeded(gh)
|
||||||
@@ -79,12 +80,12 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
|
|||||||
val gh = normalize(geohash)
|
val gh = normalize(geohash)
|
||||||
if (!membership.contains(gh)) return
|
if (!membership.contains(gh)) return
|
||||||
membership.remove(gh)
|
membership.remove(gh)
|
||||||
val updated = (_bookmarks.value ?: emptyList()).filterNot { it == gh }
|
val updated = (_bookmarks.value).filterNot { it == gh }
|
||||||
_bookmarks.postValue(updated)
|
_bookmarks.value = updated
|
||||||
// Remove stored name to avoid stale cache growth
|
// Remove stored name to avoid stale cache growth
|
||||||
val names = _bookmarkNames.value?.toMutableMap() ?: mutableMapOf()
|
val names = _bookmarkNames.value.toMutableMap()
|
||||||
if (names.remove(gh) != null) {
|
if (names.remove(gh) != null) {
|
||||||
_bookmarkNames.postValue(names)
|
_bookmarkNames.value = names
|
||||||
persistNames(names)
|
persistNames(names)
|
||||||
}
|
}
|
||||||
persist(updated)
|
persist(updated)
|
||||||
@@ -108,7 +109,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
membership.clear(); membership.addAll(seen)
|
membership.clear(); membership.addAll(seen)
|
||||||
_bookmarks.postValue(ordered)
|
_bookmarks.value = ordered
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to load bookmarks: ${e.message}")
|
Log.e(TAG, "Failed to load bookmarks: ${e.message}")
|
||||||
@@ -118,7 +119,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
|
|||||||
if (!namesJson.isNullOrEmpty()) {
|
if (!namesJson.isNullOrEmpty()) {
|
||||||
val mapType = object : TypeToken<Map<String, String>>() {}.type
|
val mapType = object : TypeToken<Map<String, String>>() {}.type
|
||||||
val dict = gson.fromJson<Map<String, String>>(namesJson, mapType)
|
val dict = gson.fromJson<Map<String, String>>(namesJson, mapType)
|
||||||
_bookmarkNames.postValue(dict)
|
_bookmarkNames.value = dict
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to load bookmark names: ${e.message}")
|
Log.e(TAG, "Failed to load bookmark names: ${e.message}")
|
||||||
@@ -127,14 +128,14 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
|
|||||||
|
|
||||||
private fun persist() {
|
private fun persist() {
|
||||||
try {
|
try {
|
||||||
val json = gson.toJson(_bookmarks.value ?: emptyList<String>())
|
val json = gson.toJson(_bookmarks.value)
|
||||||
prefs.edit().putString(STORE_KEY, json).apply()
|
prefs.edit().putString(STORE_KEY, json).apply()
|
||||||
} catch (_: Exception) {}
|
} catch (_: Exception) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun persistNames() {
|
private fun persistNames() {
|
||||||
try {
|
try {
|
||||||
val json = gson.toJson(_bookmarkNames.value ?: emptyMap<String, String>())
|
val json = gson.toJson(_bookmarkNames.value)
|
||||||
prefs.edit().putString(NAMES_STORE_KEY, json).apply()
|
prefs.edit().putString(NAMES_STORE_KEY, json).apply()
|
||||||
} catch (_: Exception) {}
|
} catch (_: Exception) {}
|
||||||
}
|
}
|
||||||
@@ -144,8 +145,8 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
|
|||||||
fun clearAll() {
|
fun clearAll() {
|
||||||
try {
|
try {
|
||||||
membership.clear()
|
membership.clear()
|
||||||
_bookmarks.postValue(emptyList())
|
_bookmarks.value = emptyList()
|
||||||
_bookmarkNames.postValue(emptyMap())
|
_bookmarkNames.value = emptyMap()
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.remove(STORE_KEY)
|
.remove(STORE_KEY)
|
||||||
.remove(NAMES_STORE_KEY)
|
.remove(NAMES_STORE_KEY)
|
||||||
@@ -209,9 +210,9 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!name.isNullOrEmpty()) {
|
if (!name.isNullOrEmpty()) {
|
||||||
val current = _bookmarkNames.value?.toMutableMap() ?: mutableMapOf()
|
val current = _bookmarkNames.value.toMutableMap()
|
||||||
current[gh] = name
|
current[gh] = name
|
||||||
_bookmarkNames.postValue(current)
|
_bookmarkNames.value = current
|
||||||
persistNames(current)
|
persistNames(current)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import android.location.LocationManager
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.google.gson.JsonSyntaxException
|
import com.google.gson.JsonSyntaxException
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages location permissions, one-shot location retrieval, and computing geohash channels.
|
* Manages location permissions, one-shot location retrieval, and computing geohash channels.
|
||||||
@@ -53,28 +53,26 @@ class LocationChannelManager private constructor(private val context: Context) {
|
|||||||
private var dataManager: com.bitchat.android.ui.DataManager? = null
|
private var dataManager: com.bitchat.android.ui.DataManager? = null
|
||||||
|
|
||||||
// Published state for UI bindings (matching iOS @Published properties)
|
// Published state for UI bindings (matching iOS @Published properties)
|
||||||
private val _permissionState = MutableLiveData(PermissionState.NOT_DETERMINED)
|
private val _permissionState = MutableStateFlow(PermissionState.NOT_DETERMINED)
|
||||||
val permissionState: LiveData<PermissionState> = _permissionState
|
val permissionState: StateFlow<PermissionState> = _permissionState
|
||||||
|
|
||||||
private val _availableChannels = MutableLiveData<List<GeohashChannel>>(emptyList())
|
private val _availableChannels = MutableStateFlow<List<GeohashChannel>>(emptyList())
|
||||||
val availableChannels: LiveData<List<GeohashChannel>> = _availableChannels
|
val availableChannels: StateFlow<List<GeohashChannel>> = _availableChannels
|
||||||
|
|
||||||
private val _selectedChannel = MutableLiveData<ChannelID>(ChannelID.Mesh)
|
private val _selectedChannel = MutableStateFlow<ChannelID>(ChannelID.Mesh)
|
||||||
val selectedChannel: LiveData<ChannelID> = _selectedChannel
|
val selectedChannel: StateFlow<ChannelID> = _selectedChannel
|
||||||
|
|
||||||
private val _teleported = MutableLiveData(false)
|
private val _teleported = MutableStateFlow(false)
|
||||||
val teleported: LiveData<Boolean> = _teleported
|
val teleported: StateFlow<Boolean> = _teleported
|
||||||
|
|
||||||
private val _locationNames = MutableLiveData<Map<GeohashChannelLevel, String>>(emptyMap())
|
private val _locationNames = MutableStateFlow<Map<GeohashChannelLevel, String>>(emptyMap())
|
||||||
val locationNames: LiveData<Map<GeohashChannelLevel, String>> = _locationNames
|
val locationNames: StateFlow<Map<GeohashChannelLevel, String>> = _locationNames
|
||||||
|
|
||||||
// Add a new LiveData property to indicate when location is being fetched
|
private val _isLoadingLocation = MutableStateFlow(false)
|
||||||
private val _isLoadingLocation = MutableLiveData(false)
|
val isLoadingLocation: StateFlow<Boolean> = _isLoadingLocation
|
||||||
val isLoadingLocation: LiveData<Boolean> = _isLoadingLocation
|
|
||||||
|
|
||||||
// Add a new LiveData property to track if location services are enabled by user
|
private val _locationServicesEnabled = MutableStateFlow(false)
|
||||||
private val _locationServicesEnabled = MutableLiveData(false)
|
val locationServicesEnabled: StateFlow<Boolean> = _locationServicesEnabled
|
||||||
val locationServicesEnabled: LiveData<Boolean> = _locationServicesEnabled
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
updatePermissionState()
|
updatePermissionState()
|
||||||
@@ -102,15 +100,15 @@ class LocationChannelManager private constructor(private val context: Context) {
|
|||||||
when (getCurrentPermissionStatus()) {
|
when (getCurrentPermissionStatus()) {
|
||||||
PermissionState.NOT_DETERMINED -> {
|
PermissionState.NOT_DETERMINED -> {
|
||||||
Log.d(TAG, "Permission not determined - user needs to grant in app settings")
|
Log.d(TAG, "Permission not determined - user needs to grant in app settings")
|
||||||
_permissionState.postValue(PermissionState.NOT_DETERMINED)
|
_permissionState.value = PermissionState.NOT_DETERMINED
|
||||||
}
|
}
|
||||||
PermissionState.DENIED, PermissionState.RESTRICTED -> {
|
PermissionState.DENIED, PermissionState.RESTRICTED -> {
|
||||||
Log.d(TAG, "Permission denied or restricted")
|
Log.d(TAG, "Permission denied or restricted")
|
||||||
_permissionState.postValue(PermissionState.DENIED)
|
_permissionState.value = PermissionState.DENIED
|
||||||
}
|
}
|
||||||
PermissionState.AUTHORIZED -> {
|
PermissionState.AUTHORIZED -> {
|
||||||
Log.d(TAG, "Permission authorized - requesting location")
|
Log.d(TAG, "Permission authorized - requesting location")
|
||||||
_permissionState.postValue(PermissionState.AUTHORIZED)
|
_permissionState.value = PermissionState.AUTHORIZED
|
||||||
requestOneShotLocation()
|
requestOneShotLocation()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,7 +178,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
|||||||
lastLocation?.let { location ->
|
lastLocation?.let { location ->
|
||||||
when (channel) {
|
when (channel) {
|
||||||
is ChannelID.Mesh -> {
|
is ChannelID.Mesh -> {
|
||||||
_teleported.postValue(false)
|
_teleported.value = false
|
||||||
}
|
}
|
||||||
is ChannelID.Location -> {
|
is ChannelID.Location -> {
|
||||||
val currentGeohash = Geohash.encode(
|
val currentGeohash = Geohash.encode(
|
||||||
@@ -189,7 +187,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
|||||||
precision = channel.channel.level.precision
|
precision = channel.channel.level.precision
|
||||||
)
|
)
|
||||||
val isTeleportedNow = currentGeohash != channel.channel.geohash
|
val isTeleportedNow = currentGeohash != channel.channel.geohash
|
||||||
_teleported.postValue(isTeleportedNow)
|
_teleported.value = isTeleportedNow
|
||||||
Log.d(TAG, "Teleported (immediate recompute): $isTeleportedNow (current: $currentGeohash, selected: ${channel.channel.geohash})")
|
Log.d(TAG, "Teleported (immediate recompute): $isTeleportedNow (current: $currentGeohash, selected: ${channel.channel.geohash})")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,7 +199,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
|||||||
*/
|
*/
|
||||||
fun setTeleported(teleported: Boolean) {
|
fun setTeleported(teleported: Boolean) {
|
||||||
Log.d(TAG, "Setting teleported status: $teleported")
|
Log.d(TAG, "Setting teleported status: $teleported")
|
||||||
_teleported.postValue(teleported)
|
_teleported.value = teleported
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -209,7 +207,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
|||||||
*/
|
*/
|
||||||
fun enableLocationServices() {
|
fun enableLocationServices() {
|
||||||
Log.d(TAG, "enableLocationServices() called by user")
|
Log.d(TAG, "enableLocationServices() called by user")
|
||||||
_locationServicesEnabled.postValue(true)
|
_locationServicesEnabled.value = true
|
||||||
saveLocationServicesState(true)
|
saveLocationServicesState(true)
|
||||||
|
|
||||||
// If we have permission, start location operations
|
// If we have permission, start location operations
|
||||||
@@ -223,15 +221,15 @@ class LocationChannelManager private constructor(private val context: Context) {
|
|||||||
*/
|
*/
|
||||||
fun disableLocationServices() {
|
fun disableLocationServices() {
|
||||||
Log.d(TAG, "disableLocationServices() called by user")
|
Log.d(TAG, "disableLocationServices() called by user")
|
||||||
_locationServicesEnabled.postValue(false)
|
_locationServicesEnabled.value = false
|
||||||
saveLocationServicesState(false)
|
saveLocationServicesState(false)
|
||||||
|
|
||||||
// Stop any ongoing location operations
|
// Stop any ongoing location operations
|
||||||
endLiveRefresh()
|
endLiveRefresh()
|
||||||
|
|
||||||
// Clear available channels when location is disabled
|
// Clear available channels when location is disabled
|
||||||
_availableChannels.postValue(emptyList())
|
_availableChannels.value = emptyList()
|
||||||
_locationNames.postValue(emptyMap())
|
_locationNames.value = emptyMap()
|
||||||
|
|
||||||
// If user had a location channel selected, switch back to mesh
|
// If user had a location channel selected, switch back to mesh
|
||||||
if (_selectedChannel.value is ChannelID.Location) {
|
if (_selectedChannel.value is ChannelID.Location) {
|
||||||
@@ -243,7 +241,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
|||||||
* Check if location services are enabled by the user
|
* Check if location services are enabled by the user
|
||||||
*/
|
*/
|
||||||
fun isLocationServicesEnabled(): Boolean {
|
fun isLocationServicesEnabled(): Boolean {
|
||||||
return _locationServicesEnabled.value ?: false
|
return _locationServicesEnabled.value
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Location Operations
|
// MARK: - Location Operations
|
||||||
@@ -280,13 +278,13 @@ class LocationChannelManager private constructor(private val context: Context) {
|
|||||||
if (lastKnownLocation != null) {
|
if (lastKnownLocation != null) {
|
||||||
Log.d(TAG, "Using last known location: ${lastKnownLocation.latitude}, ${lastKnownLocation.longitude}")
|
Log.d(TAG, "Using last known location: ${lastKnownLocation.latitude}, ${lastKnownLocation.longitude}")
|
||||||
lastLocation = lastKnownLocation
|
lastLocation = lastKnownLocation
|
||||||
_isLoadingLocation.postValue(false) // Make sure loading state is off
|
_isLoadingLocation.value = false // Make sure loading state is off
|
||||||
computeChannels(lastKnownLocation)
|
computeChannels(lastKnownLocation)
|
||||||
reverseGeocodeIfNeeded(lastKnownLocation)
|
reverseGeocodeIfNeeded(lastKnownLocation)
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "No last known location available")
|
Log.d(TAG, "No last known location available")
|
||||||
// Set loading state to true so UI can show a spinner
|
// Set loading state to true so UI can show a spinner
|
||||||
_isLoadingLocation.postValue(true)
|
_isLoadingLocation.value = true
|
||||||
|
|
||||||
// Request a fresh location only when we don't have a last known location
|
// Request a fresh location only when we don't have a last known location
|
||||||
Log.d(TAG, "Requesting fresh location...")
|
Log.d(TAG, "Requesting fresh location...")
|
||||||
@@ -294,7 +292,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
|||||||
}
|
}
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
Log.e(TAG, "Security exception requesting location: ${e.message}")
|
Log.e(TAG, "Security exception requesting location: ${e.message}")
|
||||||
_isLoadingLocation.postValue(false) // Turn off loading state on error
|
_isLoadingLocation.value = false // Turn off loading state on error
|
||||||
updatePermissionState()
|
updatePermissionState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -308,7 +306,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
|||||||
reverseGeocodeIfNeeded(location)
|
reverseGeocodeIfNeeded(location)
|
||||||
|
|
||||||
// Update loading state to indicate we have a location now
|
// Update loading state to indicate we have a location now
|
||||||
_isLoadingLocation.postValue(false)
|
_isLoadingLocation.value = false
|
||||||
|
|
||||||
// Remove this listener after getting the update
|
// Remove this listener after getting the update
|
||||||
try {
|
try {
|
||||||
@@ -322,13 +320,13 @@ class LocationChannelManager private constructor(private val context: Context) {
|
|||||||
// Request a fresh location update using getCurrentLocation instead of continuous updates
|
// Request a fresh location update using getCurrentLocation instead of continuous updates
|
||||||
private fun requestFreshLocation() {
|
private fun requestFreshLocation() {
|
||||||
if (!hasLocationPermission()) {
|
if (!hasLocationPermission()) {
|
||||||
_isLoadingLocation.postValue(false) // Turn off loading state if no permission
|
_isLoadingLocation.value = false // Turn off loading state if no permission
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Set loading state to true to indicate we're actively trying to get a location
|
// Set loading state to true to indicate we're actively trying to get a location
|
||||||
_isLoadingLocation.postValue(true)
|
_isLoadingLocation.value = true
|
||||||
|
|
||||||
// Try common providers in order of preference
|
// Try common providers in order of preference
|
||||||
val providers = listOf(
|
val providers = listOf(
|
||||||
@@ -358,7 +356,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
|||||||
Log.w(TAG, "Received null location from getCurrentLocation")
|
Log.w(TAG, "Received null location from getCurrentLocation")
|
||||||
}
|
}
|
||||||
// Update loading state to indicate we have a location now
|
// Update loading state to indicate we have a location now
|
||||||
_isLoadingLocation.postValue(false)
|
_isLoadingLocation.value = false
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -378,14 +376,14 @@ class LocationChannelManager private constructor(private val context: Context) {
|
|||||||
// If no provider was available, turn off loading state
|
// If no provider was available, turn off loading state
|
||||||
if (!providerFound) {
|
if (!providerFound) {
|
||||||
Log.w(TAG, "No location providers available")
|
Log.w(TAG, "No location providers available")
|
||||||
_isLoadingLocation.postValue(false)
|
_isLoadingLocation.value = false
|
||||||
}
|
}
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
Log.e(TAG, "Security exception requesting location: ${e.message}")
|
Log.e(TAG, "Security exception requesting location: ${e.message}")
|
||||||
_isLoadingLocation.postValue(false) // Turn off loading state on error
|
_isLoadingLocation.value = false // Turn off loading state on error
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error requesting location: ${e.message}")
|
Log.e(TAG, "Error requesting location: ${e.message}")
|
||||||
_isLoadingLocation.postValue(false) // Turn off loading state on error
|
_isLoadingLocation.value = false // Turn off loading state on error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,7 +406,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
|||||||
private fun updatePermissionState() {
|
private fun updatePermissionState() {
|
||||||
val newState = getCurrentPermissionStatus()
|
val newState = getCurrentPermissionStatus()
|
||||||
Log.d(TAG, "Permission state updated to: $newState")
|
Log.d(TAG, "Permission state updated to: $newState")
|
||||||
_permissionState.postValue(newState)
|
_permissionState.value = newState
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hasLocationPermission(): Boolean {
|
private fun hasLocationPermission(): Boolean {
|
||||||
@@ -433,13 +431,13 @@ class LocationChannelManager private constructor(private val context: Context) {
|
|||||||
Log.v(TAG, "Generated ${level.displayName}: $geohash")
|
Log.v(TAG, "Generated ${level.displayName}: $geohash")
|
||||||
}
|
}
|
||||||
|
|
||||||
_availableChannels.postValue(result)
|
_availableChannels.value = result
|
||||||
|
|
||||||
// Recompute teleported status based on current location vs selected channel
|
// Recompute teleported status based on current location vs selected channel
|
||||||
val selectedChannelValue = _selectedChannel.value
|
val selectedChannelValue = _selectedChannel.value
|
||||||
when (selectedChannelValue) {
|
when (selectedChannelValue) {
|
||||||
is ChannelID.Mesh -> {
|
is ChannelID.Mesh -> {
|
||||||
_teleported.postValue(false)
|
_teleported.value = false
|
||||||
}
|
}
|
||||||
is ChannelID.Location -> {
|
is ChannelID.Location -> {
|
||||||
val currentGeohash = Geohash.encode(
|
val currentGeohash = Geohash.encode(
|
||||||
@@ -448,12 +446,9 @@ class LocationChannelManager private constructor(private val context: Context) {
|
|||||||
precision = selectedChannelValue.channel.level.precision
|
precision = selectedChannelValue.channel.level.precision
|
||||||
)
|
)
|
||||||
val isTeleported = currentGeohash != selectedChannelValue.channel.geohash
|
val isTeleported = currentGeohash != selectedChannelValue.channel.geohash
|
||||||
_teleported.postValue(isTeleported)
|
_teleported.value = isTeleported
|
||||||
Log.d(TAG, "Teleported status: $isTeleported (current: $currentGeohash, selected: ${selectedChannelValue.channel.geohash})")
|
Log.d(TAG, "Teleported status: $isTeleported (current: $currentGeohash, selected: ${selectedChannelValue.channel.geohash})")
|
||||||
}
|
}
|
||||||
null -> {
|
|
||||||
_teleported.postValue(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,7 +477,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
|||||||
val names = namesByLevel(address)
|
val names = namesByLevel(address)
|
||||||
|
|
||||||
Log.d(TAG, "Reverse geocoding result: $names")
|
Log.d(TAG, "Reverse geocoding result: $names")
|
||||||
_locationNames.postValue(names)
|
_locationNames.value = names
|
||||||
} else {
|
} else {
|
||||||
Log.w(TAG, "No reverse geocoding results")
|
Log.w(TAG, "No reverse geocoding results")
|
||||||
}
|
}
|
||||||
@@ -601,26 +596,26 @@ class LocationChannelManager private constructor(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (channel != null) {
|
if (channel != null) {
|
||||||
_selectedChannel.postValue(channel)
|
_selectedChannel.value = channel
|
||||||
Log.d(TAG, "Restored persisted channel: ${channel.displayName}")
|
Log.d(TAG, "Restored persisted channel: ${channel.displayName}")
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "Could not restore persisted channel, defaulting to Mesh")
|
Log.d(TAG, "Could not restore persisted channel, defaulting to Mesh")
|
||||||
_selectedChannel.postValue(ChannelID.Mesh)
|
_selectedChannel.value = ChannelID.Mesh
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Log.w(TAG, "Invalid channel data format in persistence")
|
Log.w(TAG, "Invalid channel data format in persistence")
|
||||||
_selectedChannel.postValue(ChannelID.Mesh)
|
_selectedChannel.value = ChannelID.Mesh
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "No persisted channel found, defaulting to Mesh")
|
Log.d(TAG, "No persisted channel found, defaulting to Mesh")
|
||||||
_selectedChannel.postValue(ChannelID.Mesh)
|
_selectedChannel.value = ChannelID.Mesh
|
||||||
}
|
}
|
||||||
} catch (e: JsonSyntaxException) {
|
} catch (e: JsonSyntaxException) {
|
||||||
Log.e(TAG, "Failed to parse persisted channel data: ${e.message}")
|
Log.e(TAG, "Failed to parse persisted channel data: ${e.message}")
|
||||||
_selectedChannel.postValue(ChannelID.Mesh)
|
_selectedChannel.value = ChannelID.Mesh
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to load persisted channel: ${e.message}")
|
Log.e(TAG, "Failed to load persisted channel: ${e.message}")
|
||||||
_selectedChannel.postValue(ChannelID.Mesh)
|
_selectedChannel.value = ChannelID.Mesh
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -629,7 +624,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
|||||||
*/
|
*/
|
||||||
fun clearPersistedChannel() {
|
fun clearPersistedChannel() {
|
||||||
dataManager?.clearLastGeohashChannel()
|
dataManager?.clearLastGeohashChannel()
|
||||||
_selectedChannel.postValue(ChannelID.Mesh)
|
_selectedChannel.value = ChannelID.Mesh
|
||||||
Log.d(TAG, "Cleared persisted channel selection")
|
Log.d(TAG, "Cleared persisted channel selection")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -653,11 +648,11 @@ class LocationChannelManager private constructor(private val context: Context) {
|
|||||||
private fun loadLocationServicesState() {
|
private fun loadLocationServicesState() {
|
||||||
try {
|
try {
|
||||||
val enabled = dataManager?.isLocationServicesEnabled() ?: false
|
val enabled = dataManager?.isLocationServicesEnabled() ?: false
|
||||||
_locationServicesEnabled.postValue(enabled)
|
_locationServicesEnabled.value = enabled
|
||||||
Log.d(TAG, "Loaded location services state: $enabled")
|
Log.d(TAG, "Loaded location services state: $enabled")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to load location services state: ${e.message}")
|
Log.e(TAG, "Failed to load location services state: ${e.message}")
|
||||||
_locationServicesEnabled.postValue(false)
|
_locationServicesEnabled.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ class BluetoothConnectionManager(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
isActive = true
|
isActive = true
|
||||||
|
Log.d(TAG, "ConnectionManager activated (permissions and adapter OK)")
|
||||||
|
|
||||||
// set the adapter's name to our 8-character peerID for iOS privacy, TODO: Make this configurable
|
// set the adapter's name to our 8-character peerID for iOS privacy, TODO: Make this configurable
|
||||||
// try {
|
// try {
|
||||||
@@ -179,6 +180,7 @@ class BluetoothConnectionManager(
|
|||||||
this@BluetoothConnectionManager.isActive = false
|
this@BluetoothConnectionManager.isActive = false
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
Log.d(TAG, "GATT Server started")
|
||||||
} else {
|
} else {
|
||||||
Log.i(TAG, "GATT Server disabled by debug settings; not starting")
|
Log.i(TAG, "GATT Server disabled by debug settings; not starting")
|
||||||
}
|
}
|
||||||
@@ -189,6 +191,7 @@ class BluetoothConnectionManager(
|
|||||||
this@BluetoothConnectionManager.isActive = false
|
this@BluetoothConnectionManager.isActive = false
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
Log.d(TAG, "GATT Client started")
|
||||||
} else {
|
} else {
|
||||||
Log.i(TAG, "GATT Client disabled by debug settings; not starting")
|
Log.i(TAG, "GATT Client disabled by debug settings; not starting")
|
||||||
}
|
}
|
||||||
@@ -214,6 +217,7 @@ class BluetoothConnectionManager(
|
|||||||
isActive = false
|
isActive = false
|
||||||
|
|
||||||
connectionScope.launch {
|
connectionScope.launch {
|
||||||
|
Log.d(TAG, "Stopping client/server and power components...")
|
||||||
// Stop component managers
|
// Stop component managers
|
||||||
clientManager.stop()
|
clientManager.stop()
|
||||||
serverManager.stop()
|
serverManager.stop()
|
||||||
@@ -230,6 +234,18 @@ class BluetoothConnectionManager(
|
|||||||
Log.i(TAG, "All Bluetooth services stopped")
|
Log.i(TAG, "All Bluetooth services stopped")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set app background state for power optimization
|
* Set app background state for power optimization
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access
|
internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access
|
||||||
private val packetProcessor = PacketProcessor(myPeerID)
|
private val packetProcessor = PacketProcessor(myPeerID)
|
||||||
private lateinit var gossipSyncManager: GossipSyncManager
|
private lateinit var gossipSyncManager: GossipSyncManager
|
||||||
|
// Service-level notification manager for background (no-UI) DMs
|
||||||
|
private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager(
|
||||||
|
context.applicationContext,
|
||||||
|
androidx.core.app.NotificationManagerCompat.from(context.applicationContext),
|
||||||
|
com.bitchat.android.util.NotificationIntervalManager()
|
||||||
|
)
|
||||||
|
|
||||||
// Service state management
|
// Service state management
|
||||||
private var isActive = false
|
private var isActive = false
|
||||||
@@ -61,8 +67,11 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
|
|
||||||
// Coroutines
|
// Coroutines
|
||||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
// Tracks whether this instance has been terminated via stopServices()
|
||||||
|
private var terminated = false
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID")
|
||||||
setupDelegates()
|
setupDelegates()
|
||||||
messageHandler.packetProcessor = packetProcessor
|
messageHandler.packetProcessor = packetProcessor
|
||||||
//startPeriodicDebugLogging()
|
//startPeriodicDebugLogging()
|
||||||
@@ -98,6 +107,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
return signPacketBeforeBroadcast(packet)
|
return signPacketBeforeBroadcast(packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Log.d(TAG, "Delegates set up; GossipSyncManager initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,6 +115,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
*/
|
*/
|
||||||
private fun startPeriodicDebugLogging() {
|
private fun startPeriodicDebugLogging() {
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
|
Log.d(TAG, "Starting periodic debug logging loop")
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
try {
|
try {
|
||||||
delay(10000) // 10 seconds
|
delay(10000) // 10 seconds
|
||||||
@@ -116,6 +127,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
Log.e(TAG, "Error in periodic debug logging: ${e.message}")
|
Log.e(TAG, "Error in periodic debug logging: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Log.d(TAG, "Periodic debug logging loop ended (isActive=$isActive)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +136,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
*/
|
*/
|
||||||
private fun sendPeriodicBroadcastAnnounce() {
|
private fun sendPeriodicBroadcastAnnounce() {
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
|
Log.d(TAG, "Starting periodic announce loop")
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
try {
|
try {
|
||||||
delay(30000) // 30 seconds
|
delay(30000) // 30 seconds
|
||||||
@@ -132,6 +145,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
Log.e(TAG, "Error in periodic broadcast announce: ${e.message}")
|
Log.e(TAG, "Error in periodic broadcast announce: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Log.d(TAG, "Periodic announce loop ended (isActive=$isActive)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +153,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
* Setup delegate connections between components
|
* Setup delegate connections between components
|
||||||
*/
|
*/
|
||||||
private fun setupDelegates() {
|
private fun setupDelegates() {
|
||||||
|
Log.d(TAG, "Setting up component delegates")
|
||||||
// Provide nickname resolver to BLE broadcaster for detailed logs
|
// Provide nickname resolver to BLE broadcaster for detailed logs
|
||||||
try {
|
try {
|
||||||
connectionManager.setNicknameResolver { pid -> peerManager.getPeerNickname(pid) }
|
connectionManager.setNicknameResolver { pid -> peerManager.getPeerNickname(pid) }
|
||||||
@@ -146,6 +161,9 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
// PeerManager delegates to main mesh service delegate
|
// PeerManager delegates to main mesh service delegate
|
||||||
peerManager.delegate = object : PeerManagerDelegate {
|
peerManager.delegate = object : PeerManagerDelegate {
|
||||||
override fun onPeerListUpdated(peerIDs: List<String>) {
|
override fun onPeerListUpdated(peerIDs: List<String>) {
|
||||||
|
// Update process-wide state first
|
||||||
|
try { com.bitchat.android.services.AppStateStore.setPeers(peerIDs) } catch (_: Exception) { }
|
||||||
|
// Then notify UI delegate if attached
|
||||||
delegate?.didUpdatePeerList(peerIDs)
|
delegate?.didUpdatePeerList(peerIDs)
|
||||||
}
|
}
|
||||||
override fun onPeerRemoved(peerID: String) {
|
override fun onPeerRemoved(peerID: String) {
|
||||||
@@ -165,6 +183,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) {
|
override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) {
|
||||||
// Send announcement and cached messages after key exchange
|
// Send announcement and cached messages after key exchange
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
|
Log.d(TAG, "Key exchange completed with $peerID; sending follow-ups")
|
||||||
delay(100)
|
delay(100)
|
||||||
sendAnnouncementToPeer(peerID)
|
sendAnnouncementToPeer(peerID)
|
||||||
|
|
||||||
@@ -352,7 +371,36 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
override fun onMessageReceived(message: BitchatMessage) {
|
override fun onMessageReceived(message: BitchatMessage) {
|
||||||
|
// Always reflect into process-wide store so UI can hydrate after recreation
|
||||||
|
try {
|
||||||
|
when {
|
||||||
|
message.isPrivate -> {
|
||||||
|
val peer = message.senderPeerID ?: ""
|
||||||
|
if (peer.isNotEmpty()) com.bitchat.android.services.AppStateStore.addPrivateMessage(peer, message)
|
||||||
|
}
|
||||||
|
message.channel != null -> {
|
||||||
|
com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel!!, message)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
com.bitchat.android.services.AppStateStore.addPublicMessage(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
// And forward to UI delegate if attached
|
||||||
delegate?.didReceiveMessage(message)
|
delegate?.didReceiveMessage(message)
|
||||||
|
|
||||||
|
// If no UI delegate attached (app closed), show DM notification via service manager
|
||||||
|
if (delegate == null && message.isPrivate) {
|
||||||
|
try {
|
||||||
|
val senderPeerID = message.senderPeerID
|
||||||
|
if (senderPeerID != null) {
|
||||||
|
val nick = try { peerManager.getPeerNickname(senderPeerID) } catch (_: Exception) { null } ?: senderPeerID
|
||||||
|
val preview = com.bitchat.android.ui.NotificationTextUtils.buildPrivateMessagePreview(message)
|
||||||
|
serviceNotificationManager.setAppBackgroundState(true)
|
||||||
|
serviceNotificationManager.showPrivateMessageNotification(senderPeerID, nick, preview)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onChannelLeave(channel: String, fromPeer: String) {
|
override fun onChannelLeave(channel: String, fromPeer: String) {
|
||||||
@@ -477,12 +525,23 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
// BluetoothConnectionManager delegates
|
// BluetoothConnectionManager delegates
|
||||||
connectionManager.delegate = object : BluetoothConnectionManagerDelegate {
|
connectionManager.delegate = object : BluetoothConnectionManagerDelegate {
|
||||||
override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) {
|
override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) {
|
||||||
|
// Log incoming for debug graphs (do not double-count anywhere else)
|
||||||
|
try {
|
||||||
|
val nick = getPeerNicknames()[peerID]
|
||||||
|
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming(
|
||||||
|
packetType = packet.type.toString(),
|
||||||
|
fromPeerID = peerID,
|
||||||
|
fromNickname = nick,
|
||||||
|
fromDeviceAddress = device?.address
|
||||||
|
)
|
||||||
|
} catch (_: Exception) { }
|
||||||
packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address))
|
packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDeviceConnected(device: android.bluetooth.BluetoothDevice) {
|
override fun onDeviceConnected(device: android.bluetooth.BluetoothDevice) {
|
||||||
// Send initial announcements after services are ready
|
// Send initial announcements after services are ready
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
|
Log.d(TAG, "Device connected: ${device.address}; scheduling announce")
|
||||||
delay(200)
|
delay(200)
|
||||||
sendBroadcastAnnounce()
|
sendBroadcastAnnounce()
|
||||||
}
|
}
|
||||||
@@ -497,6 +556,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDeviceDisconnected(device: android.bluetooth.BluetoothDevice) {
|
override fun onDeviceDisconnected(device: android.bluetooth.BluetoothDevice) {
|
||||||
|
Log.d(TAG, "Device disconnected: ${device.address}")
|
||||||
val addr = device.address
|
val addr = device.address
|
||||||
// Remove mapping and, if that was the last direct path for the peer, clear direct flag
|
// Remove mapping and, if that was the last direct path for the peer, clear direct flag
|
||||||
val peer = connectionManager.addressPeerMap[addr]
|
val peer = connectionManager.addressPeerMap[addr]
|
||||||
@@ -535,6 +595,11 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
Log.w(TAG, "Mesh service already active, ignoring duplicate start request")
|
Log.w(TAG, "Mesh service already active, ignoring duplicate start request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (terminated) {
|
||||||
|
// This instance's scope was cancelled previously; refuse to start to avoid using dead scopes.
|
||||||
|
Log.e(TAG, "Mesh service instance was terminated; create a new instance instead of restarting")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
Log.i(TAG, "Starting Bluetooth mesh service with peer ID: $myPeerID")
|
Log.i(TAG, "Starting Bluetooth mesh service with peer ID: $myPeerID")
|
||||||
|
|
||||||
@@ -546,6 +611,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
Log.d(TAG, "Started periodic broadcast announcements (every 30 seconds)")
|
Log.d(TAG, "Started periodic broadcast announcements (every 30 seconds)")
|
||||||
// Start periodic syncs
|
// Start periodic syncs
|
||||||
gossipSyncManager.start()
|
gossipSyncManager.start()
|
||||||
|
Log.d(TAG, "GossipSyncManager started")
|
||||||
} else {
|
} else {
|
||||||
Log.e(TAG, "Failed to start Bluetooth services")
|
Log.e(TAG, "Failed to start Bluetooth services")
|
||||||
}
|
}
|
||||||
@@ -567,11 +633,14 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
sendLeaveAnnouncement()
|
sendLeaveAnnouncement()
|
||||||
|
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
|
Log.d(TAG, "Stopping subcomponents and cancelling scope...")
|
||||||
delay(200) // Give leave message time to send
|
delay(200) // Give leave message time to send
|
||||||
|
|
||||||
// Stop all components
|
// Stop all components
|
||||||
gossipSyncManager.stop()
|
gossipSyncManager.stop()
|
||||||
|
Log.d(TAG, "GossipSyncManager stopped")
|
||||||
connectionManager.stopServices()
|
connectionManager.stopServices()
|
||||||
|
Log.d(TAG, "BluetoothConnectionManager stop requested")
|
||||||
peerManager.shutdown()
|
peerManager.shutdown()
|
||||||
fragmentManager.shutdown()
|
fragmentManager.shutdown()
|
||||||
securityManager.shutdown()
|
securityManager.shutdown()
|
||||||
@@ -579,9 +648,24 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
messageHandler.shutdown()
|
messageHandler.shutdown()
|
||||||
packetProcessor.shutdown()
|
packetProcessor.shutdown()
|
||||||
|
|
||||||
|
// Mark this instance as terminated and cancel its scope so it won't be reused
|
||||||
|
terminated = true
|
||||||
serviceScope.cancel()
|
serviceScope.cancel()
|
||||||
|
Log.i(TAG, "BluetoothMeshService terminated and scope cancelled")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this instance can be safely reused. Returns false after stopServices() or if
|
||||||
|
* any critical internal scope has been cancelled.
|
||||||
|
*/
|
||||||
|
fun isReusable(): Boolean {
|
||||||
|
val reusable = !terminated && serviceScope.isActive && connectionManager.isReusable()
|
||||||
|
if (!reusable) {
|
||||||
|
Log.d(TAG, "isReusable=false (terminated=$terminated, scopeActive=${serviceScope.isActive}, connReusable=${connectionManager.isReusable()})")
|
||||||
|
}
|
||||||
|
return reusable
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send public message
|
* Send public message
|
||||||
@@ -801,7 +885,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) {
|
fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) {
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
Log.d(TAG, "📖 Sending read receipt for message $messageID to $recipientPeerID")
|
Log.d(TAG, "📖 Sending read receipt for message $messageID to $recipientPeerID")
|
||||||
|
|
||||||
// Route geohash read receipts via MessageRouter instead of here
|
// Route geohash read receipts via MessageRouter instead of here
|
||||||
val geo = runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance() }.getOrNull()
|
val geo = runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance() }.getOrNull()
|
||||||
val isGeoAlias = try {
|
val isGeoAlias = try {
|
||||||
@@ -812,8 +896,15 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
geo.sendReadReceipt(com.bitchat.android.model.ReadReceipt(messageID), recipientPeerID)
|
geo.sendReadReceipt(com.bitchat.android.model.ReadReceipt(messageID), recipientPeerID)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Avoid duplicate read receipts: check persistent store first
|
||||||
|
val seenStore = try { com.bitchat.android.services.SeenMessageStore.getInstance(context.applicationContext) } catch (_: Exception) { null }
|
||||||
|
if (seenStore?.hasRead(messageID) == true) {
|
||||||
|
Log.d(TAG, "Skipping read receipt for $messageID - already marked read")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
// Create read receipt payload using NoisePayloadType exactly like iOS
|
// Create read receipt payload using NoisePayloadType exactly like iOS
|
||||||
val readReceiptPayload = com.bitchat.android.model.NoisePayload(
|
val readReceiptPayload = com.bitchat.android.model.NoisePayload(
|
||||||
type = com.bitchat.android.model.NoisePayloadType.READ_RECEIPT,
|
type = com.bitchat.android.model.NoisePayloadType.READ_RECEIPT,
|
||||||
@@ -839,7 +930,10 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||||
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
||||||
Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID")
|
Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID")
|
||||||
|
|
||||||
|
// Persist as read after successful send
|
||||||
|
try { seenStore?.markRead(messageID) } catch (_: Exception) { }
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to send read receipt to $recipientPeerID: ${e.message}")
|
Log.e(TAG, "Failed to send read receipt to $recipientPeerID: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -852,7 +946,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
fun sendBroadcastAnnounce() {
|
fun sendBroadcastAnnounce() {
|
||||||
Log.d(TAG, "Sending broadcast announce")
|
Log.d(TAG, "Sending broadcast announce")
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
val nickname = delegate?.getNickname() ?: myPeerID
|
val nickname = try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { myPeerID }
|
||||||
|
|
||||||
// Get the static public key for the announcement
|
// Get the static public key for the announcement
|
||||||
val staticKey = encryptionService.getStaticPublicKey()
|
val staticKey = encryptionService.getStaticPublicKey()
|
||||||
@@ -901,7 +995,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
fun sendAnnouncementToPeer(peerID: String) {
|
fun sendAnnouncementToPeer(peerID: String) {
|
||||||
if (peerManager.hasAnnouncedToPeer(peerID)) return
|
if (peerManager.hasAnnouncedToPeer(peerID)) return
|
||||||
|
|
||||||
val nickname = delegate?.getNickname() ?: myPeerID
|
val nickname = try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { myPeerID }
|
||||||
|
|
||||||
// Get the static public key for the announcement
|
// Get the static public key for the announcement
|
||||||
val staticKey = encryptionService.getStaticPublicKey()
|
val staticKey = encryptionService.getStaticPublicKey()
|
||||||
@@ -1000,6 +1094,13 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
return peerManager.getFingerprintForPeer(peerID)
|
return peerManager.getFingerprintForPeer(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current active peer count (for status/notifications)
|
||||||
|
*/
|
||||||
|
fun getActivePeerCount(): Int {
|
||||||
|
return try { peerManager.getActivePeerCount() } catch (_: Exception) { 0 }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get peer info for verification purposes
|
* Get peer info for verification purposes
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -73,9 +73,17 @@ class BluetoothPacketBroadcaster(
|
|||||||
try {
|
try {
|
||||||
val fromNick = incomingPeer?.let { nicknameResolver?.invoke(it) }
|
val fromNick = incomingPeer?.let { nicknameResolver?.invoke(it) }
|
||||||
val toNick = toPeer?.let { nicknameResolver?.invoke(it) }
|
val toNick = toPeer?.let { nicknameResolver?.invoke(it) }
|
||||||
val isRelay = (incomingAddr != null || incomingPeer != null)
|
val manager = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance()
|
||||||
|
// Always log outgoing for the actual transmission target
|
||||||
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logPacketRelayDetailed(
|
manager.logOutgoing(
|
||||||
|
packetType = typeName,
|
||||||
|
toPeerID = toPeer,
|
||||||
|
toNickname = toNick,
|
||||||
|
toDeviceAddress = toDeviceAddress,
|
||||||
|
previousHopPeerID = incomingPeer
|
||||||
|
)
|
||||||
|
// Keep the verbose relay message for human readability
|
||||||
|
manager.logPacketRelayDetailed(
|
||||||
packetType = typeName,
|
packetType = typeName,
|
||||||
senderPeerID = senderPeerID,
|
senderPeerID = senderPeerID,
|
||||||
senderNickname = senderNick,
|
senderNickname = senderNick,
|
||||||
@@ -86,7 +94,7 @@ class BluetoothPacketBroadcaster(
|
|||||||
toNickname = toNick,
|
toNickname = toNick,
|
||||||
toDeviceAddress = toDeviceAddress,
|
toDeviceAddress = toDeviceAddress,
|
||||||
ttl = ttl,
|
ttl = ttl,
|
||||||
isRelay = isRelay
|
isRelay = true
|
||||||
)
|
)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Silently ignore debug logging failures
|
// Silently ignore debug logging failures
|
||||||
|
|||||||
@@ -224,26 +224,29 @@ class PowerManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePowerMode() {
|
private fun updatePowerMode() {
|
||||||
val newMode = when {
|
// Determine the base mode from battery/charging state only
|
||||||
// Always use performance mode when charging (unless in background too long)
|
val baseMode = when {
|
||||||
|
// Charging in foreground may use performance
|
||||||
isCharging && !isAppInBackground -> PowerMode.PERFORMANCE
|
isCharging && !isAppInBackground -> PowerMode.PERFORMANCE
|
||||||
|
|
||||||
// Critical battery - use ultra low power
|
// Critical battery - force ultra low power regardless of foreground/background
|
||||||
batteryLevel <= CRITICAL_BATTERY -> PowerMode.ULTRA_LOW_POWER
|
batteryLevel <= CRITICAL_BATTERY -> PowerMode.ULTRA_LOW_POWER
|
||||||
|
|
||||||
// Low battery - use power saver
|
// Low battery - prefer power saver
|
||||||
batteryLevel <= LOW_BATTERY -> PowerMode.POWER_SAVER
|
batteryLevel <= LOW_BATTERY -> PowerMode.POWER_SAVER
|
||||||
|
|
||||||
// Background app with medium battery - use power saver
|
// Otherwise balanced
|
||||||
isAppInBackground && batteryLevel <= MEDIUM_BATTERY -> PowerMode.POWER_SAVER
|
|
||||||
|
|
||||||
// Background app with good battery - use balanced
|
|
||||||
isAppInBackground -> PowerMode.BALANCED
|
|
||||||
|
|
||||||
// Foreground with good battery - use balanced
|
|
||||||
else -> PowerMode.BALANCED
|
else -> PowerMode.BALANCED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If app is in background (including when running as a foreground service),
|
||||||
|
// cap the power mode to at least POWER_SAVER. Preserve ULTRA_LOW_POWER.
|
||||||
|
val newMode = if (isAppInBackground) {
|
||||||
|
if (baseMode == PowerMode.ULTRA_LOW_POWER) PowerMode.ULTRA_LOW_POWER else PowerMode.POWER_SAVER
|
||||||
|
} else {
|
||||||
|
baseMode
|
||||||
|
}
|
||||||
|
|
||||||
if (newMode != currentMode) {
|
if (newMode != currentMode) {
|
||||||
val oldMode = currentMode
|
val oldMode = currentMode
|
||||||
currentMode = newMode
|
currentMode = newMode
|
||||||
|
|||||||
+268
-124
@@ -2,6 +2,7 @@ package com.bitchat.android.net
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.bitchat.android.util.AppConstants
|
||||||
import info.guardianproject.arti.ArtiLogListener
|
import info.guardianproject.arti.ArtiLogListener
|
||||||
import info.guardianproject.arti.ArtiProxy
|
import info.guardianproject.arti.ArtiProxy
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -11,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -24,54 +26,97 @@ import java.util.concurrent.atomic.AtomicReference
|
|||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages embedded Tor lifecycle & provides SOCKS proxy address.
|
* Tor provider implementation using custom-built Arti (Tor-in-Rust).
|
||||||
* Uses Arti (Tor in Rust) for improved security and reliability.
|
*
|
||||||
|
* This singleton provides Tor anonymity features using a custom Arti build
|
||||||
|
* compiled with 16KB page size support for Google Play compliance.
|
||||||
|
*
|
||||||
|
* Based on the original TorManager implementation.
|
||||||
*/
|
*/
|
||||||
object TorManager {
|
class ArtiTorManager private constructor() {
|
||||||
private const val TAG = "TorManager"
|
enum class TorState {
|
||||||
private const val DEFAULT_SOCKS_PORT = com.bitchat.android.util.AppConstants.Tor.DEFAULT_SOCKS_PORT
|
OFF,
|
||||||
private const val RESTART_DELAY_MS = com.bitchat.android.util.AppConstants.Tor.RESTART_DELAY_MS // 2 seconds between stop/start
|
STARTING,
|
||||||
private const val INACTIVITY_TIMEOUT_MS = com.bitchat.android.util.AppConstants.Tor.INACTIVITY_TIMEOUT_MS // 5 seconds of no activity before restart
|
BOOTSTRAPPING,
|
||||||
private const val MAX_RETRY_ATTEMPTS = com.bitchat.android.util.AppConstants.Tor.MAX_RETRY_ATTEMPTS
|
RUNNING,
|
||||||
private const val STOP_TIMEOUT_MS = com.bitchat.android.util.AppConstants.Tor.STOP_TIMEOUT_MS
|
STOPPING,
|
||||||
|
ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
data class TorStatus(
|
||||||
|
val mode: TorMode = TorMode.OFF,
|
||||||
|
val running: Boolean = false,
|
||||||
|
val bootstrapPercent: Int = 0,
|
||||||
|
val lastLogLine: String = "",
|
||||||
|
val state: TorState = TorState.OFF
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ArtiTorManager"
|
||||||
|
private const val DEFAULT_SOCKS_PORT = AppConstants.Tor.DEFAULT_SOCKS_PORT
|
||||||
|
private const val RESTART_DELAY_MS = AppConstants.Tor.RESTART_DELAY_MS
|
||||||
|
private const val INACTIVITY_TIMEOUT_MS = AppConstants.Tor.INACTIVITY_TIMEOUT_MS
|
||||||
|
private const val MAX_RETRY_ATTEMPTS = AppConstants.Tor.MAX_RETRY_ATTEMPTS
|
||||||
|
private const val STOP_TIMEOUT_MS = AppConstants.Tor.STOP_TIMEOUT_MS
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var INSTANCE: ArtiTorManager? = null
|
||||||
|
|
||||||
|
fun getInstance(): ArtiTorManager {
|
||||||
|
return INSTANCE ?: synchronized(this) {
|
||||||
|
INSTANCE ?: ArtiTorManager().also { INSTANCE = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
@Volatile private var initialized = false
|
@Volatile
|
||||||
@Volatile private var socksAddr: InetSocketAddress? = null
|
private var initialized = false
|
||||||
private val artiProxyRef = AtomicReference<ArtiProxy?>(null)
|
@Volatile
|
||||||
@Volatile private var lastMode: TorMode = TorMode.OFF
|
private var socksAddr: InetSocketAddress? = null
|
||||||
|
@Volatile
|
||||||
|
private var artiProxy: ArtiProxy? = null
|
||||||
|
@Volatile
|
||||||
|
private var lastMode: TorMode = TorMode.OFF
|
||||||
private val applyMutex = Mutex()
|
private val applyMutex = Mutex()
|
||||||
@Volatile private var desiredMode: TorMode = TorMode.OFF
|
@Volatile
|
||||||
@Volatile private var currentSocksPort: Int = DEFAULT_SOCKS_PORT
|
private var desiredMode: TorMode = TorMode.OFF
|
||||||
@Volatile private var lastLogTime = AtomicLong(0L)
|
@Volatile
|
||||||
@Volatile private var retryAttempts = 0
|
private var currentSocksPort: Int = DEFAULT_SOCKS_PORT
|
||||||
@Volatile private var bindRetryAttempts = 0
|
@Volatile
|
||||||
|
private var lastLogTime = AtomicLong(0L)
|
||||||
|
@Volatile
|
||||||
|
private var retryAttempts = 0
|
||||||
|
@Volatile
|
||||||
|
private var bindRetryAttempts = 0
|
||||||
private var inactivityJob: Job? = null
|
private var inactivityJob: Job? = null
|
||||||
private var retryJob: Job? = null
|
private var retryJob: Job? = null
|
||||||
private var currentApplication: Application? = null
|
private var currentApplication: Application? = null
|
||||||
|
|
||||||
private enum class LifecycleState { STOPPED, STARTING, RUNNING, STOPPING }
|
private enum class LifecycleState { STOPPED, STARTING, RUNNING, STOPPING }
|
||||||
@Volatile private var lifecycleState: LifecycleState = LifecycleState.STOPPED
|
|
||||||
|
|
||||||
enum class TorState { OFF, STARTING, BOOTSTRAPPING, RUNNING, STOPPING, ERROR }
|
@Volatile
|
||||||
|
private var lifecycleState: LifecycleState = LifecycleState.STOPPED
|
||||||
|
|
||||||
data class TorStatus(
|
private val _statusFlow = MutableStateFlow(
|
||||||
val mode: TorMode = TorMode.OFF,
|
TorStatus(
|
||||||
val running: Boolean = false,
|
mode = TorMode.OFF,
|
||||||
val bootstrapPercent: Int = 0, // kept for backwards compatibility with UI; 0 or 100 only
|
running = false,
|
||||||
val lastLogLine: String = "",
|
bootstrapPercent = 0,
|
||||||
val state: TorState = TorState.OFF
|
lastLogLine = "",
|
||||||
|
state = TorState.OFF
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
private val _status = MutableStateFlow(TorStatus())
|
val statusFlow: StateFlow<TorStatus> = _statusFlow.asStateFlow()
|
||||||
val statusFlow: StateFlow<TorStatus> = _status.asStateFlow()
|
|
||||||
|
|
||||||
private val stateChangeDeferred = AtomicReference<CompletableDeferred<TorState>?>(null)
|
private val stateChangeDeferred = AtomicReference<CompletableDeferred<TorState>?>(null)
|
||||||
|
|
||||||
fun isProxyEnabled(): Boolean {
|
fun isProxyEnabled(): Boolean {
|
||||||
val s = _status.value
|
val s = _statusFlow.value
|
||||||
return s.mode != TorMode.OFF && s.running && s.bootstrapPercent >= 100 && socksAddr != null && s.state == TorState.RUNNING
|
return s.mode != TorMode.OFF && s.running && s.bootstrapPercent >= 100 &&
|
||||||
|
socksAddr != null && s.state == TorState.RUNNING
|
||||||
}
|
}
|
||||||
|
|
||||||
fun init(application: Application) {
|
fun init(application: Application) {
|
||||||
@@ -82,7 +127,21 @@ object TorManager {
|
|||||||
currentApplication = application
|
currentApplication = application
|
||||||
TorPreferenceManager.init(application)
|
TorPreferenceManager.init(application)
|
||||||
|
|
||||||
// Apply saved mode at startup. If ON, set planned SOCKS immediately to avoid any leak.
|
val logListener = ArtiLogListener { logLine ->
|
||||||
|
val text = logLine ?: return@ArtiLogListener
|
||||||
|
val s = text
|
||||||
|
Log.i(TAG, "arti: $s")
|
||||||
|
lastLogTime.set(System.currentTimeMillis())
|
||||||
|
_statusFlow.update { it.copy(lastLogLine = s) }
|
||||||
|
handleArtiLogLine(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
artiProxy = ArtiProxy.Builder(application)
|
||||||
|
.setSocksPort(currentSocksPort)
|
||||||
|
.setDnsPort(currentSocksPort + 1)
|
||||||
|
.setLogListener(logListener)
|
||||||
|
.build()
|
||||||
|
|
||||||
val savedMode = TorPreferenceManager.get(application)
|
val savedMode = TorPreferenceManager.get(application)
|
||||||
if (savedMode == TorMode.ON) {
|
if (savedMode == TorMode.ON) {
|
||||||
if (currentSocksPort < DEFAULT_SOCKS_PORT) {
|
if (currentSocksPort < DEFAULT_SOCKS_PORT) {
|
||||||
@@ -90,13 +149,15 @@ object TorManager {
|
|||||||
}
|
}
|
||||||
desiredMode = savedMode
|
desiredMode = savedMode
|
||||||
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
|
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
|
||||||
try { OkHttpProvider.reset() } catch (_: Throwable) { }
|
try {
|
||||||
|
OkHttpProvider.reset()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
} // Only reset OkHttp during init
|
||||||
}
|
}
|
||||||
appScope.launch {
|
appScope.launch {
|
||||||
applyMode(application, savedMode)
|
applyMode(application, savedMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe changes
|
|
||||||
appScope.launch {
|
appScope.launch {
|
||||||
TorPreferenceManager.modeFlow.collect { mode ->
|
TorPreferenceManager.modeFlow.collect { mode ->
|
||||||
applyMode(application, mode)
|
applyMode(application, mode)
|
||||||
@@ -112,53 +173,63 @@ object TorManager {
|
|||||||
try {
|
try {
|
||||||
desiredMode = mode
|
desiredMode = mode
|
||||||
lastMode = mode
|
lastMode = mode
|
||||||
val s = _status.value
|
val s = _statusFlow.value
|
||||||
if (mode == s.mode && mode != TorMode.OFF && (lifecycleState == LifecycleState.STARTING || lifecycleState == LifecycleState.RUNNING)) {
|
if (mode == s.mode && mode != TorMode.OFF &&
|
||||||
Log.i(TAG, "applyMode: already in progress/running mode=$mode, state=$lifecycleState; skip")
|
(lifecycleState == LifecycleState.STARTING || lifecycleState == LifecycleState.RUNNING)
|
||||||
|
) {
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"applyMode: already in progress/running mode=$mode, state=$lifecycleState; skip"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
when (mode) {
|
when (mode) {
|
||||||
TorMode.OFF -> {
|
TorMode.OFF -> {
|
||||||
Log.i(TAG, "applyMode: OFF -> stopping tor")
|
Log.i(TAG, "applyMode: OFF -> stopping tor")
|
||||||
lifecycleState = LifecycleState.STOPPING
|
lifecycleState = LifecycleState.STOPPING
|
||||||
_status.value = _status.value.copy(mode = TorMode.OFF, running = false, bootstrapPercent = 0, state = TorState.STOPPING)
|
_statusFlow.value = _statusFlow.value.copy(
|
||||||
stopArti() // non-suspending immediate request
|
mode = TorMode.OFF,
|
||||||
// Best-effort wait for STOPPED before we declare OFF
|
running = false,
|
||||||
|
bootstrapPercent = 0,
|
||||||
|
state = TorState.STOPPING
|
||||||
|
)
|
||||||
|
stopArti()
|
||||||
waitForStateTransition(target = TorState.OFF, timeoutMs = STOP_TIMEOUT_MS)
|
waitForStateTransition(target = TorState.OFF, timeoutMs = STOP_TIMEOUT_MS)
|
||||||
socksAddr = null
|
socksAddr = null
|
||||||
_status.value = _status.value.copy(mode = TorMode.OFF, running = false, bootstrapPercent = 0, state = TorState.OFF)
|
_statusFlow.value = _statusFlow.value.copy(
|
||||||
|
mode = TorMode.OFF,
|
||||||
|
running = false,
|
||||||
|
bootstrapPercent = 0,
|
||||||
|
state = TorState.OFF
|
||||||
|
)
|
||||||
currentSocksPort = DEFAULT_SOCKS_PORT
|
currentSocksPort = DEFAULT_SOCKS_PORT
|
||||||
bindRetryAttempts = 0
|
bindRetryAttempts = 0
|
||||||
lifecycleState = LifecycleState.STOPPED
|
lifecycleState = LifecycleState.STOPPED
|
||||||
// Rebuild clients WITHOUT proxy and reconnect relays
|
resetNetworkConnections()
|
||||||
try {
|
|
||||||
OkHttpProvider.reset()
|
|
||||||
com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections()
|
|
||||||
} catch (_: Throwable) { }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
TorMode.ON -> {
|
TorMode.ON -> {
|
||||||
Log.i(TAG, "applyMode: ON -> starting arti")
|
Log.i(TAG, "applyMode: ON -> starting arti")
|
||||||
// Reset port to default unless we're already using a higher port
|
|
||||||
if (currentSocksPort < DEFAULT_SOCKS_PORT) {
|
if (currentSocksPort < DEFAULT_SOCKS_PORT) {
|
||||||
currentSocksPort = DEFAULT_SOCKS_PORT
|
currentSocksPort = DEFAULT_SOCKS_PORT
|
||||||
}
|
}
|
||||||
bindRetryAttempts = 0
|
bindRetryAttempts = 0
|
||||||
lifecycleState = LifecycleState.STARTING
|
lifecycleState = LifecycleState.STARTING
|
||||||
_status.value = _status.value.copy(mode = TorMode.ON, running = false, bootstrapPercent = 0, state = TorState.STARTING)
|
_statusFlow.value = _statusFlow.value.copy(
|
||||||
// Immediately set the planned SOCKS address so all traffic is forced through it,
|
mode = TorMode.ON,
|
||||||
// even before Tor is fully bootstrapped. This prevents any direct connections.
|
running = false,
|
||||||
|
bootstrapPercent = 0,
|
||||||
|
state = TorState.STARTING
|
||||||
|
)
|
||||||
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
|
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
|
||||||
try { OkHttpProvider.reset() } catch (_: Throwable) { }
|
resetNetworkConnections()
|
||||||
try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { }
|
|
||||||
startArti(application, useDelay = false)
|
startArti(application, useDelay = false)
|
||||||
// Defer enabling proxy until bootstrap completes
|
|
||||||
appScope.launch {
|
appScope.launch {
|
||||||
waitUntilBootstrapped()
|
waitUntilBootstrapped()
|
||||||
if (_status.value.running && desiredMode == TorMode.ON) {
|
if (_statusFlow.value.running && desiredMode == TorMode.ON) {
|
||||||
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
|
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
|
||||||
Log.i(TAG, "Tor ON: proxy set to ${socksAddr}")
|
Log.i(TAG, "Tor ON: proxy set to ${socksAddr}")
|
||||||
OkHttpProvider.reset()
|
resetNetworkConnections()
|
||||||
try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,84 +242,95 @@ object TorManager {
|
|||||||
|
|
||||||
private suspend fun startArti(application: Application, useDelay: Boolean = false) {
|
private suspend fun startArti(application: Application, useDelay: Boolean = false) {
|
||||||
try {
|
try {
|
||||||
// Ensure any previous instance is fully stopped before starting a new one
|
|
||||||
stopArtiAndWait()
|
stopArtiAndWait()
|
||||||
|
|
||||||
Log.i(TAG, "Starting Arti on port $currentSocksPort…")
|
Log.i(TAG, "Starting Arti on port $currentSocksPort…")
|
||||||
if (useDelay) {
|
if (useDelay) {
|
||||||
delay(RESTART_DELAY_MS)
|
delay(RESTART_DELAY_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
val logListener = ArtiLogListener { logLine ->
|
val proxy = artiProxy ?: run {
|
||||||
val text = logLine ?: return@ArtiLogListener
|
Log.e(TAG, "ArtiProxy not initialized! This should not happen.")
|
||||||
val s = text.toString()
|
_statusFlow.update { it.copy(state = TorState.ERROR) }
|
||||||
Log.i(TAG, "arti: $s")
|
return
|
||||||
lastLogTime.set(System.currentTimeMillis())
|
|
||||||
_status.value = _status.value.copy(lastLogLine = s)
|
|
||||||
handleArtiLogLine(s)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val proxy = ArtiProxy.Builder(application)
|
|
||||||
.setSocksPort(currentSocksPort)
|
|
||||||
.setDnsPort(currentSocksPort + 1)
|
|
||||||
.setLogListener(logListener)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
artiProxyRef.set(proxy)
|
|
||||||
proxy.start()
|
proxy.start()
|
||||||
lastLogTime.set(System.currentTimeMillis())
|
lastLogTime.set(System.currentTimeMillis())
|
||||||
|
|
||||||
_status.value = _status.value.copy(running = true, bootstrapPercent = 0, state = TorState.STARTING)
|
_statusFlow.update {
|
||||||
|
it.copy(
|
||||||
|
running = true,
|
||||||
|
bootstrapPercent = 0,
|
||||||
|
state = TorState.STARTING
|
||||||
|
)
|
||||||
|
}
|
||||||
lifecycleState = LifecycleState.RUNNING
|
lifecycleState = LifecycleState.RUNNING
|
||||||
startInactivityMonitoring()
|
startInactivityMonitoring()
|
||||||
|
|
||||||
// Removed onion service startup (BLE-only file transfer in this branch)
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error starting Arti on port $currentSocksPort: ${e.message}")
|
Log.e(TAG, "Error starting Arti on port $currentSocksPort: ${e.message}")
|
||||||
_status.value = _status.value.copy(state = TorState.ERROR)
|
_statusFlow.update { it.copy(state = TorState.ERROR) }
|
||||||
|
|
||||||
// Check if this is a bind error
|
|
||||||
val isBindError = isBindError(e)
|
val isBindError = isBindError(e)
|
||||||
if (isBindError && bindRetryAttempts < MAX_RETRY_ATTEMPTS) {
|
if (isBindError && bindRetryAttempts < MAX_RETRY_ATTEMPTS) {
|
||||||
bindRetryAttempts++
|
bindRetryAttempts++
|
||||||
currentSocksPort++
|
currentSocksPort++
|
||||||
Log.w(TAG, "Port bind failed (attempt $bindRetryAttempts/$MAX_RETRY_ATTEMPTS), retrying with port $currentSocksPort")
|
Log.w(
|
||||||
// Update planned SOCKS address immediately so all new connections target the new port
|
TAG,
|
||||||
|
"Port bind failed (attempt $bindRetryAttempts/$MAX_RETRY_ATTEMPTS), retrying with port $currentSocksPort"
|
||||||
|
)
|
||||||
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
|
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
|
||||||
try { OkHttpProvider.reset() } catch (_: Throwable) { }
|
resetNetworkConnections()
|
||||||
try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { }
|
|
||||||
// Immediate retry with incremented port, no exponential backoff for bind errors
|
|
||||||
startArti(application, useDelay = false)
|
startArti(application, useDelay = false)
|
||||||
} else if (isBindError) {
|
} else if (isBindError) {
|
||||||
Log.e(TAG, "Max bind retry attempts reached ($MAX_RETRY_ATTEMPTS), giving up")
|
Log.e(TAG, "Max bind retry attempts reached ($MAX_RETRY_ATTEMPTS), giving up")
|
||||||
lifecycleState = LifecycleState.STOPPED
|
lifecycleState = LifecycleState.STOPPED
|
||||||
_status.value = _status.value.copy(running = false, bootstrapPercent = 0, state = TorState.ERROR)
|
_statusFlow.update {
|
||||||
|
it.copy(
|
||||||
|
running = false,
|
||||||
|
bootstrapPercent = 0,
|
||||||
|
state = TorState.ERROR
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// For non-bind errors, use the existing retry mechanism
|
|
||||||
scheduleRetry(application)
|
scheduleRetry(application)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if the exception indicates a port binding failure
|
|
||||||
*/
|
|
||||||
private fun isBindError(exception: Exception): Boolean {
|
private fun isBindError(exception: Exception): Boolean {
|
||||||
val message = exception.message?.lowercase() ?: ""
|
val message = exception.message?.lowercase() ?: ""
|
||||||
return message.contains("bind") ||
|
return message.contains("bind") ||
|
||||||
message.contains("address already in use") ||
|
message.contains("address already in use") ||
|
||||||
message.contains("port") && message.contains("use") ||
|
message.contains("port") && message.contains("use") ||
|
||||||
message.contains("permission denied") && message.contains("port") ||
|
message.contains("permission denied") && message.contains("port") ||
|
||||||
message.contains("could not bind")
|
message.contains("could not bind")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset network connections after Tor state changes.
|
||||||
|
* Rebuilds OkHttp clients and reconnects Nostr relays.
|
||||||
|
*/
|
||||||
|
private fun resetNetworkConnections() {
|
||||||
|
try {
|
||||||
|
OkHttpProvider.reset()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopArtiInternal() {
|
private fun stopArtiInternal() {
|
||||||
try {
|
try {
|
||||||
val proxy = artiProxyRef.getAndSet(null)
|
val proxy = artiProxy
|
||||||
if (proxy != null) {
|
if (proxy != null) {
|
||||||
Log.i(TAG, "Stopping Arti…")
|
Log.i(TAG, "Stopping Arti…")
|
||||||
try { proxy.stop() } catch (_: Throwable) {}
|
try {
|
||||||
|
proxy.stop()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
stopInactivityMonitoring()
|
stopInactivityMonitoring()
|
||||||
stopRetryMonitoring()
|
stopRetryMonitoring()
|
||||||
@@ -260,15 +342,16 @@ object TorManager {
|
|||||||
private fun stopArti() {
|
private fun stopArti() {
|
||||||
stopArtiInternal()
|
stopArtiInternal()
|
||||||
socksAddr = null
|
socksAddr = null
|
||||||
_status.value = _status.value.copy(running = false, bootstrapPercent = 0, state = TorState.STOPPING)
|
_statusFlow.value = _statusFlow.value.copy(
|
||||||
|
running = false,
|
||||||
|
bootstrapPercent = 0,
|
||||||
|
state = TorState.STOPPING
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun stopArtiAndWait(timeoutMs: Long = STOP_TIMEOUT_MS) {
|
private suspend fun stopArtiAndWait(timeoutMs: Long = STOP_TIMEOUT_MS) {
|
||||||
// Request stop
|
|
||||||
stopArtiInternal()
|
stopArtiInternal()
|
||||||
// Wait for confirmation via logs (Stopped) or timeout
|
|
||||||
waitForStateTransition(target = TorState.OFF, timeoutMs = timeoutMs)
|
waitForStateTransition(target = TorState.OFF, timeoutMs = timeoutMs)
|
||||||
// Small grace period before relaunch to let file locks clear
|
|
||||||
delay(200)
|
delay(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,7 +359,7 @@ object TorManager {
|
|||||||
Log.i(TAG, "Restarting Arti (keeping SOCKS proxy enabled)...")
|
Log.i(TAG, "Restarting Arti (keeping SOCKS proxy enabled)...")
|
||||||
stopArtiAndWait()
|
stopArtiAndWait()
|
||||||
delay(RESTART_DELAY_MS)
|
delay(RESTART_DELAY_MS)
|
||||||
startArti(application, useDelay = false) // Already delayed above
|
startArti(application, useDelay = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startInactivityMonitoring() {
|
private fun startInactivityMonitoring() {
|
||||||
@@ -287,13 +370,16 @@ object TorManager {
|
|||||||
val currentTime = System.currentTimeMillis()
|
val currentTime = System.currentTimeMillis()
|
||||||
val lastActivity = lastLogTime.get()
|
val lastActivity = lastLogTime.get()
|
||||||
val timeSinceLastActivity = currentTime - lastActivity
|
val timeSinceLastActivity = currentTime - lastActivity
|
||||||
|
|
||||||
if (timeSinceLastActivity > INACTIVITY_TIMEOUT_MS) {
|
if (timeSinceLastActivity > INACTIVITY_TIMEOUT_MS) {
|
||||||
val currentMode = _status.value.mode
|
val currentMode = _statusFlow.value.mode
|
||||||
if (currentMode == TorMode.ON) {
|
if (currentMode == TorMode.ON) {
|
||||||
val bootstrapPercent = _status.value.bootstrapPercent
|
val bootstrapPercent = _statusFlow.value.bootstrapPercent
|
||||||
if (bootstrapPercent < 100) {
|
if (bootstrapPercent < 100) {
|
||||||
Log.w(TAG, "Inactivity detected (${timeSinceLastActivity}ms), restarting Arti")
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"Inactivity detected (${timeSinceLastActivity}ms), restarting Arti"
|
||||||
|
)
|
||||||
currentApplication?.let { app ->
|
currentApplication?.let { app ->
|
||||||
appScope.launch {
|
appScope.launch {
|
||||||
restartArti(app)
|
restartArti(app)
|
||||||
@@ -316,11 +402,11 @@ object TorManager {
|
|||||||
retryJob?.cancel()
|
retryJob?.cancel()
|
||||||
if (retryAttempts < MAX_RETRY_ATTEMPTS) {
|
if (retryAttempts < MAX_RETRY_ATTEMPTS) {
|
||||||
retryAttempts++
|
retryAttempts++
|
||||||
val delayMs = (1000L * (1 shl retryAttempts)).coerceAtMost(30000L) // Exponential backoff, max 30s
|
val delayMs = (1000L * (1 shl retryAttempts)).coerceAtMost(30000L)
|
||||||
Log.w(TAG, "Scheduling Arti retry attempt $retryAttempts in ${delayMs}ms")
|
Log.w(TAG, "Scheduling Arti retry attempt $retryAttempts in ${delayMs}ms")
|
||||||
retryJob = appScope.launch {
|
retryJob = appScope.launch {
|
||||||
delay(delayMs)
|
delay(delayMs)
|
||||||
val currentMode = _status.value.mode
|
val currentMode = _statusFlow.value.mode
|
||||||
if (currentMode == TorMode.ON) {
|
if (currentMode == TorMode.ON) {
|
||||||
Log.i(TAG, "Retrying Arti start (attempt $retryAttempts)")
|
Log.i(TAG, "Retrying Arti start (attempt $retryAttempts)")
|
||||||
restartArti(application)
|
restartArti(application)
|
||||||
@@ -337,50 +423,110 @@ object TorManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun waitUntilBootstrapped() {
|
private suspend fun waitUntilBootstrapped() {
|
||||||
val current = _status.value
|
val current = _statusFlow.value
|
||||||
if (!current.running) return
|
if (!current.running) return
|
||||||
if (current.bootstrapPercent >= 100 && current.state == TorState.RUNNING) return
|
if (current.bootstrapPercent >= 100 && current.state == TorState.RUNNING) return
|
||||||
// Suspend until we observe RUNNING at least once
|
|
||||||
while (true) {
|
while (true) {
|
||||||
val s = statusFlow.first { (it.bootstrapPercent >= 100 && it.state == TorState.RUNNING) || !it.running || it.state == TorState.ERROR }
|
val s = statusFlow.first {
|
||||||
|
(it.bootstrapPercent >= 100 && it.state == TorState.RUNNING) ||
|
||||||
|
!it.running ||
|
||||||
|
it.state == TorState.ERROR
|
||||||
|
}
|
||||||
if (!s.running || s.state == TorState.ERROR) return
|
if (!s.running || s.state == TorState.ERROR) return
|
||||||
if (s.bootstrapPercent >= 100 && s.state == TorState.RUNNING) return
|
if (s.bootstrapPercent >= 100 && s.state == TorState.RUNNING) return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleArtiLogLine(s: String) {
|
private fun handleArtiLogLine(s: String) {
|
||||||
|
val currentState = _statusFlow.value.state
|
||||||
|
val currentLifecycle = lifecycleState
|
||||||
|
|
||||||
when {
|
when {
|
||||||
s.contains("AMEx: state changed to Initialized", ignoreCase = true) -> {
|
s.contains("AMEx: state changed to Initialized", ignoreCase = true) -> {
|
||||||
_status.value = _status.value.copy(state = TorState.STARTING)
|
if (currentLifecycle != LifecycleState.STARTING && currentLifecycle != LifecycleState.RUNNING) {
|
||||||
|
Log.w(TAG, "Ignoring stale 'Initialized' log (lifecycle: $currentLifecycle)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_statusFlow.update { it.copy(state = TorState.STARTING) }
|
||||||
completeWaitersIf(TorState.STARTING)
|
completeWaitersIf(TorState.STARTING)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.contains("AMEx: state changed to Starting", ignoreCase = true) -> {
|
s.contains("AMEx: state changed to Starting", ignoreCase = true) -> {
|
||||||
_status.value = _status.value.copy(state = TorState.STARTING)
|
if (currentLifecycle != LifecycleState.STARTING && currentLifecycle != LifecycleState.RUNNING) {
|
||||||
|
Log.w(TAG, "Ignoring stale 'Starting' log (lifecycle: $currentLifecycle)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_statusFlow.update { it.copy(state = TorState.STARTING) }
|
||||||
completeWaitersIf(TorState.STARTING)
|
completeWaitersIf(TorState.STARTING)
|
||||||
}
|
}
|
||||||
s.contains("Sufficiently bootstrapped; system SOCKS now functional", ignoreCase = true) -> {
|
|
||||||
_status.value = _status.value.copy(bootstrapPercent = 75, state = TorState.BOOTSTRAPPING)
|
s.contains(
|
||||||
|
"Sufficiently bootstrapped; system SOCKS now functional",
|
||||||
|
ignoreCase = true
|
||||||
|
) -> {
|
||||||
|
if (currentLifecycle != LifecycleState.RUNNING) {
|
||||||
|
Log.w(TAG, "Ignoring bootstrap log (lifecycle: $currentLifecycle)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_statusFlow.update {
|
||||||
|
it.copy(
|
||||||
|
bootstrapPercent = 75,
|
||||||
|
state = TorState.BOOTSTRAPPING
|
||||||
|
)
|
||||||
|
}
|
||||||
retryAttempts = 0
|
retryAttempts = 0
|
||||||
bindRetryAttempts = 0
|
bindRetryAttempts = 0
|
||||||
startInactivityMonitoring()
|
startInactivityMonitoring()
|
||||||
}
|
}
|
||||||
//s.contains("AMEx: state changed to Running", ignoreCase = true) -> {
|
|
||||||
s.contains("We have found that guard [scrubbed] is usable.", ignoreCase = true) -> {
|
s.contains("We have found that guard [scrubbed] is usable.", ignoreCase = true) -> {
|
||||||
// If we already saw Sufficiently bootstrapped, mark as RUNNING and ready.
|
if (currentLifecycle != LifecycleState.RUNNING) {
|
||||||
val bp = if (_status.value.bootstrapPercent >= 100) 100 else 100 // treat Running as ready
|
Log.w(TAG, "Ignoring guard discovery log (lifecycle: $currentLifecycle)")
|
||||||
_status.value = _status.value.copy(state = TorState.RUNNING, bootstrapPercent = bp, running = true)
|
return
|
||||||
|
}
|
||||||
|
_statusFlow.update {
|
||||||
|
it.copy(
|
||||||
|
state = TorState.RUNNING,
|
||||||
|
bootstrapPercent = 100,
|
||||||
|
running = true
|
||||||
|
)
|
||||||
|
}
|
||||||
completeWaitersIf(TorState.RUNNING)
|
completeWaitersIf(TorState.RUNNING)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.contains("AMEx: state changed to Stopping", ignoreCase = true) -> {
|
s.contains("AMEx: state changed to Stopping", ignoreCase = true) -> {
|
||||||
_status.value = _status.value.copy(state = TorState.STOPPING, running = false)
|
if (currentLifecycle != LifecycleState.STOPPING) {
|
||||||
|
Log.w(TAG, "Ignoring stale 'Stopping' log (lifecycle: $currentLifecycle)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_statusFlow.update {
|
||||||
|
it.copy(
|
||||||
|
state = TorState.STOPPING,
|
||||||
|
running = false
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
s.contains("AMEx: state changed to Stopped", ignoreCase = true) -> {
|
s.contains("AMEx: state changed to Stopped", ignoreCase = true) -> {
|
||||||
_status.value = _status.value.copy(state = TorState.OFF, running = false, bootstrapPercent = 0)
|
if (currentLifecycle != LifecycleState.STOPPING && currentLifecycle != LifecycleState.STOPPED) {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"Ignoring stale 'Stopped' log (lifecycle: $currentLifecycle, preventing state corruption)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_statusFlow.update {
|
||||||
|
it.copy(
|
||||||
|
state = TorState.OFF,
|
||||||
|
running = false,
|
||||||
|
bootstrapPercent = 0
|
||||||
|
)
|
||||||
|
}
|
||||||
completeWaitersIf(TorState.OFF)
|
completeWaitersIf(TorState.OFF)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.contains("Another process has the lock on our state files", ignoreCase = true) -> {
|
s.contains("Another process has the lock on our state files", ignoreCase = true) -> {
|
||||||
// Signal error; we'll likely need to wait longer before restart
|
_statusFlow.update { it.copy(state = TorState.ERROR) }
|
||||||
_status.value = _status.value.copy(state = TorState.ERROR)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -395,13 +541,11 @@ object TorManager {
|
|||||||
val def = CompletableDeferred<TorState>()
|
val def = CompletableDeferred<TorState>()
|
||||||
stateChangeDeferred.getAndSet(def)?.cancel()
|
stateChangeDeferred.getAndSet(def)?.cancel()
|
||||||
return withTimeoutOrNull(timeoutMs) {
|
return withTimeoutOrNull(timeoutMs) {
|
||||||
// Fast-path: if we're already there
|
val cur = _statusFlow.value.state
|
||||||
val cur = _status.value.state
|
|
||||||
if (cur == target) return@withTimeoutOrNull cur
|
if (cur == target) return@withTimeoutOrNull cur
|
||||||
def.await()
|
def.await()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Visible for instrumentation tests to validate installation
|
fun isTorAvailable(): Boolean = true
|
||||||
fun installResourcesForTest(application: Application): Boolean { return true }
|
|
||||||
}
|
}
|
||||||
@@ -42,8 +42,9 @@ object OkHttpProvider {
|
|||||||
|
|
||||||
private fun baseBuilderForCurrentProxy(): OkHttpClient.Builder {
|
private fun baseBuilderForCurrentProxy(): OkHttpClient.Builder {
|
||||||
val builder = OkHttpClient.Builder()
|
val builder = OkHttpClient.Builder()
|
||||||
val socks: InetSocketAddress? = TorManager.currentSocksAddress()
|
val torProvider = ArtiTorManager.getInstance()
|
||||||
// If a SOCKS address is defined, always use it. TorManager sets this as soon as Tor mode is ON,
|
val socks: InetSocketAddress? = torProvider.currentSocksAddress()
|
||||||
|
// If a SOCKS address is defined, always use it. TorProvider sets this as soon as Tor mode is ON,
|
||||||
// even before bootstrap, to prevent any direct connections from occurring.
|
// even before bootstrap, to prevent any direct connections from occurring.
|
||||||
if (socks != null) {
|
if (socks != null) {
|
||||||
val proxy = Proxy(Proxy.Type.SOCKS, socks)
|
val proxy = Proxy(Proxy.Type.SOCKS, socks)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package com.bitchat.android.nostr
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import com.bitchat.android.ui.ChatState
|
import com.bitchat.android.ui.ChatState
|
||||||
import com.bitchat.android.ui.GeoPerson
|
import com.bitchat.android.ui.GeoPerson
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
@@ -112,7 +111,7 @@ class GeohashRepository(
|
|||||||
val geohash = currentGeohash
|
val geohash = currentGeohash
|
||||||
if (geohash == null) {
|
if (geohash == null) {
|
||||||
// Use postValue for thread safety - this can be called from background threads
|
// Use postValue for thread safety - this can be called from background threads
|
||||||
state.postGeohashPeople(emptyList())
|
state.setGeohashPeople(emptyList())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
|
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
|
||||||
@@ -143,7 +142,7 @@ class GeohashRepository(
|
|||||||
)
|
)
|
||||||
}.sortedByDescending { it.lastSeen }
|
}.sortedByDescending { it.lastSeen }
|
||||||
// Use postValue for thread safety - this can be called from background threads
|
// Use postValue for thread safety - this can be called from background threads
|
||||||
state.postGeohashPeople(people)
|
state.setGeohashPeople(people)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateReactiveParticipantCounts() {
|
fun updateReactiveParticipantCounts() {
|
||||||
@@ -155,7 +154,7 @@ class GeohashRepository(
|
|||||||
counts[gh] = active
|
counts[gh] = active
|
||||||
}
|
}
|
||||||
// Use postValue for thread safety - this can be called from background threads
|
// Use postValue for thread safety - this can be called from background threads
|
||||||
state.postGeohashParticipantCounts(counts)
|
state.setGeohashParticipantCounts(counts)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun putNostrKeyMapping(tempKeyOrPeer: String, pubkeyHex: String) {
|
fun putNostrKeyMapping(tempKeyOrPeer: String, pubkeyHex: String) {
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ package com.bitchat.android.nostr
|
|||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.MainThread
|
import androidx.annotation.MainThread
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages location notes (kind=1 text notes with geohash tags)
|
* Manages location notes (kind=1 text notes with geohash tags)
|
||||||
* iOS-compatible implementation with LiveData for Android UI binding
|
* iOS-compatible implementation with StateFlow for Android UI binding
|
||||||
*/
|
*/
|
||||||
@MainThread
|
@MainThread
|
||||||
class LocationNotesManager private constructor() {
|
class LocationNotesManager private constructor() {
|
||||||
@@ -63,21 +64,21 @@ class LocationNotesManager private constructor() {
|
|||||||
NO_RELAYS
|
NO_RELAYS
|
||||||
}
|
}
|
||||||
|
|
||||||
// Published state (LiveData for Android)
|
// Published state (StateFlow for Android)
|
||||||
private val _notes = MutableLiveData<List<Note>>(emptyList())
|
private val _notes = MutableStateFlow<List<Note>>(emptyList())
|
||||||
val notes: LiveData<List<Note>> = _notes
|
val notes: StateFlow<List<Note>> = _notes.asStateFlow()
|
||||||
|
|
||||||
private val _geohash = MutableLiveData<String?>(null)
|
private val _geohash = MutableStateFlow<String?>(null)
|
||||||
val geohash: LiveData<String?> = _geohash
|
val geohash: StateFlow<String?> = _geohash.asStateFlow()
|
||||||
|
|
||||||
private val _initialLoadComplete = MutableLiveData(false)
|
private val _initialLoadComplete = MutableStateFlow(false)
|
||||||
val initialLoadComplete: LiveData<Boolean> = _initialLoadComplete
|
val initialLoadComplete: StateFlow<Boolean> = _initialLoadComplete.asStateFlow()
|
||||||
|
|
||||||
private val _state = MutableLiveData(State.IDLE)
|
private val _state = MutableStateFlow(State.IDLE)
|
||||||
val state: LiveData<State> = _state
|
val state: StateFlow<State> = _state.asStateFlow()
|
||||||
|
|
||||||
private val _errorMessage = MutableLiveData<String?>(null)
|
private val _errorMessage = MutableStateFlow<String?>(null)
|
||||||
val errorMessage: LiveData<String?> = _errorMessage
|
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
|
||||||
|
|
||||||
// Private state
|
// Private state
|
||||||
private var subscriptionIDs: MutableMap<String, String> = mutableMapOf()
|
private var subscriptionIDs: MutableMap<String, String> = mutableMapOf()
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ package com.bitchat.android.nostr
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* High-level Nostr client that manages identity, connections, and messaging
|
* High-level Nostr client that manages identity, connections, and messaging
|
||||||
@@ -30,11 +31,11 @@ class NostrClient private constructor(private val context: Context) {
|
|||||||
private var currentIdentity: NostrIdentity? = null
|
private var currentIdentity: NostrIdentity? = null
|
||||||
|
|
||||||
// Client state
|
// Client state
|
||||||
private val _isInitialized = MutableLiveData<Boolean>()
|
private val _isInitialized = MutableStateFlow(false)
|
||||||
val isInitialized: LiveData<Boolean> = _isInitialized
|
val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
|
||||||
|
|
||||||
private val _currentNpub = MutableLiveData<String>()
|
private val _currentNpub = MutableStateFlow<String?>(null)
|
||||||
val currentNpub: LiveData<String> = _currentNpub
|
val currentNpub: StateFlow<String?> = _currentNpub.asStateFlow()
|
||||||
|
|
||||||
// Message processing
|
// Message processing
|
||||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||||
@@ -53,21 +54,21 @@ class NostrClient private constructor(private val context: Context) {
|
|||||||
currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context)
|
currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context)
|
||||||
|
|
||||||
if (currentIdentity != null) {
|
if (currentIdentity != null) {
|
||||||
_currentNpub.postValue(currentIdentity!!.npub)
|
_currentNpub.value = currentIdentity!!.npub
|
||||||
Log.i(TAG, "✅ Nostr identity loaded: ${currentIdentity!!.getShortNpub()}")
|
Log.i(TAG, "✅ Nostr identity loaded: ${currentIdentity!!.getShortNpub()}")
|
||||||
|
|
||||||
// Connect to relays
|
// Connect to relays
|
||||||
relayManager.connect()
|
relayManager.connect()
|
||||||
|
|
||||||
_isInitialized.postValue(true)
|
_isInitialized.value = true
|
||||||
Log.i(TAG, "✅ Nostr client initialized successfully")
|
Log.i(TAG, "✅ Nostr client initialized successfully")
|
||||||
} else {
|
} else {
|
||||||
Log.e(TAG, "❌ Failed to load/create Nostr identity")
|
Log.e(TAG, "❌ Failed to load/create Nostr identity")
|
||||||
_isInitialized.postValue(false)
|
_isInitialized.value = false
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "❌ Failed to initialize Nostr client: ${e.message}")
|
Log.e(TAG, "❌ Failed to initialize Nostr client: ${e.message}")
|
||||||
_isInitialized.postValue(false)
|
_isInitialized.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,7 +79,7 @@ class NostrClient private constructor(private val context: Context) {
|
|||||||
fun shutdown() {
|
fun shutdown() {
|
||||||
Log.d(TAG, "Shutting down Nostr client")
|
Log.d(TAG, "Shutting down Nostr client")
|
||||||
relayManager.disconnect()
|
relayManager.disconnect()
|
||||||
_isInitialized.postValue(false)
|
_isInitialized.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -227,12 +228,12 @@ class NostrClient private constructor(private val context: Context) {
|
|||||||
/**
|
/**
|
||||||
* Get relay connection status
|
* Get relay connection status
|
||||||
*/
|
*/
|
||||||
val relayConnectionStatus: LiveData<Boolean> = relayManager.isConnected
|
val relayConnectionStatus: StateFlow<Boolean> = relayManager.isConnected
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get relay information
|
* Get relay information
|
||||||
*/
|
*/
|
||||||
val relayInfo: LiveData<List<NostrRelayManager.Relay>> = relayManager.relays
|
val relayInfo: StateFlow<List<NostrRelayManager.Relay>> = relayManager.relays
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package com.bitchat.android.nostr
|
package com.bitchat.android.nostr
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import com.google.gson.JsonArray
|
import com.google.gson.JsonArray
|
||||||
import com.google.gson.JsonParser
|
import com.google.gson.JsonParser
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
@@ -72,11 +73,11 @@ class NostrRelayManager private constructor() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Published state
|
// Published state
|
||||||
private val _relays = MutableLiveData<List<Relay>>()
|
private val _relays = MutableStateFlow<List<Relay>>(emptyList())
|
||||||
val relays: LiveData<List<Relay>> = _relays
|
val relays: StateFlow<List<Relay>> = _relays.asStateFlow()
|
||||||
|
|
||||||
private val _isConnected = MutableLiveData<Boolean>()
|
private val _isConnected = MutableStateFlow<Boolean>(false)
|
||||||
val isConnected: LiveData<Boolean> = _isConnected
|
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||||
|
|
||||||
// Internal state
|
// Internal state
|
||||||
private val relaysList = mutableListOf<Relay>()
|
private val relaysList = mutableListOf<Relay>()
|
||||||
@@ -226,14 +227,14 @@ class NostrRelayManager private constructor() {
|
|||||||
"wss://nostr21.com"
|
"wss://nostr21.com"
|
||||||
)
|
)
|
||||||
relaysList.addAll(defaultRelayUrls.map { Relay(it) })
|
relaysList.addAll(defaultRelayUrls.map { Relay(it) })
|
||||||
_relays.postValue(relaysList.toList())
|
_relays.value = relaysList.toList()
|
||||||
updateConnectionStatus()
|
updateConnectionStatus()
|
||||||
Log.d(TAG, "✅ NostrRelayManager initialized with ${relaysList.size} default relays")
|
Log.d(TAG, "✅ NostrRelayManager initialized with ${relaysList.size} default relays")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to initialize NostrRelayManager: ${e.message}", e)
|
Log.e(TAG, "Failed to initialize NostrRelayManager: ${e.message}", e)
|
||||||
// Initialize with empty list as fallback
|
// Initialize with empty list as fallback
|
||||||
_relays.postValue(emptyList())
|
_relays.value = emptyList()
|
||||||
_isConnected.postValue(false)
|
_isConnected.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,12 +798,12 @@ class NostrRelayManager private constructor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun updateRelaysList() {
|
private fun updateRelaysList() {
|
||||||
_relays.postValue(relaysList.toList())
|
_relays.value = relaysList.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateConnectionStatus() {
|
private fun updateConnectionStatus() {
|
||||||
val connected = relaysList.any { it.isConnected }
|
val connected = relaysList.any { it.isConnected }
|
||||||
_isConnected.postValue(connected)
|
_isConnected.value = connected
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun generateSubscriptionId(): String {
|
private fun generateSubscriptionId(): String {
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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.async
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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())
|
||||||
|
|
||||||
|
fun requestFullShutdownAndKill(
|
||||||
|
app: Application,
|
||||||
|
mesh: BluetoothMeshService?,
|
||||||
|
notificationManager: NotificationManagerCompat,
|
||||||
|
stopForeground: () -> Unit,
|
||||||
|
stopService: () -> Unit
|
||||||
|
) {
|
||||||
|
scope.launch {
|
||||||
|
// 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
|
||||||
|
try { stopService() } catch (_: Exception) { }
|
||||||
|
|
||||||
|
// Hard kill the app process
|
||||||
|
try { Process.killProcess(Process.myPid()) } catch (_: Exception) { }
|
||||||
|
try { System.exit(0) } catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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,324 @@
|
|||||||
|
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.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
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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 -> {
|
||||||
|
// 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)
|
||||||
|
startForeground(NOTIFICATION_ID, 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)
|
||||||
|
startForeground(NOTIFICATION_ID, 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 (!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) {
|
||||||
|
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(activeUsers: 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, activeUsers)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -12,11 +12,15 @@ import androidx.compose.material.icons.filled.Bluetooth
|
|||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material.icons.filled.Public
|
import androidx.compose.material.icons.filled.Public
|
||||||
import androidx.compose.material.icons.filled.Security
|
|
||||||
import androidx.compose.material.icons.filled.Warning
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
import androidx.compose.material.icons.filled.Security
|
||||||
|
import androidx.compose.material.icons.filled.NetworkCheck
|
||||||
|
import androidx.compose.material.icons.filled.Speed
|
||||||
|
import androidx.compose.material.icons.outlined.Info
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
@@ -29,12 +33,171 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.bitchat.android.nostr.NostrProofOfWork
|
import com.bitchat.android.nostr.NostrProofOfWork
|
||||||
import com.bitchat.android.nostr.PoWPreferenceManager
|
import com.bitchat.android.nostr.PoWPreferenceManager
|
||||||
import com.bitchat.android.ui.debug.DebugSettingsSheet
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.bitchat.android.R
|
import com.bitchat.android.R
|
||||||
|
import com.bitchat.android.core.ui.component.button.CloseButton
|
||||||
|
import com.bitchat.android.net.TorMode
|
||||||
|
import com.bitchat.android.net.TorPreferenceManager
|
||||||
|
import com.bitchat.android.net.ArtiTorManager
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* About Sheet for bitchat app information
|
* Feature row for displaying app capabilities
|
||||||
* Matches the design language of LocationChannelsSheet
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun FeatureRow(
|
||||||
|
icon: ImageVector,
|
||||||
|
title: String,
|
||||||
|
subtitle: String
|
||||||
|
) {
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
verticalAlignment = Alignment.Top
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = colorScheme.primary,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = 2.dp)
|
||||||
|
.size(22.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(14.dp))
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = colorScheme.onSurface.copy(alpha = 0.6f),
|
||||||
|
lineHeight = 18.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Theme selection chip with Apple-like styling
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ThemeChip(
|
||||||
|
label: String,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = modifier,
|
||||||
|
onClick = onClick,
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
color = if (selected) {
|
||||||
|
if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
|
||||||
|
} else {
|
||||||
|
colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 10.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
|
||||||
|
color = if (selected) Color.White else colorScheme.onSurface.copy(alpha = 0.8f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified settings toggle row with icon, title, subtitle, and switch
|
||||||
|
* Apple-like design with proper spacing
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SettingsToggleRow(
|
||||||
|
icon: ImageVector,
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
checked: Boolean,
|
||||||
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
statusIndicator: (@Composable () -> Unit)? = null
|
||||||
|
) {
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (enabled) colorScheme.primary else colorScheme.onSurface.copy(alpha = 0.3f),
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(14.dp))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = if (enabled) colorScheme.onSurface else colorScheme.onSurface.copy(alpha = 0.4f)
|
||||||
|
)
|
||||||
|
statusIndicator?.invoke()
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = colorScheme.onSurface.copy(alpha = if (enabled) 0.6f else 0.3f),
|
||||||
|
lineHeight = 16.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
|
||||||
|
Switch(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = { if (enabled) onCheckedChange(it) },
|
||||||
|
enabled = enabled,
|
||||||
|
colors = SwitchDefaults.colors(
|
||||||
|
checkedThumbColor = Color.White,
|
||||||
|
checkedTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D),
|
||||||
|
uncheckedThumbColor = Color.White,
|
||||||
|
uncheckedTrackColor = colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apple-like About/Settings Sheet with high-quality design
|
||||||
|
* Professional UX optimized for checkout scenarios
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -54,8 +217,6 @@ fun AboutSheet(
|
|||||||
"1.0.0" // fallback version
|
"1.0.0" // fallback version
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bottom sheet state
|
|
||||||
|
|
||||||
val sheetState = rememberModalBottomSheetState(
|
val sheetState = rememberModalBottomSheetState(
|
||||||
skipPartiallyExpanded = true
|
skipPartiallyExpanded = true
|
||||||
@@ -68,11 +229,10 @@ fun AboutSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val topBarAlpha by animateFloatAsState(
|
val topBarAlpha by animateFloatAsState(
|
||||||
targetValue = if (isScrolled) 0.95f else 0f,
|
targetValue = if (isScrolled) 0.98f else 0f,
|
||||||
label = "topBarAlpha"
|
label = "topBarAlpha"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Color scheme matching LocationChannelsSheet
|
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||||
|
|
||||||
@@ -81,402 +241,368 @@ fun AboutSheet(
|
|||||||
modifier = modifier.statusBarsPadding(),
|
modifier = modifier.statusBarsPadding(),
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
containerColor = colorScheme.background,
|
||||||
dragHandle = null
|
dragHandle = null
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = lazyListState,
|
state = lazyListState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(top = 80.dp, bottom = 20.dp)
|
contentPadding = PaddingValues(top = 80.dp, bottom = 32.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||||
) {
|
) {
|
||||||
// Header Section
|
// Header Section - App Identity
|
||||||
item(key = "header") {
|
item(key = "header") {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 24.dp)
|
.padding(horizontal = 20.dp),
|
||||||
.padding(bottom = 16.dp),
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
Text(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
text = stringResource(R.string.app_name),
|
||||||
verticalAlignment = Alignment.Bottom
|
style = TextStyle(
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.app_name),
|
|
||||||
style = TextStyle(
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontSize = 32.sp
|
|
||||||
),
|
|
||||||
color = MaterialTheme.colorScheme.onBackground
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.version_prefix, versionName?:""),
|
|
||||||
fontSize = 11.sp,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
color = colorScheme.onBackground.copy(alpha = 0.5f),
|
fontWeight = FontWeight.Bold,
|
||||||
style = MaterialTheme.typography.bodySmall.copy(
|
fontSize = 28.sp,
|
||||||
baselineShift = BaselineShift(0.1f)
|
letterSpacing = 1.sp
|
||||||
)
|
),
|
||||||
)
|
color = colorScheme.onBackground
|
||||||
}
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.version_prefix, versionName ?: ""),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = colorScheme.onBackground.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.about_tagline),
|
text = stringResource(R.string.about_tagline),
|
||||||
fontSize = 12.sp,
|
fontSize = 13.sp,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f)
|
color = colorScheme.onBackground.copy(alpha = 0.6f),
|
||||||
|
modifier = Modifier.padding(top = 4.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Features section
|
// Features Section - Grouped Card
|
||||||
item(key = "feature_offline") {
|
item(key = "features") {
|
||||||
Row(
|
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
||||||
verticalAlignment = Alignment.Top,
|
Text(
|
||||||
modifier = Modifier
|
text = stringResource(R.string.about_appearance).uppercase(),
|
||||||
.padding(horizontal = 24.dp)
|
style = MaterialTheme.typography.labelSmall,
|
||||||
.padding(vertical = 8.dp)
|
color = colorScheme.onBackground.copy(alpha = 0.5f),
|
||||||
) {
|
letterSpacing = 0.5.sp,
|
||||||
Icon(
|
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp)
|
||||||
imageVector = Icons.Filled.Bluetooth,
|
|
||||||
contentDescription = stringResource(R.string.cd_offline_mesh_chat),
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(top = 2.dp)
|
|
||||||
.size(20.dp)
|
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
Surface(
|
||||||
Column {
|
modifier = Modifier.fillMaxWidth(),
|
||||||
Text(
|
color = colorScheme.surface,
|
||||||
text = stringResource(R.string.about_offline_mesh_title),
|
shape = RoundedCornerShape(16.dp)
|
||||||
style = MaterialTheme.typography.titleMedium,
|
) {
|
||||||
fontWeight = FontWeight.Medium,
|
Column {
|
||||||
color = MaterialTheme.colorScheme.onBackground
|
FeatureRow(
|
||||||
)
|
icon = Icons.Filled.Bluetooth,
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
title = stringResource(R.string.about_offline_mesh_title),
|
||||||
Text(
|
subtitle = stringResource(R.string.about_offline_mesh_desc)
|
||||||
text = stringResource(R.string.about_offline_mesh_desc),
|
)
|
||||||
style = MaterialTheme.typography.bodySmall,
|
HorizontalDivider(
|
||||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
|
modifier = Modifier.padding(start = 56.dp),
|
||||||
)
|
color = colorScheme.outline.copy(alpha = 0.12f)
|
||||||
}
|
)
|
||||||
}
|
FeatureRow(
|
||||||
}
|
icon = Icons.Default.Public,
|
||||||
item(key = "feature_geohash") {
|
title = stringResource(R.string.about_online_geohash_title),
|
||||||
Row(
|
subtitle = stringResource(R.string.about_online_geohash_desc)
|
||||||
verticalAlignment = Alignment.Top,
|
)
|
||||||
modifier = Modifier
|
HorizontalDivider(
|
||||||
.padding(horizontal = 24.dp)
|
modifier = Modifier.padding(start = 56.dp),
|
||||||
.padding(vertical = 8.dp)
|
color = colorScheme.outline.copy(alpha = 0.12f)
|
||||||
) {
|
)
|
||||||
Icon(
|
FeatureRow(
|
||||||
imageVector = Icons.Default.Public,
|
icon = Icons.Default.Lock,
|
||||||
contentDescription = stringResource(R.string.cd_online_geohash_channels),
|
title = stringResource(R.string.about_e2e_title),
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
subtitle = stringResource(R.string.about_e2e_desc)
|
||||||
modifier = Modifier
|
)
|
||||||
.padding(top = 2.dp)
|
}
|
||||||
.size(20.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.about_online_geohash_title),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = MaterialTheme.colorScheme.onBackground
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.about_online_geohash_desc),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item(key = "feature_encryption") {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.Top,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 24.dp)
|
|
||||||
.padding(vertical = 8.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Lock,
|
|
||||||
contentDescription = stringResource(R.string.cd_end_to_end_encryption),
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(top = 2.dp)
|
|
||||||
.size(20.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.about_e2e_title),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = MaterialTheme.colorScheme.onBackground
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.about_e2e_desc),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Appearance Section
|
// Appearance Section
|
||||||
item(key = "appearance_section") {
|
item(key = "appearance") {
|
||||||
Text(
|
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
||||||
text = stringResource(R.string.about_appearance),
|
Text(
|
||||||
style = MaterialTheme.typography.labelLarge,
|
text = "THEME",
|
||||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
|
style = MaterialTheme.typography.labelSmall,
|
||||||
modifier = Modifier
|
color = colorScheme.onBackground.copy(alpha = 0.5f),
|
||||||
.padding(horizontal = 24.dp)
|
letterSpacing = 0.5.sp,
|
||||||
.padding(top = 24.dp, bottom = 8.dp)
|
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp)
|
||||||
)
|
|
||||||
val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState()
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(horizontal = 24.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
FilterChip(
|
|
||||||
selected = themePref.isSystem,
|
|
||||||
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) },
|
|
||||||
label = { Text(stringResource(R.string.about_system), fontFamily = FontFamily.Monospace) }
|
|
||||||
)
|
|
||||||
FilterChip(
|
|
||||||
selected = themePref.isLight,
|
|
||||||
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Light) },
|
|
||||||
label = { Text(stringResource(R.string.about_light), fontFamily = FontFamily.Monospace) }
|
|
||||||
)
|
|
||||||
FilterChip(
|
|
||||||
selected = themePref.isDark,
|
|
||||||
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Dark) },
|
|
||||||
label = { Text(stringResource(R.string.about_dark), fontFamily = FontFamily.Monospace) }
|
|
||||||
)
|
)
|
||||||
|
val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState()
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = colorScheme.surface,
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(12.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
ThemeChip(
|
||||||
|
label = stringResource(R.string.about_system),
|
||||||
|
selected = themePref.isSystem,
|
||||||
|
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
ThemeChip(
|
||||||
|
label = stringResource(R.string.about_light),
|
||||||
|
selected = themePref.isLight,
|
||||||
|
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Light) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
ThemeChip(
|
||||||
|
label = stringResource(R.string.about_dark),
|
||||||
|
selected = themePref.isDark,
|
||||||
|
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Dark) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Proof of Work Section
|
|
||||||
item(key = "pow_section") {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.about_pow),
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 24.dp)
|
|
||||||
.padding(top = 24.dp, bottom = 8.dp)
|
|
||||||
)
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
PoWPreferenceManager.init(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Settings Section - Unified Card with Toggles
|
||||||
|
item(key = "settings") {
|
||||||
|
LaunchedEffect(Unit) { PoWPreferenceManager.init(context) }
|
||||||
val powEnabled by PoWPreferenceManager.powEnabled.collectAsState()
|
val powEnabled by PoWPreferenceManager.powEnabled.collectAsState()
|
||||||
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState()
|
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState()
|
||||||
|
var backgroundEnabled by remember { mutableStateOf(com.bitchat.android.service.MeshServicePreferences.isBackgroundEnabled(true)) }
|
||||||
|
val torMode = remember { mutableStateOf(TorPreferenceManager.get(context)) }
|
||||||
|
val torProvider = remember { ArtiTorManager.getInstance() }
|
||||||
|
val torStatus by torProvider.statusFlow.collectAsState()
|
||||||
|
val torAvailable = remember { torProvider.isTorAvailable() }
|
||||||
|
|
||||||
Column(
|
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
||||||
modifier = Modifier.padding(horizontal = 24.dp),
|
Text(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
text = "SETTINGS",
|
||||||
) {
|
style = MaterialTheme.typography.labelSmall,
|
||||||
Row(
|
color = colorScheme.onBackground.copy(alpha = 0.5f),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
letterSpacing = 0.5.sp,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp)
|
||||||
|
)
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = colorScheme.surface,
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
) {
|
) {
|
||||||
FilterChip(
|
Column {
|
||||||
selected = !powEnabled,
|
// Background Mode Toggle
|
||||||
onClick = { PoWPreferenceManager.setPowEnabled(false) },
|
SettingsToggleRow(
|
||||||
label = { Text(stringResource(R.string.about_pow_off), fontFamily = FontFamily.Monospace) }
|
icon = Icons.Filled.Bluetooth,
|
||||||
)
|
title = stringResource(R.string.about_background_title),
|
||||||
FilterChip(
|
subtitle = stringResource(R.string.about_background_desc),
|
||||||
selected = powEnabled,
|
checked = backgroundEnabled,
|
||||||
onClick = { PoWPreferenceManager.setPowEnabled(true) },
|
onCheckedChange = { enabled ->
|
||||||
label = {
|
backgroundEnabled = enabled
|
||||||
Row(
|
com.bitchat.android.service.MeshServicePreferences.setBackgroundEnabled(enabled)
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
if (!enabled) {
|
||||||
verticalAlignment = Alignment.CenterVertically
|
com.bitchat.android.service.MeshForegroundService.stop(context)
|
||||||
) {
|
} else {
|
||||||
Text(stringResource(R.string.about_pow_on), fontFamily = FontFamily.Monospace)
|
com.bitchat.android.service.MeshForegroundService.start(context)
|
||||||
// Show current difficulty
|
|
||||||
if (powEnabled) {
|
|
||||||
Surface(
|
|
||||||
color = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D),
|
|
||||||
shape = RoundedCornerShape(50)
|
|
||||||
) { Box(Modifier.size(8.dp)) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.about_pow_tip),
|
|
||||||
fontSize = 10.sp,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
color = colorScheme.onSurface.copy(alpha = 0.6f)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Show difficulty slider when enabled
|
|
||||||
if (powEnabled) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.about_pow_difficulty, powDifficulty, NostrProofOfWork.estimateMiningTime(powDifficulty)),
|
|
||||||
fontSize = 11.sp,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Slider(
|
HorizontalDivider(
|
||||||
value = powDifficulty.toFloat(),
|
modifier = Modifier.padding(start = 56.dp),
|
||||||
onValueChange = { PoWPreferenceManager.setPowDifficulty(it.toInt()) },
|
color = colorScheme.outline.copy(alpha = 0.12f)
|
||||||
valueRange = 0f..32f,
|
)
|
||||||
steps = 33,
|
|
||||||
colors = SliderDefaults.colors(
|
// Proof of Work Toggle
|
||||||
thumbColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D),
|
SettingsToggleRow(
|
||||||
activeTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
|
icon = Icons.Filled.Speed,
|
||||||
)
|
title = stringResource(R.string.about_pow),
|
||||||
|
subtitle = stringResource(R.string.about_pow_tip),
|
||||||
|
checked = powEnabled,
|
||||||
|
onCheckedChange = { PoWPreferenceManager.setPowEnabled(it) }
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(start = 56.dp),
|
||||||
|
color = colorScheme.outline.copy(alpha = 0.12f)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tor Toggle
|
||||||
|
SettingsToggleRow(
|
||||||
|
icon = Icons.Filled.Security,
|
||||||
|
title = "Tor Network",
|
||||||
|
subtitle = stringResource(R.string.about_tor_route),
|
||||||
|
checked = torMode.value == TorMode.ON,
|
||||||
|
onCheckedChange = { enabled ->
|
||||||
|
if (torAvailable) {
|
||||||
|
torMode.value = if (enabled) TorMode.ON else TorMode.OFF
|
||||||
|
TorPreferenceManager.set(context, torMode.value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = torAvailable,
|
||||||
|
statusIndicator = if (torMode.value == TorMode.ON) {
|
||||||
|
{
|
||||||
|
val statusColor = when {
|
||||||
|
torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
|
||||||
|
torStatus.running -> Color(0xFFFF9500)
|
||||||
|
else -> Color(0xFFFF3B30)
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
color = statusColor,
|
||||||
|
shape = CircleShape,
|
||||||
|
modifier = Modifier.size(8.dp)
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
} else null
|
||||||
)
|
)
|
||||||
|
|
||||||
// Show difficulty description
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
color = colorScheme.surfaceVariant.copy(alpha = 0.25f),
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(12.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.about_pow_difficulty_attempts, powDifficulty, NostrProofOfWork.estimateWork(powDifficulty)),
|
|
||||||
fontSize = 10.sp,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
color = colorScheme.onSurface.copy(alpha = 0.7f)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = when {
|
|
||||||
powDifficulty == 0 -> stringResource(R.string.about_pow_desc_none)
|
|
||||||
powDifficulty <= 8 -> stringResource(R.string.about_pow_desc_very_low)
|
|
||||||
powDifficulty <= 12 -> stringResource(R.string.about_pow_desc_low)
|
|
||||||
powDifficulty <= 16 -> stringResource(R.string.about_pow_desc_medium)
|
|
||||||
powDifficulty <= 20 -> stringResource(R.string.about_pow_desc_high)
|
|
||||||
powDifficulty <= 24 -> stringResource(R.string.about_pow_desc_very_high)
|
|
||||||
else -> stringResource(R.string.about_pow_desc_extreme)
|
|
||||||
},
|
|
||||||
fontSize = 10.sp,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
color = colorScheme.onSurface.copy(alpha = 0.6f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tor unavailable hint
|
||||||
|
if (!torAvailable) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.tor_not_available_in_this_build),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = colorScheme.onBackground.copy(alpha = 0.5f),
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network (Tor) section
|
// PoW Difficulty Slider (when enabled)
|
||||||
item(key = "network_section") {
|
item(key = "pow_slider") {
|
||||||
val torMode = remember { mutableStateOf(com.bitchat.android.net.TorPreferenceManager.get(context)) }
|
val powEnabled by PoWPreferenceManager.powEnabled.collectAsState()
|
||||||
val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState()
|
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState()
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.about_network),
|
if (powEnabled) {
|
||||||
style = MaterialTheme.typography.labelLarge,
|
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
||||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 24.dp)
|
|
||||||
.padding(top = 24.dp, bottom = 8.dp)
|
|
||||||
)
|
|
||||||
Column(modifier = Modifier.padding(horizontal = 24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
FilterChip(
|
|
||||||
selected = torMode.value == com.bitchat.android.net.TorMode.OFF,
|
|
||||||
onClick = {
|
|
||||||
torMode.value = com.bitchat.android.net.TorMode.OFF
|
|
||||||
com.bitchat.android.net.TorPreferenceManager.set(context, torMode.value)
|
|
||||||
},
|
|
||||||
label = { Text("tor off", fontFamily = FontFamily.Monospace) }
|
|
||||||
)
|
|
||||||
FilterChip(
|
|
||||||
selected = torMode.value == com.bitchat.android.net.TorMode.ON,
|
|
||||||
onClick = {
|
|
||||||
torMode.value = com.bitchat.android.net.TorMode.ON
|
|
||||||
com.bitchat.android.net.TorPreferenceManager.set(context, torMode.value)
|
|
||||||
},
|
|
||||||
label = {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text("tor on", fontFamily = FontFamily.Monospace)
|
|
||||||
val statusColor = when {
|
|
||||||
torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500)
|
|
||||||
torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
|
|
||||||
else -> Color.Red
|
|
||||||
}
|
|
||||||
Surface(color = statusColor, shape = CircleShape) {
|
|
||||||
Box(Modifier.size(8.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.about_tor_route),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
|
||||||
)
|
|
||||||
if (torMode.value == com.bitchat.android.net.TorMode.ON) {
|
|
||||||
val statusText = if (torStatus.running) "Running" else "Stopped"
|
|
||||||
// Debug status (temporary)
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
color = colorScheme.surfaceVariant.copy(alpha = 0.25f),
|
color = colorScheme.surface,
|
||||||
shape = RoundedCornerShape(8.dp)
|
shape = RoundedCornerShape(16.dp)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(12.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Row(
|
||||||
text = stringResource(R.string.about_tor_status, statusText, torStatus.bootstrapPercent),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
color = colorScheme.onSurface.copy(alpha = 0.75f)
|
verticalAlignment = Alignment.CenterVertically
|
||||||
)
|
) {
|
||||||
val lastLog = torStatus.lastLogLine
|
|
||||||
if (lastLog.isNotEmpty()) {
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.about_last, lastLog.take(160)),
|
text = "Difficulty",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "$powDifficulty bits • ${NostrProofOfWork.estimateMiningTime(powDifficulty)}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
color = colorScheme.onSurface.copy(alpha = 0.6f)
|
color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
value = powDifficulty.toFloat(),
|
||||||
|
onValueChange = { PoWPreferenceManager.setPowDifficulty(it.toInt()) },
|
||||||
|
valueRange = 0f..32f,
|
||||||
|
steps = 31,
|
||||||
|
colors = SliderDefaults.colors(
|
||||||
|
thumbColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D),
|
||||||
|
activeTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = when {
|
||||||
|
powDifficulty == 0 -> stringResource(R.string.about_pow_desc_none)
|
||||||
|
powDifficulty <= 8 -> stringResource(R.string.about_pow_desc_very_low)
|
||||||
|
powDifficulty <= 12 -> stringResource(R.string.about_pow_desc_low)
|
||||||
|
powDifficulty <= 16 -> stringResource(R.string.about_pow_desc_medium)
|
||||||
|
powDifficulty <= 20 -> stringResource(R.string.about_pow_desc_high)
|
||||||
|
powDifficulty <= 24 -> stringResource(R.string.about_pow_desc_very_high)
|
||||||
|
else -> stringResource(R.string.about_pow_desc_extreme)
|
||||||
|
},
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = colorScheme.onSurface.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tor Status (when enabled)
|
||||||
|
item(key = "tor_status") {
|
||||||
|
val torMode = remember { mutableStateOf(TorPreferenceManager.get(context)) }
|
||||||
|
val torProvider = remember { ArtiTorManager.getInstance() }
|
||||||
|
val torStatus by torProvider.statusFlow.collectAsState()
|
||||||
|
|
||||||
|
if (torMode.value == TorMode.ON) {
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = colorScheme.surface,
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
val statusColor = when {
|
||||||
|
torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
|
||||||
|
torStatus.running -> Color(0xFFFF9500)
|
||||||
|
else -> Color(0xFFFF3B30)
|
||||||
|
}
|
||||||
|
Surface(color = statusColor, shape = CircleShape, modifier = Modifier.size(10.dp)) {}
|
||||||
|
Text(
|
||||||
|
text = if (torStatus.running) "Connected (${torStatus.bootstrapPercent}%)" else "Disconnected",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (torStatus.lastLogLine.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = torStatus.lastLogLine.take(120),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = colorScheme.onSurface.copy(alpha = 0.5f),
|
||||||
|
maxLines = 2
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emergency Warning Section
|
// Emergency Warning
|
||||||
item(key = "warning_section") {
|
item(key = "warning") {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
|
||||||
val errorColor = colorScheme.error
|
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 24.dp, vertical = 24.dp)
|
.padding(horizontal = 20.dp)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
color = errorColor.copy(alpha = 0.1f),
|
color = colorScheme.error.copy(alpha = 0.1f),
|
||||||
shape = RoundedCornerShape(12.dp)
|
shape = RoundedCornerShape(16.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
@@ -485,61 +611,53 @@ fun AboutSheet(
|
|||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Warning,
|
imageVector = Icons.Filled.Warning,
|
||||||
contentDescription = stringResource(R.string.cd_warning),
|
contentDescription = null,
|
||||||
tint = errorColor,
|
tint = colorScheme.error,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(20.dp)
|
||||||
)
|
)
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.about_emergency_title),
|
text = stringResource(R.string.about_emergency_title),
|
||||||
fontSize = 12.sp,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontWeight = FontWeight.SemiBold,
|
||||||
fontWeight = FontWeight.Bold,
|
color = colorScheme.error
|
||||||
color = errorColor
|
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.about_emergency_tip),
|
text = stringResource(R.string.about_emergency_tip),
|
||||||
fontSize = 11.sp,
|
fontSize = 13.sp,
|
||||||
fontFamily = FontFamily.Monospace,
|
color = colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
color = colorScheme.onSurface.copy(alpha = 0.8f)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Footer Section
|
// Footer
|
||||||
item(key = "footer") {
|
item(key = "footer") {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 24.dp)
|
.fillMaxWidth()
|
||||||
.fillMaxWidth(),
|
.padding(horizontal = 20.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
if (onShowDebug != null) {
|
if (onShowDebug != null) {
|
||||||
TextButton(
|
TextButton(onClick = onShowDebug) {
|
||||||
onClick = onShowDebug,
|
|
||||||
colors = ButtonDefaults.textButtonColors(
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.about_debug_settings),
|
text = stringResource(R.string.about_debug_settings),
|
||||||
fontSize = 11.sp,
|
fontSize = 13.sp,
|
||||||
fontFamily = FontFamily.Monospace
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = colorScheme.primary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.about_footer),
|
text = stringResource(R.string.about_footer),
|
||||||
fontSize = 11.sp,
|
fontSize = 12.sp,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
color = colorScheme.onSurface.copy(alpha = 0.4f)
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
// Add extra space at bottom for gesture area
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import androidx.compose.material.icons.filled.*
|
|||||||
import androidx.compose.material.icons.outlined.*
|
import androidx.compose.material.icons.outlined.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -28,9 +27,9 @@ import androidx.compose.ui.text.input.ImeAction
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.bitchat.android.core.ui.utils.singleOrTripleClickable
|
import com.bitchat.android.core.ui.utils.singleOrTripleClickable
|
||||||
import com.bitchat.android.geohash.LocationChannelManager.PermissionState
|
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Header components for ChatScreen
|
* Header components for ChatScreen
|
||||||
@@ -59,7 +58,8 @@ fun isFavoriteReactive(
|
|||||||
fun TorStatusDot(
|
fun TorStatusDot(
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState()
|
val torProvider = remember { com.bitchat.android.net.ArtiTorManager.getInstance() }
|
||||||
|
val torStatus by torProvider.statusFlow.collectAsState()
|
||||||
|
|
||||||
if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) {
|
if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) {
|
||||||
val dotColor = when {
|
val dotColor = when {
|
||||||
@@ -248,10 +248,10 @@ fun ChatHeaderContent(
|
|||||||
when {
|
when {
|
||||||
selectedPrivatePeer != null -> {
|
selectedPrivatePeer != null -> {
|
||||||
// Private chat header - Fully reactive state tracking
|
// Private chat header - Fully reactive state tracking
|
||||||
val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet())
|
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
|
||||||
val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap())
|
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
|
||||||
val peerSessionStates by viewModel.peerSessionStates.observeAsState(emptyMap())
|
val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle()
|
||||||
val peerNicknames by viewModel.peerNicknames.observeAsState(emptyMap())
|
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Reactive favorite computation - no more static lookups!
|
// Reactive favorite computation - no more static lookups!
|
||||||
val isFavorite = isFavoriteReactive(
|
val isFavorite = isFavoriteReactive(
|
||||||
@@ -264,8 +264,8 @@ fun ChatHeaderContent(
|
|||||||
Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, isFav=$isFavorite, sessionState=$sessionState")
|
Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, isFav=$isFavorite, sessionState=$sessionState")
|
||||||
|
|
||||||
// Pass geohash context and people for NIP-17 chat title formatting
|
// Pass geohash context and people for NIP-17 chat title formatting
|
||||||
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||||
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList())
|
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
PrivateChatHeader(
|
PrivateChatHeader(
|
||||||
peerID = selectedPrivatePeer,
|
peerID = selectedPrivatePeer,
|
||||||
@@ -523,18 +523,18 @@ private fun MainHeader(
|
|||||||
viewModel: ChatViewModel
|
viewModel: ChatViewModel
|
||||||
) {
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList())
|
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
|
||||||
val joinedChannels by viewModel.joinedChannels.observeAsState(emptySet())
|
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
|
||||||
val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap())
|
val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
|
||||||
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
|
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
||||||
val isConnected by viewModel.isConnected.observeAsState(false)
|
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
|
||||||
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||||
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList())
|
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Bookmarks store for current geohash toggle (iOS parity)
|
// Bookmarks store for current geohash toggle (iOS parity)
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
val bookmarksStore = remember { com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(context) }
|
val bookmarksStore = remember { com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(context) }
|
||||||
val bookmarks by bookmarksStore.bookmarks.observeAsState(emptyList())
|
val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -653,8 +653,8 @@ private fun LocationChannelsButton(
|
|||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
|
||||||
// Get current channel selection from location manager
|
// Get current channel selection from location manager
|
||||||
val selectedChannel by viewModel.selectedLocationChannel.observeAsState()
|
val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||||
val teleported by viewModel.isTeleported.observeAsState(false)
|
val teleported by viewModel.isTeleported.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val (badgeText, badgeColor) = when (selectedChannel) {
|
val (badgeText, badgeColor) = when (selectedChannel) {
|
||||||
is com.bitchat.android.geohash.ChannelID.Mesh -> {
|
is com.bitchat.android.geohash.ChannelID.Mesh -> {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import androidx.compose.foundation.*
|
|||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -25,6 +24,7 @@ import androidx.compose.ui.text.input.TextFieldValue
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.bitchat.android.model.BitchatMessage
|
import com.bitchat.android.model.BitchatMessage
|
||||||
import com.bitchat.android.ui.media.FullScreenImageViewer
|
import com.bitchat.android.ui.media.FullScreenImageViewer
|
||||||
|
|
||||||
@@ -41,22 +41,22 @@ import com.bitchat.android.ui.media.FullScreenImageViewer
|
|||||||
@Composable
|
@Composable
|
||||||
fun ChatScreen(viewModel: ChatViewModel) {
|
fun ChatScreen(viewModel: ChatViewModel) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
val messages by viewModel.messages.observeAsState(emptyList())
|
val messages by viewModel.messages.collectAsStateWithLifecycle()
|
||||||
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList())
|
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
|
||||||
val nickname by viewModel.nickname.observeAsState("")
|
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||||
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState()
|
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
|
||||||
val currentChannel by viewModel.currentChannel.observeAsState()
|
val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
|
||||||
val joinedChannels by viewModel.joinedChannels.observeAsState(emptySet())
|
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
|
||||||
val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap())
|
val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
|
||||||
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
|
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
||||||
val privateChats by viewModel.privateChats.observeAsState(emptyMap())
|
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
|
||||||
val channelMessages by viewModel.channelMessages.observeAsState(emptyMap())
|
val channelMessages by viewModel.channelMessages.collectAsStateWithLifecycle()
|
||||||
val showSidebar by viewModel.showSidebar.observeAsState(false)
|
val showSidebar by viewModel.showSidebar.collectAsStateWithLifecycle()
|
||||||
val showCommandSuggestions by viewModel.showCommandSuggestions.observeAsState(false)
|
val showCommandSuggestions by viewModel.showCommandSuggestions.collectAsStateWithLifecycle()
|
||||||
val commandSuggestions by viewModel.commandSuggestions.observeAsState(emptyList())
|
val commandSuggestions by viewModel.commandSuggestions.collectAsStateWithLifecycle()
|
||||||
val showMentionSuggestions by viewModel.showMentionSuggestions.observeAsState(false)
|
val showMentionSuggestions by viewModel.showMentionSuggestions.collectAsStateWithLifecycle()
|
||||||
val mentionSuggestions by viewModel.mentionSuggestions.observeAsState(emptyList())
|
val mentionSuggestions by viewModel.mentionSuggestions.collectAsStateWithLifecycle()
|
||||||
val showAppInfo by viewModel.showAppInfo.observeAsState(false)
|
val showAppInfo by viewModel.showAppInfo.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
var messageText by remember { mutableStateOf(TextFieldValue("")) }
|
var messageText by remember { mutableStateOf(TextFieldValue("")) }
|
||||||
var showPasswordPrompt by remember { mutableStateOf(false) }
|
var showPasswordPrompt by remember { mutableStateOf(false) }
|
||||||
@@ -78,11 +78,11 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
|||||||
showPasswordDialog = showPasswordPrompt
|
showPasswordDialog = showPasswordPrompt
|
||||||
}
|
}
|
||||||
|
|
||||||
val isConnected by viewModel.isConnected.observeAsState(false)
|
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
|
||||||
val passwordPromptChannel by viewModel.passwordPromptChannel.observeAsState(null)
|
val passwordPromptChannel by viewModel.passwordPromptChannel.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Get location channel info for timeline switching
|
// Get location channel info for timeline switching
|
||||||
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Determine what messages to show based on current context (unified timelines)
|
// Determine what messages to show based on current context (unified timelines)
|
||||||
val displayMessages = when {
|
val displayMessages = when {
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
package com.bitchat.android.ui
|
package com.bitchat.android.ui
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import androidx.lifecycle.MediatorLiveData
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import com.bitchat.android.model.BitchatMessage
|
import com.bitchat.android.model.BitchatMessage
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Centralized state definitions and data classes for the chat system
|
* Centralized state definitions and data classes for the chat system
|
||||||
@@ -21,171 +28,162 @@ data class CommandSuggestion(
|
|||||||
/**
|
/**
|
||||||
* Contains all the observable state for the chat system
|
* Contains all the observable state for the chat system
|
||||||
*/
|
*/
|
||||||
class ChatState {
|
class ChatState(
|
||||||
|
scope: CoroutineScope
|
||||||
|
) {
|
||||||
|
|
||||||
// Core messages and peer state
|
// Core messages and peer state
|
||||||
private val _messages = MutableLiveData<List<BitchatMessage>>(emptyList())
|
private val _messages = MutableStateFlow<List<BitchatMessage>>(emptyList())
|
||||||
val messages: LiveData<List<BitchatMessage>> = _messages
|
val messages: StateFlow<List<BitchatMessage>> = _messages.asStateFlow()
|
||||||
|
|
||||||
private val _connectedPeers = MutableLiveData<List<String>>(emptyList())
|
private val _connectedPeers = MutableStateFlow<List<String>>(emptyList())
|
||||||
val connectedPeers: LiveData<List<String>> = _connectedPeers
|
val connectedPeers: StateFlow<List<String>> = _connectedPeers.asStateFlow()
|
||||||
|
|
||||||
private val _nickname = MutableLiveData<String>()
|
private val _nickname = MutableStateFlow<String>("")
|
||||||
val nickname: LiveData<String> = _nickname
|
val nickname: StateFlow<String> = _nickname.asStateFlow()
|
||||||
|
|
||||||
private val _isConnected = MutableLiveData<Boolean>(false)
|
private val _isConnected = MutableStateFlow<Boolean>(false)
|
||||||
val isConnected: LiveData<Boolean> = _isConnected
|
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||||
|
|
||||||
// Private chats
|
// Private chats
|
||||||
private val _privateChats = MutableLiveData<Map<String, List<BitchatMessage>>>(emptyMap())
|
private val _privateChats = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
|
||||||
val privateChats: LiveData<Map<String, List<BitchatMessage>>> = _privateChats
|
val privateChats: StateFlow<Map<String, List<BitchatMessage>>> = _privateChats.asStateFlow()
|
||||||
|
|
||||||
private val _selectedPrivateChatPeer = MutableLiveData<String?>(null)
|
private val _selectedPrivateChatPeer = MutableStateFlow<String?>(null)
|
||||||
val selectedPrivateChatPeer: LiveData<String?> = _selectedPrivateChatPeer
|
val selectedPrivateChatPeer: StateFlow<String?> = _selectedPrivateChatPeer.asStateFlow()
|
||||||
|
|
||||||
private val _unreadPrivateMessages = MutableLiveData<Set<String>>(emptySet())
|
private val _unreadPrivateMessages = MutableStateFlow<Set<String>>(emptySet())
|
||||||
val unreadPrivateMessages: LiveData<Set<String>> = _unreadPrivateMessages
|
val unreadPrivateMessages: StateFlow<Set<String>> = _unreadPrivateMessages.asStateFlow()
|
||||||
|
|
||||||
// Channels
|
// Channels
|
||||||
private val _joinedChannels = MutableLiveData<Set<String>>(emptySet())
|
private val _joinedChannels = MutableStateFlow<Set<String>>(emptySet())
|
||||||
val joinedChannels: LiveData<Set<String>> = _joinedChannels
|
val joinedChannels: StateFlow<Set<String>> = _joinedChannels.asStateFlow()
|
||||||
|
|
||||||
private val _currentChannel = MutableLiveData<String?>(null)
|
private val _currentChannel = MutableStateFlow<String?>(null)
|
||||||
val currentChannel: LiveData<String?> = _currentChannel
|
val currentChannel: StateFlow<String?> = _currentChannel.asStateFlow()
|
||||||
|
|
||||||
private val _channelMessages = MutableLiveData<Map<String, List<BitchatMessage>>>(emptyMap())
|
private val _channelMessages = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
|
||||||
val channelMessages: LiveData<Map<String, List<BitchatMessage>>> = _channelMessages
|
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = _channelMessages.asStateFlow()
|
||||||
|
|
||||||
private val _unreadChannelMessages = MutableLiveData<Map<String, Int>>(emptyMap())
|
private val _unreadChannelMessages = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||||
val unreadChannelMessages: LiveData<Map<String, Int>> = _unreadChannelMessages
|
val unreadChannelMessages: StateFlow<Map<String, Int>> = _unreadChannelMessages.asStateFlow()
|
||||||
|
|
||||||
private val _passwordProtectedChannels = MutableLiveData<Set<String>>(emptySet())
|
private val _passwordProtectedChannels = MutableStateFlow<Set<String>>(emptySet())
|
||||||
val passwordProtectedChannels: LiveData<Set<String>> = _passwordProtectedChannels
|
val passwordProtectedChannels: StateFlow<Set<String>> = _passwordProtectedChannels.asStateFlow()
|
||||||
|
|
||||||
private val _showPasswordPrompt = MutableLiveData<Boolean>(false)
|
private val _showPasswordPrompt = MutableStateFlow<Boolean>(false)
|
||||||
val showPasswordPrompt: LiveData<Boolean> = _showPasswordPrompt
|
val showPasswordPrompt: StateFlow<Boolean> = _showPasswordPrompt.asStateFlow()
|
||||||
|
|
||||||
private val _passwordPromptChannel = MutableLiveData<String?>(null)
|
private val _passwordPromptChannel = MutableStateFlow<String?>(null)
|
||||||
val passwordPromptChannel: LiveData<String?> = _passwordPromptChannel
|
val passwordPromptChannel: StateFlow<String?> = _passwordPromptChannel.asStateFlow()
|
||||||
|
|
||||||
// Sidebar state
|
// Sidebar state
|
||||||
private val _showSidebar = MutableLiveData(false)
|
private val _showSidebar = MutableStateFlow(false)
|
||||||
val showSidebar: LiveData<Boolean> = _showSidebar
|
val showSidebar: StateFlow<Boolean> = _showSidebar.asStateFlow()
|
||||||
|
|
||||||
// Command autocomplete
|
// Command autocomplete
|
||||||
private val _showCommandSuggestions = MutableLiveData(false)
|
private val _showCommandSuggestions = MutableStateFlow(false)
|
||||||
val showCommandSuggestions: LiveData<Boolean> = _showCommandSuggestions
|
val showCommandSuggestions: StateFlow<Boolean> = _showCommandSuggestions.asStateFlow()
|
||||||
|
|
||||||
private val _commandSuggestions = MutableLiveData<List<CommandSuggestion>>(emptyList())
|
private val _commandSuggestions = MutableStateFlow<List<CommandSuggestion>>(emptyList())
|
||||||
val commandSuggestions: LiveData<List<CommandSuggestion>> = _commandSuggestions
|
val commandSuggestions: StateFlow<List<CommandSuggestion>> = _commandSuggestions.asStateFlow()
|
||||||
|
|
||||||
// Mention autocomplete
|
// Mention autocomplete
|
||||||
private val _showMentionSuggestions = MutableLiveData(false)
|
private val _showMentionSuggestions = MutableStateFlow(false)
|
||||||
val showMentionSuggestions: LiveData<Boolean> = _showMentionSuggestions
|
val showMentionSuggestions: StateFlow<Boolean> = _showMentionSuggestions.asStateFlow()
|
||||||
|
|
||||||
private val _mentionSuggestions = MutableLiveData<List<String>>(emptyList())
|
private val _mentionSuggestions = MutableStateFlow<List<String>>(emptyList())
|
||||||
val mentionSuggestions: LiveData<List<String>> = _mentionSuggestions
|
val mentionSuggestions: StateFlow<List<String>> = _mentionSuggestions.asStateFlow()
|
||||||
|
|
||||||
// Favorites
|
// Favorites
|
||||||
private val _favoritePeers = MutableLiveData<Set<String>>(emptySet())
|
private val _favoritePeers = MutableStateFlow<Set<String>>(emptySet())
|
||||||
val favoritePeers: LiveData<Set<String>> = _favoritePeers
|
val favoritePeers: StateFlow<Set<String>> = _favoritePeers.asStateFlow()
|
||||||
|
|
||||||
// Noise session states for peers (for reactive UI updates)
|
// Noise session states for peers (for reactive UI updates)
|
||||||
private val _peerSessionStates = MutableLiveData<Map<String, String>>(emptyMap())
|
private val _peerSessionStates = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||||
val peerSessionStates: LiveData<Map<String, String>> = _peerSessionStates
|
val peerSessionStates: StateFlow<Map<String, String>> = _peerSessionStates.asStateFlow()
|
||||||
|
|
||||||
// Peer fingerprint state for reactive favorites (for reactive UI updates)
|
// Peer fingerprint state for reactive favorites (for reactive UI updates)
|
||||||
private val _peerFingerprints = MutableLiveData<Map<String, String>>(emptyMap())
|
private val _peerFingerprints = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||||
val peerFingerprints: LiveData<Map<String, String>> = _peerFingerprints
|
val peerFingerprints: StateFlow<Map<String, String>> = _peerFingerprints.asStateFlow()
|
||||||
|
|
||||||
private val _peerNicknames = MutableLiveData<Map<String, String>>(emptyMap())
|
private val _peerNicknames = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||||
val peerNicknames: LiveData<Map<String, String>> = _peerNicknames
|
val peerNicknames: StateFlow<Map<String, String>> = _peerNicknames.asStateFlow()
|
||||||
|
|
||||||
private val _peerRSSI = MutableLiveData<Map<String, Int>>(emptyMap())
|
private val _peerRSSI = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||||
val peerRSSI: LiveData<Map<String, Int>> = _peerRSSI
|
val peerRSSI: StateFlow<Map<String, Int>> = _peerRSSI.asStateFlow()
|
||||||
|
|
||||||
// Direct connection status per peer (for live UI updates)
|
// Direct connection status per peer (for live UI updates)
|
||||||
private val _peerDirect = MutableLiveData<Map<String, Boolean>>(emptyMap())
|
private val _peerDirect = MutableStateFlow<Map<String, Boolean>>(emptyMap())
|
||||||
val peerDirect: LiveData<Map<String, Boolean>> = _peerDirect
|
val peerDirect: StateFlow<Map<String, Boolean>> = _peerDirect.asStateFlow()
|
||||||
|
|
||||||
// peerIDToPublicKeyFingerprint REMOVED - fingerprints now handled centrally in PeerManager
|
// peerIDToPublicKeyFingerprint REMOVED - fingerprints now handled centrally in PeerManager
|
||||||
|
|
||||||
// Navigation state
|
// Navigation state
|
||||||
private val _showAppInfo = MutableLiveData<Boolean>(false)
|
private val _showAppInfo = MutableStateFlow<Boolean>(false)
|
||||||
val showAppInfo: LiveData<Boolean> = _showAppInfo
|
val showAppInfo: StateFlow<Boolean> = _showAppInfo.asStateFlow()
|
||||||
|
|
||||||
// Location channels state (for Nostr geohash features)
|
// Location channels state (for Nostr geohash features)
|
||||||
private val _selectedLocationChannel = MutableLiveData<com.bitchat.android.geohash.ChannelID?>(com.bitchat.android.geohash.ChannelID.Mesh)
|
private val _selectedLocationChannel = MutableStateFlow<com.bitchat.android.geohash.ChannelID?>(com.bitchat.android.geohash.ChannelID.Mesh)
|
||||||
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = _selectedLocationChannel
|
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = _selectedLocationChannel.asStateFlow()
|
||||||
|
|
||||||
private val _isTeleported = MutableLiveData<Boolean>(false)
|
private val _isTeleported = MutableStateFlow<Boolean>(false)
|
||||||
val isTeleported: LiveData<Boolean> = _isTeleported
|
val isTeleported: StateFlow<Boolean> = _isTeleported.asStateFlow()
|
||||||
|
|
||||||
// Geohash people state (iOS-compatible)
|
// Geohash people state (iOS-compatible)
|
||||||
private val _geohashPeople = MutableLiveData<List<GeoPerson>>(emptyList())
|
private val _geohashPeople = MutableStateFlow<List<GeoPerson>>(emptyList())
|
||||||
val geohashPeople: LiveData<List<GeoPerson>> = _geohashPeople
|
val geohashPeople: StateFlow<List<GeoPerson>> = _geohashPeople.asStateFlow()
|
||||||
// For background thread updates by repositories/handlers in their own scopes
|
|
||||||
val geohashPeopleMutable: MutableLiveData<List<GeoPerson>> get() = _geohashPeople
|
|
||||||
|
|
||||||
|
private val _teleportedGeo = MutableStateFlow<Set<String>>(emptySet())
|
||||||
private val _teleportedGeo = MutableLiveData<Set<String>>(emptySet())
|
val teleportedGeo: StateFlow<Set<String>> = _teleportedGeo.asStateFlow()
|
||||||
val teleportedGeo: LiveData<Set<String>> = _teleportedGeo
|
|
||||||
|
|
||||||
// Geohash participant counts reactive state (for real-time location channel counts)
|
// Geohash participant counts reactive state (for real-time location channel counts)
|
||||||
private val _geohashParticipantCounts = MutableLiveData<Map<String, Int>>(emptyMap())
|
private val _geohashParticipantCounts = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||||
val geohashParticipantCounts: LiveData<Map<String, Int>> = _geohashParticipantCounts
|
val geohashParticipantCounts: StateFlow<Map<String, Int>> = _geohashParticipantCounts.asStateFlow()
|
||||||
|
|
||||||
// Unread state computed properties
|
|
||||||
val hasUnreadChannels: MediatorLiveData<Boolean> = MediatorLiveData<Boolean>()
|
val hasUnreadChannels: StateFlow<Boolean> = _unreadChannelMessages
|
||||||
val hasUnreadPrivateMessages: MediatorLiveData<Boolean> = MediatorLiveData<Boolean>()
|
.map { unreadMap -> unreadMap.values.any { it > 0 } }
|
||||||
|
.stateIn(
|
||||||
|
scope = scope,
|
||||||
|
started = WhileSubscribed(5_000),
|
||||||
|
initialValue = false
|
||||||
|
)
|
||||||
|
|
||||||
init {
|
val hasUnreadPrivateMessages: StateFlow<Boolean> = _unreadPrivateMessages
|
||||||
// Initialize unread state mediators
|
.map { unreadSet -> unreadSet.isNotEmpty() }
|
||||||
hasUnreadChannels.addSource(_unreadChannelMessages) { unreadMap ->
|
.stateIn(
|
||||||
hasUnreadChannels.value = unreadMap.values.any { it > 0 }
|
scope = scope,
|
||||||
}
|
started = WhileSubscribed(5_000),
|
||||||
|
initialValue = false
|
||||||
hasUnreadPrivateMessages.addSource(_unreadPrivateMessages) { unreadSet ->
|
)
|
||||||
hasUnreadPrivateMessages.value = unreadSet.isNotEmpty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Getters for internal state access
|
// Getters for internal state access
|
||||||
fun getMessagesValue() = _messages.value ?: emptyList()
|
fun getMessagesValue() = _messages.value
|
||||||
fun getConnectedPeersValue() = _connectedPeers.value ?: emptyList()
|
fun getConnectedPeersValue() = _connectedPeers.value
|
||||||
fun getNicknameValue() = _nickname.value
|
fun getNicknameValue() = _nickname.value
|
||||||
fun getPrivateChatsValue() = _privateChats.value ?: emptyMap()
|
fun getPrivateChatsValue() = _privateChats.value
|
||||||
fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value
|
fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value
|
||||||
fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value ?: emptySet()
|
fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value
|
||||||
fun getJoinedChannelsValue() = _joinedChannels.value ?: emptySet()
|
fun getJoinedChannelsValue() = _joinedChannels.value
|
||||||
// Thread-safe posting helpers for background updates
|
|
||||||
fun postGeohashPeople(people: List<GeoPerson>) {
|
|
||||||
_geohashPeople.postValue(people)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun postGeohashParticipantCounts(counts: Map<String, Int>) {
|
|
||||||
_geohashParticipantCounts.postValue(counts)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fun getCurrentChannelValue() = _currentChannel.value
|
fun getCurrentChannelValue() = _currentChannel.value
|
||||||
fun getChannelMessagesValue() = _channelMessages.value ?: emptyMap()
|
fun getChannelMessagesValue() = _channelMessages.value
|
||||||
fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value ?: emptyMap()
|
fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value
|
||||||
fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value ?: emptySet()
|
fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value
|
||||||
fun getShowPasswordPromptValue() = _showPasswordPrompt.value ?: false
|
fun getShowPasswordPromptValue() = _showPasswordPrompt.value
|
||||||
fun getPasswordPromptChannelValue() = _passwordPromptChannel.value
|
fun getPasswordPromptChannelValue() = _passwordPromptChannel.value
|
||||||
fun getShowSidebarValue() = _showSidebar.value ?: false
|
fun getShowSidebarValue() = _showSidebar.value
|
||||||
fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value ?: false
|
fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value
|
||||||
fun getCommandSuggestionsValue() = _commandSuggestions.value ?: emptyList()
|
fun getCommandSuggestionsValue() = _commandSuggestions.value
|
||||||
fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value ?: false
|
fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value
|
||||||
fun getMentionSuggestionsValue() = _mentionSuggestions.value ?: emptyList()
|
fun getMentionSuggestionsValue() = _mentionSuggestions.value
|
||||||
fun getFavoritePeersValue() = _favoritePeers.value ?: emptySet()
|
fun getFavoritePeersValue() = _favoritePeers.value
|
||||||
fun getPeerSessionStatesValue() = _peerSessionStates.value ?: emptyMap()
|
fun getPeerSessionStatesValue() = _peerSessionStates.value
|
||||||
fun getPeerFingerprintsValue() = _peerFingerprints.value ?: emptyMap()
|
fun getPeerFingerprintsValue() = _peerFingerprints.value
|
||||||
fun getShowAppInfoValue() = _showAppInfo.value ?: false
|
fun getShowAppInfoValue() = _showAppInfo.value
|
||||||
fun getGeohashPeopleValue() = _geohashPeople.value ?: emptyList()
|
fun getGeohashPeopleValue() = _geohashPeople.value
|
||||||
fun getTeleportedGeoValue() = _teleportedGeo.value ?: emptySet()
|
fun getTeleportedGeoValue() = _teleportedGeo.value
|
||||||
fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value ?: emptyMap()
|
fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value
|
||||||
|
|
||||||
// Setters for state updates
|
// Setters for state updates
|
||||||
fun setMessages(messages: List<BitchatMessage>) {
|
fun setMessages(messages: List<BitchatMessage>) {
|
||||||
@@ -197,7 +195,7 @@ class ChatState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun postTeleportedGeo(teleported: Set<String>) {
|
fun postTeleportedGeo(teleported: Set<String>) {
|
||||||
_teleportedGeo.postValue(teleported)
|
_teleportedGeo.value = teleported
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setNickname(nickname: String) {
|
fun setNickname(nickname: String) {
|
||||||
@@ -269,7 +267,7 @@ class ChatState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setFavoritePeers(favorites: Set<String>) {
|
fun setFavoritePeers(favorites: Set<String>) {
|
||||||
val currentValue = _favoritePeers.value ?: emptySet()
|
val currentValue = _favoritePeers.value
|
||||||
Log.d("ChatState", "setFavoritePeers called with ${favorites.size} favorites: $favorites")
|
Log.d("ChatState", "setFavoritePeers called with ${favorites.size} favorites: $favorites")
|
||||||
Log.d("ChatState", "Current value: $currentValue")
|
Log.d("ChatState", "Current value: $currentValue")
|
||||||
Log.d("ChatState", "Values equal: ${currentValue == favorites}")
|
Log.d("ChatState", "Values equal: ${currentValue == favorites}")
|
||||||
@@ -278,8 +276,7 @@ class ChatState {
|
|||||||
// Always set the value - even if equal, this ensures observers are triggered
|
// Always set the value - even if equal, this ensures observers are triggered
|
||||||
_favoritePeers.value = favorites
|
_favoritePeers.value = favorites
|
||||||
|
|
||||||
Log.d("ChatState", "LiveData value after set: ${_favoritePeers.value}")
|
Log.d("ChatState", "StateFlow value after set: ${_favoritePeers.value}")
|
||||||
Log.d("ChatState", "LiveData has active observers: ${_favoritePeers.hasActiveObservers()}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setPeerSessionStates(states: Map<String, String>) {
|
fun setPeerSessionStates(states: Map<String, String>) {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import android.app.Application
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import com.bitchat.android.mesh.BluetoothMeshDelegate
|
import com.bitchat.android.mesh.BluetoothMeshDelegate
|
||||||
import com.bitchat.android.mesh.BluetoothMeshService
|
import com.bitchat.android.mesh.BluetoothMeshService
|
||||||
import com.bitchat.android.model.BitchatMessage
|
import com.bitchat.android.model.BitchatMessage
|
||||||
@@ -47,7 +47,9 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - State management
|
// MARK: - State management
|
||||||
private val state = ChatState()
|
private val state = ChatState(
|
||||||
|
scope = viewModelScope,
|
||||||
|
)
|
||||||
|
|
||||||
// Transfer progress tracking
|
// Transfer progress tracking
|
||||||
private val transferMessageMap = mutableMapOf<String, String>()
|
private val transferMessageMap = mutableMapOf<String, String>()
|
||||||
@@ -103,44 +105,79 @@ class ChatViewModel(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Expose state through LiveData (maintaining the same interface)
|
|
||||||
val messages: LiveData<List<BitchatMessage>> = state.messages
|
val messages: StateFlow<List<BitchatMessage>> = state.messages
|
||||||
val connectedPeers: LiveData<List<String>> = state.connectedPeers
|
val connectedPeers: StateFlow<List<String>> = state.connectedPeers
|
||||||
val nickname: LiveData<String> = state.nickname
|
val nickname: StateFlow<String> = state.nickname
|
||||||
val isConnected: LiveData<Boolean> = state.isConnected
|
val isConnected: StateFlow<Boolean> = state.isConnected
|
||||||
val privateChats: LiveData<Map<String, List<BitchatMessage>>> = state.privateChats
|
val privateChats: StateFlow<Map<String, List<BitchatMessage>>> = state.privateChats
|
||||||
val selectedPrivateChatPeer: LiveData<String?> = state.selectedPrivateChatPeer
|
val selectedPrivateChatPeer: StateFlow<String?> = state.selectedPrivateChatPeer
|
||||||
val unreadPrivateMessages: LiveData<Set<String>> = state.unreadPrivateMessages
|
val unreadPrivateMessages: StateFlow<Set<String>> = state.unreadPrivateMessages
|
||||||
val joinedChannels: LiveData<Set<String>> = state.joinedChannels
|
val joinedChannels: StateFlow<Set<String>> = state.joinedChannels
|
||||||
val currentChannel: LiveData<String?> = state.currentChannel
|
val currentChannel: StateFlow<String?> = state.currentChannel
|
||||||
val channelMessages: LiveData<Map<String, List<BitchatMessage>>> = state.channelMessages
|
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = state.channelMessages
|
||||||
val unreadChannelMessages: LiveData<Map<String, Int>> = state.unreadChannelMessages
|
val unreadChannelMessages: StateFlow<Map<String, Int>> = state.unreadChannelMessages
|
||||||
val passwordProtectedChannels: LiveData<Set<String>> = state.passwordProtectedChannels
|
val passwordProtectedChannels: StateFlow<Set<String>> = state.passwordProtectedChannels
|
||||||
val showPasswordPrompt: LiveData<Boolean> = state.showPasswordPrompt
|
val showPasswordPrompt: StateFlow<Boolean> = state.showPasswordPrompt
|
||||||
val passwordPromptChannel: LiveData<String?> = state.passwordPromptChannel
|
val passwordPromptChannel: StateFlow<String?> = state.passwordPromptChannel
|
||||||
val showSidebar: LiveData<Boolean> = state.showSidebar
|
val showSidebar: StateFlow<Boolean> = state.showSidebar
|
||||||
val hasUnreadChannels = state.hasUnreadChannels
|
val hasUnreadChannels = state.hasUnreadChannels
|
||||||
val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages
|
val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages
|
||||||
val showCommandSuggestions: LiveData<Boolean> = state.showCommandSuggestions
|
val showCommandSuggestions: StateFlow<Boolean> = state.showCommandSuggestions
|
||||||
val commandSuggestions: LiveData<List<CommandSuggestion>> = state.commandSuggestions
|
val commandSuggestions: StateFlow<List<CommandSuggestion>> = state.commandSuggestions
|
||||||
val showMentionSuggestions: LiveData<Boolean> = state.showMentionSuggestions
|
val showMentionSuggestions: StateFlow<Boolean> = state.showMentionSuggestions
|
||||||
val mentionSuggestions: LiveData<List<String>> = state.mentionSuggestions
|
val mentionSuggestions: StateFlow<List<String>> = state.mentionSuggestions
|
||||||
val favoritePeers: LiveData<Set<String>> = state.favoritePeers
|
val favoritePeers: StateFlow<Set<String>> = state.favoritePeers
|
||||||
val peerSessionStates: LiveData<Map<String, String>> = state.peerSessionStates
|
val peerSessionStates: StateFlow<Map<String, String>> = state.peerSessionStates
|
||||||
val peerFingerprints: LiveData<Map<String, String>> = state.peerFingerprints
|
val peerFingerprints: StateFlow<Map<String, String>> = state.peerFingerprints
|
||||||
val peerNicknames: LiveData<Map<String, String>> = state.peerNicknames
|
val peerNicknames: StateFlow<Map<String, String>> = state.peerNicknames
|
||||||
val peerRSSI: LiveData<Map<String, Int>> = state.peerRSSI
|
val peerRSSI: StateFlow<Map<String, Int>> = state.peerRSSI
|
||||||
val peerDirect: LiveData<Map<String, Boolean>> = state.peerDirect
|
val peerDirect: StateFlow<Map<String, Boolean>> = state.peerDirect
|
||||||
val showAppInfo: LiveData<Boolean> = state.showAppInfo
|
val showAppInfo: StateFlow<Boolean> = state.showAppInfo
|
||||||
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
|
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
|
||||||
val isTeleported: LiveData<Boolean> = state.isTeleported
|
val isTeleported: StateFlow<Boolean> = state.isTeleported
|
||||||
val geohashPeople: LiveData<List<GeoPerson>> = state.geohashPeople
|
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
|
||||||
val teleportedGeo: LiveData<Set<String>> = state.teleportedGeo
|
val teleportedGeo: StateFlow<Set<String>> = state.teleportedGeo
|
||||||
val geohashParticipantCounts: LiveData<Map<String, Int>> = state.geohashParticipantCounts
|
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Note: Mesh service delegate is now set by MainActivity
|
// Note: Mesh service delegate is now set by MainActivity
|
||||||
loadAndInitialize()
|
loadAndInitialize()
|
||||||
|
// Hydrate UI state from process-wide AppStateStore to survive Activity recreation
|
||||||
|
viewModelScope.launch {
|
||||||
|
try { com.bitchat.android.services.AppStateStore.peers.collect { peers ->
|
||||||
|
state.setConnectedPeers(peers)
|
||||||
|
state.setIsConnected(peers.isNotEmpty())
|
||||||
|
} } catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
try { com.bitchat.android.services.AppStateStore.publicMessages.collect { msgs ->
|
||||||
|
// Source of truth is AppStateStore; replace to avoid duplicate keys in LazyColumn
|
||||||
|
state.setMessages(msgs)
|
||||||
|
} } catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
try { com.bitchat.android.services.AppStateStore.privateMessages.collect { byPeer ->
|
||||||
|
// Replace with store snapshot
|
||||||
|
state.setPrivateChats(byPeer)
|
||||||
|
// Recompute unread set using SeenMessageStore for robustness across Activity recreation
|
||||||
|
try {
|
||||||
|
val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication())
|
||||||
|
val myNick = state.getNicknameValue() ?: meshService.myPeerID
|
||||||
|
val unread = mutableSetOf<String>()
|
||||||
|
byPeer.forEach { (peer, list) ->
|
||||||
|
if (list.any { msg -> msg.sender != myNick && !seen.hasRead(msg.id) }) unread.add(peer)
|
||||||
|
}
|
||||||
|
state.setUnreadPrivateMessages(unread)
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
} } catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
try { com.bitchat.android.services.AppStateStore.channelMessages.collect { byChannel ->
|
||||||
|
// Replace with store snapshot
|
||||||
|
state.setChannelMessages(byChannel)
|
||||||
|
} } catch (_: Exception) { }
|
||||||
|
}
|
||||||
// Subscribe to BLE transfer progress and reflect in message deliveryStatus
|
// Subscribe to BLE transfer progress and reflect in message deliveryStatus
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
com.bitchat.android.mesh.TransferProgressManager.events.collect { evt ->
|
com.bitchat.android.mesh.TransferProgressManager.events.collect { evt ->
|
||||||
@@ -565,7 +602,7 @@ class ChatViewModel(
|
|||||||
|
|
||||||
private fun logCurrentFavoriteState() {
|
private fun logCurrentFavoriteState() {
|
||||||
Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===")
|
Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===")
|
||||||
Log.i("ChatViewModel", "LiveData favorite peers: ${favoritePeers.value}")
|
Log.i("ChatViewModel", "StateFlow favorite peers: ${favoritePeers.value}")
|
||||||
Log.i("ChatViewModel", "DataManager favorite peers: ${dataManager.favoritePeers}")
|
Log.i("ChatViewModel", "DataManager favorite peers: ${dataManager.favoritePeers}")
|
||||||
Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}")
|
Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}")
|
||||||
Log.i("ChatViewModel", "==============================")
|
Log.i("ChatViewModel", "==============================")
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import androidx.compose.material.icons.outlined.Explore
|
|||||||
import androidx.compose.material.icons.outlined.LocationOn
|
import androidx.compose.material.icons.outlined.LocationOn
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -21,6 +20,7 @@ import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.bitchat.android.R
|
import com.bitchat.android.R
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,11 +46,11 @@ fun GeohashPeopleList(
|
|||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
|
||||||
// Observe geohash people from ChatViewModel
|
// Observe geohash people from ChatViewModel
|
||||||
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList())
|
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||||
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||||
val isTeleported by viewModel.isTeleported.observeAsState(false)
|
val isTeleported by viewModel.isTeleported.collectAsStateWithLifecycle()
|
||||||
val nickname by viewModel.nickname.observeAsState("")
|
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||||
val unreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
|
val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
// Header matching iOS style
|
// Header matching iOS style
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package com.bitchat.android.ui
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.bitchat.android.nostr.GeohashMessageHandler
|
import com.bitchat.android.nostr.GeohashMessageHandler
|
||||||
import com.bitchat.android.nostr.GeohashRepository
|
import com.bitchat.android.nostr.GeohashRepository
|
||||||
@@ -15,6 +14,7 @@ import com.bitchat.android.nostr.NostrSubscriptionManager
|
|||||||
import com.bitchat.android.nostr.PoWPreferenceManager
|
import com.bitchat.android.nostr.PoWPreferenceManager
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
@@ -55,9 +55,9 @@ class GeohashViewModel(
|
|||||||
private var geoTimer: Job? = null
|
private var geoTimer: Job? = null
|
||||||
private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null
|
private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null
|
||||||
|
|
||||||
val geohashPeople: LiveData<List<GeoPerson>> = state.geohashPeople
|
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
|
||||||
val geohashParticipantCounts: LiveData<Map<String, Int>> = state.geohashParticipantCounts
|
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
|
||||||
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
|
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
|
||||||
|
|
||||||
fun initialize() {
|
fun initialize() {
|
||||||
subscriptionManager.connect()
|
subscriptionManager.connect()
|
||||||
@@ -73,12 +73,16 @@ class GeohashViewModel(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication())
|
locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication())
|
||||||
locationChannelManager?.selectedChannel?.observeForever { channel ->
|
viewModelScope.launch {
|
||||||
state.setSelectedLocationChannel(channel)
|
locationChannelManager?.selectedChannel?.collect { channel ->
|
||||||
switchLocationChannel(channel)
|
state.setSelectedLocationChannel(channel)
|
||||||
|
switchLocationChannel(channel)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
locationChannelManager?.teleported?.observeForever { teleported ->
|
viewModelScope.launch {
|
||||||
state.setIsTeleported(teleported)
|
locationChannelManager?.teleported?.collect { teleported ->
|
||||||
|
state.setIsTeleported(teleported)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to initialize location channel state: ${e.message}")
|
Log.e(TAG, "Failed to initialize location channel state: ${e.message}")
|
||||||
@@ -120,7 +124,7 @@ class GeohashViewModel(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication())
|
val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication())
|
||||||
val teleported = state.isTeleported.value ?: false
|
val teleported = state.isTeleported.value
|
||||||
val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported)
|
val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported)
|
||||||
val relayManager = NostrRelayManager.getInstance(getApplication())
|
val relayManager = NostrRelayManager.getInstance(getApplication())
|
||||||
relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5)
|
relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5)
|
||||||
@@ -231,7 +235,7 @@ class GeohashViewModel(
|
|||||||
try {
|
try {
|
||||||
val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication())
|
val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication())
|
||||||
repo.updateParticipant(channel.channel.geohash, identity.publicKeyHex, Date())
|
repo.updateParticipant(channel.channel.geohash, identity.publicKeyHex, Date())
|
||||||
val teleported = state.isTeleported.value ?: false
|
val teleported = state.isTeleported.value
|
||||||
if (teleported) repo.markTeleported(identity.publicKeyHex)
|
if (teleported) repo.markTeleported(identity.publicKeyHex)
|
||||||
} catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") }
|
} catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") }
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import androidx.compose.material.icons.filled.PinDrop
|
|||||||
import androidx.compose.material.icons.outlined.BookmarkBorder
|
import androidx.compose.material.icons.outlined.BookmarkBorder
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
@@ -38,7 +37,9 @@ import com.bitchat.android.geohash.LocationChannelManager
|
|||||||
import com.bitchat.android.geohash.GeohashBookmarksStore
|
import com.bitchat.android.geohash.GeohashBookmarksStore
|
||||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.bitchat.android.R
|
import com.bitchat.android.R
|
||||||
|
import com.bitchat.android.core.ui.component.button.CloseButton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Location Channels Sheet for selecting geohash-based location channels
|
* Location Channels Sheet for selecting geohash-based location channels
|
||||||
@@ -57,18 +58,18 @@ fun LocationChannelsSheet(
|
|||||||
val bookmarksStore = remember { GeohashBookmarksStore.getInstance(context) }
|
val bookmarksStore = remember { GeohashBookmarksStore.getInstance(context) }
|
||||||
|
|
||||||
// Observe location manager state
|
// Observe location manager state
|
||||||
val permissionState by locationManager.permissionState.observeAsState()
|
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
|
||||||
val availableChannels by locationManager.availableChannels.observeAsState(emptyList())
|
val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
|
||||||
val selectedChannel by locationManager.selectedChannel.observeAsState()
|
val selectedChannel by locationManager.selectedChannel.collectAsStateWithLifecycle()
|
||||||
val locationNames by locationManager.locationNames.observeAsState(emptyMap())
|
val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
|
||||||
val locationServicesEnabled by locationManager.locationServicesEnabled.observeAsState(false)
|
val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Observe bookmarks state
|
// Observe bookmarks state
|
||||||
val bookmarks by bookmarksStore.bookmarks.observeAsState(emptyList())
|
val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle()
|
||||||
val bookmarkNames by bookmarksStore.bookmarkNames.observeAsState(emptyMap())
|
val bookmarkNames by bookmarksStore.bookmarkNames.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Observe reactive participant counts
|
// Observe reactive participant counts
|
||||||
val geohashParticipantCounts by viewModel.geohashParticipantCounts.observeAsState(emptyMap())
|
val geohashParticipantCounts by viewModel.geohashParticipantCounts.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
var customGeohash by remember { mutableStateOf("") }
|
var customGeohash by remember { mutableStateOf("") }
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -35,10 +35,10 @@ fun LocationNotesButton(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
// Get channel and permission state
|
// Get channel and permission state
|
||||||
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||||
val locationManager = remember { LocationChannelManager.getInstance(context) }
|
val locationManager = remember { LocationChannelManager.getInstance(context) }
|
||||||
val permissionState by locationManager.permissionState.observeAsState()
|
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
|
||||||
val locationServicesEnabled by locationManager.locationServicesEnabled.observeAsState(false)
|
val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle(false)
|
||||||
|
|
||||||
// Check both permission AND location services enabled
|
// Check both permission AND location services enabled
|
||||||
val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED
|
val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED
|
||||||
@@ -46,7 +46,7 @@ fun LocationNotesButton(
|
|||||||
|
|
||||||
// Get notes count from LocationNotesManager
|
// Get notes count from LocationNotesManager
|
||||||
val notesManager = remember { LocationNotesManager.getInstance() }
|
val notesManager = remember { LocationNotesManager.getInstance() }
|
||||||
val notes by notesManager.notes.observeAsState(emptyList())
|
val notes by notesManager.notes.collectAsStateWithLifecycle()
|
||||||
val notesCount = notes.size
|
val notesCount = notes.size
|
||||||
|
|
||||||
// Only show in mesh mode when location is authorized (iOS pattern)
|
// Only show in mesh mode when location is authorized (iOS pattern)
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.filled.ArrowUpward
|
import androidx.compose.material.icons.filled.ArrowUpward
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -25,6 +24,7 @@ import com.bitchat.android.R
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.bitchat.android.geohash.GeohashChannelLevel
|
import com.bitchat.android.geohash.GeohashChannelLevel
|
||||||
import com.bitchat.android.geohash.LocationChannelManager
|
import com.bitchat.android.geohash.LocationChannelManager
|
||||||
import com.bitchat.android.nostr.LocationNotesManager
|
import com.bitchat.android.nostr.LocationNotesManager
|
||||||
@@ -57,16 +57,16 @@ fun LocationNotesSheet(
|
|||||||
val locationManager = remember { LocationChannelManager.getInstance(context) }
|
val locationManager = remember { LocationChannelManager.getInstance(context) }
|
||||||
|
|
||||||
// State
|
// State
|
||||||
val notes by notesManager.notes.observeAsState(emptyList())
|
val notes by notesManager.notes.collectAsStateWithLifecycle()
|
||||||
val state by notesManager.state.observeAsState(LocationNotesManager.State.IDLE)
|
val state by notesManager.state.collectAsStateWithLifecycle(LocationNotesManager.State.IDLE)
|
||||||
val errorMessage by notesManager.errorMessage.observeAsState()
|
val errorMessage by notesManager.errorMessage.collectAsStateWithLifecycle()
|
||||||
val initialLoadComplete by notesManager.initialLoadComplete.observeAsState(false)
|
val initialLoadComplete by notesManager.initialLoadComplete.collectAsStateWithLifecycle(false)
|
||||||
|
|
||||||
// SIMPLIFIED: Get count directly from notes list (no separate counter needed)
|
// SIMPLIFIED: Get count directly from notes list (no separate counter needed)
|
||||||
val count = notes.size
|
val count = notes.size
|
||||||
|
|
||||||
// Get location name (building or block) - matches iOS locationNames lookup
|
// Get location name (building or block) - matches iOS locationNames lookup
|
||||||
val locationNames by locationManager.locationNames.observeAsState(emptyMap())
|
val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
|
||||||
val displayLocationName = locationNames[GeohashChannelLevel.BUILDING]?.takeIf { it.isNotEmpty() }
|
val displayLocationName = locationNames[GeohashChannelLevel.BUILDING]?.takeIf { it.isNotEmpty() }
|
||||||
?: locationNames[GeohashChannelLevel.BLOCK]?.takeIf { it.isNotEmpty() }
|
?: locationNames[GeohashChannelLevel.BLOCK]?.takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import androidx.compose.foundation.layout.*
|
|||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.bitchat.android.geohash.GeohashChannelLevel
|
import com.bitchat.android.geohash.GeohashChannelLevel
|
||||||
import com.bitchat.android.geohash.LocationChannelManager
|
import com.bitchat.android.geohash.LocationChannelManager
|
||||||
|
|
||||||
@@ -26,15 +26,15 @@ fun LocationNotesSheetPresenter(
|
|||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val locationManager = remember { LocationChannelManager.getInstance(context) }
|
val locationManager = remember { LocationChannelManager.getInstance(context) }
|
||||||
val availableChannels by locationManager.availableChannels.observeAsState(emptyList())
|
val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
|
||||||
val nickname by viewModel.nickname.observeAsState("")
|
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// iOS pattern: notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash
|
// iOS pattern: notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash
|
||||||
val buildingGeohash = availableChannels.firstOrNull { it.level == GeohashChannelLevel.BUILDING }?.geohash
|
val buildingGeohash = availableChannels.firstOrNull { it.level == GeohashChannelLevel.BUILDING }?.geohash
|
||||||
|
|
||||||
if (buildingGeohash != null) {
|
if (buildingGeohash != null) {
|
||||||
// Get location name from locationManager
|
// Get location name from locationManager
|
||||||
val locationNames by locationManager.locationNames.observeAsState(emptyMap())
|
val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
|
||||||
val locationName = locationNames[GeohashChannelLevel.BUILDING]
|
val locationName = locationNames[GeohashChannelLevel.BUILDING]
|
||||||
?: locationNames[GeohashChannelLevel.BLOCK]
|
?: locationNames[GeohashChannelLevel.BLOCK]
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -26,7 +27,7 @@ private enum class CharacterAnimationState {
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun shouldAnimateMessage(messageId: String): Boolean {
|
fun shouldAnimateMessage(messageId: String): Boolean {
|
||||||
val miningMessages by PoWMiningTracker.miningMessages.collectAsState()
|
val miningMessages by PoWMiningTracker.miningMessages.collectAsStateWithLifecycle()
|
||||||
return miningMessages.contains(messageId)
|
return miningMessages.contains(messageId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,10 +46,10 @@ class MeshDelegateHandler(
|
|||||||
if (message.isPrivate) {
|
if (message.isPrivate) {
|
||||||
// Private message
|
// Private message
|
||||||
privateChatManager.handleIncomingPrivateMessage(message)
|
privateChatManager.handleIncomingPrivateMessage(message)
|
||||||
|
|
||||||
// Reactive read receipts: Send immediately if user is currently viewing this chat
|
// Reactive read receipts: if chat is focused, send immediately for this message
|
||||||
message.senderPeerID?.let { senderPeerID ->
|
message.senderPeerID?.let { senderPeerID ->
|
||||||
sendReadReceiptIfFocused(senderPeerID)
|
sendReadReceiptIfFocused(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show notification with enhanced information - now includes senderPeerID
|
// Show notification with enhanced information - now includes senderPeerID
|
||||||
@@ -64,15 +64,26 @@ class MeshDelegateHandler(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (message.channel != null) {
|
} else if (message.channel != null) {
|
||||||
// Channel message
|
// Channel message: AppStateStore is the source of truth for list; only manage unread
|
||||||
if (state.getJoinedChannelsValue().contains(message.channel)) {
|
if (state.getJoinedChannelsValue().contains(message.channel)) {
|
||||||
channelManager.addChannelMessage(message.channel, message, message.senderPeerID)
|
val channel = message.channel
|
||||||
|
val viewingClassic = state.getCurrentChannelValue() == channel
|
||||||
|
val viewingGeohash = try {
|
||||||
|
if (channel.startsWith("geo:")) {
|
||||||
|
val geo = channel.removePrefix("geo:")
|
||||||
|
val selected = state.selectedLocationChannel.value
|
||||||
|
selected is com.bitchat.android.geohash.ChannelID.Location && selected.channel.geohash.equals(geo, ignoreCase = true)
|
||||||
|
} else false
|
||||||
|
} catch (_: Exception) { false }
|
||||||
|
if (!viewingClassic && !viewingGeohash) {
|
||||||
|
val currentUnread = state.getUnreadChannelMessagesValue().toMutableMap()
|
||||||
|
currentUnread[channel] = (currentUnread[channel] ?: 0) + 1
|
||||||
|
state.setUnreadChannelMessages(currentUnread)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Public mesh message - always store to preserve message history
|
// Public mesh message: AppStateStore is the source of truth; avoid double-adding to UI state
|
||||||
messageManager.addMessage(message)
|
// Still run mention detection/notifications
|
||||||
|
|
||||||
// Check for mentions in mesh chat
|
|
||||||
checkAndTriggerMeshMentionNotification(message)
|
checkAndTriggerMeshMentionNotification(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,21 +274,31 @@ class MeshDelegateHandler(
|
|||||||
* Uses same logic as notification system - send read receipt if user is currently
|
* Uses same logic as notification system - send read receipt if user is currently
|
||||||
* viewing the private chat with this sender AND app is in foreground.
|
* viewing the private chat with this sender AND app is in foreground.
|
||||||
*/
|
*/
|
||||||
private fun sendReadReceiptIfFocused(senderPeerID: String) {
|
private fun sendReadReceiptIfFocused(message: BitchatMessage) {
|
||||||
// Get notification manager's focus state (mirror the notification logic)
|
// Get notification manager's focus state (mirror the notification logic)
|
||||||
val isAppInBackground = notificationManager.getAppBackgroundState()
|
val isAppInBackground = notificationManager.getAppBackgroundState()
|
||||||
val currentPrivateChatPeer = notificationManager.getCurrentPrivateChatPeer()
|
val currentPrivateChatPeer = notificationManager.getCurrentPrivateChatPeer()
|
||||||
|
|
||||||
// Send read receipt if user is currently focused on this specific chat
|
// Send read receipt if user is currently focused on this specific chat
|
||||||
val shouldSendReadReceipt = !isAppInBackground && currentPrivateChatPeer == senderPeerID
|
val senderPeerID = message.senderPeerID
|
||||||
|
val shouldSendReadReceipt = !isAppInBackground && senderPeerID != null && currentPrivateChatPeer == senderPeerID
|
||||||
|
|
||||||
if (shouldSendReadReceipt) {
|
if (shouldSendReadReceipt) {
|
||||||
android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID")
|
android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID (message=${message.id})")
|
||||||
privateChatManager.sendReadReceiptsForPeer(senderPeerID, getMeshService())
|
val nickname = state.getNicknameValue() ?: "unknown"
|
||||||
} else {
|
// Send directly for this message to avoid relying on unread queues
|
||||||
android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)")
|
getMeshService().sendReadReceipt(message.id, senderPeerID!!, nickname)
|
||||||
|
// Ensure unread badge is cleared for this peer immediately
|
||||||
|
try {
|
||||||
|
val current = state.getUnreadPrivateMessagesValue().toMutableSet()
|
||||||
|
if (current.remove(senderPeerID)) {
|
||||||
|
state.setUnreadPrivateMessages(current)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
} else {
|
||||||
|
android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager
|
// registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ class MessageManager(private val state: ChatState) {
|
|||||||
val currentMessages = state.getMessagesValue().toMutableList()
|
val currentMessages = state.getMessagesValue().toMutableList()
|
||||||
currentMessages.add(message)
|
currentMessages.add(message)
|
||||||
state.setMessages(currentMessages)
|
state.setMessages(currentMessages)
|
||||||
|
// Reflect into process-wide store so snapshot replacements don't drop local outgoing messages
|
||||||
|
try { com.bitchat.android.services.AppStateStore.addPublicMessage(message) } catch (_: Exception) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log a system message into the main chat (visible to user)
|
// Log a system message into the main chat (visible to user)
|
||||||
@@ -52,6 +54,8 @@ class MessageManager(private val state: ChatState) {
|
|||||||
channelMessageList.add(message)
|
channelMessageList.add(message)
|
||||||
currentChannelMessages[channel] = channelMessageList
|
currentChannelMessages[channel] = channelMessageList
|
||||||
state.setChannelMessages(currentChannelMessages)
|
state.setChannelMessages(currentChannelMessages)
|
||||||
|
// Reflect into process-wide store
|
||||||
|
try { com.bitchat.android.services.AppStateStore.addChannelMessage(channel, message) } catch (_: Exception) { }
|
||||||
|
|
||||||
// Update unread count if not currently viewing this channel
|
// Update unread count if not currently viewing this channel
|
||||||
// Consider both classic channels (state.currentChannel) and geohash location channel selection
|
// Consider both classic channels (state.currentChannel) and geohash location channel selection
|
||||||
@@ -105,6 +109,8 @@ class MessageManager(private val state: ChatState) {
|
|||||||
chatMessages.add(message)
|
chatMessages.add(message)
|
||||||
currentPrivateChats[peerID] = chatMessages
|
currentPrivateChats[peerID] = chatMessages
|
||||||
state.setPrivateChats(currentPrivateChats)
|
state.setPrivateChats(currentPrivateChats)
|
||||||
|
// Reflect into process-wide store
|
||||||
|
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(peerID, message) } catch (_: Exception) { }
|
||||||
|
|
||||||
// Mark as unread if not currently viewing this chat
|
// Mark as unread if not currently viewing this chat
|
||||||
if (state.getSelectedPrivateChatPeerValue() != peerID && message.sender != state.getNicknameValue()) {
|
if (state.getSelectedPrivateChatPeerValue() != peerID && message.sender != state.getNicknameValue()) {
|
||||||
@@ -124,6 +130,8 @@ class MessageManager(private val state: ChatState) {
|
|||||||
chatMessages.add(message)
|
chatMessages.add(message)
|
||||||
currentPrivateChats[peerID] = chatMessages
|
currentPrivateChats[peerID] = chatMessages
|
||||||
state.setPrivateChats(currentPrivateChats)
|
state.setPrivateChats(currentPrivateChats)
|
||||||
|
// Reflect into process-wide store
|
||||||
|
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(peerID, message) } catch (_: Exception) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearPrivateMessages(peerID: String) {
|
fun clearPrivateMessages(peerID: String) {
|
||||||
@@ -206,6 +214,21 @@ class MessageManager(private val state: ChatState) {
|
|||||||
|
|
||||||
// MARK: - Delivery Status Updates
|
// MARK: - Delivery Status Updates
|
||||||
|
|
||||||
|
private fun statusPriority(status: DeliveryStatus?): Int = when (status) {
|
||||||
|
null -> 0
|
||||||
|
is DeliveryStatus.Sending -> 1
|
||||||
|
is DeliveryStatus.Sent -> 2
|
||||||
|
is DeliveryStatus.PartiallyDelivered -> 3
|
||||||
|
is DeliveryStatus.Delivered -> 4
|
||||||
|
is DeliveryStatus.Read -> 5
|
||||||
|
is DeliveryStatus.Failed -> 0 // treat as lowest for UI check marks ordering
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chooseStatus(old: DeliveryStatus?, new: DeliveryStatus): DeliveryStatus? {
|
||||||
|
// Never downgrade (e.g., Read -> Delivered). Keep the higher priority.
|
||||||
|
return if (statusPriority(new) >= statusPriority(old)) new else old
|
||||||
|
}
|
||||||
|
|
||||||
fun updateMessageDeliveryStatus(messageID: String, status: DeliveryStatus) {
|
fun updateMessageDeliveryStatus(messageID: String, status: DeliveryStatus) {
|
||||||
// Update in private chats
|
// Update in private chats
|
||||||
val updatedPrivateChats = state.getPrivateChatsValue().toMutableMap()
|
val updatedPrivateChats = state.getPrivateChatsValue().toMutableMap()
|
||||||
@@ -215,22 +238,32 @@ class MessageManager(private val state: ChatState) {
|
|||||||
val updatedMessages = messages.toMutableList()
|
val updatedMessages = messages.toMutableList()
|
||||||
val messageIndex = updatedMessages.indexOfFirst { it.id == messageID }
|
val messageIndex = updatedMessages.indexOfFirst { it.id == messageID }
|
||||||
if (messageIndex >= 0) {
|
if (messageIndex >= 0) {
|
||||||
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = status)
|
val current = updatedMessages[messageIndex].deliveryStatus
|
||||||
updatedPrivateChats[peerID] = updatedMessages
|
val finalStatus = chooseStatus(current, status)
|
||||||
updated = true
|
if (finalStatus !== current) {
|
||||||
|
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = finalStatus)
|
||||||
|
updatedPrivateChats[peerID] = updatedMessages
|
||||||
|
updated = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updated) {
|
if (updated) {
|
||||||
state.setPrivateChats(updatedPrivateChats)
|
state.setPrivateChats(updatedPrivateChats)
|
||||||
|
// Keep process-wide store in sync to prevent snapshot overwrites resetting status
|
||||||
|
try { com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(messageID, status) } catch (_: Exception) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update in main messages
|
// Update in main messages
|
||||||
val updatedMessages = state.getMessagesValue().toMutableList()
|
val updatedMessages = state.getMessagesValue().toMutableList()
|
||||||
val messageIndex = updatedMessages.indexOfFirst { it.id == messageID }
|
val messageIndex = updatedMessages.indexOfFirst { it.id == messageID }
|
||||||
if (messageIndex >= 0) {
|
if (messageIndex >= 0) {
|
||||||
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = status)
|
val current = updatedMessages[messageIndex].deliveryStatus
|
||||||
state.setMessages(updatedMessages)
|
val finalStatus = chooseStatus(current, status)
|
||||||
|
if (finalStatus !== current) {
|
||||||
|
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = finalStatus)
|
||||||
|
state.setMessages(updatedMessages)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update in channel messages
|
// Update in channel messages
|
||||||
@@ -239,8 +272,12 @@ class MessageManager(private val state: ChatState) {
|
|||||||
val channelMessagesList = messages.toMutableList()
|
val channelMessagesList = messages.toMutableList()
|
||||||
val channelMessageIndex = channelMessagesList.indexOfFirst { it.id == messageID }
|
val channelMessageIndex = channelMessagesList.indexOfFirst { it.id == messageID }
|
||||||
if (channelMessageIndex >= 0) {
|
if (channelMessageIndex >= 0) {
|
||||||
channelMessagesList[channelMessageIndex] = channelMessagesList[channelMessageIndex].copy(deliveryStatus = status)
|
val current = channelMessagesList[channelMessageIndex].deliveryStatus
|
||||||
updatedChannelMessages[channel] = channelMessagesList
|
val finalStatus = chooseStatus(current, status)
|
||||||
|
if (finalStatus !== current) {
|
||||||
|
channelMessagesList[channelMessageIndex] = channelMessagesList[channelMessageIndex].copy(deliveryStatus = finalStatus)
|
||||||
|
updatedChannelMessages[channel] = channelMessagesList
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
state.setChannelMessages(updatedChannelMessages)
|
state.setChannelMessages(updatedChannelMessages)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.bitchat.android.nostr.NostrProofOfWork
|
import com.bitchat.android.nostr.NostrProofOfWork
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.bitchat.android.R
|
import com.bitchat.android.R
|
||||||
import com.bitchat.android.nostr.PoWPreferenceManager
|
import com.bitchat.android.nostr.PoWPreferenceManager
|
||||||
|
|
||||||
@@ -27,9 +28,9 @@ fun PoWStatusIndicator(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
style: PoWIndicatorStyle = PoWIndicatorStyle.COMPACT
|
style: PoWIndicatorStyle = PoWIndicatorStyle.COMPACT
|
||||||
) {
|
) {
|
||||||
val powEnabled by PoWPreferenceManager.powEnabled.collectAsState()
|
val powEnabled by PoWPreferenceManager.powEnabled.collectAsStateWithLifecycle()
|
||||||
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState()
|
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsStateWithLifecycle()
|
||||||
val isMining by PoWPreferenceManager.isMining.collectAsState()
|
val isMining by PoWPreferenceManager.isMining.collectAsStateWithLifecycle()
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||||
|
|
||||||
|
|||||||
@@ -292,28 +292,30 @@ class PrivateChatManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun handleIncomingPrivateMessage(message: BitchatMessage, suppressUnread: Boolean) {
|
fun handleIncomingPrivateMessage(message: BitchatMessage, suppressUnread: Boolean) {
|
||||||
message.senderPeerID?.let { senderPeerID ->
|
val senderPeerID = message.senderPeerID
|
||||||
|
if (senderPeerID != null) {
|
||||||
|
// Mesh-origin private message: AppStateStore updates the list; avoid double-add here.
|
||||||
if (!isPeerBlocked(senderPeerID)) {
|
if (!isPeerBlocked(senderPeerID)) {
|
||||||
// Add to private messages
|
// Ensure chat exists
|
||||||
if (suppressUnread) {
|
messageManager.initializePrivateChat(senderPeerID)
|
||||||
messageManager.addPrivateMessageNoUnread(senderPeerID, message)
|
// Track as unread for read receipt purposes if not focused
|
||||||
} else {
|
if (!suppressUnread && state.getSelectedPrivateChatPeerValue() != senderPeerID) {
|
||||||
messageManager.addPrivateMessage(senderPeerID, message)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track as unread for read receipt purposes
|
|
||||||
var unreadCount = 0
|
|
||||||
if (!suppressUnread) {
|
|
||||||
val unreadList = unreadReceivedMessages.getOrPut(senderPeerID) { mutableListOf() }
|
val unreadList = unreadReceivedMessages.getOrPut(senderPeerID) { mutableListOf() }
|
||||||
unreadList.add(message)
|
unreadList.add(message)
|
||||||
unreadCount = unreadList.size
|
Log.d(TAG, "Queued unread from $senderPeerID (count=${unreadList.size})")
|
||||||
|
val currentUnread = state.getUnreadPrivateMessagesValue().toMutableSet()
|
||||||
|
currentUnread.add(senderPeerID)
|
||||||
|
state.setUnreadPrivateMessages(currentUnread)
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"Added received message ${message.id} from $senderPeerID to unread list (${unreadCount} unread)"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Non-mesh path (e.g., Nostr): add to UI state using existing logic
|
||||||
|
val inferredPeer = state.getSelectedPrivateChatPeerValue() ?: return
|
||||||
|
if (suppressUnread) {
|
||||||
|
messageManager.addPrivateMessageNoUnread(inferredPeer, message)
|
||||||
|
} else {
|
||||||
|
messageManager.addPrivateMessage(inferredPeer, message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,27 +324,33 @@ class PrivateChatManager(
|
|||||||
* Called when the user focuses on a private chat
|
* Called when the user focuses on a private chat
|
||||||
*/
|
*/
|
||||||
fun sendReadReceiptsForPeer(peerID: String, meshService: BluetoothMeshService) {
|
fun sendReadReceiptsForPeer(peerID: String, meshService: BluetoothMeshService) {
|
||||||
val unreadList = unreadReceivedMessages[peerID]
|
// Collect candidate messages: all incoming messages from this peer in the conversation
|
||||||
if (unreadList.isNullOrEmpty()) {
|
val chats = try { state.getPrivateChatsValue() } catch (_: Exception) { emptyMap<String, List<BitchatMessage>>() }
|
||||||
Log.d(TAG, "No unread messages to send read receipts for peer $peerID")
|
val messages = chats[peerID].orEmpty()
|
||||||
return
|
|
||||||
|
if (messages.isEmpty()) {
|
||||||
|
Log.d(TAG, "No messages found for peer $peerID to send read receipts")
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(TAG, "Sending read receipts for ${unreadList.size} unread messages from $peerID")
|
val myNickname = state.getNicknameValue() ?: "unknown"
|
||||||
|
var sentCount = 0
|
||||||
// Send read receipt for each unread message - now using direct method call
|
messages.forEach { msg ->
|
||||||
unreadList.forEach { message ->
|
// Only for incoming messages from this peer
|
||||||
try {
|
if (msg.senderPeerID == peerID) {
|
||||||
val myNickname = state.getNicknameValue() ?: "unknown"
|
try {
|
||||||
meshService.sendReadReceipt(message.id, peerID, myNickname)
|
meshService.sendReadReceipt(msg.id, peerID, myNickname)
|
||||||
Log.d(TAG, "Sent read receipt for message ${message.id} to $peerID")
|
sentCount += 1
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Failed to send read receipt for message ${message.id}: ${e.message}")
|
Log.w(TAG, "Failed to send read receipt for message ${msg.id}: ${e.message}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the unread list since we've sent read receipts
|
// Clear any locally tracked unread queue for this peer
|
||||||
unreadReceivedMessages.remove(peerID)
|
unreadReceivedMessages.remove(peerID)
|
||||||
|
// Also clear UI unread marker for this peer now that chat is focused/read
|
||||||
|
try { messageManager.clearPrivateUnreadMessages(peerID) } catch (_: Exception) { }
|
||||||
|
Log.d(TAG, "Sent $sentCount read receipts for peer $peerID (from conversation messages)")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cleanupDisconnectedPeer(peerID: String) {
|
fun cleanupDisconnectedPeer(peerID: String) {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import androidx.compose.material.icons.filled.*
|
|||||||
import androidx.compose.material.icons.outlined.*
|
import androidx.compose.material.icons.outlined.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -22,6 +21,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||||
|
|
||||||
|
|
||||||
@@ -39,14 +39,14 @@ fun SidebarOverlay(
|
|||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
|
||||||
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList())
|
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
|
||||||
val joinedChannels by viewModel.joinedChannels.observeAsState(emptyList())
|
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
|
||||||
val currentChannel by viewModel.currentChannel.observeAsState()
|
val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
|
||||||
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState()
|
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
|
||||||
val nickname by viewModel.nickname.observeAsState("")
|
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||||
val unreadChannelMessages by viewModel.unreadChannelMessages.observeAsState(emptyMap())
|
val unreadChannelMessages by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
|
||||||
val peerNicknames by viewModel.peerNicknames.observeAsState(emptyMap())
|
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
|
||||||
val peerRSSI by viewModel.peerRSSI.observeAsState(emptyMap())
|
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -110,7 +110,7 @@ fun SidebarOverlay(
|
|||||||
|
|
||||||
// People section - switch between mesh and geohash lists (iOS-compatible)
|
// People section - switch between mesh and geohash lists (iOS-compatible)
|
||||||
item {
|
item {
|
||||||
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsState()
|
||||||
|
|
||||||
when (selectedLocationChannel) {
|
when (selectedLocationChannel) {
|
||||||
is com.bitchat.android.geohash.ChannelID.Location -> {
|
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||||
@@ -291,10 +291,10 @@ fun PeopleSection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Observe reactive state for favorites and fingerprints
|
// Observe reactive state for favorites and fingerprints
|
||||||
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
|
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
||||||
val privateChats by viewModel.privateChats.observeAsState(emptyMap())
|
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
|
||||||
val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet())
|
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
|
||||||
val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap())
|
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Reactive favorite computation for all peers
|
// Reactive favorite computation for all peers
|
||||||
val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) {
|
val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) {
|
||||||
@@ -384,7 +384,7 @@ fun PeopleSection(
|
|||||||
val (bName, _) = com.bitchat.android.ui.splitSuffix(displayName)
|
val (bName, _) = com.bitchat.android.ui.splitSuffix(displayName)
|
||||||
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
||||||
|
|
||||||
val directMap by viewModel.peerDirect.observeAsState(emptyMap())
|
val directMap by viewModel.peerDirect.collectAsStateWithLifecycle()
|
||||||
val isDirectLive = directMap[peerID] ?: try { viewModel.meshService.getPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false }
|
val isDirectLive = directMap[peerID] ?: try { viewModel.meshService.getPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false }
|
||||||
PeerItem(
|
PeerItem(
|
||||||
peerID = peerID,
|
peerID = peerID,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ object DebugPreferenceManager {
|
|||||||
// GCS keys (no migration/back-compat)
|
// GCS keys (no migration/back-compat)
|
||||||
private const val KEY_GCS_MAX_BYTES = "gcs_max_filter_bytes"
|
private const val KEY_GCS_MAX_BYTES = "gcs_max_filter_bytes"
|
||||||
private const val KEY_GCS_FPR = "gcs_filter_fpr_percent"
|
private const val KEY_GCS_FPR = "gcs_filter_fpr_percent"
|
||||||
|
// Removed: persistent notification toggle is now governed by MeshServicePreferences.isBackgroundEnabled
|
||||||
|
|
||||||
private lateinit var prefs: SharedPreferences
|
private lateinit var prefs: SharedPreferences
|
||||||
|
|
||||||
@@ -100,4 +101,6 @@ object DebugPreferenceManager {
|
|||||||
fun setGcsFprPercent(value: Double) {
|
fun setGcsFprPercent(value: Double) {
|
||||||
if (ready()) prefs.edit().putLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(value)).apply()
|
if (ready()) prefs.edit().putLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(value)).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No longer storing persistent notification in debug prefs.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ class DebugSettingsManager private constructor() {
|
|||||||
private val _packetRelayEnabled = MutableStateFlow(true)
|
private val _packetRelayEnabled = MutableStateFlow(true)
|
||||||
val packetRelayEnabled: StateFlow<Boolean> = _packetRelayEnabled.asStateFlow()
|
val packetRelayEnabled: StateFlow<Boolean> = _packetRelayEnabled.asStateFlow()
|
||||||
|
|
||||||
|
// Visibility of the debug sheet; gates heavy work
|
||||||
|
private val _debugSheetVisible = MutableStateFlow(false)
|
||||||
|
val debugSheetVisible: StateFlow<Boolean> = _debugSheetVisible.asStateFlow()
|
||||||
|
fun setDebugSheetVisible(visible: Boolean) { _debugSheetVisible.value = visible }
|
||||||
|
|
||||||
// Connection limit overrides (debug)
|
// Connection limit overrides (debug)
|
||||||
private val _maxConnectionsOverall = MutableStateFlow(8)
|
private val _maxConnectionsOverall = MutableStateFlow(8)
|
||||||
val maxConnectionsOverall: StateFlow<Int> = _maxConnectionsOverall.asStateFlow()
|
val maxConnectionsOverall: StateFlow<Int> = _maxConnectionsOverall.asStateFlow()
|
||||||
@@ -75,12 +80,63 @@ class DebugSettingsManager private constructor() {
|
|||||||
|
|
||||||
// Timestamps to compute rolling window stats
|
// Timestamps to compute rolling window stats
|
||||||
private val relayTimestamps = ConcurrentLinkedQueue<Long>()
|
private val relayTimestamps = ConcurrentLinkedQueue<Long>()
|
||||||
|
// Per-device and per-peer rolling timestamps for stacked graphs
|
||||||
|
private val perDeviceRelayTimestamps = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
|
||||||
|
private val perPeerRelayTimestamps = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
|
||||||
|
|
||||||
|
// Additional buckets to split incoming vs outgoing
|
||||||
|
private val incomingTimestamps = ConcurrentLinkedQueue<Long>()
|
||||||
|
private val outgoingTimestamps = ConcurrentLinkedQueue<Long>()
|
||||||
|
private val perDeviceIncoming = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
|
||||||
|
private val perDeviceOutgoing = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
|
||||||
|
private val perPeerIncoming = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
|
||||||
|
private val perPeerOutgoing = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
|
||||||
|
|
||||||
|
// Expose current per-second rates (updated when logging/pruning occurs)
|
||||||
|
private val _perDeviceLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||||
|
val perDeviceLastSecond: StateFlow<Map<String, Int>> = _perDeviceLastSecond.asStateFlow()
|
||||||
|
private val _perPeerLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||||
|
val perPeerLastSecond: StateFlow<Map<String, Int>> = _perPeerLastSecond.asStateFlow()
|
||||||
|
// New flows used by UI for incoming/outgoing stacked plots
|
||||||
|
private val _perDeviceIncomingLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||||
|
val perDeviceIncomingLastSecond: StateFlow<Map<String, Int>> = _perDeviceIncomingLastSecond.asStateFlow()
|
||||||
|
private val _perDeviceOutgoingLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||||
|
val perDeviceOutgoingLastSecond: StateFlow<Map<String, Int>> = _perDeviceOutgoingLastSecond.asStateFlow()
|
||||||
|
private val _perPeerIncomingLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||||
|
val perPeerIncomingLastSecond: StateFlow<Map<String, Int>> = _perPeerIncomingLastSecond.asStateFlow()
|
||||||
|
private val _perPeerOutgoingLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||||
|
val perPeerOutgoingLastSecond: StateFlow<Map<String, Int>> = _perPeerOutgoingLastSecond.asStateFlow()
|
||||||
|
|
||||||
|
// Per-minute counts per key
|
||||||
|
private val _perDeviceIncomingLastMinute: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||||
|
val perDeviceIncomingLastMinute: StateFlow<Map<String, Int>> = _perDeviceIncomingLastMinute.asStateFlow()
|
||||||
|
private val _perDeviceOutgoingLastMinute: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||||
|
val perDeviceOutgoingLastMinute: StateFlow<Map<String, Int>> = _perDeviceOutgoingLastMinute.asStateFlow()
|
||||||
|
private val _perPeerIncomingLastMinute: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||||
|
val perPeerIncomingLastMinute: StateFlow<Map<String, Int>> = _perPeerIncomingLastMinute.asStateFlow()
|
||||||
|
private val _perPeerOutgoingLastMinute: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||||
|
val perPeerOutgoingLastMinute: StateFlow<Map<String, Int>> = _perPeerOutgoingLastMinute.asStateFlow()
|
||||||
|
|
||||||
|
// Totals per key (since app start)
|
||||||
|
private val deviceIncomingTotalsMap = mutableMapOf<String, Long>()
|
||||||
|
private val deviceOutgoingTotalsMap = mutableMapOf<String, Long>()
|
||||||
|
private val peerIncomingTotalsMap = mutableMapOf<String, Long>()
|
||||||
|
private val peerOutgoingTotalsMap = mutableMapOf<String, Long>()
|
||||||
|
private val _perDeviceIncomingTotalsFlow: MutableStateFlow<Map<String, Long>> = MutableStateFlow(emptyMap())
|
||||||
|
val perDeviceIncomingTotal: StateFlow<Map<String, Long>> = _perDeviceIncomingTotalsFlow.asStateFlow()
|
||||||
|
private val _perDeviceOutgoingTotalsFlow: MutableStateFlow<Map<String, Long>> = MutableStateFlow(emptyMap())
|
||||||
|
val perDeviceOutgoingTotal: StateFlow<Map<String, Long>> = _perDeviceOutgoingTotalsFlow.asStateFlow()
|
||||||
|
private val _perPeerIncomingTotalsFlow: MutableStateFlow<Map<String, Long>> = MutableStateFlow(emptyMap())
|
||||||
|
val perPeerIncomingTotal: StateFlow<Map<String, Long>> = _perPeerIncomingTotalsFlow.asStateFlow()
|
||||||
|
private val _perPeerOutgoingTotalsFlow: MutableStateFlow<Map<String, Long>> = MutableStateFlow(emptyMap())
|
||||||
|
val perPeerOutgoingTotal: StateFlow<Map<String, Long>> = _perPeerOutgoingTotalsFlow.asStateFlow()
|
||||||
|
|
||||||
// Internal data storage for managing debug data
|
// Internal data storage for managing debug data
|
||||||
private val debugMessageQueue = ConcurrentLinkedQueue<DebugMessage>()
|
private val debugMessageQueue = ConcurrentLinkedQueue<DebugMessage>()
|
||||||
private val scanResultsQueue = ConcurrentLinkedQueue<DebugScanResult>()
|
private val scanResultsQueue = ConcurrentLinkedQueue<DebugScanResult>()
|
||||||
|
|
||||||
private fun updateRelayStatsFromTimestamps() {
|
private fun updateRelayStatsFromTimestamps() {
|
||||||
|
if (!_debugSheetVisible.value) return
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
// prune older than 15m
|
// prune older than 15m
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -89,18 +145,84 @@ class DebugSettingsManager private constructor() {
|
|||||||
relayTimestamps.poll()
|
relayTimestamps.poll()
|
||||||
} else break
|
} else break
|
||||||
}
|
}
|
||||||
|
// prune per-device and per-peer and compute 1s/60s rates
|
||||||
|
fun pruneAndCount1s(map: MutableMap<String, ConcurrentLinkedQueue<Long>>): Map<String, Int> {
|
||||||
|
val result = mutableMapOf<String, Int>()
|
||||||
|
val iterator = map.entries.iterator()
|
||||||
|
while (iterator.hasNext()) {
|
||||||
|
val (key, q) = iterator.next()
|
||||||
|
// prune this queue
|
||||||
|
while (true) {
|
||||||
|
val ts = q.peek() ?: break
|
||||||
|
if (now - ts > 15 * 60 * 1000L) {
|
||||||
|
q.poll()
|
||||||
|
} else break
|
||||||
|
}
|
||||||
|
// count last 1s only
|
||||||
|
val count1s = q.count { now - it <= 1_000L }
|
||||||
|
if (q.isEmpty()) {
|
||||||
|
// cleanup empty queues to prevent unbounded growth
|
||||||
|
iterator.remove()
|
||||||
|
}
|
||||||
|
if (count1s > 0) result[key] = count1s
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
fun pruneAndCount60s(map: MutableMap<String, ConcurrentLinkedQueue<Long>>): Map<String, Int> {
|
||||||
|
val result = mutableMapOf<String, Int>()
|
||||||
|
map.forEach { (key, q) ->
|
||||||
|
val count60 = q.count { now - it <= 60_000L }
|
||||||
|
if (count60 > 0) result[key] = count60
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
val perDevice1s = pruneAndCount1s(perDeviceRelayTimestamps)
|
||||||
|
val perPeer1s = pruneAndCount1s(perPeerRelayTimestamps)
|
||||||
|
|
||||||
|
_perDeviceLastSecond.value = perDevice1s
|
||||||
|
_perPeerLastSecond.value = perPeer1s
|
||||||
|
// Also compute incoming/outgoing per-key rates
|
||||||
|
_perDeviceIncomingLastSecond.value = pruneAndCount1s(perDeviceIncoming)
|
||||||
|
_perDeviceOutgoingLastSecond.value = pruneAndCount1s(perDeviceOutgoing)
|
||||||
|
_perPeerIncomingLastSecond.value = pruneAndCount1s(perPeerIncoming)
|
||||||
|
_perPeerOutgoingLastSecond.value = pruneAndCount1s(perPeerOutgoing)
|
||||||
|
_perDeviceIncomingLastMinute.value = pruneAndCount60s(perDeviceIncoming)
|
||||||
|
_perDeviceOutgoingLastMinute.value = pruneAndCount60s(perDeviceOutgoing)
|
||||||
|
_perPeerIncomingLastMinute.value = pruneAndCount60s(perPeerIncoming)
|
||||||
|
_perPeerOutgoingLastMinute.value = pruneAndCount60s(perPeerOutgoing)
|
||||||
val last1s = relayTimestamps.count { now - it <= 1_000L }
|
val last1s = relayTimestamps.count { now - it <= 1_000L }
|
||||||
val last10s = relayTimestamps.count { now - it <= 10_000L }
|
val last10s = relayTimestamps.count { now - it <= 10_000L }
|
||||||
val last1m = relayTimestamps.count { now - it <= 60_000L }
|
val last1m = relayTimestamps.count { now - it <= 60_000L }
|
||||||
val last15m = relayTimestamps.size
|
val last15m = relayTimestamps.size
|
||||||
val total = _relayStats.value.totalRelaysCount + 1
|
// And incoming/outgoing per-second counters
|
||||||
|
val last1sIncoming = incomingTimestamps.count { now - it <= 1_000L }
|
||||||
|
val last1sOutgoing = outgoingTimestamps.count { now - it <= 1_000L }
|
||||||
|
val last10sIncoming = incomingTimestamps.count { now - it <= 10_000L }
|
||||||
|
val last10sOutgoing = outgoingTimestamps.count { now - it <= 10_000L }
|
||||||
|
val last1mIncoming = incomingTimestamps.count { now - it <= 60_000L }
|
||||||
|
val last1mOutgoing = outgoingTimestamps.count { now - it <= 60_000L }
|
||||||
|
val last15mIncoming = incomingTimestamps.size
|
||||||
|
val last15mOutgoing = outgoingTimestamps.size
|
||||||
|
val totalIncoming = _relayStats.value.totalIncomingCount
|
||||||
|
val totalOutgoing = _relayStats.value.totalOutgoingCount
|
||||||
_relayStats.value = PacketRelayStats(
|
_relayStats.value = PacketRelayStats(
|
||||||
totalRelaysCount = total,
|
totalRelaysCount = totalIncoming + totalOutgoing,
|
||||||
lastSecondRelays = last1s,
|
lastSecondRelays = last1s,
|
||||||
last10SecondRelays = last10s,
|
last10SecondRelays = last10s,
|
||||||
lastMinuteRelays = last1m,
|
lastMinuteRelays = last1m,
|
||||||
last15MinuteRelays = last15m,
|
last15MinuteRelays = last15m,
|
||||||
lastResetTime = _relayStats.value.lastResetTime
|
lastResetTime = _relayStats.value.lastResetTime,
|
||||||
|
lastSecondIncoming = last1sIncoming,
|
||||||
|
lastSecondOutgoing = last1sOutgoing,
|
||||||
|
last10SecondIncoming = last10sIncoming,
|
||||||
|
last10SecondOutgoing = last10sOutgoing,
|
||||||
|
lastMinuteIncoming = last1mIncoming,
|
||||||
|
lastMinuteOutgoing = last1mOutgoing,
|
||||||
|
last15MinuteIncoming = last15mIncoming,
|
||||||
|
last15MinuteOutgoing = last15mOutgoing,
|
||||||
|
totalIncomingCount = totalIncoming,
|
||||||
|
totalOutgoingCount = totalOutgoing
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,11 +458,61 @@ class DebugSettingsManager private constructor() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update rolling statistics only for relays
|
// Do not update counters here; this path is for readable logs only.
|
||||||
if (isRelay) {
|
}
|
||||||
relayTimestamps.offer(System.currentTimeMillis())
|
|
||||||
updateRelayStatsFromTimestamps()
|
// Explicit incoming/outgoing logging to avoid double counting
|
||||||
|
fun logIncoming(packetType: String, fromPeerID: String?, fromNickname: String?, fromDeviceAddress: String?) {
|
||||||
|
if (verboseLoggingEnabled.value) {
|
||||||
|
val who = fromNickname ?: fromPeerID ?: "unknown"
|
||||||
|
addDebugMessage(DebugMessage.PacketEvent("📥 Incoming $packetType from $who (${fromPeerID ?: "?"}, ${fromDeviceAddress ?: "?"})"))
|
||||||
}
|
}
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val visible = _debugSheetVisible.value
|
||||||
|
if (visible) incomingTimestamps.offer(now)
|
||||||
|
fromDeviceAddress?.let {
|
||||||
|
perDeviceIncoming.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now)
|
||||||
|
deviceIncomingTotalsMap[it] = (deviceIncomingTotalsMap[it] ?: 0L) + 1L
|
||||||
|
_perDeviceIncomingTotalsFlow.value = deviceIncomingTotalsMap.toMap()
|
||||||
|
}
|
||||||
|
fromPeerID?.let {
|
||||||
|
perPeerIncoming.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now)
|
||||||
|
peerIncomingTotalsMap[it] = (peerIncomingTotalsMap[it] ?: 0L) + 1L
|
||||||
|
_perPeerIncomingTotalsFlow.value = peerIncomingTotalsMap.toMap()
|
||||||
|
}
|
||||||
|
// bump totals
|
||||||
|
val cur = _relayStats.value
|
||||||
|
_relayStats.value = cur.copy(
|
||||||
|
totalIncomingCount = cur.totalIncomingCount + 1,
|
||||||
|
totalRelaysCount = cur.totalRelaysCount + 1
|
||||||
|
)
|
||||||
|
if (visible) updateRelayStatsFromTimestamps()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logOutgoing(packetType: String, toPeerID: String?, toNickname: String?, toDeviceAddress: String?, previousHopPeerID: String? = null) {
|
||||||
|
if (verboseLoggingEnabled.value) {
|
||||||
|
val who = toNickname ?: toPeerID ?: "unknown"
|
||||||
|
addDebugMessage(DebugMessage.PacketEvent("📤 Outgoing $packetType to $who (${toPeerID ?: "?"}, ${toDeviceAddress ?: "?"})"))
|
||||||
|
}
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val visible = _debugSheetVisible.value
|
||||||
|
if (visible) outgoingTimestamps.offer(now)
|
||||||
|
toDeviceAddress?.let {
|
||||||
|
perDeviceOutgoing.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now)
|
||||||
|
deviceOutgoingTotalsMap[it] = (deviceOutgoingTotalsMap[it] ?: 0L) + 1L
|
||||||
|
_perDeviceOutgoingTotalsFlow.value = deviceOutgoingTotalsMap.toMap()
|
||||||
|
}
|
||||||
|
(toPeerID ?: previousHopPeerID)?.let {
|
||||||
|
perPeerOutgoing.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now)
|
||||||
|
peerOutgoingTotalsMap[it] = (peerOutgoingTotalsMap[it] ?: 0L) + 1L
|
||||||
|
_perPeerOutgoingTotalsFlow.value = peerOutgoingTotalsMap.toMap()
|
||||||
|
}
|
||||||
|
val cur = _relayStats.value
|
||||||
|
_relayStats.value = cur.copy(
|
||||||
|
totalOutgoingCount = cur.totalOutgoingCount + 1,
|
||||||
|
totalRelaysCount = cur.totalRelaysCount + 1
|
||||||
|
)
|
||||||
|
if (visible) updateRelayStatsFromTimestamps()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Clear Data
|
// MARK: - Clear Data
|
||||||
@@ -407,5 +579,15 @@ data class PacketRelayStats(
|
|||||||
val last10SecondRelays: Int = 0,
|
val last10SecondRelays: Int = 0,
|
||||||
val lastMinuteRelays: Int = 0,
|
val lastMinuteRelays: Int = 0,
|
||||||
val last15MinuteRelays: Int = 0,
|
val last15MinuteRelays: Int = 0,
|
||||||
val lastResetTime: Date = Date()
|
val lastResetTime: Date = Date(),
|
||||||
|
val lastSecondIncoming: Int = 0,
|
||||||
|
val lastSecondOutgoing: Int = 0,
|
||||||
|
val last10SecondIncoming: Int = 0,
|
||||||
|
val last10SecondOutgoing: Int = 0,
|
||||||
|
val lastMinuteIncoming: Int = 0,
|
||||||
|
val lastMinuteOutgoing: Int = 0,
|
||||||
|
val last15MinuteIncoming: Int = 0,
|
||||||
|
val last15MinuteOutgoing: Int = 0,
|
||||||
|
val totalIncomingCount: Long = 0,
|
||||||
|
val totalOutgoingCount: Long = 0
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package com.bitchat.android.ui.debug
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Bluetooth
|
import androidx.compose.material.icons.filled.Bluetooth
|
||||||
import androidx.compose.material.icons.filled.BugReport
|
import androidx.compose.material.icons.filled.BugReport
|
||||||
@@ -15,6 +17,7 @@ import androidx.compose.material.icons.filled.PowerSettingsNew
|
|||||||
import androidx.compose.material.icons.filled.SettingsEthernet
|
import androidx.compose.material.icons.filled.SettingsEthernet
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -27,8 +30,13 @@ import com.bitchat.android.mesh.BluetoothMeshService
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import com.bitchat.android.R
|
import com.bitchat.android.R
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import com.bitchat.android.service.MeshServicePreferences
|
||||||
|
import com.bitchat.android.service.MeshForegroundService
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
private enum class GraphMode { OVERALL, PER_DEVICE, PER_PEER }
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun DebugSettingsSheet(
|
fun DebugSettingsSheet(
|
||||||
isPresented: Boolean,
|
isPresented: Boolean,
|
||||||
@@ -53,6 +61,8 @@ fun DebugSettingsSheet(
|
|||||||
val seenCapacity by manager.seenPacketCapacity.collectAsState()
|
val seenCapacity by manager.seenPacketCapacity.collectAsState()
|
||||||
val gcsMaxBytes by manager.gcsMaxBytes.collectAsState()
|
val gcsMaxBytes by manager.gcsMaxBytes.collectAsState()
|
||||||
val gcsFpr by manager.gcsFprPercent.collectAsState()
|
val gcsFpr by manager.gcsFprPercent.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
// Persistent notification is now controlled solely by MeshServicePreferences.isBackgroundEnabled
|
||||||
|
|
||||||
// Push live connected devices from mesh service whenever sheet is visible
|
// Push live connected devices from mesh service whenever sheet is visible
|
||||||
LaunchedEffect(isPresented) {
|
LaunchedEffect(isPresented) {
|
||||||
@@ -89,6 +99,11 @@ fun DebugSettingsSheet(
|
|||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
sheetState = sheetState
|
sheetState = sheetState
|
||||||
) {
|
) {
|
||||||
|
// Mark debug sheet visible/invisible to gate heavy work
|
||||||
|
LaunchedEffect(Unit) { DebugSettingsManager.getInstance().setDebugSheetVisible(true) }
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose { DebugSettingsManager.getInstance().setDebugSheetVisible(false) }
|
||||||
|
}
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -202,88 +217,256 @@ fun DebugSettingsSheet(
|
|||||||
item {
|
item {
|
||||||
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
||||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
// Persistent notification is controlled by About sheet (MeshServicePreferences.isBackgroundEnabled)
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, tint = Color(0xFFFF9500))
|
Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, tint = Color(0xFFFF9500))
|
||||||
Text(stringResource(R.string.debug_packet_relay), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
Text(stringResource(R.string.debug_packet_relay), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
Switch(checked = packetRelayEnabled, onCheckedChange = { manager.setPacketRelayEnabled(it) })
|
Switch(checked = packetRelayEnabled, onCheckedChange = { manager.setPacketRelayEnabled(it) })
|
||||||
}
|
}
|
||||||
Text(stringResource(R.string.debug_since_start_fmt, relayStats.totalRelaysCount), fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
// Removed aggregate labels; we will show per-direction compact labels below titles
|
||||||
Text(stringResource(R.string.debug_relays_window_fmt, relayStats.last10SecondRelays, relayStats.lastMinuteRelays, relayStats.last15MinuteRelays), fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
// Toggle: overall vs per-connection vs per-peer
|
||||||
// Realtime graph: per-second relays, full-width canvas, bottom-up bars, fast decay
|
var graphMode by rememberSaveable { mutableStateOf(GraphMode.OVERALL) }
|
||||||
var series by remember { mutableStateOf(List(60) { 0f }) }
|
val perDeviceIncoming by manager.perDeviceIncomingLastSecond.collectAsState()
|
||||||
LaunchedEffect(isPresented) {
|
val perPeerIncoming by manager.perPeerIncomingLastSecond.collectAsState()
|
||||||
|
val perDeviceOutgoing by manager.perDeviceOutgoingLastSecond.collectAsState()
|
||||||
|
val perPeerOutgoing by manager.perPeerOutgoingLastSecond.collectAsState()
|
||||||
|
val perDeviceIncoming1m by manager.perDeviceIncomingLastMinute.collectAsState()
|
||||||
|
val perDeviceOutgoing1m by manager.perDeviceOutgoingLastMinute.collectAsState()
|
||||||
|
val perPeerIncoming1m by manager.perPeerIncomingLastMinute.collectAsState()
|
||||||
|
val perPeerOutgoing1m by manager.perPeerOutgoingLastMinute.collectAsState()
|
||||||
|
val perDeviceIncomingTotal by manager.perDeviceIncomingTotal.collectAsState()
|
||||||
|
val perDeviceOutgoingTotal by manager.perDeviceOutgoingTotal.collectAsState()
|
||||||
|
val perPeerIncomingTotal by manager.perPeerIncomingTotal.collectAsState()
|
||||||
|
val perPeerOutgoingTotal by manager.perPeerOutgoingTotal.collectAsState()
|
||||||
|
val nicknameMap = remember { mutableStateOf<Map<String, String?>>(emptyMap()) }
|
||||||
|
val devicePeerMap = remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
try { nicknameMap.value = meshService.getPeerNicknames() } catch (_: Exception) { }
|
||||||
|
// Try to fetch device->peer map periodically for legend resolution
|
||||||
while (isPresented) {
|
while (isPresented) {
|
||||||
val s = relayStats.lastSecondRelays.toFloat()
|
try { devicePeerMap.value = meshService.getDeviceAddressToPeerMapping() } catch (_: Exception) { }
|
||||||
val last = series.lastOrNull() ?: 0f
|
kotlinx.coroutines.delay(1000)
|
||||||
// Faster decay and smoothing
|
|
||||||
val v = last * 0.5f + s * 0.5f
|
|
||||||
series = (series + v).takeLast(60)
|
|
||||||
kotlinx.coroutines.delay(400)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val maxValRaw = series.maxOrNull() ?: 0f
|
|
||||||
val maxVal = if (maxValRaw > 0f) maxValRaw else 0f
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
val leftGutter = 40.dp
|
// Mode selector
|
||||||
Box(Modifier.fillMaxWidth().height(56.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
// Graph canvas
|
FilterChip(
|
||||||
androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) {
|
selected = graphMode == GraphMode.OVERALL,
|
||||||
val axisPx = leftGutter.toPx() // reserved left gutter for labels
|
onClick = { graphMode = GraphMode.OVERALL },
|
||||||
val barCount = series.size
|
label = { Text("Overall") }
|
||||||
val availW = (size.width - axisPx).coerceAtLeast(1f)
|
|
||||||
val w = availW / barCount
|
|
||||||
val h = size.height
|
|
||||||
// Baseline at bottom (y = 0)
|
|
||||||
drawLine(
|
|
||||||
color = Color(0x33888888),
|
|
||||||
start = androidx.compose.ui.geometry.Offset(axisPx, h - 1f),
|
|
||||||
end = androidx.compose.ui.geometry.Offset(size.width, h - 1f),
|
|
||||||
strokeWidth = 1f
|
|
||||||
)
|
)
|
||||||
// Bars from bottom-up; skip zeros entirely
|
FilterChip(
|
||||||
series.forEachIndexed { i, value ->
|
selected = graphMode == GraphMode.PER_DEVICE,
|
||||||
if (value > 0f && maxVal > 0f) {
|
onClick = { graphMode = GraphMode.PER_DEVICE },
|
||||||
val ratio = (value / maxVal).coerceIn(0f, 1f)
|
label = { Text("Per Device") },
|
||||||
val barHeight = (h * ratio).coerceAtLeast(0f)
|
leadingIcon = { Icon(Icons.Filled.Devices, contentDescription = null) }
|
||||||
if (barHeight > 0.5f) {
|
)
|
||||||
drawRect(
|
FilterChip(
|
||||||
color = Color(0xFF00C851),
|
selected = graphMode == GraphMode.PER_PEER,
|
||||||
topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = h - barHeight),
|
onClick = { graphMode = GraphMode.PER_PEER },
|
||||||
size = androidx.compose.ui.geometry.Size(w, barHeight)
|
label = { Text("Per Peer") },
|
||||||
)
|
leadingIcon = { Icon(Icons.Filled.SettingsEthernet, contentDescription = null) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time series state
|
||||||
|
var overallSeriesIncoming by rememberSaveable { mutableStateOf(List(60) { 0f }) }
|
||||||
|
var overallSeriesOutgoing by rememberSaveable { mutableStateOf(List(60) { 0f }) }
|
||||||
|
var stackedKeysIncoming by rememberSaveable { mutableStateOf(listOf<String>()) }
|
||||||
|
var stackedKeysOutgoing by rememberSaveable { mutableStateOf(listOf<String>()) }
|
||||||
|
var stackedSeriesIncoming by rememberSaveable { mutableStateOf<Map<String, List<Float>>>(emptyMap()) }
|
||||||
|
var stackedSeriesOutgoing by rememberSaveable { mutableStateOf<Map<String, List<Float>>>(emptyMap()) }
|
||||||
|
var highlightedKey by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
// Color palette for stacked legend
|
||||||
|
val palette = remember {
|
||||||
|
listOf(
|
||||||
|
Color(0xFF00C851), Color(0xFF007AFF), Color(0xFFFF9500), Color(0xFFFF3B30),
|
||||||
|
Color(0xFF5AC8FA), Color(0xFFAF52DE), Color(0xFFFF2D55), Color(0xFF34C759),
|
||||||
|
Color(0xFFFFCC00), Color(0xFF5856D6)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val colorForKey = remember { mutableStateMapOf<String, Color>() }
|
||||||
|
fun stableColorFor(key: String): Color {
|
||||||
|
// Deterministic fallback color based on key hash using HSV palette
|
||||||
|
val h = (key.hashCode().toUInt().toInt() and 0x7FFFFFFF) % 360
|
||||||
|
return Color.hsv(h.toFloat(), 0.65f, 0.95f)
|
||||||
|
}
|
||||||
|
// Ensure colors are assigned for current keys before drawing
|
||||||
|
fun ensureColors(keys: List<String>) {
|
||||||
|
keys.forEachIndexed { idx, k ->
|
||||||
|
colorForKey.putIfAbsent(k, palette.getOrNull(idx) ?: stableColorFor(k))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Left gutter layout: unit + ticks neatly aligned
|
|
||||||
Row(Modifier.fillMaxSize()) {
|
// Helper functions moved to top-level composable below to avoid scope issues
|
||||||
Box(Modifier.width(leftGutter).fillMaxHeight()) {
|
|
||||||
// Unit label on the far left, centered vertically
|
// Render two blocks: Incoming and Outgoing
|
||||||
Text(
|
Text("Incoming", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||||
"p/s",
|
Text(
|
||||||
fontFamily = FontFamily.Monospace,
|
"${relayStats.lastSecondIncoming}/s • ${relayStats.lastMinuteIncoming}/m • ${relayStats.last15MinuteIncoming}/15m • total ${relayStats.totalIncomingCount}",
|
||||||
fontSize = 10.sp,
|
fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||||
color = colorScheme.onSurface.copy(alpha = 0.7f),
|
)
|
||||||
modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp).rotate(-90f)
|
DrawGraphBlock(
|
||||||
)
|
title = "Incoming",
|
||||||
// Tick labels right-aligned in gutter, top and bottom aligned
|
stackedKeys = stackedKeysIncoming,
|
||||||
Text(
|
stackedSeries = stackedSeriesIncoming,
|
||||||
"${maxVal.toInt()}",
|
overallSeries = if (graphMode == GraphMode.OVERALL) overallSeriesIncoming else null,
|
||||||
fontFamily = FontFamily.Monospace,
|
graphMode = graphMode,
|
||||||
fontSize = 10.sp,
|
highlightedKey = highlightedKey,
|
||||||
color = colorScheme.onSurface.copy(alpha = 0.7f),
|
onToggleHighlight = { key -> highlightedKey = if (highlightedKey == key) null else key },
|
||||||
modifier = Modifier.align(Alignment.TopEnd).padding(end = 4.dp, top = 0.dp)
|
ensureColors = { keys -> ensureColors(keys) },
|
||||||
)
|
colorForKey = { k -> colorForKey[k] ?: stableColorFor(k) },
|
||||||
Text(
|
legendTitleFor = { key ->
|
||||||
"0",
|
when (graphMode) {
|
||||||
fontFamily = FontFamily.Monospace,
|
GraphMode.PER_PEER -> {
|
||||||
fontSize = 10.sp,
|
val nick = nicknameMap.value[key]
|
||||||
color = colorScheme.onSurface.copy(alpha = 0.7f),
|
val prefix = key.take(6)
|
||||||
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 4.dp, bottom = 0.dp)
|
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 -> ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.weight(1f))
|
)
|
||||||
}
|
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 */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -399,3 +582,146 @@ fun DebugSettingsSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,8 +80,8 @@ object AppConstants {
|
|||||||
const val SCAN_ON_DURATION_ULTRA_LOW_MS: Long = 1_000L
|
const val SCAN_ON_DURATION_ULTRA_LOW_MS: Long = 1_000L
|
||||||
const val SCAN_OFF_DURATION_ULTRA_LOW_MS: Long = 10_000L
|
const val SCAN_OFF_DURATION_ULTRA_LOW_MS: Long = 10_000L
|
||||||
const val MAX_CONNECTIONS_NORMAL: Int = 8
|
const val MAX_CONNECTIONS_NORMAL: Int = 8
|
||||||
const val MAX_CONNECTIONS_POWER_SAVE: Int = 4
|
const val MAX_CONNECTIONS_POWER_SAVE: Int = 8
|
||||||
const val MAX_CONNECTIONS_ULTRA_LOW: Int = 2
|
const val MAX_CONNECTIONS_ULTRA_LOW: Int = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
object Nostr {
|
object Nostr {
|
||||||
|
|||||||
@@ -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.
@@ -61,6 +61,11 @@
|
|||||||
<string name="notification_more_locations">and %1$d more locations</string>
|
<string name="notification_more_locations">and %1$d more locations</string>
|
||||||
<string name="notification_and_more">and %1$d more</string>
|
<string name="notification_and_more">and %1$d more</string>
|
||||||
|
|
||||||
|
<!-- Mesh Service Foreground Notification -->
|
||||||
|
<string name="mesh_service_channel_name">Mesh Background Service</string>
|
||||||
|
<string name="mesh_service_channel_desc">Keeps the Bluetooth mesh running in the background</string>
|
||||||
|
<string name="mesh_service_notification_content">Mesh running — %1$d users connected</string>
|
||||||
|
|
||||||
<!-- Favorites accessibility -->
|
<!-- Favorites accessibility -->
|
||||||
<string name="cd_add_favorite">Add to favorites</string>
|
<string name="cd_add_favorite">Add to favorites</string>
|
||||||
<string name="cd_remove_favorite">Remove from favorites</string>
|
<string name="cd_remove_favorite">Remove from favorites</string>
|
||||||
@@ -152,6 +157,9 @@
|
|||||||
<string name="cd_remove_bookmark">Remove bookmark</string>
|
<string name="cd_remove_bookmark">Remove bookmark</string>
|
||||||
<string name="cd_teleport">Teleport</string>
|
<string name="cd_teleport">Teleport</string>
|
||||||
<string name="cd_selected">Selected</string>
|
<string name="cd_selected">Selected</string>
|
||||||
|
<string name="notification_action_quit_bitchat">Quit bitchat</string>
|
||||||
|
<string name="about_background_title">run in background</string>
|
||||||
|
<string name="about_background_desc">keep mesh active when app is closed (foreground service)</string>
|
||||||
<string name="cd_leave_channel">Leave channel</string>
|
<string name="cd_leave_channel">Leave channel</string>
|
||||||
<string name="cd_reachable_via_nostr">Reachable via Nostr</string>
|
<string name="cd_reachable_via_nostr">Reachable via Nostr</string>
|
||||||
<string name="cd_offline_favorite">Offline favorite</string>
|
<string name="cd_offline_favorite">Offline favorite</string>
|
||||||
@@ -378,8 +386,9 @@
|
|||||||
<string name="pwd_label">Password</string>
|
<string name="pwd_label">Password</string>
|
||||||
<string name="join">Join</string>
|
<string name="join">Join</string>
|
||||||
<string name="cancel">Cancel</string>
|
<string name="cancel">Cancel</string>
|
||||||
|
<string name="tor_not_available_in_this_build">Tor not available in this build</string>
|
||||||
|
|
||||||
<!-- Plurals -->
|
<!-- Plurals -->
|
||||||
<plurals name="notification_and_more">
|
<plurals name="notification_and_more">
|
||||||
<item quantity="one">and %d more</item>
|
<item quantity="one">and %d more</item>
|
||||||
<item quantity="other">and %d more</item>
|
<item quantity="other">and %d more</item>
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import androidx.test.core.app.ApplicationProvider
|
|||||||
import com.bitchat.android.mesh.BluetoothMeshService
|
import com.bitchat.android.mesh.BluetoothMeshService
|
||||||
import com.bitchat.android.model.BitchatMessage
|
import com.bitchat.android.model.BitchatMessage
|
||||||
import junit.framework.TestCase.assertEquals
|
import junit.framework.TestCase.assertEquals
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Ignore
|
import org.junit.Ignore
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -18,7 +20,10 @@ import java.util.Date
|
|||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
class CommandProcessorTest() {
|
class CommandProcessorTest() {
|
||||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
private val chatState = ChatState()
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
private val testDispatcher = UnconfinedTestDispatcher()
|
||||||
|
private val testScope = TestScope(testDispatcher)
|
||||||
|
private val chatState = ChatState(scope = testScope)
|
||||||
private lateinit var commandProcessor: CommandProcessor
|
private lateinit var commandProcessor: CommandProcessor
|
||||||
|
|
||||||
val messageManager: MessageManager = MessageManager(state = chatState)
|
val messageManager: MessageManager = MessageManager(state = chatState)
|
||||||
@@ -26,7 +31,7 @@ class CommandProcessorTest() {
|
|||||||
state = chatState,
|
state = chatState,
|
||||||
messageManager = messageManager,
|
messageManager = messageManager,
|
||||||
dataManager = DataManager(context = context),
|
dataManager = DataManager(context = context),
|
||||||
coroutineScope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Main.immediate)
|
coroutineScope = testScope
|
||||||
)
|
)
|
||||||
|
|
||||||
private val meshService: BluetoothMeshService = mock()
|
private val meshService: BluetoothMeshService = mock()
|
||||||
|
|||||||
@@ -68,12 +68,10 @@ androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
|
|||||||
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||||
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
||||||
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
|
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
|
||||||
androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" }
|
|
||||||
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
|
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
|
||||||
|
|
||||||
# Lifecycle
|
# Lifecycle
|
||||||
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle-runtime" }
|
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle-runtime" }
|
||||||
androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycle-runtime" }
|
|
||||||
|
|
||||||
# Navigation
|
# Navigation
|
||||||
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" }
|
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" }
|
||||||
@@ -130,14 +128,12 @@ compose = [
|
|||||||
"androidx-compose-ui-graphics",
|
"androidx-compose-ui-graphics",
|
||||||
"androidx-compose-ui-tooling-preview",
|
"androidx-compose-ui-tooling-preview",
|
||||||
"androidx-compose-material3",
|
"androidx-compose-material3",
|
||||||
"androidx-compose-runtime-livedata",
|
|
||||||
"androidx-compose-material-icons-extended"
|
"androidx-compose-material-icons-extended"
|
||||||
]
|
]
|
||||||
|
|
||||||
lifecycle = [
|
lifecycle = [
|
||||||
"androidx-lifecycle-runtime-ktx",
|
"androidx-lifecycle-runtime-ktx",
|
||||||
"androidx-lifecycle-viewmodel-compose",
|
"androidx-lifecycle-viewmodel-compose",
|
||||||
"androidx-lifecycle-livedata-ktx"
|
|
||||||
]
|
]
|
||||||
|
|
||||||
cryptography = [
|
cryptography = [
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
arti-v1.7.0
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "arti-android-wrapper"
|
||||||
|
version = "1.7.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
# Empty workspace table to exclude from parent workspace
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
name = "arti_android"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
arti-client = { path = "../crates/arti-client", default-features = false, features = ["tokio", "rustls", "compression", "bridge-client", "onion-service-client", "static-sqlite"] }
|
||||||
|
tor-rtcompat = { path = "../crates/tor-rtcompat", features = ["tokio", "rustls"] }
|
||||||
|
jni = "0.21"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
anyhow = "1.0"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "z" # Optimize for size
|
||||||
|
lto = true # Link-time optimization
|
||||||
|
codegen-units = 1 # Better optimization
|
||||||
|
strip = true # Strip symbols
|
||||||
|
panic = "abort" # Smaller panic handler
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
# Arti Android Build Tools
|
||||||
|
|
||||||
|
This directory contains the build scripts and source files for compiling the custom Arti (Tor in Rust) library for Android.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
bitchat-android uses a custom-built Arti library instead of Guardian Project's outdated `arti-mobile-ex` AAR. This provides:
|
||||||
|
|
||||||
|
- **Smaller APK size**: ~11MB total vs ~140MB with Guardian Project AAR (28x reduction)
|
||||||
|
- **Latest Arti version**: Currently v1.7.0 with pure Rust TLS (rustls)
|
||||||
|
- **16KB page size support**: Required for Google Play (Nov 2025)
|
||||||
|
- **Full transparency**: Build from official Arti source + our JNI wrapper
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
The pre-built `.so` files are committed to the repo, so you don't need to build unless you want to:
|
||||||
|
|
||||||
|
1. **Verify the binaries** match the source
|
||||||
|
2. **Update to a new Arti version**
|
||||||
|
3. **Modify the JNI wrapper**
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
tools/arti-build/
|
||||||
|
├── README.md # This file
|
||||||
|
├── build-arti.sh # Main build script (clones Arti, builds .so files)
|
||||||
|
├── ARTI_VERSION # Pinned Arti version tag (e.g., arti-v1.7.0)
|
||||||
|
├── Cargo.toml # Rust package configuration
|
||||||
|
├── src/
|
||||||
|
│ └── lib.rs # JNI wrapper (Rust -> Kotlin/Java bridge)
|
||||||
|
└── .arti-source/ # [GITIGNORED] Cloned official Arti repo
|
||||||
|
|
||||||
|
app/src/main/jniLibs/ # [COMMITTED] Pre-built native libraries
|
||||||
|
├── arm64-v8a/
|
||||||
|
│ └── libarti_android.so (~5.3MB)
|
||||||
|
└── x86_64/
|
||||||
|
└── libarti_android.so (~6.2MB)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rebuilding from Source
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
1. **Rust toolchain** with Android targets:
|
||||||
|
```bash
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||||
|
rustup target add aarch64-linux-android x86_64-linux-android
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **cargo-ndk** for Android cross-compilation:
|
||||||
|
```bash
|
||||||
|
cargo install cargo-ndk
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Android NDK 25+** (for 16KB page size support):
|
||||||
|
```bash
|
||||||
|
# Via Android Studio SDK Manager, or:
|
||||||
|
$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager "ndk;27.0.12077973"
|
||||||
|
|
||||||
|
# Set environment variable
|
||||||
|
export ANDROID_NDK_HOME="$HOME/Library/Android/sdk/ndk/27.0.12077973"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/arti-build
|
||||||
|
|
||||||
|
# Build both architectures (arm64 + x86_64 for emulator)
|
||||||
|
./build-arti.sh
|
||||||
|
|
||||||
|
# Build ARM64 only (smaller, for production releases)
|
||||||
|
./build-arti.sh --release
|
||||||
|
|
||||||
|
# Clean rebuild (re-clone Arti source)
|
||||||
|
./build-arti.sh --clean
|
||||||
|
```
|
||||||
|
|
||||||
|
The script will:
|
||||||
|
1. Clone official Arti from https://gitlab.torproject.org/tpo/core/arti
|
||||||
|
2. Checkout the version specified in `ARTI_VERSION`
|
||||||
|
3. Copy our JNI wrapper into the cloned repo
|
||||||
|
4. Build with `cargo ndk`
|
||||||
|
5. Copy `.so` files to `app/src/main/jniLibs/`
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
After building, verify the libraries:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check file sizes
|
||||||
|
ls -lh ../../app/src/main/jniLibs/*/libarti_android.so
|
||||||
|
|
||||||
|
# Verify JNI symbols are exported
|
||||||
|
nm -gU ../../app/src/main/jniLibs/arm64-v8a/libarti_android.so | grep Java_org_torproject
|
||||||
|
|
||||||
|
# Verify 16KB page alignment
|
||||||
|
readelf -l ../../app/src/main/jniLibs/arm64-v8a/libarti_android.so | grep LOAD
|
||||||
|
# Look for: Align 0x4000 (16KB)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updating Arti Version
|
||||||
|
|
||||||
|
1. **Check available versions**:
|
||||||
|
```bash
|
||||||
|
git ls-remote --tags https://gitlab.torproject.org/tpo/core/arti.git | grep arti-v
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Update the version file**:
|
||||||
|
```bash
|
||||||
|
echo "arti-v1.8.0" > ARTI_VERSION
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Rebuild from scratch**:
|
||||||
|
```bash
|
||||||
|
./build-arti.sh --clean
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Test the build**:
|
||||||
|
```bash
|
||||||
|
cd ../..
|
||||||
|
./gradlew clean assembleDebug
|
||||||
|
./gradlew installDebug
|
||||||
|
# Enable Tor in app and verify it works
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Commit the new libraries**:
|
||||||
|
```bash
|
||||||
|
git add app/src/main/jniLibs/ tools/arti-build/ARTI_VERSION
|
||||||
|
git commit -m "chore: update Arti to v1.8.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
## JNI Wrapper Architecture
|
||||||
|
|
||||||
|
The `src/lib.rs` file implements a JNI bridge between Kotlin and Rust:
|
||||||
|
|
||||||
|
```
|
||||||
|
Kotlin (ArtiNative.kt)
|
||||||
|
↓ JNI
|
||||||
|
Rust (lib.rs)
|
||||||
|
↓
|
||||||
|
Arti Client (TorClient)
|
||||||
|
↓
|
||||||
|
SOCKS5 Proxy (localhost:9060)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exported JNI Functions**:
|
||||||
|
- `getVersion()` - Returns Arti version string
|
||||||
|
- `setLogCallback(callback)` - Registers log listener for bootstrap progress
|
||||||
|
- `initialize(dataDir)` - Creates Tokio runtime and TorClient
|
||||||
|
- `startSocksProxy(port)` - Starts SOCKS5 proxy on specified port
|
||||||
|
- `stop()` - Stops SOCKS proxy (TorClient is reused)
|
||||||
|
|
||||||
|
**Key Design Decisions**:
|
||||||
|
- Global `TorClient` persists across stop/start cycles (fixes Nov 2024 toggle bug)
|
||||||
|
- Tokio runtime created once and never destroyed
|
||||||
|
- Log messages bridged to Java via `GlobalRef` callback
|
||||||
|
|
||||||
|
## Feature Configuration
|
||||||
|
|
||||||
|
Edit `Cargo.toml` to customize Arti features:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
arti-client = {
|
||||||
|
path = "../crates/arti-client",
|
||||||
|
default-features = false,
|
||||||
|
features = [
|
||||||
|
"tokio", # Required: async runtime
|
||||||
|
"rustls", # Required: pure Rust TLS (no OpenSSL)
|
||||||
|
"compression", # Optional: directory compression
|
||||||
|
"bridge-client", # Optional: Tor bridge support
|
||||||
|
"onion-service-client", # Optional: .onion site support
|
||||||
|
"static-sqlite" # Required: bundled SQLite
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Size Comparison
|
||||||
|
|
||||||
|
| Configuration | arm64-v8a | x86_64 | Total | APK Size |
|
||||||
|
|---------------|-----------|--------|-------|----------|
|
||||||
|
| Guardian Project AAR | - | - | ~140 MB | ~150 MB |
|
||||||
|
| Custom (both arch) | 5.3 MB | 6.2 MB | 11.5 MB | ~15 MB |
|
||||||
|
| Custom (ARM-only) | 5.3 MB | - | 5.3 MB | ~10 MB |
|
||||||
|
|
||||||
|
**28x size reduction** vs Guardian Project implementation.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "cargo-ndk not found"
|
||||||
|
```bash
|
||||||
|
cargo install cargo-ndk
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Android NDK not found"
|
||||||
|
```bash
|
||||||
|
export ANDROID_NDK_HOME="$HOME/Library/Android/sdk/ndk/27.0.12077973"
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Rust target not installed"
|
||||||
|
```bash
|
||||||
|
rustup target add aarch64-linux-android x86_64-linux-android
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Version not found"
|
||||||
|
Check available versions:
|
||||||
|
```bash
|
||||||
|
git ls-remote --tags https://gitlab.torproject.org/tpo/core/arti.git | grep arti-v | tail -10
|
||||||
|
```
|
||||||
|
|
||||||
|
### Library too large
|
||||||
|
1. Ensure building with `--release` flag
|
||||||
|
2. Verify `strip = true` in `Cargo.toml` `[profile.release]`
|
||||||
|
3. Consider removing optional features
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Arti Documentation](https://gitlab.torproject.org/tpo/core/arti/-/blob/main/doc/README.md)
|
||||||
|
- [cargo-ndk](https://github.com/bbqsrc/cargo-ndk)
|
||||||
|
- [Android NDK Guide](https://developer.android.com/ndk/guides)
|
||||||
|
- [Google Play 16KB Page Size](https://developer.android.com/guide/practices/page-sizes)
|
||||||
Executable
+570
@@ -0,0 +1,570 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Rebuild Arti native libraries from official source
|
||||||
|
#
|
||||||
|
# This script clones the official Arti repository, applies our custom JNI wrapper,
|
||||||
|
# and builds the native libraries for Android. Use this to:
|
||||||
|
# - Verify the pre-built .so files match the source
|
||||||
|
# - Update to a new Arti version
|
||||||
|
# - Debug or modify the wrapper code
|
||||||
|
#
|
||||||
|
# Requirements:
|
||||||
|
# - Bash 4+ (macOS default bash is 3.2; install via Homebrew: brew install bash)
|
||||||
|
# - Rust toolchain with Android targets:
|
||||||
|
# rustup target add aarch64-linux-android x86_64-linux-android
|
||||||
|
# - cargo-ndk: cargo install cargo-ndk
|
||||||
|
# - Android NDK 25+ (for 16KB page size support)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./build-arti.sh # Build both architectures (debug/emulator)
|
||||||
|
# ./build-arti.sh --release # Build ARM64 only (production)
|
||||||
|
# ./build-arti.sh --clean # Remove cloned Arti repo and rebuild
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Configuration
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Script and project directories
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
ARTI_SOURCE_DIR="$SCRIPT_DIR/.arti-source"
|
||||||
|
JNILIBS_DIR="$PROJECT_ROOT/app/src/main/jniLibs"
|
||||||
|
|
||||||
|
|
||||||
|
detect_default_ndk_home() {
|
||||||
|
local candidates=(
|
||||||
|
"$HOME/Library/Android/sdk/ndk/27.0.12077973"
|
||||||
|
"$HOME/Library/Android/sdk/ndk"
|
||||||
|
"$HOME/Library/Android/sdk/ndk-bundle"
|
||||||
|
"$HOME/Android/Sdk/ndk/27.0.12077973"
|
||||||
|
"$HOME/Android/Sdk/ndk"
|
||||||
|
"$HOME/Android/Sdk/ndk-bundle"
|
||||||
|
)
|
||||||
|
|
||||||
|
for candidate in "${candidates[@]}"; do
|
||||||
|
if [ -d "$candidate" ]; then
|
||||||
|
echo "$candidate"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
local base
|
||||||
|
for base in "$HOME/Library/Android/sdk/ndk" "$HOME/Android/Sdk/ndk"; do
|
||||||
|
if [ -d "$base" ]; then
|
||||||
|
local latest
|
||||||
|
latest="$(find "$base" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort | tail -1)"
|
||||||
|
if [ -n "$latest" ]; then
|
||||||
|
echo "$latest"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Read pinned version
|
||||||
|
if [ ! -f "$SCRIPT_DIR/ARTI_VERSION" ]; then
|
||||||
|
echo -e "${RED}Error: ARTI_VERSION file not found${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
VERSION="$(tr -d '[:space:]' < "$SCRIPT_DIR/ARTI_VERSION")"
|
||||||
|
|
||||||
|
# Android NDK path
|
||||||
|
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
|
||||||
|
AUTO_NDK_HOME="$(detect_default_ndk_home)"
|
||||||
|
if [ -n "$AUTO_NDK_HOME" ]; then
|
||||||
|
ANDROID_NDK_HOME="$AUTO_NDK_HOME"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
|
||||||
|
echo -e "${RED}Error: ANDROID_NDK_HOME is not set and automatic detection failed.${NC}"
|
||||||
|
echo "Set ANDROID_NDK_HOME to your NDK installation (e.g., ~/Android/Sdk/ndk/<version>)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
export ANDROID_NDK_HOME
|
||||||
|
|
||||||
|
# Min SDK version (must match bitchat-android minSdk)
|
||||||
|
MIN_SDK_VERSION=26
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
RELEASE_ONLY=false
|
||||||
|
CLEAN_BUILD=false
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--release)
|
||||||
|
RELEASE_ONLY=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--clean)
|
||||||
|
CLEAN_BUILD=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
echo "Usage: $0 [--release] [--clean]"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " --release Build ARM64 only (smaller, for production)"
|
||||||
|
echo " --clean Remove cached Arti source and rebuild from scratch"
|
||||||
|
echo ""
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}Error: Unknown argument: $1${NC}"
|
||||||
|
echo "Run: $0 --help"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Architectures to build
|
||||||
|
if [ "$RELEASE_ONLY" = true ]; then
|
||||||
|
TARGETS=("aarch64-linux-android")
|
||||||
|
else
|
||||||
|
TARGETS=("aarch64-linux-android" "x86_64-linux-android")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Map Rust targets to Android ABI names
|
||||||
|
declare -A ABI_MAP=(
|
||||||
|
["aarch64-linux-android"]="arm64-v8a"
|
||||||
|
["x86_64-linux-android"]="x86_64"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Toolchain placeholders (set in detect_ndk_host)
|
||||||
|
NDK_HOST=""
|
||||||
|
NDK_LLVM_BIN=""
|
||||||
|
LLVM_STRIP=""
|
||||||
|
LLVM_NM=""
|
||||||
|
LLVM_READELF=""
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Functions
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
print_header() {
|
||||||
|
echo -e "${BLUE}=========================================${NC}"
|
||||||
|
echo -e "${BLUE}$1${NC}"
|
||||||
|
echo -e "${BLUE}=========================================${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_success() { echo -e "${GREEN}$1${NC}"; }
|
||||||
|
print_error() { echo -e "${RED}$1${NC}"; }
|
||||||
|
print_info() { echo -e "${YELLOW}$1${NC}"; }
|
||||||
|
|
||||||
|
detect_ndk_host() {
|
||||||
|
local uname_s
|
||||||
|
uname_s="$(uname -s)"
|
||||||
|
local PREBUILT_DIR="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt"
|
||||||
|
local HOST_CANDIDATES=()
|
||||||
|
|
||||||
|
case "$uname_s" in
|
||||||
|
Darwin)
|
||||||
|
HOST_CANDIDATES=("darwin-arm64" "darwin-x86_64")
|
||||||
|
;;
|
||||||
|
Linux)
|
||||||
|
HOST_CANDIDATES=("linux-x86_64" "linux-arm64" "linux-aarch64")
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
print_error "Unsupported host OS: $uname_s"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
for candidate in "${HOST_CANDIDATES[@]}"; do
|
||||||
|
if [ -d "$PREBUILT_DIR/$candidate" ]; then
|
||||||
|
NDK_HOST="$candidate"
|
||||||
|
NDK_LLVM_BIN="$PREBUILT_DIR/$candidate/bin"
|
||||||
|
LLVM_STRIP="$NDK_LLVM_BIN/llvm-strip"
|
||||||
|
LLVM_NM="$NDK_LLVM_BIN/llvm-nm"
|
||||||
|
LLVM_READELF="$NDK_LLVM_BIN/llvm-readelf"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
print_error "No compatible NDK toolchain found under $PREBUILT_DIR"
|
||||||
|
print_info "Searched for: ${HOST_CANDIDATES[*]}"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
check_prerequisites() {
|
||||||
|
print_header "Checking Prerequisites"
|
||||||
|
|
||||||
|
# Bash version
|
||||||
|
if [ "${BASH_VERSINFO:-0}" -lt 4 ]; then
|
||||||
|
print_error "Bash 4+ is required. macOS ships bash 3.2 by default."
|
||||||
|
print_info "macOS: brew install bash"
|
||||||
|
print_info "Linux: use your distro package manager (e.g., sudo apt install bash)"
|
||||||
|
print_info "Then run with the installed bash (e.g., /opt/homebrew/bin/bash ./build-arti.sh)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
print_success "Bash found: $BASH_VERSION"
|
||||||
|
|
||||||
|
# Git
|
||||||
|
if ! command -v git >/dev/null 2>&1; then
|
||||||
|
print_error "git is not installed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
print_success "git found: $(git --version)"
|
||||||
|
|
||||||
|
# Rust
|
||||||
|
if ! command -v rustc >/dev/null 2>&1; then
|
||||||
|
print_error "Rust is not installed. Install from https://rustup.rs/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
print_success "Rust found: $(rustc --version)"
|
||||||
|
|
||||||
|
# rustup
|
||||||
|
if ! command -v rustup >/dev/null 2>&1; then
|
||||||
|
print_error "rustup is required (for managing targets). Install from https://rustup.rs/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
print_success "rustup found: $(rustup --version | head -1)"
|
||||||
|
|
||||||
|
# cargo-ndk
|
||||||
|
if ! command -v cargo-ndk >/dev/null 2>&1; then
|
||||||
|
print_error "cargo-ndk is not installed. Run: cargo install cargo-ndk"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
print_success "cargo-ndk found: $(cargo-ndk --version 2>/dev/null || echo 'installed')"
|
||||||
|
|
||||||
|
# NDK
|
||||||
|
if [ ! -d "$ANDROID_NDK_HOME" ]; then
|
||||||
|
print_error "Android NDK not found at: $ANDROID_NDK_HOME"
|
||||||
|
print_info "Set ANDROID_NDK_HOME environment variable to your NDK location"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
print_success "Android NDK found: $ANDROID_NDK_HOME"
|
||||||
|
|
||||||
|
# NDK version (should be 25+)
|
||||||
|
NDK_VERSION="$(basename "$ANDROID_NDK_HOME" | cut -d'.' -f1)"
|
||||||
|
if ! [[ "$NDK_VERSION" =~ ^[0-9]+$ ]]; then
|
||||||
|
print_error "Could not parse NDK version from ANDROID_NDK_HOME: $ANDROID_NDK_HOME"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$NDK_VERSION" -lt 25 ]; then
|
||||||
|
print_error "NDK version $NDK_VERSION is too old. NDK 25+ required for 16KB page size support"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
print_success "NDK version: $NDK_VERSION (supports 16KB page size)"
|
||||||
|
|
||||||
|
detect_ndk_host
|
||||||
|
if [ ! -d "$NDK_LLVM_BIN" ]; then
|
||||||
|
print_error "NDK LLVM toolchain bin directory not found: $NDK_LLVM_BIN"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
print_success "NDK host tag: $NDK_HOST"
|
||||||
|
|
||||||
|
if [ ! -x "$LLVM_STRIP" ]; then
|
||||||
|
print_info "llvm-strip not found at: $LLVM_STRIP (stripping will be skipped)"
|
||||||
|
else
|
||||||
|
print_success "llvm-strip found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -x "$LLVM_NM" ] && ! command -v nm >/dev/null 2>&1; then
|
||||||
|
print_error "Neither llvm-nm nor nm found. Cannot verify JNI symbols."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -x "$LLVM_NM" ]; then
|
||||||
|
print_success "llvm-nm found"
|
||||||
|
else
|
||||||
|
print_info "llvm-nm not found, will fall back to system nm"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -x "$LLVM_READELF" ] && ! command -v readelf >/dev/null 2>&1; then
|
||||||
|
print_info "Neither llvm-readelf nor readelf found. Alignment verification may be skipped."
|
||||||
|
else
|
||||||
|
print_success "readelf capability available"
|
||||||
|
fi
|
||||||
|
# Android targets
|
||||||
|
for TARGET in "${TARGETS[@]}"; do
|
||||||
|
if ! rustup target list --installed | grep -qx "$TARGET"; then
|
||||||
|
print_error "Rust target $TARGET not installed"
|
||||||
|
print_info "Run: rustup target add $TARGET"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
print_success "All Rust Android targets installed"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
clone_or_update_arti() {
|
||||||
|
print_header "Setting up Arti Source (version: $VERSION)"
|
||||||
|
|
||||||
|
if [ "$CLEAN_BUILD" = true ] && [ -d "$ARTI_SOURCE_DIR" ]; then
|
||||||
|
print_info "Cleaning existing Arti source..."
|
||||||
|
rm -rf "$ARTI_SOURCE_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$ARTI_SOURCE_DIR" ]; then
|
||||||
|
print_info "Cloning official Arti repository..."
|
||||||
|
git clone https://gitlab.torproject.org/tpo/core/arti.git "$ARTI_SOURCE_DIR"
|
||||||
|
else
|
||||||
|
print_info "Using cached Arti source at $ARTI_SOURCE_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$ARTI_SOURCE_DIR"
|
||||||
|
|
||||||
|
print_info "Fetching tags..."
|
||||||
|
git fetch --tags --quiet
|
||||||
|
|
||||||
|
print_info "Checking out version: $VERSION"
|
||||||
|
git checkout "$VERSION" --quiet 2>/dev/null || {
|
||||||
|
print_error "Version $VERSION not found. Available versions:"
|
||||||
|
git tag | grep "^arti-v" | tail -10
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure clean working tree to avoid cached modifications influencing builds
|
||||||
|
print_info "Resetting repository state (hard) and cleaning untracked files..."
|
||||||
|
git reset --hard --quiet
|
||||||
|
git clean -ffdqx --quiet
|
||||||
|
|
||||||
|
print_success "Arti source ready at version $VERSION"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_wrapper() {
|
||||||
|
print_header "Setting up JNI Wrapper"
|
||||||
|
|
||||||
|
WRAPPER_DIR="$ARTI_SOURCE_DIR/arti-android-wrapper"
|
||||||
|
|
||||||
|
# Recreate wrapper directory to avoid stale files
|
||||||
|
rm -rf "$WRAPPER_DIR"
|
||||||
|
mkdir -p "$WRAPPER_DIR/src"
|
||||||
|
|
||||||
|
cp "$SCRIPT_DIR/src/lib.rs" "$WRAPPER_DIR/src/"
|
||||||
|
cp "$SCRIPT_DIR/Cargo.toml" "$WRAPPER_DIR/"
|
||||||
|
|
||||||
|
print_success "Wrapper files copied to $WRAPPER_DIR"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
build_for_target() {
|
||||||
|
local TARGET="$1"
|
||||||
|
local ABI="${ABI_MAP[$TARGET]}"
|
||||||
|
local OUTPUT_PATH="$JNILIBS_DIR/$ABI"
|
||||||
|
|
||||||
|
print_header "Building for $ABI ($TARGET)"
|
||||||
|
|
||||||
|
mkdir -p "$OUTPUT_PATH"
|
||||||
|
|
||||||
|
print_info "Building Arti Android wrapper..."
|
||||||
|
cargo ndk \
|
||||||
|
-t "$TARGET" \
|
||||||
|
--platform "$MIN_SDK_VERSION" \
|
||||||
|
-o "$OUTPUT_PATH" \
|
||||||
|
build --release \
|
||||||
|
--locked \
|
||||||
|
--manifest-path "$ARTI_SOURCE_DIR/arti-android-wrapper/Cargo.toml"
|
||||||
|
|
||||||
|
local LIB_NAME="libarti_android.so"
|
||||||
|
local NESTED_PATH="$OUTPUT_PATH/$ABI/$LIB_NAME"
|
||||||
|
|
||||||
|
if [ -f "$NESTED_PATH" ]; then
|
||||||
|
mv "$NESTED_PATH" "$OUTPUT_PATH/$LIB_NAME"
|
||||||
|
rmdir "$OUTPUT_PATH/$ABI" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$OUTPUT_PATH/$LIB_NAME" ]; then
|
||||||
|
print_success "Built: $OUTPUT_PATH/$LIB_NAME"
|
||||||
|
|
||||||
|
# Strip debug symbols safely
|
||||||
|
print_info "Stripping debug symbols..."
|
||||||
|
if [ -x "$LLVM_STRIP" ]; then
|
||||||
|
"$LLVM_STRIP" --strip-debug "$OUTPUT_PATH/$LIB_NAME" 2>/dev/null || true
|
||||||
|
print_success "Stripped debug symbols"
|
||||||
|
else
|
||||||
|
print_info "Skipping strip (llvm-strip not available)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local SIZE
|
||||||
|
SIZE="$(du -h "$OUTPUT_PATH/$LIB_NAME" | cut -f1)"
|
||||||
|
print_success "Final size: $SIZE"
|
||||||
|
|
||||||
|
# Verify 16KB page size alignment (best-effort)
|
||||||
|
print_info "Verifying 16KB page alignment..."
|
||||||
|
local READELF_TOOL=""
|
||||||
|
if [ -x "$LLVM_READELF" ]; then
|
||||||
|
READELF_TOOL="$LLVM_READELF"
|
||||||
|
elif command -v readelf >/dev/null 2>&1; then
|
||||||
|
READELF_TOOL="$(command -v readelf)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$READELF_TOOL" ]; then
|
||||||
|
local ALIGNMENT
|
||||||
|
ALIGNMENT="$("$READELF_TOOL" -l "$OUTPUT_PATH/$LIB_NAME" 2>/dev/null | grep "LOAD" | head -1 | awk '{print $NF}' || echo "unknown")"
|
||||||
|
if [ "$ALIGNMENT" = "0x4000" ] || [ "$ALIGNMENT" = "16384" ]; then
|
||||||
|
print_success "16KB page alignment verified: $ALIGNMENT"
|
||||||
|
else
|
||||||
|
print_info "Page alignment: $ALIGNMENT (NDK handles 16KB at link time)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
print_info "Skipping alignment check (readelf not available)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
print_error "Build failed: $LIB_NAME not found"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_jni_symbols_for_lib() {
|
||||||
|
local LIB_PATH="$1"
|
||||||
|
|
||||||
|
if [ ! -f "$LIB_PATH" ]; then
|
||||||
|
print_error "Library not found: $LIB_PATH"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local EXPECTED_SYMBOLS=(
|
||||||
|
"Java_org_torproject_arti_ArtiNative_getVersion"
|
||||||
|
"Java_org_torproject_arti_ArtiNative_setLogCallback"
|
||||||
|
"Java_org_torproject_arti_ArtiNative_initialize"
|
||||||
|
"Java_org_torproject_arti_ArtiNative_startSocksProxy"
|
||||||
|
"Java_org_torproject_arti_ArtiNative_stop"
|
||||||
|
)
|
||||||
|
|
||||||
|
local ALL_FOUND=true
|
||||||
|
for SYMBOL in "${EXPECTED_SYMBOLS[@]}"; do
|
||||||
|
local FOUND=false
|
||||||
|
|
||||||
|
if [ -x "$LLVM_NM" ]; then
|
||||||
|
if "$LLVM_NM" -D --defined-only "$LIB_PATH" 2>/dev/null | grep -q "$SYMBOL"; then
|
||||||
|
FOUND=true
|
||||||
|
fi
|
||||||
|
elif command -v nm >/dev/null 2>&1; then
|
||||||
|
# Fallback (may be unreliable for ELF on macOS)
|
||||||
|
if nm -g "$LIB_PATH" 2>/dev/null | grep -q "$SYMBOL"; then
|
||||||
|
FOUND=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$FOUND" = true ]; then
|
||||||
|
print_success " Found: $SYMBOL"
|
||||||
|
else
|
||||||
|
print_error " Missing: $SYMBOL"
|
||||||
|
ALL_FOUND=false
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$ALL_FOUND" = true ]; then
|
||||||
|
print_success "All JNI symbols verified for: $LIB_PATH"
|
||||||
|
else
|
||||||
|
print_error "Some JNI symbols are missing for: $LIB_PATH"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_jni_symbols() {
|
||||||
|
print_header "Verifying JNI Symbols"
|
||||||
|
print_info "Checking exported JNI symbols..."
|
||||||
|
|
||||||
|
local FAILED=false
|
||||||
|
|
||||||
|
for TARGET in "${TARGETS[@]}"; do
|
||||||
|
local ABI="${ABI_MAP[$TARGET]}"
|
||||||
|
local LIB_PATH="$JNILIBS_DIR/$ABI/libarti_android.so"
|
||||||
|
print_info "Verifying $ABI: $LIB_PATH"
|
||||||
|
if ! verify_jni_symbols_for_lib "$LIB_PATH"; then
|
||||||
|
FAILED=true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$FAILED" = true ]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
show_summary() {
|
||||||
|
print_header "Build Complete!"
|
||||||
|
|
||||||
|
echo -e "${GREEN}Built libraries:${NC}"
|
||||||
|
for TARGET in "${TARGETS[@]}"; do
|
||||||
|
local ABI="${ABI_MAP[$TARGET]}"
|
||||||
|
local LIB_PATH="$JNILIBS_DIR/$ABI/libarti_android.so"
|
||||||
|
if [ -f "$LIB_PATH" ]; then
|
||||||
|
local SIZE
|
||||||
|
SIZE="$(du -h "$LIB_PATH" | cut -f1)"
|
||||||
|
echo -e " ${GREEN}$ABI:${NC} $SIZE"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}Arti version:${NC} $VERSION"
|
||||||
|
echo -e "${GREEN}Source:${NC} https://gitlab.torproject.org/tpo/core/arti"
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}Next steps:${NC}"
|
||||||
|
echo " 1. Test the build: ./gradlew assembleDebug"
|
||||||
|
echo " 2. Commit the .so files: git add app/src/main/jniLibs/"
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}To update Arti version:${NC}"
|
||||||
|
echo " 1. Edit ARTI_VERSION with new version tag (e.g., arti-v1.8.0)"
|
||||||
|
echo " 2. Run: ./build-arti.sh --clean"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Main
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
main() {
|
||||||
|
print_header "Arti Android Build Script"
|
||||||
|
echo -e "${BLUE}Building Arti for Android with 16KB page size support${NC}"
|
||||||
|
echo -e "${BLUE}Version: $VERSION${NC}"
|
||||||
|
echo -e "${BLUE}Architectures: ${TARGETS[*]}${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
check_prerequisites
|
||||||
|
clone_or_update_arti
|
||||||
|
setup_wrapper
|
||||||
|
ensure_wrapper_lockfile
|
||||||
|
|
||||||
|
for TARGET in "${TARGETS[@]}"; do
|
||||||
|
build_for_target "$TARGET"
|
||||||
|
done
|
||||||
|
|
||||||
|
verify_jni_symbols
|
||||||
|
show_summary
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_wrapper_lockfile() {
|
||||||
|
print_header "Ensuring wrapper Cargo.lock exists"
|
||||||
|
|
||||||
|
local WRAPPER_DIR="$ARTI_SOURCE_DIR/arti-android-wrapper"
|
||||||
|
local LOCKFILE="$WRAPPER_DIR/Cargo.lock"
|
||||||
|
|
||||||
|
if [ -f "$LOCKFILE" ]; then
|
||||||
|
print_success "Cargo.lock already exists"
|
||||||
|
echo ""
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_info "Cargo.lock missing; generating it once (network access may be required)..."
|
||||||
|
(cd "$WRAPPER_DIR" && cargo generate-lockfile)
|
||||||
|
|
||||||
|
if [ ! -f "$LOCKFILE" ]; then
|
||||||
|
print_error "Failed to generate Cargo.lock at $LOCKFILE"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_success "Generated Cargo.lock"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
main
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
use jni::JNIEnv;
|
||||||
|
use jni::objects::{JClass, JString, JObject, GlobalRef};
|
||||||
|
use jni::sys::{jint, jstring};
|
||||||
|
use jni::JavaVM;
|
||||||
|
|
||||||
|
use arti_client::TorClient;
|
||||||
|
use arti_client::config::TorClientConfigBuilder;
|
||||||
|
use tor_rtcompat::PreferredRuntime;
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex, Once};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Global State
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Global Arti client instance
|
||||||
|
static ARTI_CLIENT: Mutex<Option<Arc<TorClient<PreferredRuntime>>>> = Mutex::new(None);
|
||||||
|
|
||||||
|
/// Global Tokio runtime (must persist for Arti to work)
|
||||||
|
static TOKIO_RUNTIME: Mutex<Option<tokio::runtime::Runtime>> = Mutex::new(None);
|
||||||
|
|
||||||
|
/// Global JavaVM reference (cached on first JNI call)
|
||||||
|
static JAVA_VM: Mutex<Option<JavaVM>> = Mutex::new(None);
|
||||||
|
|
||||||
|
/// Global log callback reference
|
||||||
|
static LOG_CALLBACK: Mutex<Option<GlobalRef>> = Mutex::new(None);
|
||||||
|
|
||||||
|
/// Handle to SOCKS server task (for graceful shutdown)
|
||||||
|
static SOCKS_TASK: Mutex<Option<tokio::task::JoinHandle<()>>> = Mutex::new(None);
|
||||||
|
|
||||||
|
/// Initialization flag
|
||||||
|
static INIT_ONCE: Once = Once::new();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Logging Integration
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Send log message to Java callback
|
||||||
|
fn send_log_to_java(message: String) {
|
||||||
|
let vm_opt = JAVA_VM.lock().unwrap();
|
||||||
|
let callback_opt = LOG_CALLBACK.lock().unwrap();
|
||||||
|
|
||||||
|
if let (Some(vm), Some(callback)) = (vm_opt.as_ref(), callback_opt.as_ref()) {
|
||||||
|
if let Ok(mut env) = vm.attach_current_thread() {
|
||||||
|
if let Ok(jmessage) = env.new_string(&message) {
|
||||||
|
let _ = env.call_method(
|
||||||
|
callback.as_obj(),
|
||||||
|
"onLogLine",
|
||||||
|
"(Ljava/lang/String;)V",
|
||||||
|
&[(&jmessage).into()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Macro for logging to both Android logcat and Java callback
|
||||||
|
macro_rules! log_info {
|
||||||
|
($($arg:tt)*) => {{
|
||||||
|
let msg = format!($($arg)*);
|
||||||
|
android_logger::log(&format!("Arti: {}", msg));
|
||||||
|
send_log_to_java(msg);
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! log_error {
|
||||||
|
($($arg:tt)*) => {{
|
||||||
|
let msg = format!("ERROR: {}", format!($($arg)*));
|
||||||
|
android_logger::log(&format!("Arti: {}", msg));
|
||||||
|
send_log_to_java(msg);
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// JNI Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Get Arti version string
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn Java_org_torproject_arti_ArtiNative_getVersion(
|
||||||
|
env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
) -> jstring {
|
||||||
|
// Cache JavaVM on first call
|
||||||
|
if JAVA_VM.lock().unwrap().is_none() {
|
||||||
|
if let Ok(vm) = env.get_java_vm() {
|
||||||
|
*JAVA_VM.lock().unwrap() = Some(vm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let version = format!("Arti {} (custom build with rustls)", env!("CARGO_PKG_VERSION"));
|
||||||
|
let output = env.new_string(version).expect("Couldn't create java string!");
|
||||||
|
output.into_raw()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set log callback for Arti logs
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn Java_org_torproject_arti_ArtiNative_setLogCallback(
|
||||||
|
env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
callback: JObject,
|
||||||
|
) {
|
||||||
|
// Cache JavaVM if not already cached
|
||||||
|
if JAVA_VM.lock().unwrap().is_none() {
|
||||||
|
if let Ok(vm) = env.get_java_vm() {
|
||||||
|
*JAVA_VM.lock().unwrap() = Some(vm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store global reference to callback
|
||||||
|
if let Ok(global_ref) = env.new_global_ref(callback) {
|
||||||
|
*LOG_CALLBACK.lock().unwrap() = Some(global_ref);
|
||||||
|
log_info!("Log callback registered");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize Arti runtime
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn Java_org_torproject_arti_ArtiNative_initialize(
|
||||||
|
mut env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
data_dir: JString,
|
||||||
|
) -> jint {
|
||||||
|
// Cache JavaVM if not already cached
|
||||||
|
if JAVA_VM.lock().unwrap().is_none() {
|
||||||
|
if let Ok(vm) = env.get_java_vm() {
|
||||||
|
*JAVA_VM.lock().unwrap() = Some(vm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let data_dir_str: String = match env.get_string(&data_dir) {
|
||||||
|
Ok(s) => s.into(),
|
||||||
|
Err(e) => {
|
||||||
|
log_error!("Failed to convert data_dir: {:?}", e);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
log_info!("AMEx: state changed to Initialized");
|
||||||
|
log_info!("Initializing Arti with data directory: {}", data_dir_str);
|
||||||
|
|
||||||
|
// Initialize Tokio runtime (once)
|
||||||
|
INIT_ONCE.call_once(|| {
|
||||||
|
match tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(rt) => {
|
||||||
|
log_info!("Tokio runtime created successfully");
|
||||||
|
*TOKIO_RUNTIME.lock().unwrap() = Some(rt);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log_error!("Failed to create Tokio runtime: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if runtime exists
|
||||||
|
let runtime_guard = TOKIO_RUNTIME.lock().unwrap();
|
||||||
|
let runtime = match runtime_guard.as_ref() {
|
||||||
|
Some(rt) => rt,
|
||||||
|
None => {
|
||||||
|
log_error!("Tokio runtime not initialized");
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create config with explicit Android paths
|
||||||
|
let data_path = PathBuf::from(data_dir_str);
|
||||||
|
let cache_dir = data_path.join("cache");
|
||||||
|
let state_dir = data_path.join("state");
|
||||||
|
|
||||||
|
// Create directories if they don't exist
|
||||||
|
std::fs::create_dir_all(&cache_dir).ok();
|
||||||
|
std::fs::create_dir_all(&state_dir).ok();
|
||||||
|
|
||||||
|
let result: Result<()> = runtime.block_on(async {
|
||||||
|
log_info!("Creating Arti client...");
|
||||||
|
log_info!("Cache dir: {:?}", cache_dir);
|
||||||
|
log_info!("State dir: {:?}", state_dir);
|
||||||
|
|
||||||
|
// Create config with Android-specific directories
|
||||||
|
let config = TorClientConfigBuilder::from_directories(state_dir, cache_dir)
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
// Create client with Android-specific config
|
||||||
|
let client = TorClient::create_bootstrapped(config).await?;
|
||||||
|
|
||||||
|
log_info!("Arti client created successfully");
|
||||||
|
|
||||||
|
// Store client globally
|
||||||
|
*ARTI_CLIENT.lock().unwrap() = Some(Arc::new(client));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(_) => {
|
||||||
|
log_info!("Arti initialized successfully");
|
||||||
|
0
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log_error!("Failed to initialize Arti: {:?}", e);
|
||||||
|
-3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start SOCKS proxy on specified port
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn Java_org_torproject_arti_ArtiNative_startSocksProxy(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
port: jint,
|
||||||
|
) -> jint {
|
||||||
|
log_info!("AMEx: state changed to Starting");
|
||||||
|
log_info!("Starting SOCKS proxy on port {}", port);
|
||||||
|
|
||||||
|
// Stop any existing SOCKS server first
|
||||||
|
if let Some(handle) = SOCKS_TASK.lock().unwrap().take() {
|
||||||
|
log_info!("Aborting previous SOCKS server task");
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
let client_guard = ARTI_CLIENT.lock().unwrap();
|
||||||
|
let client = match client_guard.as_ref() {
|
||||||
|
Some(c) => Arc::clone(c),
|
||||||
|
None => {
|
||||||
|
log_error!("Arti client not initialized - call initialize() first");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
drop(client_guard);
|
||||||
|
|
||||||
|
let runtime_guard = TOKIO_RUNTIME.lock().unwrap();
|
||||||
|
let runtime = match runtime_guard.as_ref() {
|
||||||
|
Some(rt) => rt,
|
||||||
|
None => {
|
||||||
|
log_error!("Tokio runtime not initialized");
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to bind IMMEDIATELY to detect port conflicts before returning
|
||||||
|
let addr = format!("127.0.0.1:{}", port);
|
||||||
|
|
||||||
|
// Use block_on to synchronously attempt binding
|
||||||
|
let bind_result = runtime.block_on(async {
|
||||||
|
tokio::net::TcpListener::bind(&addr).await
|
||||||
|
});
|
||||||
|
|
||||||
|
let listener = match bind_result {
|
||||||
|
Ok(l) => {
|
||||||
|
log_info!("SOCKS proxy bound to {}", addr);
|
||||||
|
l
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log_error!("Failed to bind SOCKS proxy to {}: {:?}", addr, e);
|
||||||
|
return -3;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Now spawn the background task with the already-bound listener
|
||||||
|
let handle = runtime.spawn(async move {
|
||||||
|
log_info!("SOCKS proxy listening on {}", addr);
|
||||||
|
log_info!("Sufficiently bootstrapped; system SOCKS now functional");
|
||||||
|
|
||||||
|
// Signal bootstrap completion to bitchat-android (expected by ArtiTorManager)
|
||||||
|
// This sets bootstrapPercent to 100% and stops inactivity restarts
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||||
|
log_info!("We have found that guard [scrubbed] is usable.");
|
||||||
|
|
||||||
|
// Accept connections
|
||||||
|
loop {
|
||||||
|
match listener.accept().await {
|
||||||
|
Ok((stream, peer_addr)) => {
|
||||||
|
log_info!("SOCKS connection from: {}", peer_addr);
|
||||||
|
let client_clone = Arc::clone(&client);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = handle_socks_connection(stream, client_clone).await {
|
||||||
|
log_error!("SOCKS connection error: {:?}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log_error!("Failed to accept SOCKS connection: {:?}", e);
|
||||||
|
break; // Exit loop on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log_info!("SOCKS proxy task exiting");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store handle for cleanup
|
||||||
|
*SOCKS_TASK.lock().unwrap() = Some(handle);
|
||||||
|
|
||||||
|
log_info!("SOCKS proxy started on port {}", port);
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a single SOCKS connection
|
||||||
|
async fn handle_socks_connection(
|
||||||
|
mut stream: tokio::net::TcpStream,
|
||||||
|
client: Arc<TorClient<PreferredRuntime>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
|
// Simple SOCKS5 handshake
|
||||||
|
let mut buf = [0u8; 512];
|
||||||
|
|
||||||
|
// Read version + methods
|
||||||
|
let n = stream.read(&mut buf).await?;
|
||||||
|
if n < 2 {
|
||||||
|
return Err(anyhow::anyhow!("Invalid SOCKS handshake"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send "no auth required" response
|
||||||
|
stream.write_all(&[0x05, 0x00]).await?;
|
||||||
|
|
||||||
|
// Read request
|
||||||
|
let n = stream.read(&mut buf).await?;
|
||||||
|
if n < 10 {
|
||||||
|
return Err(anyhow::anyhow!("Invalid SOCKS request"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse SOCKS5 request: VER(1) CMD(1) RSV(1) ATYP(1) DST.ADDR DST.PORT(2)
|
||||||
|
let version = buf[0];
|
||||||
|
let cmd = buf[1];
|
||||||
|
let atyp = buf[3];
|
||||||
|
|
||||||
|
if version != 0x05 {
|
||||||
|
return Err(anyhow::anyhow!("Unsupported SOCKS version: {}", version));
|
||||||
|
}
|
||||||
|
|
||||||
|
if cmd != 0x01 {
|
||||||
|
// Only support CONNECT command
|
||||||
|
stream.write_all(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?;
|
||||||
|
return Err(anyhow::anyhow!("Unsupported SOCKS command: {}", cmd));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse target address and port
|
||||||
|
let (target_host, target_port) = match atyp {
|
||||||
|
0x01 => {
|
||||||
|
// IPv4: 4 bytes
|
||||||
|
let ip = format!("{}.{}.{}.{}", buf[4], buf[5], buf[6], buf[7]);
|
||||||
|
let port = u16::from_be_bytes([buf[8], buf[9]]);
|
||||||
|
(ip, port)
|
||||||
|
}
|
||||||
|
0x03 => {
|
||||||
|
// Domain name: length byte + domain
|
||||||
|
let len = buf[4] as usize;
|
||||||
|
if n < 5 + len + 2 {
|
||||||
|
return Err(anyhow::anyhow!("Invalid domain name length"));
|
||||||
|
}
|
||||||
|
let domain = String::from_utf8_lossy(&buf[5..5 + len]).to_string();
|
||||||
|
let port = u16::from_be_bytes([buf[5 + len], buf[5 + len + 1]]);
|
||||||
|
(domain, port)
|
||||||
|
}
|
||||||
|
0x04 => {
|
||||||
|
// IPv6: 16 bytes + 2 bytes port = 22 bytes total
|
||||||
|
if n < 22 {
|
||||||
|
stream.write_all(&[0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?;
|
||||||
|
return Err(anyhow::anyhow!("Truncated IPv6 request"));
|
||||||
|
}
|
||||||
|
let ip = format!(
|
||||||
|
"{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}",
|
||||||
|
buf[4], buf[5], buf[6], buf[7], buf[8], buf[9], buf[10], buf[11],
|
||||||
|
buf[12], buf[13], buf[14], buf[15], buf[16], buf[17], buf[18], buf[19]
|
||||||
|
);
|
||||||
|
let port = u16::from_be_bytes([buf[20], buf[21]]);
|
||||||
|
(ip, port)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
stream.write_all(&[0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?;
|
||||||
|
return Err(anyhow::anyhow!("Unsupported address type: {}", atyp));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
log_info!("SOCKS5 CONNECT to {}:{}", target_host, target_port);
|
||||||
|
|
||||||
|
// Establish Tor connection
|
||||||
|
let tor_stream = match client.connect((target_host.as_str(), target_port)).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
log_error!("Failed to connect through Tor: {:?}", e);
|
||||||
|
// Send SOCKS5 error: general failure
|
||||||
|
stream.write_all(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?;
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
log_info!("Tor connection established to {}:{}", target_host, target_port);
|
||||||
|
|
||||||
|
// Send SOCKS5 success response
|
||||||
|
stream.write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?;
|
||||||
|
|
||||||
|
// Bidirectional data forwarding
|
||||||
|
let (mut client_read, mut client_write) = stream.split();
|
||||||
|
let (mut tor_read, mut tor_write) = tor_stream.split();
|
||||||
|
|
||||||
|
let client_to_tor = async {
|
||||||
|
tokio::io::copy(&mut client_read, &mut tor_write).await
|
||||||
|
};
|
||||||
|
|
||||||
|
let tor_to_client = async {
|
||||||
|
tokio::io::copy(&mut tor_read, &mut client_write).await
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run both directions concurrently, exit when either completes
|
||||||
|
tokio::select! {
|
||||||
|
result = client_to_tor => {
|
||||||
|
if let Err(ref e) = result {
|
||||||
|
log_error!("Client->Tor copy error: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = tor_to_client => {
|
||||||
|
if let Err(ref e) = result {
|
||||||
|
log_error!("Tor->Client copy error: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
log_info!("SOCKS connection closed for {}:{}", target_host, target_port);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop Arti and cleanup
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn Java_org_torproject_arti_ArtiNative_stop(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_class: JClass,
|
||||||
|
) -> jint {
|
||||||
|
log_info!("AMEx: state changed to Stopping");
|
||||||
|
log_info!("Stopping Arti...");
|
||||||
|
|
||||||
|
// Abort SOCKS proxy task (releases the port)
|
||||||
|
if let Some(handle) = SOCKS_TASK.lock().unwrap().take() {
|
||||||
|
log_info!("Aborting SOCKS server task");
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give the abort a moment to complete and release the port
|
||||||
|
if let Some(rt) = TOKIO_RUNTIME.lock().unwrap().as_ref() {
|
||||||
|
rt.block_on(async {
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: We do NOT clear ARTI_CLIENT here!
|
||||||
|
// The TorClient can be reused for multiple SOCKS proxy start/stop cycles.
|
||||||
|
// Only clear it if you want to force full reinitialization.
|
||||||
|
|
||||||
|
// Uncomment this line only if you want to force reinitialization on every start:
|
||||||
|
// *ARTI_CLIENT.lock().unwrap() = None;
|
||||||
|
|
||||||
|
log_info!("AMEx: state changed to Stopped");
|
||||||
|
log_info!("Arti stopped successfully");
|
||||||
|
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Android Logger (simple implementation)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
mod android_logger {
|
||||||
|
use std::ffi::CString;
|
||||||
|
|
||||||
|
#[allow(non_camel_case_types)]
|
||||||
|
type c_int = i32;
|
||||||
|
|
||||||
|
#[allow(non_camel_case_types)]
|
||||||
|
type c_char = i8;
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
fn __android_log_write(prio: c_int, tag: *const c_char, text: *const c_char) -> c_int;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANDROID_LOG_INFO: c_int = 4;
|
||||||
|
|
||||||
|
pub fn log(message: &str) {
|
||||||
|
unsafe {
|
||||||
|
let tag = CString::new("ArtiNative").unwrap();
|
||||||
|
let text = CString::new(message).unwrap();
|
||||||
|
__android_log_write(ANDROID_LOG_INFO, tag.as_ptr() as *const c_char, text.as_ptr() as *const c_char);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user