Merge remote-tracking branch 'upstream/main'

This commit is contained in:
yet300
2025-12-13 18:59:22 +04:00
64 changed files with 4433 additions and 1132 deletions
+18 -60
View File
@@ -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
+21 -15
View File
@@ -19,7 +19,7 @@ jobs:
with: with:
distribution: temurin distribution: temurin
java-version: 17 java-version: 17
- name: Setup Gradle - name: Setup Gradle
uses: gradle/gradle-build-action@v2 uses: gradle/gradle-build-action@v2
with: with:
@@ -38,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
View File
@@ -43,7 +43,9 @@ dependency-reduced-pom.xml
# Other # Other
*.log *.log
.cxx/ .cxx/
*build/ # Gradle/Android build directories (but not tools/arti-build/)
**/build/
!tools/arti-build/
out/ out/
gen/ gen/
*~ *~
@@ -57,3 +59,7 @@ google-services.json
# Keystore files # Keystore files
*.jks *.jks
*.keystore *.keystore
# Arti build artifacts (cloned repo and Rust build cache)
tools/arti-build/.arti-source/
tools/arti-build/target/
+1
View File
@@ -102,6 +102,7 @@ The app requires the following permissions (automatically requested):
- **Bluetooth**: Core BLE functionality - **Bluetooth**: Core BLE functionality
- **Location**: Required for BLE scanning on Android - **Location**: Required for BLE scanning on Android
- **Network**: Expand your mesh through public internet relays
- **Notifications**: Message alerts and background updates - **Notifications**: Message alerts and background updates
### Hardware Requirements ### Hardware Requirements
+18 -4
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.bitchat.droid" applicationId = "com.bitchat.droid"
minSdk = libs.versions.minSdk.get().toInt() minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = 26 versionCode = 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)
+7 -4
View File
@@ -17,9 +17,12 @@
-keep class com.bitchat.android.nostr.** { *; } -keep class com.bitchat.android.nostr.** { *; }
-keep class com.bitchat.android.identity.** { *; } -keep class com.bitchat.android.identity.** { *; }
# Arti (Tor) ProGuard rules # Keep Tor implementation (always included)
-keep class com.bitchat.android.net.RealTorProvider { *; }
# Arti (Custom Tor implementation in Rust) ProGuard rules
-keep class info.guardianproject.arti.** { *; } -keep class info.guardianproject.arti.** { *; }
-keep class org.torproject.jni.** { *; } -keep class org.torproject.arti.** { *; }
-keepnames class org.torproject.jni.** -keepnames class org.torproject.arti.**
-dontwarn info.guardianproject.arti.** -dontwarn info.guardianproject.arti.**
-dontwarn org.torproject.jni.** -dontwarn org.torproject.arti.**
+36
View File
@@ -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
@@ -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
}
Binary file not shown.
Binary file not shown.
+10 -1
View File
@@ -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()
-4
View File
@@ -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 = [
+1
View File
@@ -0,0 +1 @@
arti-v1.7.0
+25
View File
@@ -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
+223
View File
@@ -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)
+570
View File
@@ -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
+493
View File
@@ -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);
}
}
}