Merge branch 'main' into gossip-routing-2

This commit is contained in:
callebtc
2026-01-07 05:52:25 +07:00
121 changed files with 9291 additions and 1713 deletions
@@ -3,18 +3,21 @@ package com.bitchat.android
import android.app.Application
import com.bitchat.android.nostr.RelayDirectory
import com.bitchat.android.ui.theme.ThemePreferenceManager
import com.bitchat.android.net.TorManager
import com.bitchat.android.net.ArtiTorManager
/**
* Main application class for bitchat Android
*/
class BitchatApplication : Application() {
override fun onCreate() {
super.onCreate()
// Initialize Tor first so any early network goes over Tor
try { TorManager.init(this) } catch (_: Exception) { }
try {
val torProvider = ArtiTorManager.getInstance()
torProvider.init(this)
} catch (_: Exception){}
// Initialize relay directory (loads assets/nostr_relays.csv)
RelayDirectory.initialize(this)
@@ -38,6 +41,12 @@ class BitchatApplication : Application() {
// Initialize debug preference manager (persists debug toggles)
try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { }
// Initialize mesh service preferences
try { com.bitchat.android.service.MeshServicePreferences.init(this) } catch (_: Exception) { }
// Proactively start the foreground service to keep mesh alive
try { com.bitchat.android.service.MeshForegroundService.start(this) } catch (_: Exception) { }
// TorManager already initialized above
}
}
@@ -26,6 +26,7 @@ import com.bitchat.android.onboarding.BatteryOptimizationManager
import com.bitchat.android.onboarding.BatteryOptimizationPreferenceManager
import com.bitchat.android.onboarding.BatteryOptimizationScreen
import com.bitchat.android.onboarding.BatteryOptimizationStatus
import com.bitchat.android.onboarding.BackgroundLocationPermissionScreen
import com.bitchat.android.onboarding.InitializationErrorScreen
import com.bitchat.android.onboarding.InitializingScreen
import com.bitchat.android.onboarding.LocationCheckScreen
@@ -40,6 +41,7 @@ import com.bitchat.android.ui.ChatViewModel
import com.bitchat.android.ui.OrientationAwareActivity
import com.bitchat.android.ui.theme.BitchatTheme
import com.bitchat.android.nostr.PoWPreferenceManager
import com.bitchat.android.services.VerificationService
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -51,7 +53,7 @@ class MainActivity : OrientationAwareActivity() {
private lateinit var locationStatusManager: LocationStatusManager
private lateinit var batteryOptimizationManager: BatteryOptimizationManager
// Core mesh service - managed at app level
// Core mesh service - provided by the foreground service holder
private lateinit var meshService: BluetoothMeshService
private val mainViewModel: MainViewModel by viewModels()
private val chatViewModel: ChatViewModel by viewModels {
@@ -63,16 +65,55 @@ class MainActivity : OrientationAwareActivity() {
}
}
private val forceFinishReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: android.content.Context, intent: android.content.Intent) {
if (intent.action == com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH) {
android.util.Log.i("MainActivity", "Received force finish broadcast, closing UI")
finishAffinity()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Register receiver for force finish signal from shutdown coordinator
val filter = android.content.IntentFilter(com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH)
if (android.os.Build.VERSION.SDK_INT >= 33) {
registerReceiver(
forceFinishReceiver,
filter,
com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH,
null,
android.content.Context.RECEIVER_NOT_EXPORTED
)
} else {
@Suppress("DEPRECATION")
registerReceiver(
forceFinishReceiver,
filter,
com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH,
null
)
}
// Check if this is a quit request from the notification
if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) {
android.util.Log.d("MainActivity", "Quit request received in onCreate, finishing activity")
finish()
return
}
com.bitchat.android.service.AppShutdownCoordinator.cancelPendingShutdown()
// Enable edge-to-edge display for modern Android look
enableEdgeToEdge()
// Initialize permission management
permissionManager = PermissionManager(this)
// Initialize core mesh service first
meshService = BluetoothMeshService(this)
// Ensure foreground service is running and get mesh instance from holder
try { com.bitchat.android.service.MeshForegroundService.start(applicationContext) } catch (_: Exception) { }
meshService = com.bitchat.android.service.MeshServiceHolder.getOrCreate(applicationContext)
bluetoothStatusManager = BluetoothStatusManager(
activity = this,
context = this,
@@ -95,6 +136,9 @@ class MainActivity : OrientationAwareActivity() {
activity = this,
permissionManager = permissionManager,
onOnboardingComplete = ::handleOnboardingComplete,
onBackgroundLocationRequired = {
mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION)
},
onOnboardingFailed = ::handleOnboardingFailed
)
@@ -227,6 +271,21 @@ class MainActivity : OrientationAwareActivity() {
)
}
OnboardingState.BACKGROUND_LOCATION_EXPLANATION -> {
BackgroundLocationPermissionScreen(
modifier = modifier,
onContinue = {
onboardingCoordinator.requestBackgroundLocation()
},
onRetry = {
onboardingCoordinator.checkBackgroundLocationAndProceed()
},
onSkip = {
onboardingCoordinator.skipBackgroundLocation()
}
)
}
OnboardingState.CHECKING, OnboardingState.INITIALIZING, OnboardingState.COMPLETE -> {
// Set up back navigation handling for the chat screen
val backCallback = object : OnBackPressedCallback(true) {
@@ -340,10 +399,17 @@ class MainActivity : OrientationAwareActivity() {
if (permissionManager.isFirstTimeLaunch()) {
Log.d("MainActivity", "First time launch, showing permission explanation")
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
} else if (permissionManager.areAllPermissionsGranted()) {
Log.d("MainActivity", "Existing user with permissions, initializing app")
mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING)
initializeApp()
} else if (permissionManager.areRequiredPermissionsGranted()) {
Log.d("MainActivity", "Existing user with required permissions")
if (permissionManager.needsBackgroundLocationPermission() &&
!permissionManager.isBackgroundLocationGranted() &&
!com.bitchat.android.onboarding.BackgroundLocationPreferenceManager.isSkipped(this@MainActivity)
) {
mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION)
} else {
mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING)
initializeApp()
}
} else {
Log.d("MainActivity", "Existing user missing permissions, showing explanation")
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
@@ -616,6 +682,7 @@ class MainActivity : OrientationAwareActivity() {
// Handle any notification intent
handleNotificationIntent(intent)
handleVerificationIntent(intent)
// Small delay to ensure mesh service is fully initialized
delay(500)
@@ -630,9 +697,21 @@ class MainActivity : OrientationAwareActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
// Check if this is a quit request from the notification
if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) {
android.util.Log.d("MainActivity", "Quit request received, finishing activity")
finish()
return
}
com.bitchat.android.service.AppShutdownCoordinator.cancelPendingShutdown()
// Handle notification intents when app is already running
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
handleNotificationIntent(intent)
handleVerificationIntent(intent)
}
}
@@ -640,9 +719,8 @@ class MainActivity : OrientationAwareActivity() {
super.onResume()
// Check Bluetooth and Location status on resume and handle accordingly
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
// Set app foreground state
meshService.connectionManager.setAppBackgroundState(false)
chatViewModel.setAppBackgroundState(false)
// Reattach mesh delegate to new ChatViewModel instance after Activity recreation
try { meshService.delegate = chatViewModel } catch (_: Exception) { }
// Check if Bluetooth was disabled while app was backgrounded
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
@@ -665,13 +743,12 @@ class MainActivity : OrientationAwareActivity() {
}
}
override fun onPause() {
override fun onPause() {
super.onPause()
// Only set background state if app is fully initialized
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
// Set app background state
meshService.connectionManager.setAppBackgroundState(true)
chatViewModel.setAppBackgroundState(true)
// Detach UI delegate so the foreground service can own DM notifications while UI is closed
try { meshService.delegate = null } catch (_: Exception) { }
}
}
@@ -734,10 +811,23 @@ class MainActivity : OrientationAwareActivity() {
}
}
private fun handleVerificationIntent(intent: Intent) {
val uri = intent.data ?: return
if (uri.scheme != "bitchat" || uri.host != "verify") return
chatViewModel.showVerificationSheet()
val qr = VerificationService.verifyScannedQR(uri.toString())
if (qr != null) {
chatViewModel.beginQRVerification(qr)
}
}
override fun onDestroy() {
super.onDestroy()
try { unregisterReceiver(forceFinishReceiver) } catch (_: Exception) { }
// Cleanup location status manager
try {
locationStatusManager.cleanup()
@@ -746,14 +836,6 @@ class MainActivity : OrientationAwareActivity() {
Log.w("MainActivity", "Error cleaning up location status manager: ${e.message}")
}
// Stop mesh services if app was fully initialized
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
try {
meshService.stopServices()
Log.d("MainActivity", "Mesh services stopped successfully")
} catch (e: Exception) {
Log.w("MainActivity", "Error stopping mesh services in onDestroy: ${e.message}")
}
}
// Do not stop mesh here; ForegroundService owns lifecycle for background reliability
}
}
@@ -0,0 +1,34 @@
package com.bitchat.android.core.ui.component.button
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun CloseButton(
onClick: () -> Unit,
modifier: Modifier = Modifier.Companion
) {
IconButton(
onClick = onClick,
modifier = modifier
.size(32.dp),
colors = IconButtonDefaults.iconButtonColors(
contentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
containerColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.1f)
)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close",
modifier = Modifier.Companion.size(18.dp)
)
}
}
@@ -19,7 +19,7 @@ import java.util.concurrent.ConcurrentHashMap
* This is the main interface for all encryption/decryption operations in bitchat.
* It now uses the Noise protocol for secure transport encryption with proper session management.
*/
class EncryptionService(private val context: Context) {
open class EncryptionService(private val context: Context) {
companion object {
private const val TAG = "EncryptionService"
@@ -27,14 +27,14 @@ class EncryptionService(private val context: Context) {
}
// Core Noise encryption service
private val noiseService: NoiseEncryptionService = NoiseEncryptionService(context)
private val noiseService: NoiseEncryptionService by lazy { NoiseEncryptionService(context) }
// Session tracking for established connections
private val establishedSessions = ConcurrentHashMap<String, String>() // peerID -> fingerprint
// Ed25519 signing keys (separate from Noise static keys)
private val ed25519PrivateKey: Ed25519PrivateKeyParameters
private val ed25519PublicKey: Ed25519PublicKeyParameters
private lateinit var ed25519PrivateKey: Ed25519PrivateKeyParameters
private lateinit var ed25519PublicKey: Ed25519PublicKeyParameters
// Callbacks for UI state updates
var onSessionEstablished: ((String) -> Unit)? = null // peerID
@@ -42,6 +42,13 @@ class EncryptionService(private val context: Context) {
var onHandshakeRequired: ((String) -> Unit)? = null // peerID
init {
initialize()
}
/**
* Initialization logic moved to method to allow overriding in tests
*/
protected open fun initialize() {
// Initialize or load Ed25519 signing keys
val keyPair = loadOrCreateEd25519KeyPair()
ed25519PrivateKey = keyPair.private as Ed25519PrivateKeyParameters
@@ -356,7 +363,7 @@ class EncryptionService(private val context: Context) {
/**
* Verify Ed25519 signature against data using a public key
*/
fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean {
open fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean {
return try {
val publicKey = Ed25519PublicKeyParameters(publicKeyBytes, 0)
val verifier = Ed25519Signer()
@@ -5,13 +5,14 @@ import android.location.Geocoder
import android.location.Location
import android.location.LocationManager
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.Locale
/**
@@ -46,11 +47,11 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
private val membership = mutableSetOf<String>()
private val _bookmarks = MutableLiveData<List<String>>(emptyList())
val bookmarks: LiveData<List<String>> = _bookmarks
private val _bookmarks = MutableStateFlow<List<String>>(emptyList())
val bookmarks: StateFlow<List<String>> = _bookmarks.asStateFlow()
private val _bookmarkNames = MutableLiveData<Map<String, String>>(emptyMap())
val bookmarkNames: LiveData<Map<String, String>> = _bookmarkNames
private val _bookmarkNames = MutableStateFlow<Map<String, String>>(emptyMap())
val bookmarkNames: StateFlow<Map<String, String>> = _bookmarkNames.asStateFlow()
// For throttling / preventing duplicate geocode lookups
private val resolving = mutableSetOf<String>()
@@ -68,8 +69,8 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
val gh = normalize(geohash)
if (gh.isEmpty() || membership.contains(gh)) return
membership.add(gh)
val updated = listOf(gh) + (_bookmarks.value ?: emptyList())
_bookmarks.postValue(updated)
val updated = listOf(gh) + (_bookmarks.value)
_bookmarks.value = updated
persist(updated)
// Resolve friendly name asynchronously
resolveNameIfNeeded(gh)
@@ -79,12 +80,12 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
val gh = normalize(geohash)
if (!membership.contains(gh)) return
membership.remove(gh)
val updated = (_bookmarks.value ?: emptyList()).filterNot { it == gh }
_bookmarks.postValue(updated)
val updated = (_bookmarks.value).filterNot { it == gh }
_bookmarks.value = updated
// Remove stored name to avoid stale cache growth
val names = _bookmarkNames.value?.toMutableMap() ?: mutableMapOf()
val names = _bookmarkNames.value.toMutableMap()
if (names.remove(gh) != null) {
_bookmarkNames.postValue(names)
_bookmarkNames.value = names
persistNames(names)
}
persist(updated)
@@ -108,7 +109,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
}
}
membership.clear(); membership.addAll(seen)
_bookmarks.postValue(ordered)
_bookmarks.value = ordered
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load bookmarks: ${e.message}")
@@ -118,7 +119,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
if (!namesJson.isNullOrEmpty()) {
val mapType = object : TypeToken<Map<String, String>>() {}.type
val dict = gson.fromJson<Map<String, String>>(namesJson, mapType)
_bookmarkNames.postValue(dict)
_bookmarkNames.value = dict
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load bookmark names: ${e.message}")
@@ -127,14 +128,14 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
private fun persist() {
try {
val json = gson.toJson(_bookmarks.value ?: emptyList<String>())
val json = gson.toJson(_bookmarks.value)
prefs.edit().putString(STORE_KEY, json).apply()
} catch (_: Exception) {}
}
private fun persistNames() {
try {
val json = gson.toJson(_bookmarkNames.value ?: emptyMap<String, String>())
val json = gson.toJson(_bookmarkNames.value)
prefs.edit().putString(NAMES_STORE_KEY, json).apply()
} catch (_: Exception) {}
}
@@ -144,8 +145,8 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
fun clearAll() {
try {
membership.clear()
_bookmarks.postValue(emptyList())
_bookmarkNames.postValue(emptyMap())
_bookmarks.value = emptyList()
_bookmarkNames.value = emptyMap()
prefs.edit()
.remove(STORE_KEY)
.remove(NAMES_STORE_KEY)
@@ -209,9 +210,9 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
}
if (!name.isNullOrEmpty()) {
val current = _bookmarkNames.value?.toMutableMap() ?: mutableMapOf()
val current = _bookmarkNames.value.toMutableMap()
current[gh] = name
_bookmarkNames.postValue(current)
_bookmarkNames.value = current
persistNames(current)
}
} catch (e: Exception) {
@@ -10,12 +10,12 @@ import android.location.LocationManager
import android.os.Bundle
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.*
import java.util.*
import com.google.gson.Gson
import com.google.gson.JsonSyntaxException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Manages location permissions, one-shot location retrieval, and computing geohash channels.
@@ -53,28 +53,26 @@ class LocationChannelManager private constructor(private val context: Context) {
private var dataManager: com.bitchat.android.ui.DataManager? = null
// Published state for UI bindings (matching iOS @Published properties)
private val _permissionState = MutableLiveData(PermissionState.NOT_DETERMINED)
val permissionState: LiveData<PermissionState> = _permissionState
private val _permissionState = MutableStateFlow(PermissionState.NOT_DETERMINED)
val permissionState: StateFlow<PermissionState> = _permissionState
private val _availableChannels = MutableLiveData<List<GeohashChannel>>(emptyList())
val availableChannels: LiveData<List<GeohashChannel>> = _availableChannels
private val _availableChannels = MutableStateFlow<List<GeohashChannel>>(emptyList())
val availableChannels: StateFlow<List<GeohashChannel>> = _availableChannels
private val _selectedChannel = MutableLiveData<ChannelID>(ChannelID.Mesh)
val selectedChannel: LiveData<ChannelID> = _selectedChannel
private val _selectedChannel = MutableStateFlow<ChannelID>(ChannelID.Mesh)
val selectedChannel: StateFlow<ChannelID> = _selectedChannel
private val _teleported = MutableLiveData(false)
val teleported: LiveData<Boolean> = _teleported
private val _teleported = MutableStateFlow(false)
val teleported: StateFlow<Boolean> = _teleported
private val _locationNames = MutableLiveData<Map<GeohashChannelLevel, String>>(emptyMap())
val locationNames: LiveData<Map<GeohashChannelLevel, String>> = _locationNames
private val _locationNames = MutableStateFlow<Map<GeohashChannelLevel, String>>(emptyMap())
val locationNames: StateFlow<Map<GeohashChannelLevel, String>> = _locationNames
// Add a new LiveData property to indicate when location is being fetched
private val _isLoadingLocation = MutableLiveData(false)
val isLoadingLocation: LiveData<Boolean> = _isLoadingLocation
private val _isLoadingLocation = MutableStateFlow(false)
val isLoadingLocation: StateFlow<Boolean> = _isLoadingLocation
// Add a new LiveData property to track if location services are enabled by user
private val _locationServicesEnabled = MutableLiveData(false)
val locationServicesEnabled: LiveData<Boolean> = _locationServicesEnabled
private val _locationServicesEnabled = MutableStateFlow(false)
val locationServicesEnabled: StateFlow<Boolean> = _locationServicesEnabled
init {
updatePermissionState()
@@ -102,15 +100,15 @@ class LocationChannelManager private constructor(private val context: Context) {
when (getCurrentPermissionStatus()) {
PermissionState.NOT_DETERMINED -> {
Log.d(TAG, "Permission not determined - user needs to grant in app settings")
_permissionState.postValue(PermissionState.NOT_DETERMINED)
_permissionState.value = PermissionState.NOT_DETERMINED
}
PermissionState.DENIED, PermissionState.RESTRICTED -> {
Log.d(TAG, "Permission denied or restricted")
_permissionState.postValue(PermissionState.DENIED)
_permissionState.value = PermissionState.DENIED
}
PermissionState.AUTHORIZED -> {
Log.d(TAG, "Permission authorized - requesting location")
_permissionState.postValue(PermissionState.AUTHORIZED)
_permissionState.value = PermissionState.AUTHORIZED
requestOneShotLocation()
}
}
@@ -126,36 +124,55 @@ class LocationChannelManager private constructor(private val context: Context) {
}
/**
* Begin periodic one-shot location refreshes while a selector UI is visible
* Begin real-time location updates while a selector UI is visible
* Uses requestLocationUpdates for continuous updates, plus a one-shot to prime state immediately
*/
fun beginLiveRefresh(interval: Long = 5000L) {
Log.d(TAG, "Beginning live refresh with interval ${interval}ms")
Log.d(TAG, "Beginning live refresh (continuous updates)")
if (_permissionState.value != PermissionState.AUTHORIZED) {
Log.w(TAG, "Cannot start live refresh - permission not authorized")
return
}
if (!isLocationServicesEnabled()) {
Log.w(TAG, "Cannot start live refresh - location services disabled by user")
return
}
// Cancel existing timer
// Cancel any existing timer-based refreshers
refreshTimer?.cancel()
// Start new timer with coroutines
refreshTimer = CoroutineScope(Dispatchers.IO).launch {
while (isActive) {
if (isLocationServicesEnabled()) {
requestOneShotLocation()
refreshTimer = null
// Register for continuous updates from available providers
try {
if (hasLocationPermission()) {
val providers = listOf(
LocationManager.GPS_PROVIDER,
LocationManager.NETWORK_PROVIDER
)
providers.forEach { provider ->
if (locationManager.isProviderEnabled(provider)) {
// 2s min time, 5m min distance for responsive yet battery-aware updates
locationManager.requestLocationUpdates(
provider,
interval,
5f,
continuousLocationListener
)
Log.d(TAG, "Registered continuous updates for $provider")
}
}
delay(interval)
// Prime state immediately with last known / current location
requestOneShotLocation()
}
} catch (e: SecurityException) {
Log.e(TAG, "Missing location permission for continuous updates: ${e.message}")
} catch (e: Exception) {
Log.e(TAG, "Failed to register continuous updates: ${e.message}")
}
// Kick off immediately
requestOneShotLocation()
}
/**
@@ -165,6 +182,12 @@ class LocationChannelManager private constructor(private val context: Context) {
Log.d(TAG, "Ending live refresh")
refreshTimer?.cancel()
refreshTimer = null
// Unregister continuous updates listener
try {
locationManager.removeUpdates(continuousLocationListener)
} catch (_: SecurityException) {
} catch (_: Exception) {
}
}
/**
@@ -180,7 +203,7 @@ class LocationChannelManager private constructor(private val context: Context) {
lastLocation?.let { location ->
when (channel) {
is ChannelID.Mesh -> {
_teleported.postValue(false)
_teleported.value = false
}
is ChannelID.Location -> {
val currentGeohash = Geohash.encode(
@@ -189,7 +212,7 @@ class LocationChannelManager private constructor(private val context: Context) {
precision = channel.channel.level.precision
)
val isTeleportedNow = currentGeohash != channel.channel.geohash
_teleported.postValue(isTeleportedNow)
_teleported.value = isTeleportedNow
Log.d(TAG, "Teleported (immediate recompute): $isTeleportedNow (current: $currentGeohash, selected: ${channel.channel.geohash})")
}
}
@@ -201,7 +224,7 @@ class LocationChannelManager private constructor(private val context: Context) {
*/
fun setTeleported(teleported: Boolean) {
Log.d(TAG, "Setting teleported status: $teleported")
_teleported.postValue(teleported)
_teleported.value = teleported
}
/**
@@ -209,7 +232,7 @@ class LocationChannelManager private constructor(private val context: Context) {
*/
fun enableLocationServices() {
Log.d(TAG, "enableLocationServices() called by user")
_locationServicesEnabled.postValue(true)
_locationServicesEnabled.value = true
saveLocationServicesState(true)
// If we have permission, start location operations
@@ -223,15 +246,15 @@ class LocationChannelManager private constructor(private val context: Context) {
*/
fun disableLocationServices() {
Log.d(TAG, "disableLocationServices() called by user")
_locationServicesEnabled.postValue(false)
_locationServicesEnabled.value = false
saveLocationServicesState(false)
// Stop any ongoing location operations
endLiveRefresh()
// Clear available channels when location is disabled
_availableChannels.postValue(emptyList())
_locationNames.postValue(emptyMap())
_availableChannels.value = emptyList()
_locationNames.value = emptyMap()
// If user had a location channel selected, switch back to mesh
if (_selectedChannel.value is ChannelID.Location) {
@@ -243,7 +266,7 @@ class LocationChannelManager private constructor(private val context: Context) {
* Check if location services are enabled by the user
*/
fun isLocationServicesEnabled(): Boolean {
return _locationServicesEnabled.value ?: false
return _locationServicesEnabled.value
}
// MARK: - Location Operations
@@ -280,13 +303,13 @@ class LocationChannelManager private constructor(private val context: Context) {
if (lastKnownLocation != null) {
Log.d(TAG, "Using last known location: ${lastKnownLocation.latitude}, ${lastKnownLocation.longitude}")
lastLocation = lastKnownLocation
_isLoadingLocation.postValue(false) // Make sure loading state is off
_isLoadingLocation.value = false // Make sure loading state is off
computeChannels(lastKnownLocation)
reverseGeocodeIfNeeded(lastKnownLocation)
} else {
Log.d(TAG, "No last known location available")
// Set loading state to true so UI can show a spinner
_isLoadingLocation.postValue(true)
_isLoadingLocation.value = true
// Request a fresh location only when we don't have a last known location
Log.d(TAG, "Requesting fresh location...")
@@ -294,21 +317,45 @@ class LocationChannelManager private constructor(private val context: Context) {
}
} catch (e: SecurityException) {
Log.e(TAG, "Security exception requesting location: ${e.message}")
_isLoadingLocation.postValue(false) // Turn off loading state on error
_isLoadingLocation.value = false // Turn off loading state on error
updatePermissionState()
}
}
// Continuous location listener for real-time updates
private val continuousLocationListener = object : LocationListener {
override fun onLocationChanged(location: Location) {
Log.d(TAG, "Real-time location: ${location.latitude}, ${location.longitude} acc=${location.accuracy}m")
lastLocation = location
_isLoadingLocation.value = false
computeChannels(location)
reverseGeocodeIfNeeded(location)
}
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {
// Deprecated but can still be called on older devices
Log.v(TAG, "Provider status changed: $provider -> $status")
}
override fun onProviderEnabled(provider: String) {
Log.d(TAG, "Provider enabled: $provider")
}
override fun onProviderDisabled(provider: String) {
Log.d(TAG, "Provider disabled: $provider")
}
}
// One-time location listener to get a fresh location update
private val oneShotLocationListener = object : LocationListener {
override fun onLocationChanged(location: Location) {
Log.d(TAG, "Fresh location received: ${location.latitude}, ${location.longitude}")
Log.d(TAG, "One-shot location: ${location.latitude}, ${location.longitude}")
lastLocation = location
computeChannels(location)
reverseGeocodeIfNeeded(location)
// Update loading state to indicate we have a location now
_isLoadingLocation.postValue(false)
_isLoadingLocation.value = false
// Remove this listener after getting the update
try {
@@ -317,18 +364,30 @@ class LocationChannelManager private constructor(private val context: Context) {
Log.e(TAG, "Error removing location listener: ${e.message}")
}
}
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {
// Required for compatibility with older platform versions
}
override fun onProviderEnabled(provider: String) {
// Required for compatibility with older platform versions
}
override fun onProviderDisabled(provider: String) {
// Required for compatibility with older platform versions
}
}
// Request a fresh location update using getCurrentLocation instead of continuous updates
private fun requestFreshLocation() {
if (!hasLocationPermission()) {
_isLoadingLocation.postValue(false) // Turn off loading state if no permission
_isLoadingLocation.value = false // Turn off loading state if no permission
return
}
try {
// Set loading state to true to indicate we're actively trying to get a location
_isLoadingLocation.postValue(true)
_isLoadingLocation.value = true
// Try common providers in order of preference
val providers = listOf(
@@ -358,7 +417,7 @@ class LocationChannelManager private constructor(private val context: Context) {
Log.w(TAG, "Received null location from getCurrentLocation")
}
// Update loading state to indicate we have a location now
_isLoadingLocation.postValue(false)
_isLoadingLocation.value = false
}
)
} else {
@@ -378,14 +437,14 @@ class LocationChannelManager private constructor(private val context: Context) {
// If no provider was available, turn off loading state
if (!providerFound) {
Log.w(TAG, "No location providers available")
_isLoadingLocation.postValue(false)
_isLoadingLocation.value = false
}
} catch (e: SecurityException) {
Log.e(TAG, "Security exception requesting location: ${e.message}")
_isLoadingLocation.postValue(false) // Turn off loading state on error
_isLoadingLocation.value = false // Turn off loading state on error
} catch (e: Exception) {
Log.e(TAG, "Error requesting location: ${e.message}")
_isLoadingLocation.postValue(false) // Turn off loading state on error
_isLoadingLocation.value = false // Turn off loading state on error
}
}
@@ -408,7 +467,7 @@ class LocationChannelManager private constructor(private val context: Context) {
private fun updatePermissionState() {
val newState = getCurrentPermissionStatus()
Log.d(TAG, "Permission state updated to: $newState")
_permissionState.postValue(newState)
_permissionState.value = newState
}
private fun hasLocationPermission(): Boolean {
@@ -433,13 +492,13 @@ class LocationChannelManager private constructor(private val context: Context) {
Log.v(TAG, "Generated ${level.displayName}: $geohash")
}
_availableChannels.postValue(result)
_availableChannels.value = result
// Recompute teleported status based on current location vs selected channel
val selectedChannelValue = _selectedChannel.value
when (selectedChannelValue) {
is ChannelID.Mesh -> {
_teleported.postValue(false)
_teleported.value = false
}
is ChannelID.Location -> {
val currentGeohash = Geohash.encode(
@@ -448,12 +507,9 @@ class LocationChannelManager private constructor(private val context: Context) {
precision = selectedChannelValue.channel.level.precision
)
val isTeleported = currentGeohash != selectedChannelValue.channel.geohash
_teleported.postValue(isTeleported)
_teleported.value = isTeleported
Log.d(TAG, "Teleported status: $isTeleported (current: $currentGeohash, selected: ${selectedChannelValue.channel.geohash})")
}
null -> {
_teleported.postValue(false)
}
}
}
@@ -482,7 +538,7 @@ class LocationChannelManager private constructor(private val context: Context) {
val names = namesByLevel(address)
Log.d(TAG, "Reverse geocoding result: $names")
_locationNames.postValue(names)
_locationNames.value = names
} else {
Log.w(TAG, "No reverse geocoding results")
}
@@ -601,26 +657,26 @@ class LocationChannelManager private constructor(private val context: Context) {
}
if (channel != null) {
_selectedChannel.postValue(channel)
_selectedChannel.value = channel
Log.d(TAG, "Restored persisted channel: ${channel.displayName}")
} else {
Log.d(TAG, "Could not restore persisted channel, defaulting to Mesh")
_selectedChannel.postValue(ChannelID.Mesh)
_selectedChannel.value = ChannelID.Mesh
}
} else {
Log.w(TAG, "Invalid channel data format in persistence")
_selectedChannel.postValue(ChannelID.Mesh)
_selectedChannel.value = ChannelID.Mesh
}
} else {
Log.d(TAG, "No persisted channel found, defaulting to Mesh")
_selectedChannel.postValue(ChannelID.Mesh)
_selectedChannel.value = ChannelID.Mesh
}
} catch (e: JsonSyntaxException) {
Log.e(TAG, "Failed to parse persisted channel data: ${e.message}")
_selectedChannel.postValue(ChannelID.Mesh)
_selectedChannel.value = ChannelID.Mesh
} catch (e: Exception) {
Log.e(TAG, "Failed to load persisted channel: ${e.message}")
_selectedChannel.postValue(ChannelID.Mesh)
_selectedChannel.value = ChannelID.Mesh
}
}
@@ -629,7 +685,7 @@ class LocationChannelManager private constructor(private val context: Context) {
*/
fun clearPersistedChannel() {
dataManager?.clearLastGeohashChannel()
_selectedChannel.postValue(ChannelID.Mesh)
_selectedChannel.value = ChannelID.Mesh
Log.d(TAG, "Cleared persisted channel selection")
}
@@ -653,11 +709,11 @@ class LocationChannelManager private constructor(private val context: Context) {
private fun loadLocationServicesState() {
try {
val enabled = dataManager?.isLocationServicesEnabled() ?: false
_locationServicesEnabled.postValue(enabled)
_locationServicesEnabled.value = enabled
Log.d(TAG, "Loaded location services state: $enabled")
} catch (e: Exception) {
Log.e(TAG, "Failed to load location services state: ${e.message}")
_locationServicesEnabled.postValue(false)
_locationServicesEnabled.value = false
}
}
@@ -668,16 +724,9 @@ class LocationChannelManager private constructor(private val context: Context) {
Log.d(TAG, "Cleaning up LocationChannelManager")
endLiveRefresh()
// For older Android versions, remove any remaining location listener to prevent memory leaks
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.R) {
try {
locationManager.removeUpdates(oneShotLocationListener)
} catch (e: SecurityException) {
Log.e(TAG, "Error removing location listener during cleanup: ${e.message}")
} catch (e: Exception) {
Log.e(TAG, "Error during cleanup: ${e.message}")
}
}
// Remove listeners to prevent memory leaks
try { locationManager.removeUpdates(oneShotLocationListener) } catch (_: Exception) {}
try { locationManager.removeUpdates(continuousLocationListener) } catch (_: Exception) {}
// For Android 11+, getCurrentLocation doesn't need explicit cleanup as it's a one-time operation
}
}
@@ -5,7 +5,10 @@ import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import java.security.MessageDigest
import android.util.Base64
import android.util.Log
import com.bitchat.android.util.hexEncodedString
import androidx.core.content.edit
/**
* Manages persistent identity storage and peer ID rotation - 100% compatible with iOS implementation
@@ -24,9 +27,15 @@ class SecureIdentityStateManager(private val context: Context) {
private const val KEY_STATIC_PUBLIC_KEY = "static_public_key"
private const val KEY_SIGNING_PRIVATE_KEY = "signing_private_key"
private const val KEY_SIGNING_PUBLIC_KEY = "signing_public_key"
private const val KEY_VERIFIED_FINGERPRINTS = "verified_fingerprints"
private const val KEY_CACHED_PEER_FINGERPRINTS = "cached_peer_fingerprints"
private const val KEY_CACHED_PEER_NOISE_KEYS = "cached_peer_noise_keys"
private const val KEY_CACHED_NOISE_FINGERPRINTS = "cached_noise_fingerprints"
private const val KEY_CACHED_FINGERPRINT_NICKNAMES = "cached_fingerprint_nicknames"
}
private val prefs: SharedPreferences
private val lock = Any()
init {
// Create master key for encryption
@@ -168,7 +177,7 @@ class SecureIdentityStateManager(private val context: Context) {
fun generateFingerprint(publicKeyData: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(publicKeyData)
return hash.joinToString("") { "%02x".format(it) }
return hash.hexEncodedString()
}
/**
@@ -178,6 +187,112 @@ class SecureIdentityStateManager(private val context: Context) {
// SHA-256 fingerprint should be 64 hex characters
return fingerprint.matches(Regex("^[a-fA-F0-9]{64}$"))
}
// MARK: - Verified Fingerprints
fun getVerifiedFingerprints(): Set<String> {
return prefs.getStringSet(KEY_VERIFIED_FINGERPRINTS, emptySet())?.toSet() ?: emptySet()
}
fun isVerifiedFingerprint(fingerprint: String): Boolean {
return getVerifiedFingerprints().contains(fingerprint)
}
fun setVerifiedFingerprint(fingerprint: String, verified: Boolean) {
if (!isValidFingerprint(fingerprint)) return
synchronized(lock) {
val current = prefs.getStringSet(KEY_VERIFIED_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf()
if (verified) {
current.add(fingerprint)
} else {
current.remove(fingerprint)
}
prefs.edit { putStringSet(KEY_VERIFIED_FINGERPRINTS, current) }
}
}
fun getCachedPeerFingerprint(peerID: String): String? {
val pid = peerID.lowercase()
// Reading is safe without lock for SharedPreferences, but synchronizing ensures memory visibility
// if we are paranoid, but SharedPreferences is generally thread-safe for reads.
// However, to ensure we don't read a partial update (unlikely with SP), we can leave it.
// The critical part is the write.
val entries = prefs.getStringSet(KEY_CACHED_PEER_FINGERPRINTS, emptySet()) ?: return null
val entry = entries.firstOrNull { it.startsWith("$pid:") } ?: return null
return entry.substringAfter(':').takeIf { isValidFingerprint(it) }
}
fun cachePeerFingerprint(peerID: String, fingerprint: String) {
if (!isValidFingerprint(fingerprint)) return
val pid = peerID.lowercase()
synchronized(lock) {
val current = prefs.getStringSet(KEY_CACHED_PEER_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf()
current.removeAll { it.startsWith("$pid:") }
current.add("$pid:$fingerprint")
prefs.edit { putStringSet(KEY_CACHED_PEER_FINGERPRINTS, current) }
}
}
fun getCachedNoiseKey(peerID: String): String? {
val pid = peerID.lowercase()
val entries = prefs.getStringSet(KEY_CACHED_PEER_NOISE_KEYS, emptySet()) ?: return null
val entry = entries.firstOrNull { it.startsWith("$pid=") } ?: return null
return entry.substringAfter('=').takeIf { it.matches(Regex("^[a-fA-F0-9]{64}$")) }
}
fun cachePeerNoiseKey(peerID: String, noiseKeyHex: String) {
if (!noiseKeyHex.matches(Regex("^[a-fA-F0-9]{64}$"))) return
val pid = peerID.lowercase()
synchronized(lock) {
val current = prefs.getStringSet(KEY_CACHED_PEER_NOISE_KEYS, emptySet())?.toMutableSet() ?: mutableSetOf()
current.removeAll { it.startsWith("$pid=") }
current.add("$pid=${noiseKeyHex.lowercase()}")
prefs.edit { putStringSet(KEY_CACHED_PEER_NOISE_KEYS, current) }
}
}
fun getCachedNoiseFingerprint(noiseKeyHex: String): String? {
val key = noiseKeyHex.lowercase()
val entries = prefs.getStringSet(KEY_CACHED_NOISE_FINGERPRINTS, emptySet()) ?: return null
val entry = entries.firstOrNull { it.startsWith("$key=") } ?: return null
return entry.substringAfter('=').takeIf { isValidFingerprint(it) }
}
fun cacheNoiseFingerprint(noiseKeyHex: String, fingerprint: String) {
if (!isValidFingerprint(fingerprint)) return
if (!noiseKeyHex.matches(Regex("^[a-fA-F0-9]{64}$"))) return
val key = noiseKeyHex.lowercase()
synchronized(lock) {
val current = prefs.getStringSet(KEY_CACHED_NOISE_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf()
current.removeAll { it.startsWith("$key=") }
current.add("$key=$fingerprint")
prefs.edit { putStringSet(KEY_CACHED_NOISE_FINGERPRINTS, current) }
}
}
fun getCachedFingerprintNickname(fingerprint: String): String? {
if (!isValidFingerprint(fingerprint)) return null
val key = fingerprint.lowercase()
val entries = prefs.getStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, emptySet()) ?: return null
val entry = entries.firstOrNull { it.startsWith("$key=") } ?: return null
val encoded = entry.substringAfter('=')
return runCatching {
val bytes = Base64.decode(encoded, Base64.NO_WRAP)
String(bytes, Charsets.UTF_8)
}.getOrNull()
}
fun cacheFingerprintNickname(fingerprint: String, nickname: String) {
if (!isValidFingerprint(fingerprint)) return
val key = fingerprint.lowercase()
val encoded = Base64.encodeToString(nickname.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
synchronized(lock) {
val current = prefs.getStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, emptySet())?.toMutableSet() ?: mutableSetOf()
current.removeAll { it.startsWith("$key=") }
current.add("$key=$encoded")
prefs.edit { putStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, current) }
}
}
// MARK: - Peer ID Rotation Management (removed)
// Android now derives peer ID from the persisted Noise identity fingerprint.
@@ -84,6 +84,10 @@ class BluetoothConnectionManager(
// Public property for address-peer mapping
val addressPeerMap get() = connectionTracker.addressPeerMap
// Expose first-announce helpers to higher layers
fun noteAnnounceReceived(address: String) { connectionTracker.noteAnnounceReceived(address) }
fun hasSeenFirstAnnounce(address: String): Boolean = connectionTracker.hasSeenFirstAnnounce(address)
init {
powerManager.delegate = this
@@ -145,6 +149,7 @@ class BluetoothConnectionManager(
try {
isActive = true
Log.d(TAG, "ConnectionManager activated (permissions and adapter OK)")
// set the adapter's name to our 8-character peerID for iOS privacy, TODO: Make this configurable
// try {
@@ -175,6 +180,7 @@ class BluetoothConnectionManager(
this@BluetoothConnectionManager.isActive = false
return@launch
}
Log.d(TAG, "GATT Server started")
} else {
Log.i(TAG, "GATT Server disabled by debug settings; not starting")
}
@@ -185,6 +191,7 @@ class BluetoothConnectionManager(
this@BluetoothConnectionManager.isActive = false
return@launch
}
Log.d(TAG, "GATT Client started")
} else {
Log.i(TAG, "GATT Client disabled by debug settings; not starting")
}
@@ -210,6 +217,7 @@ class BluetoothConnectionManager(
isActive = false
connectionScope.launch {
Log.d(TAG, "Stopping client/server and power components...")
// Stop component managers
clientManager.stop()
serverManager.stop()
@@ -226,14 +234,19 @@ class BluetoothConnectionManager(
Log.i(TAG, "All Bluetooth services stopped")
}
}
/**
* Set app background state for power optimization
*/
fun setAppBackgroundState(inBackground: Boolean) {
powerManager.setAppBackgroundState(inBackground)
}
/**
* Indicates whether this instance can be safely reused for a future start.
* Returns false if its coroutine scope has been cancelled.
*/
fun isReusable(): Boolean {
val active = connectionScope.isActive
if (!active) {
Log.d(TAG, "BluetoothConnectionManager isReusable=false (scope cancelled)")
}
return active
}
/**
* Broadcast packet to connected devices with connection limit enforcement
* Automatically fragments large packets to fit within BLE MTU limits
@@ -30,6 +30,8 @@ class BluetoothConnectionTracker(
private val connectedDevices = ConcurrentHashMap<String, DeviceConnection>()
private val subscribedDevices = CopyOnWriteArrayList<BluetoothDevice>()
val addressPeerMap = ConcurrentHashMap<String, String>()
// Track whether we have seen the first ANNOUNCE on a given device connection
private val firstAnnounceSeen = ConcurrentHashMap<String, Boolean>()
// RSSI tracking from scan results (for devices we discover but may connect as servers)
private val scanRSSI = ConcurrentHashMap<String, Int>()
@@ -91,6 +93,8 @@ class BluetoothConnectionTracker(
Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}")
connectedDevices[deviceAddress] = deviceConn
pendingConnections.remove(deviceAddress)
// Mark as awaiting first ANNOUNCE on this connection
firstAnnounceSeen[deviceAddress] = false
}
/**
@@ -194,7 +198,8 @@ class BluetoothConnectionTracker(
}
// Update connection attempt atomically
val attempts = (currentAttempt?.attempts ?: 0) + 1
// If the previous attempt window expired, reset backoff to 1; otherwise increment
val attempts = if (currentAttempt?.isExpired() == true) 1 else (currentAttempt?.attempts ?: 0) + 1
pendingConnections[deviceAddress] = ConnectionAttempt(attempts)
Log.d(TAG, "Tracker: Added pending connection for $deviceAddress (attempts: $attempts)")
return true
@@ -279,7 +284,7 @@ class BluetoothConnectionTracker(
subscribedDevices.removeAll { it.address == deviceAddress }
addressPeerMap.remove(deviceAddress)
}
pendingConnections.remove(deviceAddress)
firstAnnounceSeen.remove(deviceAddress)
Log.d(TAG, "Cleaned up device connection for $deviceAddress")
}
@@ -313,6 +318,21 @@ class BluetoothConnectionTracker(
addressPeerMap.clear()
pendingConnections.clear()
scanRSSI.clear()
firstAnnounceSeen.clear()
}
/**
* Mark that we have received the first ANNOUNCE over this device connection.
*/
fun noteAnnounceReceived(deviceAddress: String) {
firstAnnounceSeen[deviceAddress] = true
}
/**
* Check whether the first ANNOUNCE has been seen for a device connection.
*/
fun hasSeenFirstAnnounce(deviceAddress: String): Boolean {
return firstAnnounceSeen[deviceAddress] == true
}
/**
@@ -7,12 +7,15 @@ import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.protocol.MessagePadding
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.model.NoisePayload
import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.protocol.SpecialRecipients
import com.bitchat.android.model.RequestSyncPacket
import com.bitchat.android.sync.GossipSyncManager
import com.bitchat.android.util.toHexString
import com.bitchat.android.services.VerificationService
import kotlinx.coroutines.*
import java.util.*
import kotlin.math.sign
@@ -52,6 +55,12 @@ class BluetoothMeshService(private val context: Context) {
internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access
private val packetProcessor = PacketProcessor(myPeerID)
private lateinit var gossipSyncManager: GossipSyncManager
// Service-level notification manager for background (no-UI) DMs
private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager(
context.applicationContext,
androidx.core.app.NotificationManagerCompat.from(context.applicationContext),
com.bitchat.android.util.NotificationIntervalManager()
)
// Service state management
private var isActive = false
@@ -61,8 +70,12 @@ class BluetoothMeshService(private val context: Context) {
// Coroutines
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// Tracks whether this instance has been terminated via stopServices()
private var terminated = false
init {
Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID")
VerificationService.configure(encryptionService)
setupDelegates()
messageHandler.packetProcessor = packetProcessor
//startPeriodicDebugLogging()
@@ -98,6 +111,7 @@ class BluetoothMeshService(private val context: Context) {
return signPacketBeforeBroadcast(packet)
}
}
Log.d(TAG, "Delegates set up; GossipSyncManager initialized")
}
/**
@@ -105,6 +119,7 @@ class BluetoothMeshService(private val context: Context) {
*/
private fun startPeriodicDebugLogging() {
serviceScope.launch {
Log.d(TAG, "Starting periodic debug logging loop")
while (isActive) {
try {
delay(10000) // 10 seconds
@@ -116,6 +131,7 @@ class BluetoothMeshService(private val context: Context) {
Log.e(TAG, "Error in periodic debug logging: ${e.message}")
}
}
Log.d(TAG, "Periodic debug logging loop ended (isActive=$isActive)")
}
}
@@ -124,6 +140,7 @@ class BluetoothMeshService(private val context: Context) {
*/
private fun sendPeriodicBroadcastAnnounce() {
serviceScope.launch {
Log.d(TAG, "Starting periodic announce loop")
while (isActive) {
try {
delay(30000) // 30 seconds
@@ -132,6 +149,7 @@ class BluetoothMeshService(private val context: Context) {
Log.e(TAG, "Error in periodic broadcast announce: ${e.message}")
}
}
Log.d(TAG, "Periodic announce loop ended (isActive=$isActive)")
}
}
@@ -139,6 +157,7 @@ class BluetoothMeshService(private val context: Context) {
* Setup delegate connections between components
*/
private fun setupDelegates() {
Log.d(TAG, "Setting up component delegates")
// Provide nickname resolver to BLE broadcaster for detailed logs
try {
connectionManager.setNicknameResolver { pid -> peerManager.getPeerNickname(pid) }
@@ -146,6 +165,9 @@ class BluetoothMeshService(private val context: Context) {
// PeerManager delegates to main mesh service delegate
peerManager.delegate = object : PeerManagerDelegate {
override fun onPeerListUpdated(peerIDs: List<String>) {
// Update process-wide state first
try { com.bitchat.android.services.AppStateStore.setPeers(peerIDs) } catch (_: Exception) { }
// Then notify UI delegate if attached
delegate?.didUpdatePeerList(peerIDs)
}
override fun onPeerRemoved(peerID: String) {
@@ -165,6 +187,7 @@ class BluetoothMeshService(private val context: Context) {
override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) {
// Send announcement and cached messages after key exchange
serviceScope.launch {
Log.d(TAG, "Key exchange completed with $peerID; sending follow-ups")
delay(100)
sendAnnouncementToPeer(peerID)
@@ -352,7 +375,36 @@ class BluetoothMeshService(private val context: Context) {
// Callbacks
override fun onMessageReceived(message: BitchatMessage) {
// Always reflect into process-wide store so UI can hydrate after recreation
try {
when {
message.isPrivate -> {
val peer = message.senderPeerID ?: ""
if (peer.isNotEmpty()) com.bitchat.android.services.AppStateStore.addPrivateMessage(peer, message)
}
message.channel != null -> {
com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel!!, message)
}
else -> {
com.bitchat.android.services.AppStateStore.addPublicMessage(message)
}
}
} catch (_: Exception) { }
// And forward to UI delegate if attached
delegate?.didReceiveMessage(message)
// If no UI delegate attached (app closed), show DM notification via service manager
if (delegate == null && message.isPrivate) {
try {
val senderPeerID = message.senderPeerID
if (senderPeerID != null) {
val nick = try { peerManager.getPeerNickname(senderPeerID) } catch (_: Exception) { null } ?: senderPeerID
val preview = com.bitchat.android.ui.NotificationTextUtils.buildPrivateMessagePreview(message)
serviceNotificationManager.setAppBackgroundState(true)
serviceNotificationManager.showPrivateMessageNotification(senderPeerID, nick, preview)
}
} catch (_: Exception) { }
}
}
override fun onChannelLeave(channel: String, fromPeer: String) {
@@ -366,6 +418,14 @@ class BluetoothMeshService(private val context: Context) {
override fun onReadReceiptReceived(messageID: String, peerID: String) {
delegate?.didReceiveReadReceipt(messageID, peerID)
}
override fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
delegate?.didReceiveVerifyChallenge(peerID, payload, timestampMs)
}
override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
}
}
// PacketProcessor delegates
@@ -408,25 +468,17 @@ class BluetoothMeshService(private val context: Context) {
val deviceAddress = routed.relayAddress
val pid = routed.peerID
if (deviceAddress != null && pid != null) {
// Only set mapping if not already mapped
if (!connectionManager.addressPeerMap.containsKey(deviceAddress)) {
// First ANNOUNCE over a device connection defines a direct neighbor.
if (!connectionManager.hasSeenFirstAnnounce(deviceAddress)) {
// Bind or rebind this device address to the announcing peer
connectionManager.addressPeerMap[deviceAddress] = pid
Log.d(TAG, "Mapped device $deviceAddress to peer $pid on ANNOUNCE")
connectionManager.noteAnnounceReceived(deviceAddress)
Log.d(TAG, "Mapped device $deviceAddress to peer $pid on FIRST-ANNOUNCE for this connection")
// Mark this peer as directly connected for UI
try {
peerManager.getPeerInfo(pid)?.let {
// Set direct connection flag
// (This will also trigger a peer list update)
peerManager.setDirectConnection(pid, true)
// Also push reactive directness state to UI (best-effort)
try {
// Note: UI observes via didUpdatePeerList, but we can also update ChatState on a timer
} catch (_: Exception) { }
}
} catch (_: Exception) { }
// Mark as directly connected (upgrades from routed if needed)
try { peerManager.setDirectConnection(pid, true) } catch (_: Exception) { }
// Schedule initial sync for this new directly connected peer only
// Initial sync for this newly direct peer
try { gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { }
}
}
@@ -489,12 +541,23 @@ class BluetoothMeshService(private val context: Context) {
// BluetoothConnectionManager delegates
connectionManager.delegate = object : BluetoothConnectionManagerDelegate {
override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) {
// Log incoming for debug graphs (do not double-count anywhere else)
try {
val nick = getPeerNicknames()[peerID]
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming(
packetType = packet.type.toString(),
fromPeerID = peerID,
fromNickname = nick,
fromDeviceAddress = device?.address
)
} catch (_: Exception) { }
packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address))
}
override fun onDeviceConnected(device: android.bluetooth.BluetoothDevice) {
// Send initial announcements after services are ready
serviceScope.launch {
Log.d(TAG, "Device connected: ${device.address}; scheduling announce")
delay(200)
sendBroadcastAnnounce()
}
@@ -509,6 +572,7 @@ class BluetoothMeshService(private val context: Context) {
}
override fun onDeviceDisconnected(device: android.bluetooth.BluetoothDevice) {
Log.d(TAG, "Device disconnected: ${device.address}")
val addr = device.address
// Remove mapping and, if that was the last direct path for the peer, clear direct flag
val peer = connectionManager.addressPeerMap[addr]
@@ -547,6 +611,11 @@ class BluetoothMeshService(private val context: Context) {
Log.w(TAG, "Mesh service already active, ignoring duplicate start request")
return
}
if (terminated) {
// This instance's scope was cancelled previously; refuse to start to avoid using dead scopes.
Log.e(TAG, "Mesh service instance was terminated; create a new instance instead of restarting")
return
}
Log.i(TAG, "Starting Bluetooth mesh service with peer ID: $myPeerID")
@@ -558,6 +627,7 @@ class BluetoothMeshService(private val context: Context) {
Log.d(TAG, "Started periodic broadcast announcements (every 30 seconds)")
// Start periodic syncs
gossipSyncManager.start()
Log.d(TAG, "GossipSyncManager started")
} else {
Log.e(TAG, "Failed to start Bluetooth services")
}
@@ -579,11 +649,14 @@ class BluetoothMeshService(private val context: Context) {
sendLeaveAnnouncement()
serviceScope.launch {
Log.d(TAG, "Stopping subcomponents and cancelling scope...")
delay(200) // Give leave message time to send
// Stop all components
gossipSyncManager.stop()
Log.d(TAG, "GossipSyncManager stopped")
connectionManager.stopServices()
Log.d(TAG, "BluetoothConnectionManager stop requested")
peerManager.shutdown()
fragmentManager.shutdown()
securityManager.shutdown()
@@ -591,9 +664,24 @@ class BluetoothMeshService(private val context: Context) {
messageHandler.shutdown()
packetProcessor.shutdown()
// Mark this instance as terminated and cancel its scope so it won't be reused
terminated = true
serviceScope.cancel()
Log.i(TAG, "BluetoothMeshService terminated and scope cancelled")
}
}
/**
* Whether this instance can be safely reused. Returns false after stopServices() or if
* any critical internal scope has been cancelled.
*/
fun isReusable(): Boolean {
val reusable = !terminated && serviceScope.isActive && connectionManager.isReusable()
if (!reusable) {
Log.d(TAG, "isReusable=false (terminated=$terminated, scopeActive=${serviceScope.isActive}, connReusable=${connectionManager.isReusable()})")
}
return reusable
}
/**
* Send public message
@@ -813,7 +901,7 @@ class BluetoothMeshService(private val context: Context) {
fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) {
serviceScope.launch {
Log.d(TAG, "📖 Sending read receipt for message $messageID to $recipientPeerID")
// Route geohash read receipts via MessageRouter instead of here
val geo = runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance() }.getOrNull()
val isGeoAlias = try {
@@ -824,8 +912,15 @@ class BluetoothMeshService(private val context: Context) {
geo.sendReadReceipt(com.bitchat.android.model.ReadReceipt(messageID), recipientPeerID)
return@launch
}
try {
// Avoid duplicate read receipts: check persistent store first
val seenStore = try { com.bitchat.android.services.SeenMessageStore.getInstance(context.applicationContext) } catch (_: Exception) { null }
if (seenStore?.hasRead(messageID) == true) {
Log.d(TAG, "Skipping read receipt for $messageID - already marked read")
return@launch
}
// Create read receipt payload using NoisePayloadType exactly like iOS
val readReceiptPayload = com.bitchat.android.model.NoisePayload(
type = com.bitchat.android.model.NoisePayloadType.READ_RECEIPT,
@@ -851,12 +946,59 @@ class BluetoothMeshService(private val context: Context) {
val signedPacket = signPacketBeforeBroadcast(packet)
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID")
// Persist as read after successful send
try { seenStore?.markRead(messageID) } catch (_: Exception) { }
} catch (e: Exception) {
Log.e(TAG, "Failed to send read receipt to $recipientPeerID: ${e.message}")
}
}
}
// MARK: QR Verification over Noise
fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
val tlv = VerificationService.buildVerifyChallenge(noiseKeyHex, nonceA)
val payload = NoisePayload(
type = NoisePayloadType.VERIFY_CHALLENGE,
data = tlv
)
sendNoisePayloadToPeer(payload, peerID, "verify challenge")
}
fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
val tlv = VerificationService.buildVerifyResponse(noiseKeyHex, nonceA) ?: return
val payload = NoisePayload(
type = NoisePayloadType.VERIFY_RESPONSE,
data = tlv
)
sendNoisePayloadToPeer(payload, peerID, "verify response")
}
private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String, label: String) {
serviceScope.launch {
try {
val encrypted = encryptionService.encrypt(payload.encode(), recipientPeerID)
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(recipientPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = encrypted,
signature = null,
ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
)
val signedPacket = signPacketBeforeBroadcast(packet)
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
Log.d(TAG, "📤 Sent $label to $recipientPeerID (${payload.data.size} bytes)")
} catch (e: Exception) {
Log.e(TAG, "Failed to send $label to $recipientPeerID: ${e.message}")
}
}
}
/**
* Send broadcast announce with TLV-encoded identity announcement - exactly like iOS
@@ -864,7 +1006,7 @@ class BluetoothMeshService(private val context: Context) {
fun sendBroadcastAnnounce() {
Log.d(TAG, "Sending broadcast announce")
serviceScope.launch {
val nickname = delegate?.getNickname() ?: myPeerID
val nickname = try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { myPeerID }
// Get the static public key for the announcement
val staticKey = encryptionService.getStaticPublicKey()
@@ -927,7 +1069,7 @@ class BluetoothMeshService(private val context: Context) {
fun sendAnnouncementToPeer(peerID: String) {
if (peerManager.hasAnnouncedToPeer(peerID)) return
val nickname = delegate?.getNickname() ?: myPeerID
val nickname = try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { myPeerID }
// Get the static public key for the announcement
val staticKey = encryptionService.getStaticPublicKey()
@@ -1003,12 +1145,11 @@ class BluetoothMeshService(private val context: Context) {
* Send leave announcement
*/
private fun sendLeaveAnnouncement() {
val nickname = delegate?.getNickname() ?: myPeerID
val packet = BitchatPacket(
type = MessageType.LEAVE.value,
ttl = MAX_TTL,
senderID = myPeerID,
payload = nickname.toByteArray()
payload = byteArrayOf()
)
// Sign the packet before broadcasting
@@ -1055,6 +1196,13 @@ class BluetoothMeshService(private val context: Context) {
return peerManager.getFingerprintForPeer(peerID)
}
/**
* Get current active peer count (for status/notifications)
*/
fun getActivePeerCount(): Int {
return try { peerManager.getActivePeerCount() } catch (_: Exception) { 0 }
}
/**
* Get peer info for verification purposes
*/
@@ -1081,6 +1229,10 @@ class BluetoothMeshService(private val context: Context) {
fun getIdentityFingerprint(): String {
return encryptionService.getIdentityFingerprint()
}
fun getStaticNoisePublicKey(): ByteArray? {
return encryptionService.getStaticPublicKey()
}
/**
* Check if encryption icon should be shown for a peer
@@ -1253,6 +1405,8 @@ interface BluetoothMeshDelegate {
fun didReceiveChannelLeave(channel: String, fromPeer: String)
fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String)
fun didReceiveReadReceipt(messageID: String, recipientPeerID: String)
fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long)
fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long)
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
fun getNickname(): String?
fun isFavorite(peerID: String): Boolean
@@ -73,9 +73,17 @@ class BluetoothPacketBroadcaster(
try {
val fromNick = incomingPeer?.let { nicknameResolver?.invoke(it) }
val toNick = toPeer?.let { nicknameResolver?.invoke(it) }
val isRelay = (incomingAddr != null || incomingPeer != null)
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logPacketRelayDetailed(
val manager = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance()
// Always log outgoing for the actual transmission target
manager.logOutgoing(
packetType = typeName,
toPeerID = toPeer,
toNickname = toNick,
toDeviceAddress = toDeviceAddress,
previousHopPeerID = incomingPeer
)
// Keep the verbose relay message for human readability
manager.logPacketRelayDetailed(
packetType = typeName,
senderPeerID = senderPeerID,
senderNickname = senderNick,
@@ -86,7 +94,7 @@ class BluetoothPacketBroadcaster(
toNickname = toNick,
toDeviceAddress = toDeviceAddress,
ttl = ttl,
isRelay = isRelay
isRelay = true
)
} catch (_: Exception) {
// Silently ignore debug logging failures
@@ -33,9 +33,9 @@ class BluetoothPermissionManager(private val context: Context) {
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION
))
return permissions.all {
ActivityCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
}
}
}
@@ -157,6 +157,14 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
// Simplified: Call delegate with messageID and peerID directly
delegate?.onReadReceiptReceived(messageID, peerID)
}
com.bitchat.android.model.NoisePayloadType.VERIFY_CHALLENGE -> {
Log.d(TAG, "🔐 Verify challenge received from $peerID (${noisePayload.data.size} bytes)")
delegate?.onVerifyChallengeReceived(peerID, noisePayload.data, packet.timestamp.toLong())
}
com.bitchat.android.model.NoisePayloadType.VERIFY_RESPONSE -> {
Log.d(TAG, "🔐 Verify response received from $peerID (${noisePayload.data.size} bytes)")
delegate?.onVerifyResponseReceived(peerID, noisePayload.data, packet.timestamp.toLong())
}
}
} catch (e: Exception) {
@@ -618,4 +626,6 @@ interface MessageHandlerDelegate {
fun onChannelLeave(channel: String, fromPeer: String)
fun onDeliveryAckReceived(messageID: String, peerID: String)
fun onReadReceiptReceived(messageID: String, peerID: String)
fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long)
fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long)
}
@@ -299,8 +299,7 @@ class PeerManager {
*/
fun isPeerActive(peerID: String): Boolean {
val info = peers[peerID] ?: return false
val now = System.currentTimeMillis()
return (now - info.lastSeen) <= stalePeerTimeoutMs && info.isConnected
return info.isConnected
}
/**
@@ -328,8 +327,7 @@ class PeerManager {
* Get list of active peer IDs
*/
fun getActivePeerIDs(): List<String> {
val now = System.currentTimeMillis()
return peers.filterValues { (now - it.lastSeen) <= stalePeerTimeoutMs && it.isConnected }
return peers.filterValues { it.isConnected }
.keys
.toList()
.sorted()
@@ -7,7 +7,13 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import kotlinx.coroutines.*
import kotlin.math.max
@@ -15,7 +21,7 @@ import kotlin.math.max
* Power-aware Bluetooth management for bitchat
* Adjusts scanning, advertising, and connection behavior based on battery state
*/
class PowerManager(private val context: Context) {
class PowerManager(private val context: Context) : LifecycleEventObserver {
companion object {
private const val TAG = "PowerManager"
@@ -49,7 +55,7 @@ class PowerManager(private val context: Context) {
private var currentMode = PowerMode.BALANCED
private var isCharging = false
private var batteryLevel = 100
private var isAppInBackground = false
private var isAppInBackground = true
private val powerScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var dutyCycleJob: Job? = null
@@ -87,6 +93,16 @@ class PowerManager(private val context: Context) {
init {
registerBatteryReceiver()
// Register for process lifecycle events on the main thread
Handler(Looper.getMainLooper()).post {
try {
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
} catch (e: Exception) {
Log.e(TAG, "Failed to register lifecycle observer: ${e.message}")
}
}
updatePowerMode()
}
@@ -99,13 +115,30 @@ class PowerManager(private val context: Context) {
Log.i(TAG, "Stopping power management")
powerScope.cancel()
unregisterBatteryReceiver()
// Unregister lifecycle observer
Handler(Looper.getMainLooper()).post {
try {
ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
} catch (e: Exception) {
Log.e(TAG, "Failed to remove lifecycle observer: ${e.message}")
}
}
}
fun setAppBackgroundState(inBackground: Boolean) {
if (isAppInBackground != inBackground) {
isAppInBackground = inBackground
Log.d(TAG, "App background state changed: $inBackground")
updatePowerMode()
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
when (event) {
Lifecycle.Event.ON_START -> {
Log.d(TAG, "Process lifecycle: ON_START (App coming to foreground)")
isAppInBackground = false
updatePowerMode()
}
Lifecycle.Event.ON_STOP -> {
Log.d(TAG, "Process lifecycle: ON_STOP (App going to background)")
isAppInBackground = true
updatePowerMode()
}
else -> {}
}
}
@@ -133,7 +166,7 @@ class PowerManager(private val context: Context) {
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
PowerMode.ULTRA_LOW_POWER -> builder
.setScanMode(ScanSettings.SCAN_MODE_OPPORTUNISTIC)
.setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
.setMatchMode(ScanSettings.MATCH_MODE_STICKY)
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
}
@@ -224,26 +257,29 @@ class PowerManager(private val context: Context) {
}
private fun updatePowerMode() {
val newMode = when {
// Always use performance mode when charging (unless in background too long)
// Determine the base mode from battery/charging state only
val baseMode = when {
// Charging in foreground may use performance
isCharging && !isAppInBackground -> PowerMode.PERFORMANCE
// Critical battery - use ultra low power
// Critical battery - force ultra low power regardless of foreground/background
batteryLevel <= CRITICAL_BATTERY -> PowerMode.ULTRA_LOW_POWER
// Low battery - use power saver
// Low battery - prefer power saver
batteryLevel <= LOW_BATTERY -> PowerMode.POWER_SAVER
// Background app with medium battery - use power saver
isAppInBackground && batteryLevel <= MEDIUM_BATTERY -> PowerMode.POWER_SAVER
// Background app with good battery - use balanced
isAppInBackground -> PowerMode.BALANCED
// Foreground with good battery - use balanced
// Otherwise balanced
else -> PowerMode.BALANCED
}
// If app is in background (including when running as a foreground service),
// cap the power mode to at least POWER_SAVER. Preserve ULTRA_LOW_POWER.
val newMode = if (isAppInBackground) {
if (baseMode == PowerMode.ULTRA_LOW_POWER) PowerMode.ULTRA_LOW_POWER else PowerMode.POWER_SAVER
} else {
baseMode
}
if (newMode != currentMode) {
val oldMode = currentMode
currentMode = newMode
@@ -50,35 +50,36 @@ class SecurityManager(private val encryptionService: EncryptionService, private
return false
}
// Validate packet payload
if (packet.payload.isEmpty()) {
Log.d(TAG, "Dropping packet with empty payload")
return false
}
// Replay attack protection (same 5-minute window as iOS)
val currentTime = System.currentTimeMillis()
val packetTime = packet.timestamp.toLong()
val timeDiff = kotlin.math.abs(currentTime - packetTime)
// if (timeDiff > MESSAGE_TIMEOUT) {
// Log.d(TAG, "Dropping old packet from $peerID, time diff: ${timeDiff/1000}s")
// return false
// }
val messageType = MessageType.fromValue(packet.type)
// Duplicate detection
val messageID = generateMessageID(packet, peerID)
if (processedMessages.contains(messageID)) {
Log.d(TAG, "Dropping duplicate packet: $messageID")
return false
}
if (processedMessages.contains(messageID)) {
// Check for ANNOUNCE exception: allow if it looks like a direct neighbor (max TTL)
// This ensures we catch the "first announce" on a new connection for binding,
// while still dropping looped/relayed duplicates.
val isFreshAnnounce = messageType == MessageType.ANNOUNCE &&
packet.ttl >= com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
if (!isFreshAnnounce) {
Log.d(TAG, "Dropping duplicate packet: $messageID")
return false
}
Log.d(TAG, "Allowing duplicate ANNOUNCE from direct neighbor: $messageID")
}
// Add to processed messages
processedMessages.add(messageID)
messageTimestamps[messageID] = currentTime
// NEW: Signature verification logging (not rejecting yet)
verifyPacketSignatureWithLogging(packet, peerID)
// Enforce mandatory signature verification
if (!verifyPacketSignature(packet, peerID)) {
Log.w(TAG, "Dropping packet from $peerID due to signature verification failure")
return false
}
Log.d(TAG, "Packet validation passed for $peerID, messageID: $messageID")
return true
@@ -231,34 +232,58 @@ class SecurityManager(private val encryptionService: EncryptionService, private
}
/**
* Verify packet signature using peer's signing public key and log the result
* Verify packet signature using peer's signing public key
* Returns true only if signature is present and valid
*/
private fun verifyPacketSignatureWithLogging(packet: BitchatPacket, peerID: String) {
private fun verifyPacketSignature(packet: BitchatPacket, peerID: String): Boolean {
try {
// Check if packet has a signature
// only verify ANNOUNCE, MESSAGE, and FILE_TRANSFER
if (MessageType.fromValue(packet.type) !in setOf(
MessageType.ANNOUNCE,
MessageType.MESSAGE,
MessageType.FILE_TRANSFER
)) {
return true
}
// 1. Mandatory Signature Check
if (packet.signature == null) {
Log.d(TAG, "📝 Signature check for $peerID: NO_SIGNATURE (packet type ${packet.type})")
return
Log.w(TAG, " Signature check for $peerID: NO_SIGNATURE (packet type ${packet.type})")
return false
}
// Try to get peer's signing public key from peer info
val peerInfo = delegate?.getPeerInfo(peerID)
val signingPublicKey = peerInfo?.signingPublicKey
// 2. Get Signing Public Key
var signingPublicKey: ByteArray? = null
if (MessageType.fromValue(packet.type) == MessageType.ANNOUNCE) {
// Special Case: ANNOUNCE packets carry their own signing key
try {
val announcement = com.bitchat.android.model.IdentityAnnouncement.decode(packet.payload)
signingPublicKey = announcement?.signingPublicKey
} catch (e: Exception) {
Log.w(TAG, "Failed to decode announcement for key extraction: ${e.message}")
}
} else {
// Standard Case: Get key from known peer info
val peerInfo = delegate?.getPeerInfo(peerID)
signingPublicKey = peerInfo?.signingPublicKey
}
if (signingPublicKey == null) {
Log.d(TAG, "📝 Signature check for $peerID: NO_SIGNING_KEY (packet type ${packet.type})")
return
// If we don't have a key (and it's not an announce), we can't verify.
// For security, we must reject packets from unknown peers unless it's an announce.
Log.w(TAG, "❌ Signature check for $peerID: NO_SIGNING_KEY_AVAILABLE (packet type ${packet.type})")
return false
}
// Get the canonical packet data for signature verification (without signature)
// 3. Get Canonical Data
val packetDataForSigning = packet.toBinaryDataForSigning()
if (packetDataForSigning == null) {
Log.w(TAG, "📝 Signature check for $peerID: ENCODING_ERROR (packet type ${packet.type})")
return
Log.w(TAG, " Signature check for $peerID: ENCODING_ERROR (packet type ${packet.type})")
return false
}
// Verify the signature using the peer's signing public key
val signature = packet.signature!! // We already checked for null above
// 4. Verify Signature
val signature = packet.signature!!
val isSignatureValid = encryptionService.verifyEd25519Signature(
signature,
packetDataForSigning,
@@ -266,13 +291,16 @@ class SecurityManager(private val encryptionService: EncryptionService, private
)
if (isSignatureValid) {
Log.d(TAG, "📝 Signature check for $peerID: ✅ VALID (packet type ${packet.type})")
// Log.v(TAG, "✅ Signature verified for $peerID (type ${packet.type})")
return true
} else {
Log.w(TAG, "📝 Signature check for $peerID: ❌ INVALID (packet type ${packet.type})")
Log.w(TAG, " Signature INVALID for $peerID (type ${packet.type})")
return false
}
} catch (e: Exception) {
Log.w(TAG, "📝 Signature check for $peerID: ERROR - ${e.message} (packet type ${packet.type})")
Log.e(TAG, " Signature verification error for $peerID: ${e.message}")
return false
}
}
@@ -21,6 +21,8 @@ enum class NoisePayloadType(val value: UByte) {
PRIVATE_MESSAGE(0x01u), // Private chat message with TLV encoding
READ_RECEIPT(0x02u), // Message was read
DELIVERED(0x03u), // Message was delivered
VERIFY_CHALLENGE(0x10u), // Verification challenge
VERIFY_RESPONSE(0x11u), // Verification response
FILE_TRANSFER(0x20u);
@@ -2,6 +2,7 @@ package com.bitchat.android.net
import android.app.Application
import android.util.Log
import com.bitchat.android.util.AppConstants
import info.guardianproject.arti.ArtiLogListener
import info.guardianproject.arti.ArtiProxy
import kotlinx.coroutines.CoroutineScope
@@ -11,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.launch
@@ -24,54 +26,97 @@ import java.util.concurrent.atomic.AtomicReference
import java.util.concurrent.atomic.AtomicLong
/**
* Manages embedded Tor lifecycle & provides SOCKS proxy address.
* Uses Arti (Tor in Rust) for improved security and reliability.
* Tor provider implementation using custom-built Arti (Tor-in-Rust).
*
* This singleton provides Tor anonymity features using a custom Arti build
* compiled with 16KB page size support for Google Play compliance.
*
* Based on the original TorManager implementation.
*/
object TorManager {
private const val TAG = "TorManager"
private const val DEFAULT_SOCKS_PORT = com.bitchat.android.util.AppConstants.Tor.DEFAULT_SOCKS_PORT
private const val RESTART_DELAY_MS = com.bitchat.android.util.AppConstants.Tor.RESTART_DELAY_MS // 2 seconds between stop/start
private const val INACTIVITY_TIMEOUT_MS = com.bitchat.android.util.AppConstants.Tor.INACTIVITY_TIMEOUT_MS // 5 seconds of no activity before restart
private const val MAX_RETRY_ATTEMPTS = com.bitchat.android.util.AppConstants.Tor.MAX_RETRY_ATTEMPTS
private const val STOP_TIMEOUT_MS = com.bitchat.android.util.AppConstants.Tor.STOP_TIMEOUT_MS
class ArtiTorManager private constructor() {
enum class TorState {
OFF,
STARTING,
BOOTSTRAPPING,
RUNNING,
STOPPING,
ERROR
}
data class TorStatus(
val mode: TorMode = TorMode.OFF,
val running: Boolean = false,
val bootstrapPercent: Int = 0,
val lastLogLine: String = "",
val state: TorState = TorState.OFF
)
companion object {
private const val TAG = "ArtiTorManager"
private const val DEFAULT_SOCKS_PORT = AppConstants.Tor.DEFAULT_SOCKS_PORT
private const val RESTART_DELAY_MS = AppConstants.Tor.RESTART_DELAY_MS
private const val INACTIVITY_TIMEOUT_MS = AppConstants.Tor.INACTIVITY_TIMEOUT_MS
private const val MAX_RETRY_ATTEMPTS = AppConstants.Tor.MAX_RETRY_ATTEMPTS
private const val STOP_TIMEOUT_MS = AppConstants.Tor.STOP_TIMEOUT_MS
@Volatile
private var INSTANCE: ArtiTorManager? = null
fun getInstance(): ArtiTorManager {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: ArtiTorManager().also { INSTANCE = it }
}
}
}
private val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@Volatile private var initialized = false
@Volatile private var socksAddr: InetSocketAddress? = null
private val artiProxyRef = AtomicReference<ArtiProxy?>(null)
@Volatile private var lastMode: TorMode = TorMode.OFF
@Volatile
private var initialized = false
@Volatile
private var socksAddr: InetSocketAddress? = null
@Volatile
private var artiProxy: ArtiProxy? = null
@Volatile
private var lastMode: TorMode = TorMode.OFF
private val applyMutex = Mutex()
@Volatile private var desiredMode: TorMode = TorMode.OFF
@Volatile private var currentSocksPort: Int = DEFAULT_SOCKS_PORT
@Volatile private var lastLogTime = AtomicLong(0L)
@Volatile private var retryAttempts = 0
@Volatile private var bindRetryAttempts = 0
@Volatile
private var desiredMode: TorMode = TorMode.OFF
@Volatile
private var currentSocksPort: Int = DEFAULT_SOCKS_PORT
@Volatile
private var lastLogTime = AtomicLong(0L)
@Volatile
private var retryAttempts = 0
@Volatile
private var bindRetryAttempts = 0
private var inactivityJob: Job? = null
private var retryJob: Job? = null
private var currentApplication: Application? = null
private enum class LifecycleState { STOPPED, STARTING, RUNNING, STOPPING }
@Volatile private var lifecycleState: LifecycleState = LifecycleState.STOPPED
enum class TorState { OFF, STARTING, BOOTSTRAPPING, RUNNING, STOPPING, ERROR }
@Volatile
private var lifecycleState: LifecycleState = LifecycleState.STOPPED
data class TorStatus(
val mode: TorMode = TorMode.OFF,
val running: Boolean = false,
val bootstrapPercent: Int = 0, // kept for backwards compatibility with UI; 0 or 100 only
val lastLogLine: String = "",
val state: TorState = TorState.OFF
private val _statusFlow = MutableStateFlow(
TorStatus(
mode = TorMode.OFF,
running = false,
bootstrapPercent = 0,
lastLogLine = "",
state = TorState.OFF
)
)
private val _status = MutableStateFlow(TorStatus())
val statusFlow: StateFlow<TorStatus> = _status.asStateFlow()
val statusFlow: StateFlow<TorStatus> = _statusFlow.asStateFlow()
private val stateChangeDeferred = AtomicReference<CompletableDeferred<TorState>?>(null)
fun isProxyEnabled(): Boolean {
val s = _status.value
return s.mode != TorMode.OFF && s.running && s.bootstrapPercent >= 100 && socksAddr != null && s.state == TorState.RUNNING
val s = _statusFlow.value
return s.mode != TorMode.OFF && s.running && s.bootstrapPercent >= 100 &&
socksAddr != null && s.state == TorState.RUNNING
}
fun init(application: Application) {
@@ -82,7 +127,21 @@ object TorManager {
currentApplication = application
TorPreferenceManager.init(application)
// Apply saved mode at startup. If ON, set planned SOCKS immediately to avoid any leak.
val logListener = ArtiLogListener { logLine ->
val text = logLine ?: return@ArtiLogListener
val s = text
Log.i(TAG, "arti: $s")
lastLogTime.set(System.currentTimeMillis())
_statusFlow.update { it.copy(lastLogLine = s) }
handleArtiLogLine(s)
}
artiProxy = ArtiProxy.Builder(application)
.setSocksPort(currentSocksPort)
.setDnsPort(currentSocksPort + 1)
.setLogListener(logListener)
.build()
val savedMode = TorPreferenceManager.get(application)
if (savedMode == TorMode.ON) {
if (currentSocksPort < DEFAULT_SOCKS_PORT) {
@@ -90,13 +149,15 @@ object TorManager {
}
desiredMode = savedMode
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
try { OkHttpProvider.reset() } catch (_: Throwable) { }
try {
OkHttpProvider.reset()
} catch (_: Throwable) {
} // Only reset OkHttp during init
}
appScope.launch {
applyMode(application, savedMode)
}
// Observe changes
appScope.launch {
TorPreferenceManager.modeFlow.collect { mode ->
applyMode(application, mode)
@@ -112,53 +173,63 @@ object TorManager {
try {
desiredMode = mode
lastMode = mode
val s = _status.value
if (mode == s.mode && mode != TorMode.OFF && (lifecycleState == LifecycleState.STARTING || lifecycleState == LifecycleState.RUNNING)) {
Log.i(TAG, "applyMode: already in progress/running mode=$mode, state=$lifecycleState; skip")
val s = _statusFlow.value
if (mode == s.mode && mode != TorMode.OFF &&
(lifecycleState == LifecycleState.STARTING || lifecycleState == LifecycleState.RUNNING)
) {
Log.i(
TAG,
"applyMode: already in progress/running mode=$mode, state=$lifecycleState; skip"
)
return
}
when (mode) {
TorMode.OFF -> {
Log.i(TAG, "applyMode: OFF -> stopping tor")
lifecycleState = LifecycleState.STOPPING
_status.value = _status.value.copy(mode = TorMode.OFF, running = false, bootstrapPercent = 0, state = TorState.STOPPING)
stopArti() // non-suspending immediate request
// Best-effort wait for STOPPED before we declare OFF
_statusFlow.value = _statusFlow.value.copy(
mode = TorMode.OFF,
running = false,
bootstrapPercent = 0,
state = TorState.STOPPING
)
stopArti()
waitForStateTransition(target = TorState.OFF, timeoutMs = STOP_TIMEOUT_MS)
socksAddr = null
_status.value = _status.value.copy(mode = TorMode.OFF, running = false, bootstrapPercent = 0, state = TorState.OFF)
_statusFlow.value = _statusFlow.value.copy(
mode = TorMode.OFF,
running = false,
bootstrapPercent = 0,
state = TorState.OFF
)
currentSocksPort = DEFAULT_SOCKS_PORT
bindRetryAttempts = 0
lifecycleState = LifecycleState.STOPPED
// Rebuild clients WITHOUT proxy and reconnect relays
try {
OkHttpProvider.reset()
com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections()
} catch (_: Throwable) { }
resetNetworkConnections()
}
TorMode.ON -> {
Log.i(TAG, "applyMode: ON -> starting arti")
// Reset port to default unless we're already using a higher port
if (currentSocksPort < DEFAULT_SOCKS_PORT) {
currentSocksPort = DEFAULT_SOCKS_PORT
}
bindRetryAttempts = 0
lifecycleState = LifecycleState.STARTING
_status.value = _status.value.copy(mode = TorMode.ON, running = false, bootstrapPercent = 0, state = TorState.STARTING)
// Immediately set the planned SOCKS address so all traffic is forced through it,
// even before Tor is fully bootstrapped. This prevents any direct connections.
_statusFlow.value = _statusFlow.value.copy(
mode = TorMode.ON,
running = false,
bootstrapPercent = 0,
state = TorState.STARTING
)
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
try { OkHttpProvider.reset() } catch (_: Throwable) { }
try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { }
resetNetworkConnections()
startArti(application, useDelay = false)
// Defer enabling proxy until bootstrap completes
appScope.launch {
waitUntilBootstrapped()
if (_status.value.running && desiredMode == TorMode.ON) {
if (_statusFlow.value.running && desiredMode == TorMode.ON) {
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
Log.i(TAG, "Tor ON: proxy set to ${socksAddr}")
OkHttpProvider.reset()
try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { }
resetNetworkConnections()
}
}
}
@@ -171,84 +242,95 @@ object TorManager {
private suspend fun startArti(application: Application, useDelay: Boolean = false) {
try {
// Ensure any previous instance is fully stopped before starting a new one
stopArtiAndWait()
Log.i(TAG, "Starting Arti on port $currentSocksPort")
if (useDelay) {
delay(RESTART_DELAY_MS)
}
val logListener = ArtiLogListener { logLine ->
val text = logLine ?: return@ArtiLogListener
val s = text.toString()
Log.i(TAG, "arti: $s")
lastLogTime.set(System.currentTimeMillis())
_status.value = _status.value.copy(lastLogLine = s)
handleArtiLogLine(s)
val proxy = artiProxy ?: run {
Log.e(TAG, "ArtiProxy not initialized! This should not happen.")
_statusFlow.update { it.copy(state = TorState.ERROR) }
return
}
val proxy = ArtiProxy.Builder(application)
.setSocksPort(currentSocksPort)
.setDnsPort(currentSocksPort + 1)
.setLogListener(logListener)
.build()
artiProxyRef.set(proxy)
proxy.start()
lastLogTime.set(System.currentTimeMillis())
_status.value = _status.value.copy(running = true, bootstrapPercent = 0, state = TorState.STARTING)
_statusFlow.update {
it.copy(
running = true,
bootstrapPercent = 0,
state = TorState.STARTING
)
}
lifecycleState = LifecycleState.RUNNING
startInactivityMonitoring()
// Removed onion service startup (BLE-only file transfer in this branch)
} catch (e: Exception) {
Log.e(TAG, "Error starting Arti on port $currentSocksPort: ${e.message}")
_status.value = _status.value.copy(state = TorState.ERROR)
// Check if this is a bind error
_statusFlow.update { it.copy(state = TorState.ERROR) }
val isBindError = isBindError(e)
if (isBindError && bindRetryAttempts < MAX_RETRY_ATTEMPTS) {
bindRetryAttempts++
currentSocksPort++
Log.w(TAG, "Port bind failed (attempt $bindRetryAttempts/$MAX_RETRY_ATTEMPTS), retrying with port $currentSocksPort")
// Update planned SOCKS address immediately so all new connections target the new port
Log.w(
TAG,
"Port bind failed (attempt $bindRetryAttempts/$MAX_RETRY_ATTEMPTS), retrying with port $currentSocksPort"
)
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
try { OkHttpProvider.reset() } catch (_: Throwable) { }
try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { }
// Immediate retry with incremented port, no exponential backoff for bind errors
resetNetworkConnections()
startArti(application, useDelay = false)
} else if (isBindError) {
Log.e(TAG, "Max bind retry attempts reached ($MAX_RETRY_ATTEMPTS), giving up")
lifecycleState = LifecycleState.STOPPED
_status.value = _status.value.copy(running = false, bootstrapPercent = 0, state = TorState.ERROR)
_statusFlow.update {
it.copy(
running = false,
bootstrapPercent = 0,
state = TorState.ERROR
)
}
} else {
// For non-bind errors, use the existing retry mechanism
scheduleRetry(application)
}
}
}
/**
* Checks if the exception indicates a port binding failure
*/
private fun isBindError(exception: Exception): Boolean {
val message = exception.message?.lowercase() ?: ""
return message.contains("bind") ||
message.contains("address already in use") ||
message.contains("port") && message.contains("use") ||
message.contains("permission denied") && message.contains("port") ||
message.contains("could not bind")
message.contains("address already in use") ||
message.contains("port") && message.contains("use") ||
message.contains("permission denied") && message.contains("port") ||
message.contains("could not bind")
}
/**
* Reset network connections after Tor state changes.
* Rebuilds OkHttp clients and reconnects Nostr relays.
*/
private fun resetNetworkConnections() {
try {
OkHttpProvider.reset()
} catch (_: Throwable) {
}
try {
com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections()
} catch (_: Throwable) {
}
}
private fun stopArtiInternal() {
try {
val proxy = artiProxyRef.getAndSet(null)
val proxy = artiProxy
if (proxy != null) {
Log.i(TAG, "Stopping Arti…")
try { proxy.stop() } catch (_: Throwable) {}
try {
proxy.stop()
} catch (_: Throwable) {
}
}
stopInactivityMonitoring()
stopRetryMonitoring()
@@ -260,15 +342,16 @@ object TorManager {
private fun stopArti() {
stopArtiInternal()
socksAddr = null
_status.value = _status.value.copy(running = false, bootstrapPercent = 0, state = TorState.STOPPING)
_statusFlow.value = _statusFlow.value.copy(
running = false,
bootstrapPercent = 0,
state = TorState.STOPPING
)
}
private suspend fun stopArtiAndWait(timeoutMs: Long = STOP_TIMEOUT_MS) {
// Request stop
stopArtiInternal()
// Wait for confirmation via logs (Stopped) or timeout
waitForStateTransition(target = TorState.OFF, timeoutMs = timeoutMs)
// Small grace period before relaunch to let file locks clear
delay(200)
}
@@ -276,7 +359,7 @@ object TorManager {
Log.i(TAG, "Restarting Arti (keeping SOCKS proxy enabled)...")
stopArtiAndWait()
delay(RESTART_DELAY_MS)
startArti(application, useDelay = false) // Already delayed above
startArti(application, useDelay = false)
}
private fun startInactivityMonitoring() {
@@ -287,13 +370,16 @@ object TorManager {
val currentTime = System.currentTimeMillis()
val lastActivity = lastLogTime.get()
val timeSinceLastActivity = currentTime - lastActivity
if (timeSinceLastActivity > INACTIVITY_TIMEOUT_MS) {
val currentMode = _status.value.mode
val currentMode = _statusFlow.value.mode
if (currentMode == TorMode.ON) {
val bootstrapPercent = _status.value.bootstrapPercent
val bootstrapPercent = _statusFlow.value.bootstrapPercent
if (bootstrapPercent < 100) {
Log.w(TAG, "Inactivity detected (${timeSinceLastActivity}ms), restarting Arti")
Log.w(
TAG,
"Inactivity detected (${timeSinceLastActivity}ms), restarting Arti"
)
currentApplication?.let { app ->
appScope.launch {
restartArti(app)
@@ -316,11 +402,11 @@ object TorManager {
retryJob?.cancel()
if (retryAttempts < MAX_RETRY_ATTEMPTS) {
retryAttempts++
val delayMs = (1000L * (1 shl retryAttempts)).coerceAtMost(30000L) // Exponential backoff, max 30s
val delayMs = (1000L * (1 shl retryAttempts)).coerceAtMost(30000L)
Log.w(TAG, "Scheduling Arti retry attempt $retryAttempts in ${delayMs}ms")
retryJob = appScope.launch {
delay(delayMs)
val currentMode = _status.value.mode
val currentMode = _statusFlow.value.mode
if (currentMode == TorMode.ON) {
Log.i(TAG, "Retrying Arti start (attempt $retryAttempts)")
restartArti(application)
@@ -337,50 +423,110 @@ object TorManager {
}
private suspend fun waitUntilBootstrapped() {
val current = _status.value
val current = _statusFlow.value
if (!current.running) return
if (current.bootstrapPercent >= 100 && current.state == TorState.RUNNING) return
// Suspend until we observe RUNNING at least once
while (true) {
val s = statusFlow.first { (it.bootstrapPercent >= 100 && it.state == TorState.RUNNING) || !it.running || it.state == TorState.ERROR }
val s = statusFlow.first {
(it.bootstrapPercent >= 100 && it.state == TorState.RUNNING) ||
!it.running ||
it.state == TorState.ERROR
}
if (!s.running || s.state == TorState.ERROR) return
if (s.bootstrapPercent >= 100 && s.state == TorState.RUNNING) return
}
}
private fun handleArtiLogLine(s: String) {
val currentState = _statusFlow.value.state
val currentLifecycle = lifecycleState
when {
s.contains("AMEx: state changed to Initialized", ignoreCase = true) -> {
_status.value = _status.value.copy(state = TorState.STARTING)
if (currentLifecycle != LifecycleState.STARTING && currentLifecycle != LifecycleState.RUNNING) {
Log.w(TAG, "Ignoring stale 'Initialized' log (lifecycle: $currentLifecycle)")
return
}
_statusFlow.update { it.copy(state = TorState.STARTING) }
completeWaitersIf(TorState.STARTING)
}
s.contains("AMEx: state changed to Starting", ignoreCase = true) -> {
_status.value = _status.value.copy(state = TorState.STARTING)
if (currentLifecycle != LifecycleState.STARTING && currentLifecycle != LifecycleState.RUNNING) {
Log.w(TAG, "Ignoring stale 'Starting' log (lifecycle: $currentLifecycle)")
return
}
_statusFlow.update { it.copy(state = TorState.STARTING) }
completeWaitersIf(TorState.STARTING)
}
s.contains("Sufficiently bootstrapped; system SOCKS now functional", ignoreCase = true) -> {
_status.value = _status.value.copy(bootstrapPercent = 75, state = TorState.BOOTSTRAPPING)
s.contains(
"Sufficiently bootstrapped; system SOCKS now functional",
ignoreCase = true
) -> {
if (currentLifecycle != LifecycleState.RUNNING) {
Log.w(TAG, "Ignoring bootstrap log (lifecycle: $currentLifecycle)")
return
}
_statusFlow.update {
it.copy(
bootstrapPercent = 75,
state = TorState.BOOTSTRAPPING
)
}
retryAttempts = 0
bindRetryAttempts = 0
startInactivityMonitoring()
}
//s.contains("AMEx: state changed to Running", ignoreCase = true) -> {
s.contains("We have found that guard [scrubbed] is usable.", ignoreCase = true) -> {
// If we already saw Sufficiently bootstrapped, mark as RUNNING and ready.
val bp = if (_status.value.bootstrapPercent >= 100) 100 else 100 // treat Running as ready
_status.value = _status.value.copy(state = TorState.RUNNING, bootstrapPercent = bp, running = true)
if (currentLifecycle != LifecycleState.RUNNING) {
Log.w(TAG, "Ignoring guard discovery log (lifecycle: $currentLifecycle)")
return
}
_statusFlow.update {
it.copy(
state = TorState.RUNNING,
bootstrapPercent = 100,
running = true
)
}
completeWaitersIf(TorState.RUNNING)
}
s.contains("AMEx: state changed to Stopping", ignoreCase = true) -> {
_status.value = _status.value.copy(state = TorState.STOPPING, running = false)
if (currentLifecycle != LifecycleState.STOPPING) {
Log.w(TAG, "Ignoring stale 'Stopping' log (lifecycle: $currentLifecycle)")
return
}
_statusFlow.update {
it.copy(
state = TorState.STOPPING,
running = false
)
}
}
s.contains("AMEx: state changed to Stopped", ignoreCase = true) -> {
_status.value = _status.value.copy(state = TorState.OFF, running = false, bootstrapPercent = 0)
if (currentLifecycle != LifecycleState.STOPPING && currentLifecycle != LifecycleState.STOPPED) {
Log.w(
TAG,
"Ignoring stale 'Stopped' log (lifecycle: $currentLifecycle, preventing state corruption)"
)
return
}
_statusFlow.update {
it.copy(
state = TorState.OFF,
running = false,
bootstrapPercent = 0
)
}
completeWaitersIf(TorState.OFF)
}
s.contains("Another process has the lock on our state files", ignoreCase = true) -> {
// Signal error; we'll likely need to wait longer before restart
_status.value = _status.value.copy(state = TorState.ERROR)
_statusFlow.update { it.copy(state = TorState.ERROR) }
}
}
}
@@ -395,13 +541,11 @@ object TorManager {
val def = CompletableDeferred<TorState>()
stateChangeDeferred.getAndSet(def)?.cancel()
return withTimeoutOrNull(timeoutMs) {
// Fast-path: if we're already there
val cur = _status.value.state
val cur = _statusFlow.value.state
if (cur == target) return@withTimeoutOrNull cur
def.await()
}
}
// Visible for instrumentation tests to validate installation
fun installResourcesForTest(application: Application): Boolean { return true }
fun isTorAvailable(): Boolean = true
}
@@ -42,8 +42,9 @@ object OkHttpProvider {
private fun baseBuilderForCurrentProxy(): OkHttpClient.Builder {
val builder = OkHttpClient.Builder()
val socks: InetSocketAddress? = TorManager.currentSocksAddress()
// If a SOCKS address is defined, always use it. TorManager sets this as soon as Tor mode is ON,
val torProvider = ArtiTorManager.getInstance()
val socks: InetSocketAddress? = torProvider.currentSocksAddress()
// If a SOCKS address is defined, always use it. TorProvider sets this as soon as Tor mode is ON,
// even before bootstrap, to prevent any direct connections from occurring.
if (socks != null) {
val proxy = Proxy(Proxy.Type.SOCKS, socks)
@@ -2,7 +2,6 @@ package com.bitchat.android.nostr
import android.app.Application
import android.util.Log
import androidx.lifecycle.LiveData
import com.bitchat.android.ui.ChatState
import com.bitchat.android.ui.GeoPerson
import java.util.Date
@@ -112,7 +111,7 @@ class GeohashRepository(
val geohash = currentGeohash
if (geohash == null) {
// Use postValue for thread safety - this can be called from background threads
state.postGeohashPeople(emptyList())
state.setGeohashPeople(emptyList())
return
}
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
@@ -143,7 +142,7 @@ class GeohashRepository(
)
}.sortedByDescending { it.lastSeen }
// Use postValue for thread safety - this can be called from background threads
state.postGeohashPeople(people)
state.setGeohashPeople(people)
}
fun updateReactiveParticipantCounts() {
@@ -155,7 +154,7 @@ class GeohashRepository(
counts[gh] = active
}
// Use postValue for thread safety - this can be called from background threads
state.postGeohashParticipantCounts(counts)
state.setGeohashParticipantCounts(counts)
}
fun putNostrKeyMapping(tempKeyOrPeer: String, pubkeyHex: String) {
@@ -2,13 +2,14 @@ package com.bitchat.android.nostr
import android.util.Log
import androidx.annotation.MainThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Manages location notes (kind=1 text notes with geohash tags)
* iOS-compatible implementation with LiveData for Android UI binding
* iOS-compatible implementation with StateFlow for Android UI binding
*/
@MainThread
class LocationNotesManager private constructor() {
@@ -63,21 +64,21 @@ class LocationNotesManager private constructor() {
NO_RELAYS
}
// Published state (LiveData for Android)
private val _notes = MutableLiveData<List<Note>>(emptyList())
val notes: LiveData<List<Note>> = _notes
// Published state (StateFlow for Android)
private val _notes = MutableStateFlow<List<Note>>(emptyList())
val notes: StateFlow<List<Note>> = _notes.asStateFlow()
private val _geohash = MutableLiveData<String?>(null)
val geohash: LiveData<String?> = _geohash
private val _geohash = MutableStateFlow<String?>(null)
val geohash: StateFlow<String?> = _geohash.asStateFlow()
private val _initialLoadComplete = MutableLiveData(false)
val initialLoadComplete: LiveData<Boolean> = _initialLoadComplete
private val _initialLoadComplete = MutableStateFlow(false)
val initialLoadComplete: StateFlow<Boolean> = _initialLoadComplete.asStateFlow()
private val _state = MutableLiveData(State.IDLE)
val state: LiveData<State> = _state
private val _state = MutableStateFlow(State.IDLE)
val state: StateFlow<State> = _state.asStateFlow()
private val _errorMessage = MutableLiveData<String?>(null)
val errorMessage: LiveData<String?> = _errorMessage
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
// Private state
private var subscriptionIDs: MutableMap<String, String> = mutableMapOf()
@@ -2,9 +2,10 @@ package com.bitchat.android.nostr
import android.content.Context
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* High-level Nostr client that manages identity, connections, and messaging
@@ -30,11 +31,11 @@ class NostrClient private constructor(private val context: Context) {
private var currentIdentity: NostrIdentity? = null
// Client state
private val _isInitialized = MutableLiveData<Boolean>()
val isInitialized: LiveData<Boolean> = _isInitialized
private val _isInitialized = MutableStateFlow(false)
val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
private val _currentNpub = MutableLiveData<String>()
val currentNpub: LiveData<String> = _currentNpub
private val _currentNpub = MutableStateFlow<String?>(null)
val currentNpub: StateFlow<String?> = _currentNpub.asStateFlow()
// Message processing
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
@@ -53,21 +54,21 @@ class NostrClient private constructor(private val context: Context) {
currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context)
if (currentIdentity != null) {
_currentNpub.postValue(currentIdentity!!.npub)
_currentNpub.value = currentIdentity!!.npub
Log.i(TAG, "✅ Nostr identity loaded: ${currentIdentity!!.getShortNpub()}")
// Connect to relays
relayManager.connect()
_isInitialized.postValue(true)
_isInitialized.value = true
Log.i(TAG, "✅ Nostr client initialized successfully")
} else {
Log.e(TAG, "❌ Failed to load/create Nostr identity")
_isInitialized.postValue(false)
_isInitialized.value = false
}
} catch (e: Exception) {
Log.e(TAG, "❌ Failed to initialize Nostr client: ${e.message}")
_isInitialized.postValue(false)
_isInitialized.value = false
}
}
}
@@ -78,7 +79,7 @@ class NostrClient private constructor(private val context: Context) {
fun shutdown() {
Log.d(TAG, "Shutting down Nostr client")
relayManager.disconnect()
_isInitialized.postValue(false)
_isInitialized.value = false
}
/**
@@ -227,12 +228,12 @@ class NostrClient private constructor(private val context: Context) {
/**
* Get relay connection status
*/
val relayConnectionStatus: LiveData<Boolean> = relayManager.isConnected
val relayConnectionStatus: StateFlow<Boolean> = relayManager.isConnected
/**
* Get relay information
*/
val relayInfo: LiveData<List<NostrRelayManager.Relay>> = relayManager.relays
val relayInfo: StateFlow<List<NostrRelayManager.Relay>> = relayManager.relays
// MARK: - Private Methods
@@ -2,8 +2,12 @@ package com.bitchat.android.nostr
import android.app.Application
import android.util.Log
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.model.NoisePayload
import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.model.PrivateMessagePacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.services.SeenMessageStore
import com.bitchat.android.ui.ChatState
@@ -71,7 +75,7 @@ class NostrDirectMessageHandler(
if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch
val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return@launch
val noisePayload = NoisePayload.decode(packet.payload) ?: return@launch
val messageTimestamp = Date(giftWrap.createdAt * 1000L)
val convKey = "nostr_${senderPubkey.take(16)}"
repo.putNostrKeyMapping(convKey, senderPubkey)
@@ -104,7 +108,7 @@ class NostrDirectMessageHandler(
}
private suspend fun processNoisePayload(
payload: com.bitchat.android.model.NoisePayload,
payload: NoisePayload,
convKey: String,
senderNickname: String,
timestamp: Date,
@@ -112,8 +116,8 @@ class NostrDirectMessageHandler(
recipientIdentity: NostrIdentity
) {
when (payload.type) {
com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE -> {
val pm = com.bitchat.android.model.PrivateMessagePacket.decode(payload.data) ?: return
NoisePayloadType.PRIVATE_MESSAGE -> {
val pm = PrivateMessagePacket.decode(payload.data) ?: return
val existingMessages = state.getPrivateChatsValue()[convKey] ?: emptyList()
if (existingMessages.any { it.id == pm.messageID }) return
@@ -148,21 +152,21 @@ class NostrDirectMessageHandler(
seenStore.markRead(pm.messageID)
}
}
com.bitchat.android.model.NoisePayloadType.DELIVERED -> {
NoisePayloadType.DELIVERED -> {
val messageId = String(payload.data, Charsets.UTF_8)
withContext(Dispatchers.Main) {
meshDelegateHandler.didReceiveDeliveryAck(messageId, convKey)
}
}
com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> {
NoisePayloadType.READ_RECEIPT -> {
val messageId = String(payload.data, Charsets.UTF_8)
withContext(Dispatchers.Main) {
meshDelegateHandler.didReceiveReadReceipt(messageId, convKey)
}
}
com.bitchat.android.model.NoisePayloadType.FILE_TRANSFER -> {
NoisePayloadType.FILE_TRANSFER -> {
// Properly handle encrypted file transfer
val file = com.bitchat.android.model.BitchatFilePacket.decode(payload.data)
val file = BitchatFilePacket.decode(payload.data)
if (file != null) {
val uniqueMsgId = java.util.UUID.randomUUID().toString().uppercase()
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(application, file)
@@ -185,6 +189,8 @@ class NostrDirectMessageHandler(
Log.w(TAG, "⚠️ Failed to decode Nostr file transfer from $convKey")
}
}
NoisePayloadType.VERIFY_CHALLENGE,
NoisePayloadType.VERIFY_RESPONSE -> Unit // Ignore verification payloads in Nostr direct messages
}
}
@@ -1,9 +1,10 @@
package com.bitchat.android.nostr
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.google.gson.Gson
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import com.google.gson.JsonArray
import com.google.gson.JsonParser
import kotlinx.coroutines.*
@@ -72,11 +73,11 @@ class NostrRelayManager private constructor() {
)
// Published state
private val _relays = MutableLiveData<List<Relay>>()
val relays: LiveData<List<Relay>> = _relays
private val _relays = MutableStateFlow<List<Relay>>(emptyList())
val relays: StateFlow<List<Relay>> = _relays.asStateFlow()
private val _isConnected = MutableLiveData<Boolean>()
val isConnected: LiveData<Boolean> = _isConnected
private val _isConnected = MutableStateFlow<Boolean>(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
// Internal state
private val relaysList = mutableListOf<Relay>()
@@ -226,14 +227,14 @@ class NostrRelayManager private constructor() {
"wss://nostr21.com"
)
relaysList.addAll(defaultRelayUrls.map { Relay(it) })
_relays.postValue(relaysList.toList())
_relays.value = relaysList.toList()
updateConnectionStatus()
Log.d(TAG, "✅ NostrRelayManager initialized with ${relaysList.size} default relays")
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize NostrRelayManager: ${e.message}", e)
// Initialize with empty list as fallback
_relays.postValue(emptyList())
_isConnected.postValue(false)
_relays.value = emptyList()
_isConnected.value = false
}
}
@@ -797,12 +798,12 @@ class NostrRelayManager private constructor() {
}
private fun updateRelaysList() {
_relays.postValue(relaysList.toList())
_relays.value = relaysList.toList()
}
private fun updateConnectionStatus() {
val connected = relaysList.any { it.isConnected }
_isConnected.postValue(connected)
_isConnected.value = connected
}
private fun generateSubscriptionId(): String {
@@ -0,0 +1,251 @@
package com.bitchat.android.onboarding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Security
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
/**
* Explanation screen shown before requesting background location permission.
*/
@Composable
fun BackgroundLocationPermissionScreen(
modifier: Modifier,
onContinue: () -> Unit,
onRetry: () -> Unit,
onSkip: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
val scrollState = rememberScrollState()
Box(modifier = modifier) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp)
.padding(bottom = 88.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(24.dp))
HeaderSection(colorScheme)
Surface(
modifier = Modifier.fillMaxWidth(),
color = colorScheme.surfaceVariant.copy(alpha = 0.25f),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Filled.LocationOn,
contentDescription = stringResource(R.string.cd_location_services),
tint = colorScheme.primary,
modifier = Modifier
.padding(top = 2.dp)
.size(20.dp)
)
Column {
Text(
text = stringResource(R.string.background_location_required_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = colorScheme.onBackground
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.background_location_explanation),
style = MaterialTheme.typography.bodySmall,
color = colorScheme.onBackground.copy(alpha = 0.8f)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.background_location_settings_tip),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
),
color = colorScheme.onBackground.copy(alpha = 0.8f)
)
}
}
}
}
Surface(
modifier = Modifier.fillMaxWidth(),
color = colorScheme.surfaceVariant.copy(alpha = 0.25f),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Filled.Security,
contentDescription = stringResource(R.string.cd_privacy_protected),
tint = colorScheme.primary,
modifier = Modifier
.padding(top = 2.dp)
.size(20.dp)
)
Column {
Text(
text = stringResource(R.string.background_location_needs_for),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = colorScheme.onBackground
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.background_location_needs_bullets),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = colorScheme.onBackground.copy(alpha = 0.8f)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.background_location_privacy_note),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium
),
color = colorScheme.onBackground
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
}
Surface(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth(),
color = colorScheme.surface,
shadowElevation = 8.dp
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Button(
onClick = onContinue,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = colorScheme.primary
)
) {
Text(
text = stringResource(R.string.grant_background_location),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
),
modifier = Modifier.padding(vertical = 4.dp)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = onRetry,
modifier = Modifier.weight(1f)
) {
Text(
text = stringResource(R.string.check_again),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
)
)
}
TextButton(
onClick = onSkip,
modifier = Modifier.weight(1f)
) {
Text(
text = stringResource(R.string.battery_optimization_skip),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
)
)
}
}
}
}
}
}
@Composable
private fun HeaderSection(colorScheme: ColorScheme) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineLarge.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 32.sp
),
color = colorScheme.onBackground
)
Text(
text = stringResource(R.string.background_location_required_subtitle),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onBackground.copy(alpha = 0.7f)
)
}
}
@@ -0,0 +1,21 @@
package com.bitchat.android.onboarding
import android.content.Context
/**
* Preference manager for background location skip choice.
*/
object BackgroundLocationPreferenceManager {
private const val PREFS_NAME = "bitchat_settings"
private const val KEY_BACKGROUND_LOCATION_SKIP = "background_location_skipped"
fun setSkipped(context: Context, skipped: Boolean) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putBoolean(KEY_BACKGROUND_LOCATION_SKIP, skipped).apply()
}
fun isSkipped(context: Context): Boolean {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(KEY_BACKGROUND_LOCATION_SKIP, false)
}
}
@@ -19,6 +19,7 @@ class OnboardingCoordinator(
private val activity: ComponentActivity,
private val permissionManager: PermissionManager,
private val onOnboardingComplete: () -> Unit,
private val onBackgroundLocationRequired: () -> Unit,
private val onOnboardingFailed: (String) -> Unit
) {
@@ -27,9 +28,11 @@ class OnboardingCoordinator(
}
private var permissionLauncher: ActivityResultLauncher<Array<String>>? = null
private var backgroundLocationLauncher: ActivityResultLauncher<String>? = null
init {
setupPermissionLauncher()
setupBackgroundLocationLauncher()
}
/**
@@ -43,6 +46,14 @@ class OnboardingCoordinator(
}
}
private fun setupBackgroundLocationLauncher() {
backgroundLocationLauncher = activity.registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
handleBackgroundLocationResult(granted)
}
}
/**
* Start the onboarding process
*/
@@ -50,9 +61,14 @@ class OnboardingCoordinator(
Log.d(TAG, "Starting onboarding process")
permissionManager.logPermissionStatus()
if (permissionManager.areAllPermissionsGranted()) {
Log.d(TAG, "All permissions already granted, completing onboarding")
completeOnboarding()
if (permissionManager.areRequiredPermissionsGranted()) {
if (shouldRequestBackgroundLocation()) {
Log.d(TAG, "Foreground permissions granted; background location recommended")
onBackgroundLocationRequired()
} else {
Log.d(TAG, "Required permissions already granted, completing onboarding")
completeOnboarding()
}
} else {
Log.d(TAG, "Missing permissions, need to start explanation flow")
// The explanation screen will be shown by the calling activity
@@ -76,7 +92,11 @@ class OnboardingCoordinator(
val missingPermissions = (missingRequired + optionalToRequest).distinct()
if (missingPermissions.isEmpty()) {
completeOnboarding()
if (shouldRequestBackgroundLocation()) {
onBackgroundLocationRequired()
} else {
completeOnboarding()
}
return
}
@@ -98,13 +118,19 @@ class OnboardingCoordinator(
val criticalGranted = criticalPermissions.all { permissions[it] == true }
when {
allGranted -> {
Log.d(TAG, "All permissions granted successfully")
completeOnboarding()
}
criticalGranted -> {
Log.d(TAG, "Critical permissions granted, can proceed with limited functionality")
showPartialPermissionWarning(permissions)
if (shouldRequestBackgroundLocation()) {
Log.d(TAG, "Foreground permissions granted; requesting background location next")
onBackgroundLocationRequired()
return
}
if (allGranted) {
Log.d(TAG, "All permissions granted successfully")
completeOnboarding()
} else {
Log.d(TAG, "Critical permissions granted, can proceed with limited functionality")
showPartialPermissionWarning(permissions)
}
}
else -> {
Log.d(TAG, "Critical permissions denied")
@@ -113,15 +139,50 @@ class OnboardingCoordinator(
}
}
fun requestBackgroundLocation() {
val permission = permissionManager.getBackgroundLocationPermission()
if (permission == null) {
completeOnboarding()
return
}
Log.d(TAG, "Requesting background location permission")
backgroundLocationLauncher?.launch(permission)
}
private fun handleBackgroundLocationResult(granted: Boolean) {
if (granted) {
Log.d(TAG, "Background location permission granted")
} else {
Log.w(TAG, "Background location permission denied; continuing without it")
}
completeOnboarding()
}
fun skipBackgroundLocation() {
Log.d(TAG, "User skipped background location permission")
BackgroundLocationPreferenceManager.setSkipped(activity, true)
completeOnboarding()
}
fun checkBackgroundLocationAndProceed() {
if (!shouldRequestBackgroundLocation()) {
completeOnboarding()
}
}
private fun shouldRequestBackgroundLocation(): Boolean {
return permissionManager.needsBackgroundLocationPermission() &&
!permissionManager.isBackgroundLocationGranted() &&
!BackgroundLocationPreferenceManager.isSkipped(activity)
}
/**
* Get the list of critical permissions that are absolutely required
*/
private fun getCriticalPermissions(): List<String> {
// For bitchat, Bluetooth and location permissions are critical
// Notifications are nice-to-have but not critical
return permissionManager.getRequiredPermissions().filter { permission ->
!permission.contains("POST_NOTIFICATIONS")
}
// Notifications are nice-to-have but not critical and are not included in getRequiredPermissions()
return permissionManager.getRequiredPermissions()
}
/**
@@ -208,6 +269,7 @@ class OnboardingCoordinator(
private fun getPermissionDisplayName(permission: String): String {
return when {
permission.contains("BLUETOOTH") -> "Bluetooth/Nearby Devices"
permission.contains("BACKGROUND") -> "Background Location"
permission.contains("LOCATION") -> "Location (for Bluetooth scanning)"
permission.contains("NOTIFICATION") -> "Notifications"
else -> permission.substringAfterLast(".")
@@ -6,8 +6,9 @@ enum class OnboardingState {
LOCATION_CHECK,
BATTERY_OPTIMIZATION_CHECK,
PERMISSION_EXPLANATION,
BACKGROUND_LOCATION_EXPLANATION,
PERMISSION_REQUESTING,
INITIALIZING,
COMPLETE,
ERROR
}
}
@@ -12,12 +12,10 @@ import androidx.compose.material.icons.filled.Power
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@@ -209,29 +207,6 @@ private fun PermissionCategoryCard(
color = colorScheme.onBackground.copy(alpha = 0.8f)
)
if (category.type == PermissionType.PRECISE_LOCATION) {
// Extra emphasis for location permission
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = stringResource(R.string.cd_warning),
tint = Color(0xFFFF9800),
modifier = Modifier.size(16.dp)
)
Text(
text = stringResource(R.string.location_tracking_warning),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
color = Color(0xFFFF9800)
)
)
}
}
}
}
}
@@ -240,6 +215,7 @@ private fun getPermissionIcon(permissionType: PermissionType): ImageVector {
return when (permissionType) {
PermissionType.NEARBY_DEVICES -> Icons.Filled.Bluetooth
PermissionType.PRECISE_LOCATION -> Icons.Filled.LocationOn
PermissionType.BACKGROUND_LOCATION -> Icons.Filled.LocationOn
PermissionType.MICROPHONE -> Icons.Filled.Mic
PermissionType.NOTIFICATIONS -> Icons.Filled.Notifications
PermissionType.BATTERY_OPTIMIZATION -> Icons.Filled.Power
@@ -7,6 +7,7 @@ import android.os.Build
import android.os.PowerManager
import android.util.Log
import androidx.core.content.ContextCompat
import com.bitchat.android.R
/**
* Centralized permission management for bitchat app
@@ -40,7 +41,8 @@ class PermissionManager(private val context: Context) {
}
/**
* Get all permissions required by the app
* Get required permissions that can be requested together.
* Background location is handled separately to ensure correct request order.
* Note: Notification permission is optional and not included here,
* so the app works without notification access.
*/
@@ -72,6 +74,27 @@ class PermissionManager(private val context: Context) {
return permissions
}
/**
* Background location permission is required on Android 10+ for background BLE scanning.
* Must be requested after foreground location permissions are granted.
*/
fun needsBackgroundLocationPermission(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
}
fun getBackgroundLocationPermission(): String? {
return if (needsBackgroundLocationPermission()) {
Manifest.permission.ACCESS_BACKGROUND_LOCATION
} else {
null
}
}
fun isBackgroundLocationGranted(): Boolean {
val permission = getBackgroundLocationPermission() ?: return true
return isPermissionGranted(permission)
}
/**
* Get optional permissions that improve the experience but aren't required.
* Currently includes POST_NOTIFICATIONS on Android 13+.
@@ -93,9 +116,13 @@ class PermissionManager(private val context: Context) {
}
/**
* Check if all required permissions are granted
* Check if all required permissions are granted (background location is optional).
*/
fun areAllPermissionsGranted(): Boolean {
return areRequiredPermissionsGranted()
}
fun areRequiredPermissionsGranted(): Boolean {
return getRequiredPermissions().all { isPermissionGranted(it) }
}
@@ -131,6 +158,11 @@ class PermissionManager(private val context: Context) {
return getRequiredPermissions().filter { !isPermissionGranted(it) }
}
fun getMissingBackgroundLocationPermission(): List<String> {
val permission = getBackgroundLocationPermission() ?: return emptyList()
return if (isPermissionGranted(permission)) emptyList() else listOf(permission)
}
/**
* Get categorized permission information for display
*/
@@ -177,6 +209,19 @@ class PermissionManager(private val context: Context) {
)
)
if (needsBackgroundLocationPermission()) {
val backgroundPermission = listOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
categories.add(
PermissionCategory(
type = PermissionType.BACKGROUND_LOCATION,
description = context.getString(R.string.perm_background_location_desc),
permissions = backgroundPermission,
isGranted = backgroundPermission.all { isPermissionGranted(it) },
systemDescription = context.getString(R.string.perm_background_location_system)
)
)
}
// Notifications category (if applicable)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
categories.add(
@@ -216,7 +261,7 @@ class PermissionManager(private val context: Context) {
appendLine("Permission Diagnostics:")
appendLine("Android SDK: ${Build.VERSION.SDK_INT}")
appendLine("First time launch: ${isFirstTimeLaunch()}")
appendLine("All permissions granted: ${areAllPermissionsGranted()}")
appendLine("Required permissions granted: ${areAllPermissionsGranted()}")
appendLine()
getCategorizedPermissions().forEach { category ->
@@ -228,7 +273,7 @@ class PermissionManager(private val context: Context) {
appendLine()
}
val missing = getMissingPermissions()
val missing = getMissingPermissions() + getMissingBackgroundLocationPermission()
if (missing.isNotEmpty()) {
appendLine("Missing permissions:")
missing.forEach { permission ->
@@ -260,6 +305,7 @@ data class PermissionCategory(
enum class PermissionType(val nameValue: String) {
NEARBY_DEVICES("Nearby Devices"),
PRECISE_LOCATION("Precise Location"),
BACKGROUND_LOCATION("Background Location"),
MICROPHONE("Microphone"),
NOTIFICATIONS("Notifications"),
BATTERY_OPTIMIZATION("Battery Optimization"),
@@ -0,0 +1,95 @@
package com.bitchat.android.service
import android.app.Application
import android.os.Process
import androidx.core.app.NotificationManagerCompat
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.net.ArtiTorManager
import com.bitchat.android.net.TorMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.atomic.AtomicLong
/**
* Coordinates a full application shutdown:
* - Stop mesh cleanly
* - Stop Tor without changing persistent setting
* - Clear in-memory AppState
* - Stop foreground service/notification
* - Kill the process after completion or after a 5s timeout
*/
object AppShutdownCoordinator {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val shutdownToken = AtomicLong(0L)
@Volatile
private var shutdownJob: Job? = null
fun cancelPendingShutdown() {
shutdownToken.incrementAndGet()
shutdownJob?.cancel()
shutdownJob = null
}
fun requestFullShutdownAndKill(
app: Application,
mesh: BluetoothMeshService?,
notificationManager: NotificationManagerCompat,
stopForeground: () -> Unit,
stopService: () -> Unit
) {
val token = shutdownToken.incrementAndGet()
shutdownJob?.cancel()
val job = scope.launch {
// Signal UI to finish gracefully before we kill the process
try {
val intent = android.content.Intent(com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH)
.setPackage(app.packageName)
app.sendBroadcast(intent, com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH)
} catch (_: Exception) { }
// Stop mesh (best-effort)
try { mesh?.stopServices() } catch (_: Exception) { }
// Stop Tor temporarily (do not change user setting)
val torProvider = ArtiTorManager.getInstance()
val torStop = async {
try { torProvider.applyMode(app, TorMode.OFF) } catch (_: Exception) { }
}
// Clear AppState in-memory store
try { com.bitchat.android.services.AppStateStore.clear() } catch (_: Exception) { }
// Stop foreground and clear notification
try { stopForeground() } catch (_: Exception) { }
try { notificationManager.cancel(10001) } catch (_: Exception) { }
// Wait up to 5 seconds for shutdown tasks
withTimeoutOrNull(5000) {
try { torStop.await() } catch (_: Exception) { }
delay(100)
}
// Stop the service itself
if (!isActive || shutdownToken.get() != token) return@launch
try { stopService() } catch (_: Exception) { }
// Hard kill the app process
if (!isActive || shutdownToken.get() != token) return@launch
try { Process.killProcess(Process.myPid()) } catch (_: Exception) { }
try { System.exit(0) } catch (_: Exception) { }
}
shutdownJob = job
job.invokeOnCompletion {
if (shutdownJob === job) {
shutdownJob = null
}
}
}
}
@@ -0,0 +1,16 @@
package com.bitchat.android.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
class BootCompletedReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
// Ensure preferences are initialized on cold boot before reading values
try { MeshServicePreferences.init(context.applicationContext) } catch (_: Exception) { }
if (MeshServicePreferences.isAutoStartEnabled(true)) {
MeshForegroundService.start(context.applicationContext)
}
}
}
@@ -0,0 +1,373 @@
package com.bitchat.android.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.bitchat.android.MainActivity
import com.bitchat.android.R
import com.bitchat.android.mesh.BluetoothMeshService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class MeshForegroundService : Service() {
companion object {
private const val CHANNEL_ID = "bitchat_mesh_service"
private const val NOTIFICATION_ID = 10001
const val ACTION_START = "com.bitchat.android.service.START"
const val ACTION_STOP = "com.bitchat.android.service.STOP"
const val ACTION_QUIT = "com.bitchat.android.service.QUIT"
const val ACTION_UPDATE_NOTIFICATION = "com.bitchat.android.service.UPDATE_NOTIFICATION"
const val ACTION_NOTIFICATION_PERMISSION_GRANTED = "com.bitchat.android.action.NOTIFICATION_PERMISSION_GRANTED"
fun start(context: Context) {
val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_START }
// On API >= 26, avoid background-service start restrictions by using startForegroundService
// only when we can actually post a notification (Android 13+ requires runtime notif permission)
val bgEnabled = MeshServicePreferences.isBackgroundEnabled(true)
val hasNotifPerm = hasNotificationPermissionStatic(context)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (bgEnabled && hasNotifPerm) {
context.startForegroundService(intent)
} else {
// Do not attempt to start a background service from headless context without notif permission
// or when background is disabled, to avoid BackgroundServiceStartNotAllowedException.
android.util.Log.i(
"MeshForegroundService",
"Not starting service on API>=26 (bgEnabled=$bgEnabled, hasNotifPerm=$hasNotifPerm)"
)
}
} else {
if (bgEnabled) {
context.startService(intent)
} else {
android.util.Log.i("MeshForegroundService", "Background disabled; not starting service (pre-O)")
}
}
}
/**
* Helper to be invoked right after POST_NOTIFICATIONS is granted to try
* promoting/starting the foreground service immediately without polling.
*/
fun onNotificationPermissionGranted(context: Context) {
// If background is enabled and permission now granted, start/promo service
val hasNotifPerm = hasNotificationPermissionStatic(context)
if (!MeshServicePreferences.isBackgroundEnabled(true) || !hasNotifPerm) return
val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_UPDATE_NOTIFICATION }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Safe now that we can show a notification
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_STOP }
context.startService(intent)
}
private fun shouldStartAsForeground(context: Context): Boolean {
return MeshServicePreferences.isBackgroundEnabled(true) &&
hasBluetoothPermissionsStatic(context) &&
hasNotificationPermissionStatic(context)
}
private fun hasBluetoothPermissionsStatic(ctx: Context): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.BLUETOOTH_ADVERTISE) == android.content.pm.PackageManager.PERMISSION_GRANTED &&
androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.BLUETOOTH_CONNECT) == android.content.pm.PackageManager.PERMISSION_GRANTED &&
androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.BLUETOOTH_SCAN) == android.content.pm.PackageManager.PERMISSION_GRANTED
} else {
val fine = androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.ACCESS_FINE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED
val coarse = androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.ACCESS_COARSE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED
fine || coarse
}
}
private fun hasNotificationPermissionStatic(ctx: Context): Boolean {
return if (Build.VERSION.SDK_INT >= 33) {
androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED
} else true
}
}
private lateinit var notificationManager: NotificationManagerCompat
private var updateJob: Job? = null
private var meshService: BluetoothMeshService? = null
private val serviceJob = Job()
private val scope = CoroutineScope(Dispatchers.Default + serviceJob)
private var isInForeground: Boolean = false
private var isShuttingDown: Boolean = false
override fun onCreate() {
super.onCreate()
notificationManager = NotificationManagerCompat.from(this)
createChannel()
// Adopt or create the mesh service
val existing = MeshServiceHolder.meshService
meshService = existing ?: MeshServiceHolder.getOrCreate(applicationContext)
if (existing != null) {
android.util.Log.d("MeshForegroundService", "Adopted existing BluetoothMeshService from holder")
} else {
android.util.Log.i("MeshForegroundService", "Created/adopted new BluetoothMeshService via holder")
}
MeshServiceHolder.attach(meshService!!)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (isShuttingDown && intent?.action == ACTION_START) {
AppShutdownCoordinator.cancelPendingShutdown()
isShuttingDown = false
}
if (isShuttingDown && intent?.action != ACTION_QUIT) {
return START_NOT_STICKY
}
when (intent?.action) {
ACTION_STOP -> {
// Stop FGS and mesh cleanly
try { meshService?.stopServices() } catch (_: Exception) { }
try { MeshServiceHolder.clear() } catch (_: Exception) { }
try { stopForeground(true) } catch (_: Exception) { }
notificationManager.cancel(NOTIFICATION_ID)
isInForeground = false
stopSelf()
return START_NOT_STICKY
}
ACTION_QUIT -> {
isShuttingDown = true
updateJob?.cancel()
updateJob = null
try { stopForeground(true) } catch (_: Exception) { }
notificationManager.cancel(NOTIFICATION_ID)
isInForeground = false
// Fully stop all background activity, stop Tor (without changing setting), then kill the app
AppShutdownCoordinator.requestFullShutdownAndKill(
app = application,
mesh = meshService,
notificationManager = notificationManager,
stopForeground = {
try { stopForeground(true) } catch (_: Exception) { }
isInForeground = false
},
stopService = { stopSelf() }
)
return START_NOT_STICKY
}
ACTION_UPDATE_NOTIFICATION -> {
// If we became eligible and are not in foreground yet, promote once
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
val n = buildNotification(meshService?.getActivePeerCount() ?: 0)
startForegroundCompat(n)
isInForeground = true
} else {
updateNotification(force = true)
}
}
else -> { /* ACTION_START or null */ }
}
// Ensure mesh is running (only after permissions are granted)
ensureMeshStarted()
// Promote exactly once when eligible, otherwise stay background (or stop)
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
val notification = buildNotification(meshService?.getActivePeerCount() ?: 0)
startForegroundCompat(notification)
isInForeground = true
}
// Periodically refresh the notification with live network size
if (updateJob == null) {
updateJob = scope.launch {
while (isActive) {
// Retry enabling mesh/foreground once permissions become available
ensureMeshStarted()
val eligible = MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()
if (eligible) {
// Only update the notification; do not re-call startForeground()
updateNotification(force = false)
} else {
// If disabled or perms missing, ensure we are not in foreground and clear notif
if (isInForeground) {
try { stopForeground(false) } catch (_: Exception) { }
isInForeground = false
}
notificationManager.cancel(NOTIFICATION_ID)
}
delay(5000)
}
}
}
return START_STICKY
}
private fun ensureMeshStarted() {
if (isShuttingDown) return
if (!hasBluetoothPermissions()) return
try {
android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started")
meshService?.startServices()
} catch (e: Exception) {
android.util.Log.e("MeshForegroundService", "Failed to start mesh service: ${e.message}")
}
}
private fun updateNotification(force: Boolean) {
if (isShuttingDown) {
notificationManager.cancel(NOTIFICATION_ID)
return
}
val count = meshService?.getActivePeerCount() ?: 0
val notification = buildNotification(count)
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) {
notificationManager.notify(NOTIFICATION_ID, notification)
} else if (force) {
// If disabled and forced, make sure to remove any prior foreground state
try { stopForeground(false) } catch (_: Exception) { }
notificationManager.cancel(NOTIFICATION_ID)
isInForeground = false
}
}
private fun hasAllRequiredPermissions(): Boolean {
// For starting FGS with connectedDevice|dataSync, we need:
// - Foreground service permissions (declared in manifest)
// - One of the device-related permissions (we request BL perms at runtime)
// - On Android 13+, POST_NOTIFICATIONS to actually show notification
return hasBluetoothPermissions() && hasNotificationPermission()
}
private fun hasBluetoothPermissions(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_ADVERTISE) == android.content.pm.PackageManager.PERMISSION_GRANTED &&
androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_CONNECT) == android.content.pm.PackageManager.PERMISSION_GRANTED &&
androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_SCAN) == android.content.pm.PackageManager.PERMISSION_GRANTED
} else {
// Prior to S, scanning requires location permissions
val fine = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED
val coarse = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_COARSE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED
fine || coarse
}
}
private fun hasNotificationPermission(): Boolean {
return if (Build.VERSION.SDK_INT >= 33) {
androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED
} else true
}
private fun buildNotification(activePeers: Int): Notification {
val openIntent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this, 0, openIntent,
PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0)
)
// Action: Quit Bitchat
val quitIntent = Intent(this, MeshForegroundService::class.java).apply { action = ACTION_QUIT }
val quitPendingIntent = PendingIntent.getService(
this, 1, quitIntent,
PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0)
)
val title = getString(R.string.app_name)
val content = getString(R.string.mesh_service_notification_content, activePeers)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setContentText(content)
.setSmallIcon(R.mipmap.ic_launcher)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setContentIntent(pendingIntent)
// Add an action button that appears when notification is expanded
.addAction(
android.R.drawable.ic_menu_close_clear_cancel,
getString(R.string.notification_action_quit_bitchat),
quitPendingIntent
)
.build()
}
private fun createChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.mesh_service_channel_name),
NotificationManager.IMPORTANCE_LOW
).apply {
description = getString(R.string.mesh_service_channel_desc)
setShowBadge(false)
}
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
.createNotificationChannel(channel)
}
}
private fun hasLocationPermission(): Boolean {
val fine = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED
val coarse = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_COARSE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED
return fine || coarse
}
private fun startForegroundCompat(notification: Notification) {
if (Build.VERSION.SDK_INT >= 34) {
val type = if (hasLocationPermission()) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
} else {
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
}
try {
startForeground(NOTIFICATION_ID, notification, type)
} catch (e: SecurityException) {
// Fallback for cases where "While In Use" permission exists but background start is restricted
if (type and ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION != 0) {
android.util.Log.w("MeshForegroundService", "Failed to start with LOCATION type, falling back to CONNECTED_DEVICE: ${e.message}")
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE)
} else {
throw e
}
}
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
override fun onDestroy() {
updateJob?.cancel()
updateJob = null
// Cancel the service coroutine scope to prevent leaks
try { serviceJob.cancel() } catch (_: Exception) { }
// Best-effort ensure we are not marked foreground
if (isInForeground) {
try { stopForeground(true) } catch (_: Exception) { }
isInForeground = false
}
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
}
@@ -0,0 +1,60 @@
package com.bitchat.android.service
import android.content.Context
import com.bitchat.android.mesh.BluetoothMeshService
/**
* Process-wide holder to share a single BluetoothMeshService instance
* between the foreground service and UI (MainActivity/ViewModels).
*/
object MeshServiceHolder {
private const val TAG = "MeshServiceHolder"
@Volatile
var meshService: BluetoothMeshService? = null
private set
@Synchronized
fun getOrCreate(context: Context): BluetoothMeshService {
val existing = meshService
if (existing != null) {
// If the existing instance is healthy, reuse it; otherwise, replace it.
return try {
if (existing.isReusable()) {
android.util.Log.d(TAG, "Reusing existing BluetoothMeshService instance")
existing
} else {
android.util.Log.w(TAG, "Existing BluetoothMeshService not reusable; replacing with a fresh instance")
// Best-effort stop before replacing
try { existing.stopServices() } catch (e: Exception) {
android.util.Log.w(TAG, "Error while stopping non-reusable instance: ${e.message}")
}
val created = BluetoothMeshService(context.applicationContext)
android.util.Log.i(TAG, "Created new BluetoothMeshService (replacement)")
meshService = created
created
}
} catch (e: Exception) {
android.util.Log.e(TAG, "Error checking service reusability; creating new instance: ${e.message}")
val created = BluetoothMeshService(context.applicationContext)
meshService = created
created
}
}
val created = BluetoothMeshService(context.applicationContext)
android.util.Log.i(TAG, "Created new BluetoothMeshService (no existing instance)")
meshService = created
return created
}
@Synchronized
fun attach(service: BluetoothMeshService) {
android.util.Log.d(TAG, "Attaching BluetoothMeshService to holder")
meshService = service
}
@Synchronized
fun clear() {
android.util.Log.d(TAG, "Clearing BluetoothMeshService from holder")
meshService = null
}
}
@@ -0,0 +1,32 @@
package com.bitchat.android.service
import android.content.Context
import android.content.SharedPreferences
object MeshServicePreferences {
private const val PREFS_NAME = "bitchat_mesh_service_prefs"
private const val KEY_AUTO_START = "auto_start_on_boot"
private const val KEY_BACKGROUND_ENABLED = "background_enabled"
private lateinit var prefs: SharedPreferences
fun init(context: Context) {
prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
fun isAutoStartEnabled(default: Boolean = true): Boolean {
return prefs.getBoolean(KEY_AUTO_START, default)
}
fun setAutoStartEnabled(enabled: Boolean) {
prefs.edit().putBoolean(KEY_AUTO_START, enabled).apply()
}
fun isBackgroundEnabled(default: Boolean = true): Boolean {
return prefs.getBoolean(KEY_BACKGROUND_ENABLED, default)
}
fun setBackgroundEnabled(enabled: Boolean) {
prefs.edit().putBoolean(KEY_BACKGROUND_ENABLED, enabled).apply()
}
}
@@ -0,0 +1,109 @@
package com.bitchat.android.services
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Process-wide in-memory state store that survives Activity recreation.
* The foreground Mesh service updates this store; UI subscribes/hydrates from it.
*/
object AppStateStore {
// Global de-dup set by message id to avoid duplicate keys in Compose lists
private val seenMessageIds = mutableSetOf<String>()
// Connected peer IDs (mesh ephemeral IDs)
private val _peers = MutableStateFlow<List<String>>(emptyList())
val peers: StateFlow<List<String>> = _peers.asStateFlow()
// Public mesh timeline messages (non-channel)
private val _publicMessages = MutableStateFlow<List<BitchatMessage>>(emptyList())
val publicMessages: StateFlow<List<BitchatMessage>> = _publicMessages.asStateFlow()
// Private messages by peerID
private val _privateMessages = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
val privateMessages: StateFlow<Map<String, List<BitchatMessage>>> = _privateMessages.asStateFlow()
// Channel messages by channel name
private val _channelMessages = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = _channelMessages.asStateFlow()
fun setPeers(ids: List<String>) {
_peers.value = ids
}
fun addPublicMessage(msg: BitchatMessage) {
synchronized(this) {
if (seenMessageIds.contains(msg.id)) return
seenMessageIds.add(msg.id)
_publicMessages.value = _publicMessages.value + msg
}
}
fun addPrivateMessage(peerID: String, msg: BitchatMessage) {
synchronized(this) {
if (seenMessageIds.contains(msg.id)) return
seenMessageIds.add(msg.id)
val map = _privateMessages.value.toMutableMap()
val list = (map[peerID] ?: emptyList()) + msg
map[peerID] = list
_privateMessages.value = map
}
}
private fun statusPriority(status: DeliveryStatus?): Int = when (status) {
null -> 0
is DeliveryStatus.Sending -> 1
is DeliveryStatus.Sent -> 2
is DeliveryStatus.PartiallyDelivered -> 3
is DeliveryStatus.Delivered -> 4
is DeliveryStatus.Read -> 5
is DeliveryStatus.Failed -> 0
}
fun updatePrivateMessageStatus(messageID: String, status: DeliveryStatus) {
synchronized(this) {
val map = _privateMessages.value.toMutableMap()
var changed = false
map.keys.toList().forEach { peer ->
val list = map[peer]?.toMutableList() ?: mutableListOf()
val idx = list.indexOfFirst { it.id == messageID }
if (idx >= 0) {
val current = list[idx].deliveryStatus
// Do not downgrade (e.g., Read -> Delivered)
if (statusPriority(status) >= statusPriority(current)) {
list[idx] = list[idx].copy(deliveryStatus = status)
map[peer] = list
changed = true
}
}
}
if (changed) {
_privateMessages.value = map
}
}
}
fun addChannelMessage(channel: String, msg: BitchatMessage) {
synchronized(this) {
if (seenMessageIds.contains(msg.id)) return
seenMessageIds.add(msg.id)
val map = _channelMessages.value.toMutableMap()
val list = (map[channel] ?: emptyList()) + msg
map[channel] = list
_channelMessages.value = map
}
}
// Clear all in-memory state (used for full app shutdown)
fun clear() {
synchronized(this) {
seenMessageIds.clear()
_peers.value = emptyList()
_publicMessages.value = emptyList()
_privateMessages.value = emptyMap()
_channelMessages.value = emptyMap()
}
}
}
@@ -0,0 +1,21 @@
package com.bitchat.android.services
import android.content.Context
import com.bitchat.android.ui.DataManager
/**
* Provides current user's nickname for announcements and leave messages.
* If no nickname saved, falls back to the provided peerID.
*/
object NicknameProvider {
fun getNickname(context: Context, myPeerID: String): String {
return try {
val dm = DataManager(context.applicationContext)
val nick = dm.loadNickname()
if (nick.isNullOrBlank()) myPeerID else nick
} catch (_: Exception) {
myPeerID
}
}
}
@@ -0,0 +1,294 @@
package com.bitchat.android.services
import android.net.Uri
import android.util.Base64
import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.util.AppConstants
import com.bitchat.android.util.dataFromHexString
import com.bitchat.android.util.hexEncodedString
import java.io.ByteArrayOutputStream
import java.security.SecureRandom
import androidx.core.net.toUri
import java.lang.ref.WeakReference
/**
* QR verification helpers: schema, signing, and basic challenge/response helpers.
*/
object VerificationService {
private const val CONTEXT = "bitchat-verify-v1"
private const val RESPONSE_CONTEXT = "bitchat-verify-resp-v1"
private var encryptionServiceRef: WeakReference<EncryptionService>? = null
fun configure(encryptionService: EncryptionService) {
this.encryptionServiceRef = WeakReference(encryptionService)
}
data class VerificationQR(
val v: Int,
val noiseKeyHex: String,
val signKeyHex: String,
val npub: String?,
val nickname: String,
val ts: Long,
val nonceB64: String,
val sigHex: String
) {
fun canonicalBytes(): ByteArray {
val out = ByteArrayOutputStream()
fun appendField(value: String) {
val data = value.toByteArray(Charsets.UTF_8)
val len = minOf(data.size, 255)
out.write(len)
out.write(data, 0, len)
}
appendField(CONTEXT)
appendField(v.toString())
appendField(noiseKeyHex.lowercase())
appendField(signKeyHex.lowercase())
appendField(npub ?: "")
appendField(nickname)
appendField(ts.toString())
appendField(nonceB64)
return out.toByteArray()
}
fun toUrlString(): String {
val builder = Uri.Builder()
.scheme("bitchat")
.authority("verify")
.appendQueryParameter("v", v.toString())
.appendQueryParameter("noise", noiseKeyHex)
.appendQueryParameter("sign", signKeyHex)
.appendQueryParameter("nick", nickname)
.appendQueryParameter("ts", ts.toString())
.appendQueryParameter("nonce", nonceB64)
.appendQueryParameter("sig", sigHex)
if (npub != null) {
builder.appendQueryParameter("npub", npub)
}
return builder.build().toString()
}
companion object {
fun fromUrlString(urlString: String): VerificationQR? {
val uri = runCatching { urlString.toUri() }.getOrNull() ?: return null
if (uri.scheme != "bitchat" || uri.host != "verify") return null
val vStr = uri.getQueryParameter("v") ?: return null
val v = vStr.toIntOrNull() ?: return null
val noise = uri.getQueryParameter("noise") ?: return null
val sign = uri.getQueryParameter("sign") ?: return null
val nick = uri.getQueryParameter("nick") ?: return null
val tsStr = uri.getQueryParameter("ts") ?: return null
val ts = tsStr.toLongOrNull() ?: return null
val nonce = uri.getQueryParameter("nonce") ?: return null
val sig = uri.getQueryParameter("sig") ?: return null
val npub = uri.getQueryParameter("npub")
return VerificationQR(
v = v,
noiseKeyHex = noise,
signKeyHex = sign,
npub = npub,
nickname = nick,
ts = ts,
nonceB64 = nonce,
sigHex = sig
)
}
}
}
fun buildMyQRString(nickname: String, npub: String?): String? {
val service = encryptionServiceRef?.get() ?: return null
val cache = Cache.last
if (cache != null && cache.nickname == nickname && cache.npub == npub) {
if (System.currentTimeMillis() - cache.builtAtMs < 60_000L) {
return cache.value
}
}
val noiseKey = service.getStaticPublicKey()?.hexEncodedString() ?: return null
val signKey = service.getSigningPublicKey()?.hexEncodedString() ?: return null
val ts = System.currentTimeMillis() / 1000L
val nonce = ByteArray(16)
SecureRandom().nextBytes(nonce)
val nonceB64 = Base64.encodeToString(
nonce,
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
)
val payload = VerificationQR(
v = 1,
noiseKeyHex = noiseKey,
signKeyHex = signKey,
npub = npub,
nickname = nickname,
ts = ts,
nonceB64 = nonceB64,
sigHex = ""
)
val signature = service.signData(payload.canonicalBytes()) ?: return null
val signed = payload.copy(sigHex = signature.hexEncodedString())
val out = signed.toUrlString()
Cache.last = CacheEntry(nickname, npub, System.currentTimeMillis(), out)
return out
}
fun verifyScannedQR(
urlString: String,
maxAgeSeconds: Long = AppConstants.Verification.QR_MAX_AGE_SECONDS
): VerificationQR? {
val service = encryptionServiceRef?.get() ?: return null
val qr = VerificationQR.fromUrlString(urlString) ?: return null
val now = System.currentTimeMillis() / 1000L
if (now - qr.ts > maxAgeSeconds) return null
val sig = qr.sigHex.dataFromHexString() ?: return null
val signKey = qr.signKeyHex.dataFromHexString() ?: return null
val ok = service.verifyEd25519Signature(sig, qr.canonicalBytes(), signKey)
return if (ok) qr else null
}
fun buildVerifyChallenge(noiseKeyHex: String, nonceA: ByteArray): ByteArray {
val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8)
val out = ByteArrayOutputStream()
out.write(0x01)
out.write(minOf(noiseData.size, 255))
out.write(noiseData, 0, minOf(noiseData.size, 255))
out.write(0x02)
out.write(minOf(nonceA.size, 255))
out.write(nonceA, 0, minOf(nonceA.size, 255))
return out.toByteArray()
}
fun buildVerifyResponse(noiseKeyHex: String, nonceA: ByteArray): ByteArray? {
val service = encryptionServiceRef?.get() ?: return null
val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8)
val msg = ByteArrayOutputStream()
msg.write(RESPONSE_CONTEXT.toByteArray(Charsets.UTF_8))
msg.write(minOf(noiseData.size, 255))
msg.write(noiseData, 0, minOf(noiseData.size, 255))
msg.write(nonceA)
val sig = service.signData(msg.toByteArray()) ?: return null
val out = ByteArrayOutputStream()
out.write(0x01)
out.write(minOf(noiseData.size, 255))
out.write(noiseData, 0, minOf(noiseData.size, 255))
out.write(0x02)
out.write(minOf(nonceA.size, 255))
out.write(nonceA, 0, minOf(nonceA.size, 255))
out.write(0x03)
out.write(minOf(sig.size, 255))
out.write(sig, 0, minOf(sig.size, 255))
return out.toByteArray()
}
fun parseVerifyChallenge(data: ByteArray): Pair<String, ByteArray>? {
var idx = 0
fun take(n: Int): ByteArray? {
if (idx + n > data.size) return null
val out = data.copyOfRange(idx, idx + n)
idx += n
return out
}
val t1 = take(1) ?: return null
if (t1[0].toInt() != 0x01) return null
val l1 = take(1)?.get(0)?.toInt() ?: return null
val noiseBytes = take(l1) ?: return null
val noise = noiseBytes.toString(Charsets.UTF_8)
val t2 = take(1) ?: return null
if (t2[0].toInt() != 0x02) return null
val l2 = take(1)?.get(0)?.toInt() ?: return null
val nonce = take(l2) ?: return null
return noise to nonce
}
data class VerifyResponse(val noiseKeyHex: String, val nonceA: ByteArray, val signature: ByteArray) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as VerifyResponse
if (noiseKeyHex != other.noiseKeyHex) return false
if (!nonceA.contentEquals(other.nonceA)) return false
if (!signature.contentEquals(other.signature)) return false
return true
}
override fun hashCode(): Int {
var result = noiseKeyHex.hashCode()
result = 31 * result + nonceA.contentHashCode()
result = 31 * result + signature.contentHashCode()
return result
}
}
fun parseVerifyResponse(data: ByteArray): VerifyResponse? {
var idx = 0
fun take(n: Int): ByteArray? {
if (idx + n > data.size) return null
val out = data.copyOfRange(idx, idx + n)
idx += n
return out
}
val t1 = take(1) ?: return null
if (t1[0].toInt() != 0x01) return null
val l1 = take(1)?.get(0)?.toInt() ?: return null
val noiseBytes = take(l1) ?: return null
val noise = noiseBytes.toString(Charsets.UTF_8)
val t2 = take(1) ?: return null
if (t2[0].toInt() != 0x02) return null
val l2 = take(1)?.get(0)?.toInt() ?: return null
val nonce = take(l2) ?: return null
val t3 = take(1) ?: return null
if (t3[0].toInt() != 0x03) return null
val l3 = take(1)?.get(0)?.toInt() ?: return null
val sig = take(l3) ?: return null
return VerifyResponse(noise, nonce, sig)
}
fun verifyResponseSignature(
noiseKeyHex: String,
nonceA: ByteArray,
signature: ByteArray,
signerPublicKeyHex: String
): Boolean {
val service = encryptionServiceRef?.get() ?: return false
val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8)
val msg = ByteArrayOutputStream()
msg.write(RESPONSE_CONTEXT.toByteArray(Charsets.UTF_8))
msg.write(minOf(noiseData.size, 255))
msg.write(noiseData, 0, minOf(noiseData.size, 255))
msg.write(nonceA)
val signerKey = signerPublicKeyHex.dataFromHexString() ?: return false
return service.verifyEd25519Signature(signature, msg.toByteArray(), signerKey)
}
private data class CacheEntry(
val nickname: String,
val npub: String?,
val builtAtMs: Long,
val value: String
)
private object Cache {
var last: CacheEntry? = null
}
}
File diff suppressed because it is too large Load Diff
@@ -14,7 +14,6 @@ import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -28,9 +27,9 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.core.ui.utils.singleOrTripleClickable
import com.bitchat.android.geohash.LocationChannelManager.PermissionState
import androidx.compose.foundation.Canvas
import androidx.compose.ui.geometry.Offset
import androidx.lifecycle.compose.collectAsStateWithLifecycle
/**
* Header components for ChatScreen
@@ -59,7 +58,8 @@ fun isFavoriteReactive(
fun TorStatusDot(
modifier: Modifier = Modifier
) {
val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState()
val torProvider = remember { com.bitchat.android.net.ArtiTorManager.getInstance() }
val torStatus by torProvider.statusFlow.collectAsState()
if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) {
val dotColor = when {
@@ -248,10 +248,10 @@ fun ChatHeaderContent(
when {
selectedPrivatePeer != null -> {
// Private chat header - Fully reactive state tracking
val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet())
val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap())
val peerSessionStates by viewModel.peerSessionStates.observeAsState(emptyMap())
val peerNicknames by viewModel.peerNicknames.observeAsState(emptyMap())
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle()
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
// Reactive favorite computation - no more static lookups!
val isFavorite = isFavoriteReactive(
@@ -264,8 +264,8 @@ fun ChatHeaderContent(
Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, isFav=$isFavorite, sessionState=$sessionState")
// Pass geohash context and people for NIP-17 chat title formatting
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList())
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
PrivateChatHeader(
peerID = selectedPrivatePeer,
@@ -318,6 +318,10 @@ private fun PrivateChatHeader(
) {
val colorScheme = MaterialTheme.colorScheme
val isNostrDM = peerID.startsWith("nostr_") || peerID.startsWith("nostr:")
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
val isVerified = remember(peerID, verifiedFingerprints) {
viewModel.isPeerVerified(peerID, verifiedFingerprints)
}
// Determine mutual favorite state for this peer (supports mesh ephemeral 16-hex via favorites lookup)
val isMutualFavorite = remember(peerID, peerNicknames) {
try {
@@ -413,17 +417,33 @@ private fun PrivateChatHeader(
// Show a globe when chatting via Nostr alias, or when mesh session not established but mutual favorite exists
val showGlobe = isNostrDM || (sessionState != "established" && isMutualFavorite)
val securityModifier = if (!isNostrDM) {
Modifier.clickable { viewModel.showSecurityVerificationSheet() }
} else {
Modifier
}
if (showGlobe) {
Icon(
imageVector = Icons.Outlined.Public,
contentDescription = stringResource(R.string.cd_nostr_reachable),
modifier = Modifier.size(14.dp),
modifier = Modifier.size(14.dp).then(securityModifier),
tint = Color(0xFF9B59B6) // Purple like iOS
)
} else {
NoiseSessionIcon(
sessionState = sessionState,
modifier = Modifier.size(14.dp)
modifier = Modifier.size(14.dp).then(securityModifier)
)
}
if (isVerified) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.Filled.Verified,
contentDescription = stringResource(R.string.verify_title),
modifier = Modifier.size(14.dp).then(securityModifier),
tint = Color(0xFF32D74B)
)
}
@@ -523,18 +543,18 @@ private fun MainHeader(
viewModel: ChatViewModel
) {
val colorScheme = MaterialTheme.colorScheme
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList())
val joinedChannels by viewModel.joinedChannels.observeAsState(emptySet())
val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap())
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
val isConnected by viewModel.isConnected.observeAsState(false)
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList())
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
// Bookmarks store for current geohash toggle (iOS parity)
val context = androidx.compose.ui.platform.LocalContext.current
val bookmarksStore = remember { com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(context) }
val bookmarks by bookmarksStore.bookmarks.observeAsState(emptyList())
val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle()
Row(
modifier = Modifier.fillMaxWidth(),
@@ -653,8 +673,8 @@ private fun LocationChannelsButton(
val colorScheme = MaterialTheme.colorScheme
// Get current channel selection from location manager
val selectedChannel by viewModel.selectedLocationChannel.observeAsState()
val teleported by viewModel.isTeleported.observeAsState(false)
val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val teleported by viewModel.isTeleported.collectAsStateWithLifecycle()
val (badgeText, badgeColor) = when (selectedChannel) {
is com.bitchat.android.geohash.ChannelID.Mesh -> {
@@ -10,7 +10,6 @@ import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.Alignment
@@ -25,6 +24,7 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.ui.media.FullScreenImageViewer
@@ -41,22 +41,24 @@ import com.bitchat.android.ui.media.FullScreenImageViewer
@Composable
fun ChatScreen(viewModel: ChatViewModel) {
val colorScheme = MaterialTheme.colorScheme
val messages by viewModel.messages.observeAsState(emptyList())
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList())
val nickname by viewModel.nickname.observeAsState("")
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState()
val currentChannel by viewModel.currentChannel.observeAsState()
val joinedChannels by viewModel.joinedChannels.observeAsState(emptySet())
val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap())
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
val privateChats by viewModel.privateChats.observeAsState(emptyMap())
val channelMessages by viewModel.channelMessages.observeAsState(emptyMap())
val showSidebar by viewModel.showSidebar.observeAsState(false)
val showCommandSuggestions by viewModel.showCommandSuggestions.observeAsState(false)
val commandSuggestions by viewModel.commandSuggestions.observeAsState(emptyList())
val showMentionSuggestions by viewModel.showMentionSuggestions.observeAsState(false)
val mentionSuggestions by viewModel.mentionSuggestions.observeAsState(emptyList())
val showAppInfo by viewModel.showAppInfo.observeAsState(false)
val messages by viewModel.messages.collectAsStateWithLifecycle()
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
val channelMessages by viewModel.channelMessages.collectAsStateWithLifecycle()
val showSidebar by viewModel.showSidebar.collectAsStateWithLifecycle()
val showCommandSuggestions by viewModel.showCommandSuggestions.collectAsStateWithLifecycle()
val commandSuggestions by viewModel.commandSuggestions.collectAsStateWithLifecycle()
val showMentionSuggestions by viewModel.showMentionSuggestions.collectAsStateWithLifecycle()
val mentionSuggestions by viewModel.mentionSuggestions.collectAsStateWithLifecycle()
val showAppInfo by viewModel.showAppInfo.collectAsStateWithLifecycle()
val showVerificationSheet by viewModel.showVerificationSheet.collectAsStateWithLifecycle()
val showSecurityVerificationSheet by viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle()
var messageText by remember { mutableStateOf(TextFieldValue("")) }
var showPasswordPrompt by remember { mutableStateOf(false) }
@@ -78,11 +80,11 @@ fun ChatScreen(viewModel: ChatViewModel) {
showPasswordDialog = showPasswordPrompt
}
val isConnected by viewModel.isConnected.observeAsState(false)
val passwordPromptChannel by viewModel.passwordPromptChannel.observeAsState(null)
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
val passwordPromptChannel by viewModel.passwordPromptChannel.collectAsStateWithLifecycle()
// Get location channel info for timeline switching
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
// Determine what messages to show based on current context (unified timelines)
val displayMessages = when {
@@ -327,6 +329,10 @@ fun ChatScreen(viewModel: ChatViewModel) {
SidebarOverlay(
viewModel = viewModel,
onDismiss = { viewModel.hideSidebar() },
onShowVerification = {
viewModel.showVerificationSheet(fromSidebar = true)
viewModel.hideSidebar()
},
modifier = Modifier.fillMaxSize()
)
}
@@ -373,7 +379,11 @@ fun ChatScreen(viewModel: ChatViewModel) {
},
selectedUserForSheet = selectedUserForSheet,
selectedMessageForSheet = selectedMessageForSheet,
viewModel = viewModel
viewModel = viewModel,
showVerificationSheet = showVerificationSheet,
onVerificationSheetDismiss = viewModel::hideVerificationSheet,
showSecurityVerificationSheet = showSecurityVerificationSheet,
onSecurityVerificationSheetDismiss = viewModel::hideSecurityVerificationSheet
)
}
@@ -513,7 +523,11 @@ private fun ChatDialogs(
onUserSheetDismiss: () -> Unit,
selectedUserForSheet: String,
selectedMessageForSheet: BitchatMessage?,
viewModel: ChatViewModel
viewModel: ChatViewModel,
showVerificationSheet: Boolean,
onVerificationSheetDismiss: () -> Unit,
showSecurityVerificationSheet: Boolean,
onSecurityVerificationSheetDismiss: () -> Unit
) {
// Password dialog
PasswordPromptDialog(
@@ -567,4 +581,20 @@ private fun ChatDialogs(
viewModel = viewModel
)
}
if (showVerificationSheet) {
VerificationSheet(
isPresented = showVerificationSheet,
onDismiss = onVerificationSheetDismiss,
viewModel = viewModel
)
}
if (showSecurityVerificationSheet) {
SecurityVerificationSheet(
isPresented = showSecurityVerificationSheet,
onDismiss = onSecurityVerificationSheetDismiss,
viewModel = viewModel
)
}
}
@@ -1,10 +1,17 @@
package com.bitchat.android.ui
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import com.bitchat.android.model.BitchatMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/**
* Centralized state definitions and data classes for the chat system
@@ -21,171 +28,168 @@ data class CommandSuggestion(
/**
* Contains all the observable state for the chat system
*/
class ChatState {
class ChatState(
scope: CoroutineScope
) {
// Core messages and peer state
private val _messages = MutableLiveData<List<BitchatMessage>>(emptyList())
val messages: LiveData<List<BitchatMessage>> = _messages
private val _messages = MutableStateFlow<List<BitchatMessage>>(emptyList())
val messages: StateFlow<List<BitchatMessage>> = _messages.asStateFlow()
private val _connectedPeers = MutableLiveData<List<String>>(emptyList())
val connectedPeers: LiveData<List<String>> = _connectedPeers
private val _connectedPeers = MutableStateFlow<List<String>>(emptyList())
val connectedPeers: StateFlow<List<String>> = _connectedPeers.asStateFlow()
private val _nickname = MutableLiveData<String>()
val nickname: LiveData<String> = _nickname
private val _nickname = MutableStateFlow<String>("")
val nickname: StateFlow<String> = _nickname.asStateFlow()
private val _isConnected = MutableLiveData<Boolean>(false)
val isConnected: LiveData<Boolean> = _isConnected
private val _isConnected = MutableStateFlow<Boolean>(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
// Private chats
private val _privateChats = MutableLiveData<Map<String, List<BitchatMessage>>>(emptyMap())
val privateChats: LiveData<Map<String, List<BitchatMessage>>> = _privateChats
private val _privateChats = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
val privateChats: StateFlow<Map<String, List<BitchatMessage>>> = _privateChats.asStateFlow()
private val _selectedPrivateChatPeer = MutableLiveData<String?>(null)
val selectedPrivateChatPeer: LiveData<String?> = _selectedPrivateChatPeer
private val _selectedPrivateChatPeer = MutableStateFlow<String?>(null)
val selectedPrivateChatPeer: StateFlow<String?> = _selectedPrivateChatPeer.asStateFlow()
private val _unreadPrivateMessages = MutableLiveData<Set<String>>(emptySet())
val unreadPrivateMessages: LiveData<Set<String>> = _unreadPrivateMessages
private val _unreadPrivateMessages = MutableStateFlow<Set<String>>(emptySet())
val unreadPrivateMessages: StateFlow<Set<String>> = _unreadPrivateMessages.asStateFlow()
// Channels
private val _joinedChannels = MutableLiveData<Set<String>>(emptySet())
val joinedChannels: LiveData<Set<String>> = _joinedChannels
private val _joinedChannels = MutableStateFlow<Set<String>>(emptySet())
val joinedChannels: StateFlow<Set<String>> = _joinedChannels.asStateFlow()
private val _currentChannel = MutableLiveData<String?>(null)
val currentChannel: LiveData<String?> = _currentChannel
private val _currentChannel = MutableStateFlow<String?>(null)
val currentChannel: StateFlow<String?> = _currentChannel.asStateFlow()
private val _channelMessages = MutableLiveData<Map<String, List<BitchatMessage>>>(emptyMap())
val channelMessages: LiveData<Map<String, List<BitchatMessage>>> = _channelMessages
private val _channelMessages = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = _channelMessages.asStateFlow()
private val _unreadChannelMessages = MutableLiveData<Map<String, Int>>(emptyMap())
val unreadChannelMessages: LiveData<Map<String, Int>> = _unreadChannelMessages
private val _unreadChannelMessages = MutableStateFlow<Map<String, Int>>(emptyMap())
val unreadChannelMessages: StateFlow<Map<String, Int>> = _unreadChannelMessages.asStateFlow()
private val _passwordProtectedChannels = MutableLiveData<Set<String>>(emptySet())
val passwordProtectedChannels: LiveData<Set<String>> = _passwordProtectedChannels
private val _passwordProtectedChannels = MutableStateFlow<Set<String>>(emptySet())
val passwordProtectedChannels: StateFlow<Set<String>> = _passwordProtectedChannels.asStateFlow()
private val _showPasswordPrompt = MutableLiveData<Boolean>(false)
val showPasswordPrompt: LiveData<Boolean> = _showPasswordPrompt
private val _showPasswordPrompt = MutableStateFlow<Boolean>(false)
val showPasswordPrompt: StateFlow<Boolean> = _showPasswordPrompt.asStateFlow()
private val _passwordPromptChannel = MutableLiveData<String?>(null)
val passwordPromptChannel: LiveData<String?> = _passwordPromptChannel
private val _passwordPromptChannel = MutableStateFlow<String?>(null)
val passwordPromptChannel: StateFlow<String?> = _passwordPromptChannel.asStateFlow()
// Sidebar state
private val _showSidebar = MutableLiveData(false)
val showSidebar: LiveData<Boolean> = _showSidebar
private val _showSidebar = MutableStateFlow(false)
val showSidebar: StateFlow<Boolean> = _showSidebar.asStateFlow()
// Command autocomplete
private val _showCommandSuggestions = MutableLiveData(false)
val showCommandSuggestions: LiveData<Boolean> = _showCommandSuggestions
private val _showCommandSuggestions = MutableStateFlow(false)
val showCommandSuggestions: StateFlow<Boolean> = _showCommandSuggestions.asStateFlow()
private val _commandSuggestions = MutableLiveData<List<CommandSuggestion>>(emptyList())
val commandSuggestions: LiveData<List<CommandSuggestion>> = _commandSuggestions
private val _commandSuggestions = MutableStateFlow<List<CommandSuggestion>>(emptyList())
val commandSuggestions: StateFlow<List<CommandSuggestion>> = _commandSuggestions.asStateFlow()
// Mention autocomplete
private val _showMentionSuggestions = MutableLiveData(false)
val showMentionSuggestions: LiveData<Boolean> = _showMentionSuggestions
private val _showMentionSuggestions = MutableStateFlow(false)
val showMentionSuggestions: StateFlow<Boolean> = _showMentionSuggestions.asStateFlow()
private val _mentionSuggestions = MutableLiveData<List<String>>(emptyList())
val mentionSuggestions: LiveData<List<String>> = _mentionSuggestions
private val _mentionSuggestions = MutableStateFlow<List<String>>(emptyList())
val mentionSuggestions: StateFlow<List<String>> = _mentionSuggestions.asStateFlow()
// Favorites
private val _favoritePeers = MutableLiveData<Set<String>>(emptySet())
val favoritePeers: LiveData<Set<String>> = _favoritePeers
private val _favoritePeers = MutableStateFlow<Set<String>>(emptySet())
val favoritePeers: StateFlow<Set<String>> = _favoritePeers.asStateFlow()
// Noise session states for peers (for reactive UI updates)
private val _peerSessionStates = MutableLiveData<Map<String, String>>(emptyMap())
val peerSessionStates: LiveData<Map<String, String>> = _peerSessionStates
private val _peerSessionStates = MutableStateFlow<Map<String, String>>(emptyMap())
val peerSessionStates: StateFlow<Map<String, String>> = _peerSessionStates.asStateFlow()
// Peer fingerprint state for reactive favorites (for reactive UI updates)
private val _peerFingerprints = MutableLiveData<Map<String, String>>(emptyMap())
val peerFingerprints: LiveData<Map<String, String>> = _peerFingerprints
private val _peerFingerprints = MutableStateFlow<Map<String, String>>(emptyMap())
val peerFingerprints: StateFlow<Map<String, String>> = _peerFingerprints.asStateFlow()
private val _peerNicknames = MutableLiveData<Map<String, String>>(emptyMap())
val peerNicknames: LiveData<Map<String, String>> = _peerNicknames
private val _peerNicknames = MutableStateFlow<Map<String, String>>(emptyMap())
val peerNicknames: StateFlow<Map<String, String>> = _peerNicknames.asStateFlow()
private val _peerRSSI = MutableLiveData<Map<String, Int>>(emptyMap())
val peerRSSI: LiveData<Map<String, Int>> = _peerRSSI
private val _peerRSSI = MutableStateFlow<Map<String, Int>>(emptyMap())
val peerRSSI: StateFlow<Map<String, Int>> = _peerRSSI.asStateFlow()
// Direct connection status per peer (for live UI updates)
private val _peerDirect = MutableLiveData<Map<String, Boolean>>(emptyMap())
val peerDirect: LiveData<Map<String, Boolean>> = _peerDirect
private val _peerDirect = MutableStateFlow<Map<String, Boolean>>(emptyMap())
val peerDirect: StateFlow<Map<String, Boolean>> = _peerDirect.asStateFlow()
// peerIDToPublicKeyFingerprint REMOVED - fingerprints now handled centrally in PeerManager
// Navigation state
private val _showAppInfo = MutableLiveData<Boolean>(false)
val showAppInfo: LiveData<Boolean> = _showAppInfo
private val _showAppInfo = MutableStateFlow<Boolean>(false)
val showAppInfo: StateFlow<Boolean> = _showAppInfo.asStateFlow()
private val _showVerificationSheet = MutableStateFlow(false)
val showVerificationSheet: StateFlow<Boolean> = _showVerificationSheet.asStateFlow()
private val _showSecurityVerificationSheet = MutableStateFlow(false)
val showSecurityVerificationSheet: StateFlow<Boolean> = _showSecurityVerificationSheet.asStateFlow()
// Location channels state (for Nostr geohash features)
private val _selectedLocationChannel = MutableLiveData<com.bitchat.android.geohash.ChannelID?>(com.bitchat.android.geohash.ChannelID.Mesh)
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = _selectedLocationChannel
private val _selectedLocationChannel = MutableStateFlow<com.bitchat.android.geohash.ChannelID?>(com.bitchat.android.geohash.ChannelID.Mesh)
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = _selectedLocationChannel.asStateFlow()
private val _isTeleported = MutableLiveData<Boolean>(false)
val isTeleported: LiveData<Boolean> = _isTeleported
private val _isTeleported = MutableStateFlow<Boolean>(false)
val isTeleported: StateFlow<Boolean> = _isTeleported.asStateFlow()
// Geohash people state (iOS-compatible)
private val _geohashPeople = MutableLiveData<List<GeoPerson>>(emptyList())
val geohashPeople: LiveData<List<GeoPerson>> = _geohashPeople
// For background thread updates by repositories/handlers in their own scopes
val geohashPeopleMutable: MutableLiveData<List<GeoPerson>> get() = _geohashPeople
private val _geohashPeople = MutableStateFlow<List<GeoPerson>>(emptyList())
val geohashPeople: StateFlow<List<GeoPerson>> = _geohashPeople.asStateFlow()
private val _teleportedGeo = MutableLiveData<Set<String>>(emptySet())
val teleportedGeo: LiveData<Set<String>> = _teleportedGeo
private val _teleportedGeo = MutableStateFlow<Set<String>>(emptySet())
val teleportedGeo: StateFlow<Set<String>> = _teleportedGeo.asStateFlow()
// Geohash participant counts reactive state (for real-time location channel counts)
private val _geohashParticipantCounts = MutableLiveData<Map<String, Int>>(emptyMap())
val geohashParticipantCounts: LiveData<Map<String, Int>> = _geohashParticipantCounts
private val _geohashParticipantCounts = MutableStateFlow<Map<String, Int>>(emptyMap())
val geohashParticipantCounts: StateFlow<Map<String, Int>> = _geohashParticipantCounts.asStateFlow()
// Unread state computed properties
val hasUnreadChannels: MediatorLiveData<Boolean> = MediatorLiveData<Boolean>()
val hasUnreadPrivateMessages: MediatorLiveData<Boolean> = MediatorLiveData<Boolean>()
val hasUnreadChannels: StateFlow<Boolean> = _unreadChannelMessages
.map { unreadMap -> unreadMap.values.any { it > 0 } }
.stateIn(
scope = scope,
started = WhileSubscribed(5_000),
initialValue = false
)
init {
// Initialize unread state mediators
hasUnreadChannels.addSource(_unreadChannelMessages) { unreadMap ->
hasUnreadChannels.value = unreadMap.values.any { it > 0 }
}
hasUnreadPrivateMessages.addSource(_unreadPrivateMessages) { unreadSet ->
hasUnreadPrivateMessages.value = unreadSet.isNotEmpty()
}
}
val hasUnreadPrivateMessages: StateFlow<Boolean> = _unreadPrivateMessages
.map { unreadSet -> unreadSet.isNotEmpty() }
.stateIn(
scope = scope,
started = WhileSubscribed(5_000),
initialValue = false
)
// Getters for internal state access
fun getMessagesValue() = _messages.value ?: emptyList()
fun getConnectedPeersValue() = _connectedPeers.value ?: emptyList()
fun getMessagesValue() = _messages.value
fun getConnectedPeersValue() = _connectedPeers.value
fun getNicknameValue() = _nickname.value
fun getPrivateChatsValue() = _privateChats.value ?: emptyMap()
fun getPrivateChatsValue() = _privateChats.value
fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value
fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value ?: emptySet()
fun getJoinedChannelsValue() = _joinedChannels.value ?: emptySet()
// Thread-safe posting helpers for background updates
fun postGeohashPeople(people: List<GeoPerson>) {
_geohashPeople.postValue(people)
}
fun postGeohashParticipantCounts(counts: Map<String, Int>) {
_geohashParticipantCounts.postValue(counts)
}
fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value
fun getJoinedChannelsValue() = _joinedChannels.value
fun getCurrentChannelValue() = _currentChannel.value
fun getChannelMessagesValue() = _channelMessages.value ?: emptyMap()
fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value ?: emptyMap()
fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value ?: emptySet()
fun getShowPasswordPromptValue() = _showPasswordPrompt.value ?: false
fun getChannelMessagesValue() = _channelMessages.value
fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value
fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value
fun getShowPasswordPromptValue() = _showPasswordPrompt.value
fun getPasswordPromptChannelValue() = _passwordPromptChannel.value
fun getShowSidebarValue() = _showSidebar.value ?: false
fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value ?: false
fun getCommandSuggestionsValue() = _commandSuggestions.value ?: emptyList()
fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value ?: false
fun getMentionSuggestionsValue() = _mentionSuggestions.value ?: emptyList()
fun getFavoritePeersValue() = _favoritePeers.value ?: emptySet()
fun getPeerSessionStatesValue() = _peerSessionStates.value ?: emptyMap()
fun getPeerFingerprintsValue() = _peerFingerprints.value ?: emptyMap()
fun getShowAppInfoValue() = _showAppInfo.value ?: false
fun getGeohashPeopleValue() = _geohashPeople.value ?: emptyList()
fun getTeleportedGeoValue() = _teleportedGeo.value ?: emptySet()
fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value ?: emptyMap()
fun getShowSidebarValue() = _showSidebar.value
fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value
fun getCommandSuggestionsValue() = _commandSuggestions.value
fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value
fun getMentionSuggestionsValue() = _mentionSuggestions.value
fun getFavoritePeersValue() = _favoritePeers.value
fun getPeerSessionStatesValue() = _peerSessionStates.value
fun getPeerFingerprintsValue() = _peerFingerprints.value
fun getShowAppInfoValue() = _showAppInfo.value
fun getGeohashPeopleValue() = _geohashPeople.value
fun getTeleportedGeoValue() = _teleportedGeo.value
fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value
// Setters for state updates
fun setMessages(messages: List<BitchatMessage>) {
@@ -197,7 +201,7 @@ class ChatState {
}
fun postTeleportedGeo(teleported: Set<String>) {
_teleportedGeo.postValue(teleported)
_teleportedGeo.value = teleported
}
fun setNickname(nickname: String) {
@@ -269,7 +273,7 @@ class ChatState {
}
fun setFavoritePeers(favorites: Set<String>) {
val currentValue = _favoritePeers.value ?: emptySet()
val currentValue = _favoritePeers.value
Log.d("ChatState", "setFavoritePeers called with ${favorites.size} favorites: $favorites")
Log.d("ChatState", "Current value: $currentValue")
Log.d("ChatState", "Values equal: ${currentValue == favorites}")
@@ -278,8 +282,7 @@ class ChatState {
// Always set the value - even if equal, this ensures observers are triggered
_favoritePeers.value = favorites
Log.d("ChatState", "LiveData value after set: ${_favoritePeers.value}")
Log.d("ChatState", "LiveData has active observers: ${_favoritePeers.hasActiveObservers()}")
Log.d("ChatState", "StateFlow value after set: ${_favoritePeers.value}")
}
fun setPeerSessionStates(states: Map<String, String>) {
@@ -305,6 +308,14 @@ class ChatState {
fun setShowAppInfo(show: Boolean) {
_showAppInfo.value = show
}
fun setShowVerificationSheet(show: Boolean) {
_showVerificationSheet.value = show
}
fun setShowSecurityVerificationSheet(show: Boolean) {
_showSecurityVerificationSheet.value = show
}
fun setSelectedLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) {
_selectedLocationChannel.value = channel
@@ -4,12 +4,16 @@ import android.app.Application
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope
import com.bitchat.android.favorites.FavoritesPersistenceService
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import com.bitchat.android.mesh.BluetoothMeshDelegate
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.nostr.NostrIdentityBridge
import com.bitchat.android.protocol.BitchatPacket
@@ -19,6 +23,13 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.Date
import kotlin.random.Random
import com.bitchat.android.services.VerificationService
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.noise.NoiseSession
import com.bitchat.android.nostr.GeohashAliasRegistry
import com.bitchat.android.util.dataFromHexString
import com.bitchat.android.util.hexEncodedString
import java.security.MessageDigest
/**
* Refactored ChatViewModel - Main coordinator for bitchat functionality
@@ -46,8 +57,24 @@ class ChatViewModel(
mediaSendingManager.sendImageNote(toPeerIDOrNull, channelOrNull, filePath)
}
fun getCurrentNpub(): String? {
return try {
NostrIdentityBridge
.getCurrentNostrIdentity(getApplication())
?.npub
} catch (_: Exception) {
null
}
}
fun buildMyQRString(nickname: String, npub: String?): String {
return VerificationService.buildMyQRString(nickname, npub) ?: ""
}
// MARK: - State management
private val state = ChatState()
private val state = ChatState(
scope = viewModelScope,
)
// Transfer progress tracking
private val transferMessageMap = mutableMapOf<String, String>()
@@ -55,6 +82,7 @@ class ChatViewModel(
// Specialized managers
private val dataManager = DataManager(application.applicationContext)
private val identityManager by lazy { SecureIdentityStateManager(getApplication()) }
private val messageManager = MessageManager(state)
private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope)
@@ -73,6 +101,17 @@ class ChatViewModel(
NotificationIntervalManager()
)
private val verificationHandler = VerificationHandler(
context = application.applicationContext,
scope = viewModelScope,
meshService = meshService,
identityManager = identityManager,
state = state,
notificationManager = notificationManager,
messageManager = messageManager
)
val verifiedFingerprints = verificationHandler.verifiedFingerprints
// Media file sending manager
private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager, meshService)
@@ -103,44 +142,81 @@ class ChatViewModel(
// Expose state through LiveData (maintaining the same interface)
val messages: LiveData<List<BitchatMessage>> = state.messages
val connectedPeers: LiveData<List<String>> = state.connectedPeers
val nickname: LiveData<String> = state.nickname
val isConnected: LiveData<Boolean> = state.isConnected
val privateChats: LiveData<Map<String, List<BitchatMessage>>> = state.privateChats
val selectedPrivateChatPeer: LiveData<String?> = state.selectedPrivateChatPeer
val unreadPrivateMessages: LiveData<Set<String>> = state.unreadPrivateMessages
val joinedChannels: LiveData<Set<String>> = state.joinedChannels
val currentChannel: LiveData<String?> = state.currentChannel
val channelMessages: LiveData<Map<String, List<BitchatMessage>>> = state.channelMessages
val unreadChannelMessages: LiveData<Map<String, Int>> = state.unreadChannelMessages
val passwordProtectedChannels: LiveData<Set<String>> = state.passwordProtectedChannels
val showPasswordPrompt: LiveData<Boolean> = state.showPasswordPrompt
val passwordPromptChannel: LiveData<String?> = state.passwordPromptChannel
val showSidebar: LiveData<Boolean> = state.showSidebar
val messages: StateFlow<List<BitchatMessage>> = state.messages
val connectedPeers: StateFlow<List<String>> = state.connectedPeers
val nickname: StateFlow<String> = state.nickname
val isConnected: StateFlow<Boolean> = state.isConnected
val privateChats: StateFlow<Map<String, List<BitchatMessage>>> = state.privateChats
val selectedPrivateChatPeer: StateFlow<String?> = state.selectedPrivateChatPeer
val unreadPrivateMessages: StateFlow<Set<String>> = state.unreadPrivateMessages
val joinedChannels: StateFlow<Set<String>> = state.joinedChannels
val currentChannel: StateFlow<String?> = state.currentChannel
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = state.channelMessages
val unreadChannelMessages: StateFlow<Map<String, Int>> = state.unreadChannelMessages
val passwordProtectedChannels: StateFlow<Set<String>> = state.passwordProtectedChannels
val showPasswordPrompt: StateFlow<Boolean> = state.showPasswordPrompt
val passwordPromptChannel: StateFlow<String?> = state.passwordPromptChannel
val showSidebar: StateFlow<Boolean> = state.showSidebar
val hasUnreadChannels = state.hasUnreadChannels
val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages
val showCommandSuggestions: LiveData<Boolean> = state.showCommandSuggestions
val commandSuggestions: LiveData<List<CommandSuggestion>> = state.commandSuggestions
val showMentionSuggestions: LiveData<Boolean> = state.showMentionSuggestions
val mentionSuggestions: LiveData<List<String>> = state.mentionSuggestions
val favoritePeers: LiveData<Set<String>> = state.favoritePeers
val peerSessionStates: LiveData<Map<String, String>> = state.peerSessionStates
val peerFingerprints: LiveData<Map<String, String>> = state.peerFingerprints
val peerNicknames: LiveData<Map<String, String>> = state.peerNicknames
val peerRSSI: LiveData<Map<String, Int>> = state.peerRSSI
val peerDirect: LiveData<Map<String, Boolean>> = state.peerDirect
val showAppInfo: LiveData<Boolean> = state.showAppInfo
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
val isTeleported: LiveData<Boolean> = state.isTeleported
val geohashPeople: LiveData<List<GeoPerson>> = state.geohashPeople
val teleportedGeo: LiveData<Set<String>> = state.teleportedGeo
val geohashParticipantCounts: LiveData<Map<String, Int>> = state.geohashParticipantCounts
val showCommandSuggestions: StateFlow<Boolean> = state.showCommandSuggestions
val commandSuggestions: StateFlow<List<CommandSuggestion>> = state.commandSuggestions
val showMentionSuggestions: StateFlow<Boolean> = state.showMentionSuggestions
val mentionSuggestions: StateFlow<List<String>> = state.mentionSuggestions
val favoritePeers: StateFlow<Set<String>> = state.favoritePeers
val peerSessionStates: StateFlow<Map<String, String>> = state.peerSessionStates
val peerFingerprints: StateFlow<Map<String, String>> = state.peerFingerprints
val peerNicknames: StateFlow<Map<String, String>> = state.peerNicknames
val peerRSSI: StateFlow<Map<String, Int>> = state.peerRSSI
val peerDirect: StateFlow<Map<String, Boolean>> = state.peerDirect
val showAppInfo: StateFlow<Boolean> = state.showAppInfo
val showVerificationSheet: StateFlow<Boolean> = state.showVerificationSheet
val showSecurityVerificationSheet: StateFlow<Boolean> = state.showSecurityVerificationSheet
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
val isTeleported: StateFlow<Boolean> = state.isTeleported
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
val teleportedGeo: StateFlow<Set<String>> = state.teleportedGeo
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
init {
// Note: Mesh service delegate is now set by MainActivity
loadAndInitialize()
// Hydrate UI state from process-wide AppStateStore to survive Activity recreation
viewModelScope.launch {
try { com.bitchat.android.services.AppStateStore.peers.collect { peers ->
state.setConnectedPeers(peers)
state.setIsConnected(peers.isNotEmpty())
} } catch (_: Exception) { }
}
viewModelScope.launch {
try { com.bitchat.android.services.AppStateStore.publicMessages.collect { msgs ->
// Source of truth is AppStateStore; replace to avoid duplicate keys in LazyColumn
state.setMessages(msgs)
} } catch (_: Exception) { }
}
viewModelScope.launch {
try { com.bitchat.android.services.AppStateStore.privateMessages.collect { byPeer ->
// Replace with store snapshot
state.setPrivateChats(byPeer)
// Recompute unread set using SeenMessageStore for robustness across Activity recreation
try {
val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication())
val myNick = state.getNicknameValue() ?: meshService.myPeerID
val unread = mutableSetOf<String>()
byPeer.forEach { (peer, list) ->
if (list.any { msg -> msg.sender != myNick && !seen.hasRead(msg.id) }) unread.add(peer)
}
state.setUnreadPrivateMessages(unread)
} catch (_: Exception) { }
} } catch (_: Exception) { }
}
viewModelScope.launch {
try { com.bitchat.android.services.AppStateStore.channelMessages.collect { byChannel ->
// Replace with store snapshot
state.setChannelMessages(byChannel)
} } catch (_: Exception) { }
}
// Subscribe to BLE transfer progress and reflect in message deliveryStatus
viewModelScope.launch {
com.bitchat.android.mesh.TransferProgressManager.events.collect { evt ->
@@ -210,6 +286,9 @@ class ChatViewModel(
// Initialize favorites persistence service
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication())
// Load verified fingerprints from secure storage
verificationHandler.loadVerifiedFingerprints()
// Ensure NostrTransport knows our mesh peer ID for embedded packets
try {
@@ -565,7 +644,7 @@ class ChatViewModel(
private fun logCurrentFavoriteState() {
Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===")
Log.i("ChatViewModel", "LiveData favorite peers: ${favoritePeers.value}")
Log.i("ChatViewModel", "StateFlow favorite peers: ${favoritePeers.value}")
Log.i("ChatViewModel", "DataManager favorite peers: ${dataManager.favoritePeers}")
Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}")
Log.i("ChatViewModel", "==============================")
@@ -609,6 +688,18 @@ class ChatViewModel(
// Update fingerprint mappings from centralized manager
val fingerprints = privateChatManager.getAllPeerFingerprints()
state.setPeerFingerprints(fingerprints)
fingerprints.forEach { (peerID, fingerprint) ->
identityManager.cachePeerFingerprint(peerID, fingerprint)
val info = try { meshService.getPeerInfo(peerID) } catch (_: Exception) { null }
val noiseKeyHex = info?.noisePublicKey?.hexEncodedString()
if (noiseKeyHex != null) {
identityManager.cachePeerNoiseKey(peerID, noiseKeyHex)
identityManager.cacheNoiseFingerprint(noiseKeyHex, fingerprint)
}
info?.nickname?.takeIf { it.isNotBlank() }?.let { nickname ->
identityManager.cacheFingerprintNickname(fingerprint, nickname)
}
}
val nicknames = meshService.getPeerNicknames()
state.setPeerNicknames(nicknames)
@@ -623,6 +714,34 @@ class ChatViewModel(
}
state.setPeerDirect(directMap)
} catch (_: Exception) { }
// Flush any pending QR verification once a Noise session is established
currentPeers.forEach { peerID ->
if (meshService.getSessionState(peerID) is NoiseSession.NoiseSessionState.Established) {
verificationHandler.sendPendingVerificationIfNeeded(peerID)
}
}
}
// MARK: - QR Verification
fun isPeerVerified(peerID: String, verifiedFingerprints: Set<String>): Boolean {
if (peerID.startsWith("nostr_") || peerID.startsWith("nostr:")) return false
val fingerprint = verificationHandler.getPeerFingerprintForDisplay(peerID)
return fingerprint != null && verifiedFingerprints.contains(fingerprint)
}
fun isNoisePublicKeyVerified(noisePublicKey: ByteArray, verifiedFingerprints: Set<String>): Boolean {
val fingerprint = verificationHandler.fingerprintFromNoiseBytes(noisePublicKey)
return verifiedFingerprints.contains(fingerprint)
}
fun unverifyFingerprint(peerID: String) {
verificationHandler.unverifyFingerprint(peerID)
}
fun beginQRVerification(qr: VerificationService.VerificationQR): Boolean {
return verificationHandler.beginQRVerification(qr)
}
// MARK: - Debug and Troubleshooting
@@ -631,41 +750,71 @@ class ChatViewModel(
return meshService.getDebugStatus()
}
// Note: Mesh service restart is now handled by MainActivity
// This function is no longer needed
fun setAppBackgroundState(inBackground: Boolean) {
// Forward to notification manager for notification logic
notificationManager.setAppBackgroundState(inBackground)
}
fun setCurrentPrivateChatPeer(peerID: String?) {
// Update notification manager with current private chat peer
notificationManager.setCurrentPrivateChatPeer(peerID)
}
fun setCurrentGeohash(geohash: String?) {
// Update notification manager with current geohash for notification logic
notificationManager.setCurrentGeohash(geohash)
}
fun clearNotificationsForSender(peerID: String) {
// Clear notifications when user opens a chat
notificationManager.clearNotificationsForSender(peerID)
}
fun clearNotificationsForGeohash(geohash: String) {
// Clear notifications when user opens a geohash chat
notificationManager.clearNotificationsForGeohash(geohash)
}
/**
* Clear mesh mention notifications when user opens mesh chat
*/
fun clearMeshMentionNotifications() {
notificationManager.clearMeshMentionNotifications()
}
private var reopenSidebarAfterVerification = false
fun showVerificationSheet(fromSidebar: Boolean = false) {
if (fromSidebar) {
reopenSidebarAfterVerification = true
}
state.setShowVerificationSheet(true)
}
fun hideVerificationSheet() {
state.setShowVerificationSheet(false)
if (reopenSidebarAfterVerification) {
reopenSidebarAfterVerification = false
state.setShowSidebar(true)
}
}
fun showSecurityVerificationSheet() {
state.setShowSecurityVerificationSheet(true)
}
fun hideSecurityVerificationSheet() {
state.setShowSecurityVerificationSheet(false)
}
fun getPeerFingerprintForDisplay(peerID: String): String? {
return verificationHandler.getPeerFingerprintForDisplay(peerID)
}
fun getMyFingerprint(): String {
return verificationHandler.getMyFingerprint()
}
fun resolvePeerDisplayNameForFingerprint(peerID: String): String {
return verificationHandler.resolvePeerDisplayNameForFingerprint(peerID)
}
fun verifyFingerprintValue(fingerprint: String) {
verificationHandler.verifyFingerprintValue(fingerprint)
}
fun unverifyFingerprintValue(fingerprint: String) {
verificationHandler.unverifyFingerprintValue(fingerprint)
}
// MARK: - Command Autocomplete (delegated)
fun updateCommandSuggestions(input: String) {
@@ -707,6 +856,14 @@ class ChatViewModel(
override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) {
meshDelegateHandler.didReceiveReadReceipt(messageID, recipientPeerID)
}
override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {
verificationHandler.didReceiveVerifyChallenge(peerID, payload)
}
override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {
verificationHandler.didReceiveVerifyResponse(peerID, payload)
}
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
return meshDelegateHandler.decryptChannelMessage(encryptedContent, channel)
@@ -790,7 +947,7 @@ class ChatViewModel(
// Clear secure identity state (if used)
try {
val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication())
val identityManager = SecureIdentityStateManager(getApplication())
identityManager.clearIdentityData()
// Also clear secure values used by FavoritesPersistenceService (favorites + peerID index)
try {
@@ -803,7 +960,7 @@ class ChatViewModel(
// Clear FavoritesPersistenceService persistent relationships
try {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.clearAllFavorites()
FavoritesPersistenceService.shared.clearAllFavorites()
Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships")
} catch (_: Exception) { }
@@ -932,5 +1089,6 @@ class ChatViewModel(
*/
fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark)
}
}
}
@@ -9,7 +9,6 @@ import androidx.compose.material.icons.outlined.Explore
import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -21,6 +20,7 @@ import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import java.util.*
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
/**
@@ -46,11 +46,11 @@ fun GeohashPeopleList(
val colorScheme = MaterialTheme.colorScheme
// Observe geohash people from ChatViewModel
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList())
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
val isTeleported by viewModel.isTeleported.observeAsState(false)
val nickname by viewModel.nickname.observeAsState("")
val unreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val isTeleported by viewModel.isTeleported.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
Column {
// Header matching iOS style
@@ -3,7 +3,6 @@ package com.bitchat.android.ui
import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope
import com.bitchat.android.nostr.GeohashMessageHandler
import com.bitchat.android.nostr.GeohashRepository
@@ -15,6 +14,7 @@ import com.bitchat.android.nostr.NostrSubscriptionManager
import com.bitchat.android.nostr.PoWPreferenceManager
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import java.util.Date
@@ -55,9 +55,9 @@ class GeohashViewModel(
private var geoTimer: Job? = null
private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null
val geohashPeople: LiveData<List<GeoPerson>> = state.geohashPeople
val geohashParticipantCounts: LiveData<Map<String, Int>> = state.geohashParticipantCounts
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
fun initialize() {
subscriptionManager.connect()
@@ -73,12 +73,16 @@ class GeohashViewModel(
}
try {
locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication())
locationChannelManager?.selectedChannel?.observeForever { channel ->
state.setSelectedLocationChannel(channel)
switchLocationChannel(channel)
viewModelScope.launch {
locationChannelManager?.selectedChannel?.collect { channel ->
state.setSelectedLocationChannel(channel)
switchLocationChannel(channel)
}
}
locationChannelManager?.teleported?.observeForever { teleported ->
state.setIsTeleported(teleported)
viewModelScope.launch {
locationChannelManager?.teleported?.collect { teleported ->
state.setIsTeleported(teleported)
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize location channel state: ${e.message}")
@@ -120,7 +124,7 @@ class GeohashViewModel(
}
try {
val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication())
val teleported = state.isTeleported.value ?: false
val teleported = state.isTeleported.value
val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported)
val relayManager = NostrRelayManager.getInstance(getApplication())
relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5)
@@ -231,7 +235,7 @@ class GeohashViewModel(
try {
val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication())
repo.updateParticipant(channel.channel.geohash, identity.publicKeyHex, Date())
val teleported = state.isTeleported.value ?: false
val teleported = state.isTeleported.value
if (teleported) repo.markTeleported(identity.publicKeyHex)
} catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") }
@@ -18,7 +18,6 @@ import androidx.compose.material.icons.filled.PinDrop
import androidx.compose.material.icons.outlined.BookmarkBorder
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
@@ -38,7 +37,9 @@ import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.geohash.GeohashBookmarksStore
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.core.ui.component.button.CloseButton
/**
* Location Channels Sheet for selecting geohash-based location channels
@@ -57,18 +58,18 @@ fun LocationChannelsSheet(
val bookmarksStore = remember { GeohashBookmarksStore.getInstance(context) }
// Observe location manager state
val permissionState by locationManager.permissionState.observeAsState()
val availableChannels by locationManager.availableChannels.observeAsState(emptyList())
val selectedChannel by locationManager.selectedChannel.observeAsState()
val locationNames by locationManager.locationNames.observeAsState(emptyMap())
val locationServicesEnabled by locationManager.locationServicesEnabled.observeAsState(false)
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
val selectedChannel by locationManager.selectedChannel.collectAsStateWithLifecycle()
val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle()
// Observe bookmarks state
val bookmarks by bookmarksStore.bookmarks.observeAsState(emptyList())
val bookmarkNames by bookmarksStore.bookmarkNames.observeAsState(emptyMap())
val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle()
val bookmarkNames by bookmarksStore.bookmarkNames.collectAsStateWithLifecycle()
// Observe reactive participant counts
val geohashParticipantCounts by viewModel.geohashParticipantCounts.observeAsState(emptyMap())
val geohashParticipantCounts by viewModel.geohashParticipantCounts.collectAsStateWithLifecycle()
// UI state
var customGeohash by remember { mutableStateOf("") }
@@ -551,18 +552,12 @@ fun LocationChannelsSheet(
.height(56.dp)
.background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha))
) {
TextButton(
CloseButton(
onClick = onDismiss,
modifier = Modifier
modifier = modifier
.align(Alignment.CenterEnd)
.padding(horizontal = 16.dp)
) {
Text(
text = stringResource(R.string.close_plain),
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onBackground
)
}
.padding(horizontal = 16.dp),
)
}
}
}
@@ -7,8 +7,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -35,10 +35,10 @@ fun LocationNotesButton(
val context = LocalContext.current
// Get channel and permission state
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val locationManager = remember { LocationChannelManager.getInstance(context) }
val permissionState by locationManager.permissionState.observeAsState()
val locationServicesEnabled by locationManager.locationServicesEnabled.observeAsState(false)
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle(false)
// Check both permission AND location services enabled
val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED
@@ -46,7 +46,7 @@ fun LocationNotesButton(
// Get notes count from LocationNotesManager
val notesManager = remember { LocationNotesManager.getInstance() }
val notes by notesManager.notes.observeAsState(emptyList())
val notes by notesManager.notes.collectAsStateWithLifecycle()
val notesCount = notes.size
// Only show in mesh mode when location is authorized (iOS pattern)
@@ -12,7 +12,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -25,6 +24,7 @@ import com.bitchat.android.R
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.nostr.LocationNotesManager
@@ -57,16 +57,16 @@ fun LocationNotesSheet(
val locationManager = remember { LocationChannelManager.getInstance(context) }
// State
val notes by notesManager.notes.observeAsState(emptyList())
val state by notesManager.state.observeAsState(LocationNotesManager.State.IDLE)
val errorMessage by notesManager.errorMessage.observeAsState()
val initialLoadComplete by notesManager.initialLoadComplete.observeAsState(false)
val notes by notesManager.notes.collectAsStateWithLifecycle()
val state by notesManager.state.collectAsStateWithLifecycle(LocationNotesManager.State.IDLE)
val errorMessage by notesManager.errorMessage.collectAsStateWithLifecycle()
val initialLoadComplete by notesManager.initialLoadComplete.collectAsStateWithLifecycle(false)
// SIMPLIFIED: Get count directly from notes list (no separate counter needed)
val count = notes.size
// Get location name (building or block) - matches iOS locationNames lookup
val locationNames by locationManager.locationNames.observeAsState(emptyMap())
val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
val displayLocationName = locationNames[GeohashChannelLevel.BUILDING]?.takeIf { it.isNotEmpty() }
?: locationNames[GeohashChannelLevel.BLOCK]?.takeIf { it.isNotEmpty() }
@@ -4,12 +4,12 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
@@ -26,15 +26,15 @@ fun LocationNotesSheetPresenter(
) {
val context = LocalContext.current
val locationManager = remember { LocationChannelManager.getInstance(context) }
val availableChannels by locationManager.availableChannels.observeAsState(emptyList())
val nickname by viewModel.nickname.observeAsState("")
val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
// iOS pattern: notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash
val buildingGeohash = availableChannels.firstOrNull { it.level == GeohashChannelLevel.BUILDING }?.geohash
if (buildingGeohash != null) {
// Get location name from locationManager
val locationNames by locationManager.locationNames.observeAsState(emptyMap())
val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
val locationName = locationNames[GeohashChannelLevel.BUILDING]
?: locationNames[GeohashChannelLevel.BLOCK]
@@ -5,6 +5,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -26,7 +27,7 @@ private enum class CharacterAnimationState {
*/
@Composable
fun shouldAnimateMessage(messageId: String): Boolean {
val miningMessages by PoWMiningTracker.miningMessages.collectAsState()
val miningMessages by PoWMiningTracker.miningMessages.collectAsStateWithLifecycle()
return miningMessages.contains(messageId)
}
@@ -46,10 +46,10 @@ class MeshDelegateHandler(
if (message.isPrivate) {
// Private message
privateChatManager.handleIncomingPrivateMessage(message)
// Reactive read receipts: Send immediately if user is currently viewing this chat
// Reactive read receipts: if chat is focused, send immediately for this message
message.senderPeerID?.let { senderPeerID ->
sendReadReceiptIfFocused(senderPeerID)
sendReadReceiptIfFocused(message)
}
// Show notification with enhanced information - now includes senderPeerID
@@ -64,15 +64,26 @@ class MeshDelegateHandler(
)
}
} else if (message.channel != null) {
// Channel message
// Channel message: AppStateStore is the source of truth for list; only manage unread
if (state.getJoinedChannelsValue().contains(message.channel)) {
channelManager.addChannelMessage(message.channel, message, message.senderPeerID)
val channel = message.channel
val viewingClassic = state.getCurrentChannelValue() == channel
val viewingGeohash = try {
if (channel.startsWith("geo:")) {
val geo = channel.removePrefix("geo:")
val selected = state.selectedLocationChannel.value
selected is com.bitchat.android.geohash.ChannelID.Location && selected.channel.geohash.equals(geo, ignoreCase = true)
} else false
} catch (_: Exception) { false }
if (!viewingClassic && !viewingGeohash) {
val currentUnread = state.getUnreadChannelMessagesValue().toMutableMap()
currentUnread[channel] = (currentUnread[channel] ?: 0) + 1
state.setUnreadChannelMessages(currentUnread)
}
}
} else {
// Public mesh message - always store to preserve message history
messageManager.addMessage(message)
// Check for mentions in mesh chat
// Public mesh message: AppStateStore is the source of truth; avoid double-adding to UI state
// Still run mention detection/notifications
checkAndTriggerMeshMentionNotification(message)
}
@@ -205,6 +216,14 @@ class MeshDelegateHandler(
messageManager.updateMessageDeliveryStatus(messageID, DeliveryStatus.Read(recipientPeerID, Date()))
}
}
override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {
// Handled by ChatViewModel for verification flow
}
override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {
// Handled by ChatViewModel for verification flow
}
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
return channelManager.decryptChannelMessage(encryptedContent, channel)
@@ -263,21 +282,31 @@ class MeshDelegateHandler(
* Uses same logic as notification system - send read receipt if user is currently
* viewing the private chat with this sender AND app is in foreground.
*/
private fun sendReadReceiptIfFocused(senderPeerID: String) {
private fun sendReadReceiptIfFocused(message: BitchatMessage) {
// Get notification manager's focus state (mirror the notification logic)
val isAppInBackground = notificationManager.getAppBackgroundState()
val currentPrivateChatPeer = notificationManager.getCurrentPrivateChatPeer()
// Send read receipt if user is currently focused on this specific chat
val shouldSendReadReceipt = !isAppInBackground && currentPrivateChatPeer == senderPeerID
val senderPeerID = message.senderPeerID
val shouldSendReadReceipt = !isAppInBackground && senderPeerID != null && currentPrivateChatPeer == senderPeerID
if (shouldSendReadReceipt) {
android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID")
privateChatManager.sendReadReceiptsForPeer(senderPeerID, getMeshService())
} else {
android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)")
if (shouldSendReadReceipt) {
android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID (message=${message.id})")
val nickname = state.getNicknameValue() ?: "unknown"
// Send directly for this message to avoid relying on unread queues
getMeshService().sendReadReceipt(message.id, senderPeerID!!, nickname)
// Ensure unread badge is cleared for this peer immediately
try {
val current = state.getUnreadPrivateMessagesValue().toMutableSet()
if (current.remove(senderPeerID)) {
state.setUnreadPrivateMessages(current)
}
} catch (_: Exception) { }
} else {
android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)")
}
}
}
// registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager
@@ -22,6 +22,8 @@ class MessageManager(private val state: ChatState) {
val currentMessages = state.getMessagesValue().toMutableList()
currentMessages.add(message)
state.setMessages(currentMessages)
// Reflect into process-wide store so snapshot replacements don't drop local outgoing messages
try { com.bitchat.android.services.AppStateStore.addPublicMessage(message) } catch (_: Exception) { }
}
// Log a system message into the main chat (visible to user)
@@ -52,6 +54,8 @@ class MessageManager(private val state: ChatState) {
channelMessageList.add(message)
currentChannelMessages[channel] = channelMessageList
state.setChannelMessages(currentChannelMessages)
// Reflect into process-wide store
try { com.bitchat.android.services.AppStateStore.addChannelMessage(channel, message) } catch (_: Exception) { }
// Update unread count if not currently viewing this channel
// Consider both classic channels (state.currentChannel) and geohash location channel selection
@@ -105,6 +109,8 @@ class MessageManager(private val state: ChatState) {
chatMessages.add(message)
currentPrivateChats[peerID] = chatMessages
state.setPrivateChats(currentPrivateChats)
// Reflect into process-wide store
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(peerID, message) } catch (_: Exception) { }
// Mark as unread if not currently viewing this chat
if (state.getSelectedPrivateChatPeerValue() != peerID && message.sender != state.getNicknameValue()) {
@@ -124,6 +130,8 @@ class MessageManager(private val state: ChatState) {
chatMessages.add(message)
currentPrivateChats[peerID] = chatMessages
state.setPrivateChats(currentPrivateChats)
// Reflect into process-wide store
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(peerID, message) } catch (_: Exception) { }
}
fun clearPrivateMessages(peerID: String) {
@@ -206,6 +214,21 @@ class MessageManager(private val state: ChatState) {
// MARK: - Delivery Status Updates
private fun statusPriority(status: DeliveryStatus?): Int = when (status) {
null -> 0
is DeliveryStatus.Sending -> 1
is DeliveryStatus.Sent -> 2
is DeliveryStatus.PartiallyDelivered -> 3
is DeliveryStatus.Delivered -> 4
is DeliveryStatus.Read -> 5
is DeliveryStatus.Failed -> 0 // treat as lowest for UI check marks ordering
}
private fun chooseStatus(old: DeliveryStatus?, new: DeliveryStatus): DeliveryStatus? {
// Never downgrade (e.g., Read -> Delivered). Keep the higher priority.
return if (statusPriority(new) >= statusPriority(old)) new else old
}
fun updateMessageDeliveryStatus(messageID: String, status: DeliveryStatus) {
// Update in private chats
val updatedPrivateChats = state.getPrivateChatsValue().toMutableMap()
@@ -215,22 +238,32 @@ class MessageManager(private val state: ChatState) {
val updatedMessages = messages.toMutableList()
val messageIndex = updatedMessages.indexOfFirst { it.id == messageID }
if (messageIndex >= 0) {
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = status)
updatedPrivateChats[peerID] = updatedMessages
updated = true
val current = updatedMessages[messageIndex].deliveryStatus
val finalStatus = chooseStatus(current, status)
if (finalStatus !== current) {
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = finalStatus)
updatedPrivateChats[peerID] = updatedMessages
updated = true
}
}
}
if (updated) {
state.setPrivateChats(updatedPrivateChats)
// Keep process-wide store in sync to prevent snapshot overwrites resetting status
try { com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(messageID, status) } catch (_: Exception) { }
}
// Update in main messages
val updatedMessages = state.getMessagesValue().toMutableList()
val messageIndex = updatedMessages.indexOfFirst { it.id == messageID }
if (messageIndex >= 0) {
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = status)
state.setMessages(updatedMessages)
val current = updatedMessages[messageIndex].deliveryStatus
val finalStatus = chooseStatus(current, status)
if (finalStatus !== current) {
updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = finalStatus)
state.setMessages(updatedMessages)
}
}
// Update in channel messages
@@ -239,8 +272,12 @@ class MessageManager(private val state: ChatState) {
val channelMessagesList = messages.toMutableList()
val channelMessageIndex = channelMessagesList.indexOfFirst { it.id == messageID }
if (channelMessageIndex >= 0) {
channelMessagesList[channelMessageIndex] = channelMessagesList[channelMessageIndex].copy(deliveryStatus = status)
updatedChannelMessages[channel] = channelMessagesList
val current = channelMessagesList[channelMessageIndex].deliveryStatus
val finalStatus = chooseStatus(current, status)
if (finalStatus !== current) {
channelMessagesList[channelMessageIndex] = channelMessagesList[channelMessageIndex].copy(deliveryStatus = finalStatus)
updatedChannelMessages[channel] = channelMessagesList
}
}
}
state.setChannelMessages(updatedChannelMessages)
@@ -280,6 +280,37 @@ class NotificationManager(
Log.d(TAG, "Displayed notification for $contentTitle with ID $notificationId")
}
fun showVerificationNotification(title: String, body: String, peerID: String? = null) {
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
if (peerID != null) {
putExtra(EXTRA_OPEN_PRIVATE_CHAT, true)
putExtra(EXTRA_PEER_ID, peerID)
putExtra(EXTRA_SENDER_NICKNAME, body)
}
}
val pendingIntent = PendingIntent.getActivity(
context,
(System.currentTimeMillis() and 0x7FFFFFFF).toInt(),
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setShowWhen(true)
.setWhen(System.currentTimeMillis())
notificationManager.notify((System.currentTimeMillis() and 0x7FFFFFFF).toInt(), builder.build())
}
private fun showNotificationForActivePeers(peersSize: Int) {
// Create intent to open the app
val intent = Intent(context, MainActivity::class.java).apply {
@@ -16,6 +16,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.nostr.NostrProofOfWork
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.nostr.PoWPreferenceManager
@@ -27,9 +28,9 @@ fun PoWStatusIndicator(
modifier: Modifier = Modifier,
style: PoWIndicatorStyle = PoWIndicatorStyle.COMPACT
) {
val powEnabled by PoWPreferenceManager.powEnabled.collectAsState()
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState()
val isMining by PoWPreferenceManager.isMining.collectAsState()
val powEnabled by PoWPreferenceManager.powEnabled.collectAsStateWithLifecycle()
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsStateWithLifecycle()
val isMining by PoWPreferenceManager.isMining.collectAsStateWithLifecycle()
val colorScheme = MaterialTheme.colorScheme
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
@@ -292,28 +292,30 @@ class PrivateChatManager(
}
fun handleIncomingPrivateMessage(message: BitchatMessage, suppressUnread: Boolean) {
message.senderPeerID?.let { senderPeerID ->
val senderPeerID = message.senderPeerID
if (senderPeerID != null) {
// Mesh-origin private message: AppStateStore updates the list; avoid double-add here.
if (!isPeerBlocked(senderPeerID)) {
// Add to private messages
if (suppressUnread) {
messageManager.addPrivateMessageNoUnread(senderPeerID, message)
} else {
messageManager.addPrivateMessage(senderPeerID, message)
}
// Track as unread for read receipt purposes
var unreadCount = 0
if (!suppressUnread) {
// Ensure chat exists
messageManager.initializePrivateChat(senderPeerID)
// Track as unread for read receipt purposes if not focused
if (!suppressUnread && state.getSelectedPrivateChatPeerValue() != senderPeerID) {
val unreadList = unreadReceivedMessages.getOrPut(senderPeerID) { mutableListOf() }
unreadList.add(message)
unreadCount = unreadList.size
Log.d(TAG, "Queued unread from $senderPeerID (count=${unreadList.size})")
val currentUnread = state.getUnreadPrivateMessagesValue().toMutableSet()
currentUnread.add(senderPeerID)
state.setUnreadPrivateMessages(currentUnread)
}
Log.d(
TAG,
"Added received message ${message.id} from $senderPeerID to unread list (${unreadCount} unread)"
)
}
return
}
// Non-mesh path (e.g., Nostr): add to UI state using existing logic
val inferredPeer = state.getSelectedPrivateChatPeerValue() ?: return
if (suppressUnread) {
messageManager.addPrivateMessageNoUnread(inferredPeer, message)
} else {
messageManager.addPrivateMessage(inferredPeer, message)
}
}
@@ -322,27 +324,33 @@ class PrivateChatManager(
* Called when the user focuses on a private chat
*/
fun sendReadReceiptsForPeer(peerID: String, meshService: BluetoothMeshService) {
val unreadList = unreadReceivedMessages[peerID]
if (unreadList.isNullOrEmpty()) {
Log.d(TAG, "No unread messages to send read receipts for peer $peerID")
return
// Collect candidate messages: all incoming messages from this peer in the conversation
val chats = try { state.getPrivateChatsValue() } catch (_: Exception) { emptyMap<String, List<BitchatMessage>>() }
val messages = chats[peerID].orEmpty()
if (messages.isEmpty()) {
Log.d(TAG, "No messages found for peer $peerID to send read receipts")
}
Log.d(TAG, "Sending read receipts for ${unreadList.size} unread messages from $peerID")
// Send read receipt for each unread message - now using direct method call
unreadList.forEach { message ->
try {
val myNickname = state.getNicknameValue() ?: "unknown"
meshService.sendReadReceipt(message.id, peerID, myNickname)
Log.d(TAG, "Sent read receipt for message ${message.id} to $peerID")
} catch (e: Exception) {
Log.w(TAG, "Failed to send read receipt for message ${message.id}: ${e.message}")
val myNickname = state.getNicknameValue() ?: "unknown"
var sentCount = 0
messages.forEach { msg ->
// Only for incoming messages from this peer
if (msg.senderPeerID == peerID) {
try {
meshService.sendReadReceipt(msg.id, peerID, myNickname)
sentCount += 1
} catch (e: Exception) {
Log.w(TAG, "Failed to send read receipt for message ${msg.id}: ${e.message}")
}
}
}
// Clear the unread list since we've sent read receipts
// Clear any locally tracked unread queue for this peer
unreadReceivedMessages.remove(peerID)
// Also clear UI unread marker for this peer now that chat is focused/read
try { messageManager.clearPrivateUnreadMessages(peerID) } catch (_: Exception) { }
Log.d(TAG, "Sent $sentCount read receipts for peer $peerID (from conversation messages)")
}
fun cleanupDisconnectedPeer(peerID: String) {
@@ -0,0 +1,443 @@
package com.bitchat.android.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.outlined.NoEncryption
import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material.icons.outlined.Warning as OutlinedWarning
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
private data class SecurityStatusInfo(
val text: String,
val icon: ImageVector,
val tint: Color
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SecurityVerificationSheet(
isPresented: Boolean,
onDismiss: () -> Unit,
viewModel: ChatViewModel,
modifier: Modifier = Modifier
) {
if (!isPresented) return
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val peerID by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle()
val isDark = isSystemInDarkTheme()
val accent = if (isDark) Color.Green else Color(0xFF008000)
val boxColor = if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.06f)
val peerHexRegex = remember { Regex("^[0-9a-fA-F]{16}$") }
ModalBottomSheet(
modifier = modifier.statusBarsPadding(),
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.background,
dragHandle = null
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
SecurityVerificationHeader(
accent = accent,
onClose = onDismiss
)
if (peerID == null) {
Text(
text = stringResource(R.string.fingerprint_no_peer),
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
} else {
val selectedPeerID = peerID!!
val displayName = viewModel.resolvePeerDisplayNameForFingerprint(selectedPeerID)
val fingerprint = viewModel.getPeerFingerprintForDisplay(selectedPeerID)
val isVerified = fingerprint != null && verifiedFingerprints.contains(fingerprint)
val sessionState = peerSessionStates[selectedPeerID]
val statusInfo = buildStatusInfo(
isVerified = isVerified,
sessionState = sessionState,
accent = accent
)
SecurityStatusCard(
displayName = displayName,
accent = accent,
boxColor = boxColor,
statusInfo = statusInfo
)
FingerprintBlock(
title = stringResource(R.string.fingerprint_their),
fingerprint = fingerprint,
boxColor = boxColor,
accent = accent
)
FingerprintBlock(
title = stringResource(R.string.fingerprint_yours),
fingerprint = viewModel.getMyFingerprint(),
boxColor = boxColor,
accent = accent
)
SecurityVerificationActions(
isVerified = isVerified,
fingerprint = fingerprint,
displayName = displayName,
accent = accent,
canStartHandshake = fingerprint == null && selectedPeerID.matches(peerHexRegex),
onStartHandshake = { viewModel.meshService.initiateNoiseHandshake(selectedPeerID) },
onVerify = { fp -> viewModel.verifyFingerprintValue(fp) },
onUnverify = { fp -> viewModel.unverifyFingerprintValue(fp) }
)
}
}
}
}
@Composable
private fun SecurityVerificationHeader(
accent: Color,
onClose: () -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.security_verification_title),
style = MaterialTheme.typography.titleSmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
),
color = accent
)
Spacer(modifier = Modifier.weight(1f))
CloseButton(onClick = onClose)
}
}
@Composable
private fun buildStatusInfo(
isVerified: Boolean,
sessionState: String?,
accent: Color
): SecurityStatusInfo {
val text = when {
isVerified -> stringResource(R.string.fingerprint_status_verified)
sessionState == "established" -> stringResource(R.string.fingerprint_status_encrypted)
sessionState == "handshaking" -> stringResource(R.string.fingerprint_status_handshaking)
sessionState == "failed" -> stringResource(R.string.fingerprint_status_failed)
else -> stringResource(R.string.fingerprint_status_uninitialized)
}
val icon = when {
isVerified -> Icons.Filled.Verified
sessionState == "handshaking" -> Icons.Outlined.Sync
sessionState == "failed" -> Icons.Outlined.OutlinedWarning
sessionState == "established" -> Icons.Filled.Lock
else -> Icons.Outlined.NoEncryption
}
val tint = when {
isVerified -> Color(0xFF32D74B)
sessionState == "failed" -> Color(0xFFFF3B30)
sessionState == "handshaking" -> Color(0xFFFF9500)
sessionState == "established" -> Color(0xFF32D74B)
else -> accent.copy(alpha = 0.6f)
}
return SecurityStatusInfo(text, icon, tint)
}
@Composable
private fun SecurityStatusCard(
displayName: String,
accent: Color,
boxColor: Color,
statusInfo: SecurityStatusInfo
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(boxColor, shape = MaterialTheme.shapes.medium)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = statusInfo.icon,
contentDescription = null,
tint = statusInfo.tint
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = displayName,
style = MaterialTheme.typography.titleMedium.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
),
color = accent
)
Text(
text = statusInfo.text,
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
),
color = accent.copy(alpha = 0.8f)
)
}
}
}
@Composable
private fun SecurityVerificationActions(
isVerified: Boolean,
fingerprint: String?,
displayName: String,
accent: Color,
canStartHandshake: Boolean,
onStartHandshake: () -> Unit,
onVerify: (String) -> Unit,
onUnverify: (String) -> Unit
) {
if (canStartHandshake) {
Button(
onClick = onStartHandshake,
colors = ButtonDefaults.buttonColors(
containerColor = accent,
contentColor = Color.White
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.fingerprint_start_handshake),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
}
}
if (isVerified) {
VerificationStatusRow(
icon = Icons.Filled.Verified,
iconTint = Color(0xFF32D74B),
text = stringResource(R.string.fingerprint_verified_label),
textTint = Color(0xFF32D74B)
)
Text(
text = stringResource(R.string.fingerprint_verified_message),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
),
color = accent.copy(alpha = 0.7f),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
Button(
onClick = { fingerprint?.let(onUnverify) },
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFFF3B30),
contentColor = Color.White
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.verify_remove),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
}
} else {
VerificationStatusRow(
icon = Icons.Filled.Warning,
iconTint = Color(0xFFFF9500),
text = stringResource(R.string.fingerprint_not_verified_label),
textTint = Color(0xFFFF9500)
)
Text(
text = stringResource(R.string.fingerprint_not_verified_message_fmt, displayName),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
),
color = accent.copy(alpha = 0.7f),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
if (fingerprint != null) {
Button(
onClick = { onVerify(fingerprint) },
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF34C759),
contentColor = Color.White
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.fingerprint_mark_verified),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
}
}
}
}
@Composable
private fun VerificationStatusRow(
icon: ImageVector,
iconTint: Color,
text: String,
textTint: Color
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = iconTint
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = text,
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
),
color = textTint
)
}
}
@Composable
private fun FingerprintBlock(
title: String,
fingerprint: String?,
boxColor: Color,
accent: Color
) {
val clipboardManager = LocalClipboardManager.current
var showMenu by remember(fingerprint) { mutableStateOf(false) }
val interactionSource = remember { MutableInteractionSource() }
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.labelSmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
),
color = accent.copy(alpha = 0.8f)
)
if (fingerprint != null) {
Column {
Text(
text = formatFingerprint(fingerprint),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp
),
color = accent,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
interactionSource = interactionSource,
indication = null,
onClick = {},
onLongClick = { showMenu = true }
)
.background(boxColor, shape = MaterialTheme.shapes.small)
.padding(16.dp),
)
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
text = { Text(text = stringResource(R.string.fingerprint_copy)) },
onClick = {
clipboardManager.setText(AnnotatedString(fingerprint))
showMenu = false
}
)
}
}
} else {
Text(
text = stringResource(R.string.fingerprint_pending),
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
color = Color(0xFFFF9500),
modifier = Modifier.padding(16.dp)
)
}
}
}
private fun formatFingerprint(fingerprint: String): String {
val upper = fingerprint.uppercase()
val sb = StringBuilder()
upper.forEachIndexed { index, c ->
if (index > 0 && index % 4 == 0) {
if (index % 16 == 0) sb.append('\n') else sb.append(' ')
}
sb.append(c)
}
return sb.toString()
}
@@ -12,7 +12,6 @@ import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -22,7 +21,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import com.bitchat.android.util.hexEncodedString
/**
@@ -34,19 +35,20 @@ import com.bitchat.android.ui.theme.BASE_FONT_SIZE
fun SidebarOverlay(
viewModel: ChatViewModel,
onDismiss: () -> Unit,
onShowVerification: () -> Unit,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val interactionSource = remember { MutableInteractionSource() }
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList())
val joinedChannels by viewModel.joinedChannels.observeAsState(emptyList())
val currentChannel by viewModel.currentChannel.observeAsState()
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState()
val nickname by viewModel.nickname.observeAsState("")
val unreadChannelMessages by viewModel.unreadChannelMessages.observeAsState(emptyMap())
val peerNicknames by viewModel.peerNicknames.observeAsState(emptyMap())
val peerRSSI by viewModel.peerRSSI.observeAsState(emptyMap())
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val unreadChannelMessages by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
Box(
modifier = modifier
@@ -110,7 +112,7 @@ fun SidebarOverlay(
// People section - switch between mesh and geohash lists (iOS-compatible)
item {
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsState()
when (selectedLocationChannel) {
is com.bitchat.android.geohash.ChannelID.Location -> {
@@ -131,6 +133,7 @@ fun SidebarOverlay(
colorScheme = colorScheme,
selectedPrivatePeer = selectedPrivatePeer,
viewModel = viewModel,
onShowVerification = onShowVerification,
onPrivateChatStart = { peerID ->
viewModel.startPrivateChat(peerID)
onDismiss()
@@ -257,8 +260,11 @@ fun PeopleSection(
colorScheme: ColorScheme,
selectedPrivatePeer: String?,
viewModel: ChatViewModel,
onShowVerification: () -> Unit,
onPrivateChatStart: (String) -> Unit
) {
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
Column(modifier = modifier) {
Row(
modifier = Modifier
@@ -279,6 +285,17 @@ fun PeopleSection(
color = colorScheme.onSurface.copy(alpha = 0.6f),
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.weight(1f))
if (selectedLocationChannel !is com.bitchat.android.geohash.ChannelID.Location) {
IconButton(onClick = onShowVerification, modifier = Modifier.size(24.dp)) {
Icon(
imageVector = Icons.Outlined.QrCode,
contentDescription = stringResource(R.string.verify_title),
tint = colorScheme.onSurface.copy(alpha = 0.8f),
modifier = Modifier.size(16.dp)
)
}
}
}
if (connectedPeers.isEmpty()) {
@@ -291,10 +308,11 @@ fun PeopleSection(
}
// Observe reactive state for favorites and fingerprints
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
val privateChats by viewModel.privateChats.observeAsState(emptyMap())
val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet())
val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap())
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
// Reactive favorite computation for all peers
val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) {
@@ -308,10 +326,16 @@ fun PeopleSection(
// Build mapping of connected peerID -> noise key hex to unify with offline favorites
val noiseHexByPeerID: Map<String, String> = connectedPeers.associateWith { pid ->
try {
viewModel.meshService.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
viewModel.meshService.getPeerInfo(pid)?.noisePublicKey?.hexEncodedString()
} catch (_: Exception) { null }
}.filterValues { it != null }.mapValues { it.value!! }
val peerVerifiedStates = remember(verifiedFingerprints, peerFingerprints, connectedPeers) {
connectedPeers.associateWith { peerID ->
viewModel.isPeerVerified(peerID, verifiedFingerprints)
}
}
Log.d("SidebarComponents", "Recomposing with ${favoritePeers.size} favorites, peer states: $peerFavoriteStates")
// Smart sorting: unread DMs first, then by most recent DM, then favorites, then alphabetical
@@ -342,7 +366,7 @@ fun PeopleSection(
// Offline favorites (exclude ones mapped to connected)
val offlineFavorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites()
offlineFavorites.forEach { fav ->
val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
val favPeerID = fav.peerNoisePublicKey.hexEncodedString()
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
if (!isMappedToConnected) {
val dn = peerNicknames[favPeerID] ?: fav.peerNickname
@@ -368,6 +392,7 @@ fun PeopleSection(
sortedPeers.forEach { peerID ->
val isFavorite = peerFavoriteStates[peerID] ?: false
val isVerified = peerVerifiedStates[peerID] ?: false
// fingerprint and favorite relationship resolution not needed here; UI will show Nostr globe for appended offline favorites below
val noiseHex = noiseHexByPeerID[peerID]
@@ -384,7 +409,7 @@ fun PeopleSection(
val (bName, _) = com.bitchat.android.ui.splitSuffix(displayName)
val showHash = (baseNameCounts[bName] ?: 0) > 1
val directMap by viewModel.peerDirect.observeAsState(emptyMap())
val directMap by viewModel.peerDirect.collectAsStateWithLifecycle()
val isDirectLive = directMap[peerID] ?: try { viewModel.meshService.getPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false }
PeerItem(
peerID = peerID,
@@ -392,6 +417,7 @@ fun PeopleSection(
isDirect = isDirectLive,
isSelected = peerID == selectedPrivatePeer,
isFavorite = isFavorite,
isVerified = isVerified,
hasUnreadDM = combinedHasUnread,
colorScheme = colorScheme,
viewModel = viewModel,
@@ -408,7 +434,7 @@ fun PeopleSection(
// Append offline favorites we actively favorite (and not currently connected)
offlineFavorites.forEach { fav ->
val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
val favPeerID = fav.peerNoisePublicKey.hexEncodedString()
// If any connected peer maps to this noise key, skip showing the offline entry
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
if (isMappedToConnected) return@forEach
@@ -419,7 +445,7 @@ fun PeopleSection(
if (npubOrHex != null) {
val hex = if (npubOrHex.startsWith("npub")) {
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npubOrHex)
if (hrp == "npub") data.joinToString("") { "%02x".format(it) } else null
if (hrp == "npub") data.hexEncodedString() else null
} else {
npubOrHex.lowercase()
}
@@ -435,6 +461,7 @@ fun PeopleSection(
val dn = peerNicknames[favPeerID] ?: fav.peerNickname
val (bName, _) = com.bitchat.android.ui.splitSuffix(dn)
val showHash = (baseNameCounts[bName] ?: 0) > 1
val isVerified = viewModel.isNoisePublicKeyVerified(fav.peerNoisePublicKey, verifiedFingerprints)
// Compute unreadCount from either noise conversation or Nostr conversation
val unreadCount = (
@@ -449,6 +476,7 @@ fun PeopleSection(
isDirect = false,
isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer,
isFavorite = true,
isVerified = isVerified,
hasUnreadDM = hasUnread,
colorScheme = colorScheme,
viewModel = viewModel,
@@ -516,6 +544,7 @@ private fun PeerItem(
isDirect: Boolean,
isSelected: Boolean,
isFavorite: Boolean,
isVerified: Boolean,
hasUnreadDM: Boolean,
colorScheme: ColorScheme,
viewModel: ChatViewModel,
@@ -616,6 +645,16 @@ private fun PeerItem(
color = baseColor.copy(alpha = 0.6f)
)
}
if (isVerified) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.Filled.Verified,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = Color(0xFF32D74B)
)
}
}
// Favorite star with proper filled/outlined states
@@ -0,0 +1,368 @@
package com.bitchat.android.ui
import android.content.Context
import com.bitchat.android.R
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.noise.NoiseSession
import com.bitchat.android.nostr.GeohashAliasRegistry
import com.bitchat.android.services.VerificationService
import com.bitchat.android.util.dataFromHexString
import com.bitchat.android.util.hexEncodedString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.security.MessageDigest
import java.util.Date
import java.util.concurrent.ConcurrentHashMap
/**
* Handles QR verification logic and state, extracted from ChatViewModel.
*/
class VerificationHandler(
private val context: Context,
private val scope: CoroutineScope,
private val meshService: BluetoothMeshService,
private val identityManager: SecureIdentityStateManager,
private val state: ChatState,
private val notificationManager: NotificationManager,
private val messageManager: MessageManager
) {
private val _verifiedFingerprints = MutableStateFlow<Set<String>>(emptySet())
val verifiedFingerprints: StateFlow<Set<String>> = _verifiedFingerprints.asStateFlow()
private val pendingQRVerifications = ConcurrentHashMap<String, PendingVerification>()
private val lastVerifyNonceByPeer = ConcurrentHashMap<String, ByteArray>()
private val lastInboundVerifyChallengeAt = ConcurrentHashMap<String, Long>()
private val lastMutualToastAt = ConcurrentHashMap<String, Long>()
fun loadVerifiedFingerprints() {
_verifiedFingerprints.value = identityManager.getVerifiedFingerprints()
}
fun isPeerVerified(peerID: String): Boolean {
if (peerID.startsWith("nostr_") || peerID.startsWith("nostr:")) return false
val fingerprint = getPeerFingerprintForDisplay(peerID)
return fingerprint != null && _verifiedFingerprints.value.contains(fingerprint)
}
fun isNoisePublicKeyVerified(noisePublicKey: ByteArray): Boolean {
val fingerprint = fingerprintFromNoiseBytes(noisePublicKey)
return _verifiedFingerprints.value.contains(fingerprint)
}
fun unverifyFingerprint(peerID: String) {
val fingerprint = meshService.getPeerFingerprint(peerID) ?: return
identityManager.setVerifiedFingerprint(fingerprint, false)
val current = _verifiedFingerprints.value.toMutableSet()
current.remove(fingerprint)
_verifiedFingerprints.value = current
}
fun beginQRVerification(qr: VerificationService.VerificationQR): Boolean {
val targetNoise = qr.noiseKeyHex.lowercase()
val peerID = state.getConnectedPeersValue().firstOrNull { pid ->
val noiseKeyHex = meshService.getPeerInfo(pid)?.noisePublicKey?.hexEncodedString()?.lowercase()
noiseKeyHex == targetNoise
} ?: return false
if (pendingQRVerifications.containsKey(peerID)) return true
val nonce = ByteArray(16)
java.security.SecureRandom().nextBytes(nonce)
val pending = PendingVerification(qr.noiseKeyHex, qr.signKeyHex, nonce, System.currentTimeMillis(), false)
pendingQRVerifications[peerID] = pending
if (meshService.getSessionState(peerID) is NoiseSession.NoiseSessionState.Established) {
meshService.sendVerifyChallenge(peerID, qr.noiseKeyHex, nonce)
pendingQRVerifications[peerID] = pending.copy(sent = true)
} else {
meshService.initiateNoiseHandshake(peerID)
}
fingerprintFromNoiseHex(qr.noiseKeyHex)?.let { fp ->
identityManager.cacheFingerprintNickname(fp, qr.nickname)
identityManager.cacheNoiseFingerprint(qr.noiseKeyHex, fp)
identityManager.cachePeerNoiseKey(peerID, qr.noiseKeyHex)
}
return true
}
fun sendPendingVerificationIfNeeded(peerID: String) {
val pending = pendingQRVerifications[peerID] ?: return
if (pending.sent) return
meshService.sendVerifyChallenge(peerID, pending.noiseKeyHex, pending.nonceA)
pendingQRVerifications[peerID] = pending.copy(sent = true)
}
fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray) {
scope.launch {
val parsed = VerificationService.parseVerifyChallenge(payload) ?: return@launch
val myNoiseHex = meshService.getStaticNoisePublicKey()?.hexEncodedString()?.lowercase() ?: return@launch
if (parsed.first.lowercase() != myNoiseHex) return@launch
val lastNonce = lastVerifyNonceByPeer[peerID]
if (lastNonce != null && lastNonce.contentEquals(parsed.second)) return@launch
lastVerifyNonceByPeer[peerID] = parsed.second
val fp = meshService.getPeerFingerprint(peerID)
if (fp != null) {
lastInboundVerifyChallengeAt[fp] = System.currentTimeMillis()
if (_verifiedFingerprints.value.contains(fp)) {
val lastToast = lastMutualToastAt[fp] ?: 0L
if (System.currentTimeMillis() - lastToast > 60_000L) {
lastMutualToastAt[fp] = System.currentTimeMillis()
val name = resolvePeerDisplayName(peerID)
val body = context.getString(R.string.verify_mutual_match_body, name)
addVerificationSystemMessage(peerID, context.getString(R.string.verify_mutual_system_message, name))
sendVerificationNotification(context.getString(R.string.verify_mutual_match_title), body, peerID)
}
}
}
meshService.sendVerifyResponse(peerID, parsed.first, parsed.second)
}
}
fun didReceiveVerifyResponse(peerID: String, payload: ByteArray) {
scope.launch {
val resp = VerificationService.parseVerifyResponse(payload) ?: return@launch
val pending = pendingQRVerifications[peerID] ?: return@launch
if (!resp.noiseKeyHex.equals(pending.noiseKeyHex, ignoreCase = true)) return@launch
if (!resp.nonceA.contentEquals(pending.nonceA)) return@launch
val ok = VerificationService.verifyResponseSignature(
noiseKeyHex = resp.noiseKeyHex,
nonceA = resp.nonceA,
signature = resp.signature,
signerPublicKeyHex = pending.signKeyHex
)
if (!ok) return@launch
pendingQRVerifications.remove(peerID)
val fp = meshService.getPeerFingerprint(peerID) ?: return@launch
identityManager.setVerifiedFingerprint(fp, true)
val current = _verifiedFingerprints.value.toMutableSet()
current.add(fp)
_verifiedFingerprints.value = current
val name = resolvePeerDisplayName(peerID)
identityManager.cacheFingerprintNickname(fp, name)
val noiseKeyHex = try {
meshService.getPeerInfo(peerID)?.noisePublicKey?.hexEncodedString()
} catch (_: Exception) {
null
}
if (noiseKeyHex != null) {
identityManager.cachePeerNoiseKey(peerID, noiseKeyHex)
identityManager.cacheNoiseFingerprint(noiseKeyHex, fp)
}
addVerificationSystemMessage(peerID, context.getString(R.string.verify_success_system_message, name))
sendVerificationNotification(context.getString(R.string.verify_success_title), context.getString(R.string.verify_success_body, name), peerID)
val lastChallenge = lastInboundVerifyChallengeAt[fp] ?: 0L
if (System.currentTimeMillis() - lastChallenge < 600_000L) {
val lastToast = lastMutualToastAt[fp] ?: 0L
if (System.currentTimeMillis() - lastToast > 60_000L) {
lastMutualToastAt[fp] = System.currentTimeMillis()
val body = context.getString(R.string.verify_mutual_match_body, name)
addVerificationSystemMessage(peerID, context.getString(R.string.verify_mutual_system_message, name))
sendVerificationNotification(context.getString(R.string.verify_mutual_match_title), body, peerID)
}
}
}
}
fun getPeerFingerprintForDisplay(peerID: String): String? {
val fromMap = state.getPeerFingerprintsValue()[peerID]
if (fromMap != null) return fromMap
val hexRegex = Regex("^[0-9a-fA-F]+$")
return try {
when {
peerID.length == 64 && peerID.matches(hexRegex) -> {
identityManager.getCachedNoiseFingerprint(peerID)?.let { return it }
fingerprintFromNoiseHex(peerID)?.also { identityManager.cacheNoiseFingerprint(peerID, it) }
}
peerID.length == 16 && peerID.matches(hexRegex) -> {
val meshFp = meshService.getPeerFingerprint(peerID)
if (meshFp != null) return meshFp
identityManager.getCachedPeerFingerprint(peerID)?.let { return it }
identityManager.getCachedNoiseKey(peerID)?.let { noiseHex ->
identityManager.getCachedNoiseFingerprint(noiseHex)?.let { return it }
return fingerprintFromNoiseHex(noiseHex)?.also { identityManager.cacheNoiseFingerprint(noiseHex, it) }
}
val favorite = try {
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
} catch (_: Exception) {
null
}
favorite?.peerNoisePublicKey?.let { fingerprintFromNoiseBytes(it) }
}
peerID.startsWith("nostr_") -> {
val pubHex = GeohashAliasRegistry.get(peerID)
val noiseKey = pubHex?.let {
FavoritesPersistenceService.shared.findNoiseKey(it)
}
noiseKey?.let {
val noiseHex = it.hexEncodedString()
identityManager.getCachedNoiseFingerprint(noiseHex) ?: fingerprintFromNoiseBytes(it)
}
}
peerID.startsWith("nostr:") -> {
val prefix = peerID.removePrefix("nostr:").lowercase()
val pubHex = GeohashAliasRegistry
.snapshot()
.values
.firstOrNull { it.lowercase().startsWith(prefix) }
val noiseKey = pubHex?.let {
FavoritesPersistenceService.shared.findNoiseKey(it)
}
noiseKey?.let {
val noiseHex = it.hexEncodedString()
identityManager.getCachedNoiseFingerprint(noiseHex) ?: fingerprintFromNoiseBytes(it)
}
}
else -> {
val meshFp = meshService.getPeerFingerprint(peerID)
if (meshFp != null) return meshFp
identityManager.getCachedPeerFingerprint(peerID)?.let { return it }
identityManager.getCachedNoiseKey(peerID)?.let { noiseHex ->
identityManager.getCachedNoiseFingerprint(noiseHex)?.let { return it }
return fingerprintFromNoiseHex(noiseHex)?.also { identityManager.cacheNoiseFingerprint(noiseHex, it) }
}
val favorite = try {
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
} catch (_: Exception) {
null
}
favorite?.peerNoisePublicKey?.let { fingerprintFromNoiseBytes(it) }
}
}
} catch (_: Exception) {
null
}
}
fun resolvePeerDisplayNameForFingerprint(peerID: String): String {
val nicknameMap = state.peerNicknames.value
nicknameMap[peerID]?.let { return it }
try {
meshService.getPeerInfo(peerID)?.nickname?.let { return it }
} catch (_: Exception) { }
val fingerprint = getPeerFingerprintForDisplay(peerID)
fingerprint?.let { fp ->
identityManager.getCachedFingerprintNickname(fp)?.let { cached ->
if (cached.isNotBlank()) return cached
}
}
val hexRegex = Regex("^[0-9a-fA-F]+$")
if (peerID.length == 64 && peerID.matches(hexRegex)) {
val noiseKeyBytes = try {
peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
} catch (_: Exception) { null }
val favorite = noiseKeyBytes?.let {
FavoritesPersistenceService.shared.getFavoriteStatus(it)
}
favorite?.peerNickname?.takeIf { it.isNotBlank() }?.let { return it }
}
if (peerID.length == 16 && peerID.matches(hexRegex)) {
val favorite = try {
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
} catch (_: Exception) {
null
}
favorite?.peerNickname?.takeIf { it.isNotBlank() }?.let { return it }
}
return peerID.take(8)
}
fun getMyFingerprint(): String {
return meshService.getIdentityFingerprint()
}
fun verifyFingerprintValue(fingerprint: String) {
if (fingerprint.isBlank()) return
identityManager.setVerifiedFingerprint(fingerprint, true)
val current = _verifiedFingerprints.value.toMutableSet()
current.add(fingerprint)
_verifiedFingerprints.value = current
}
fun unverifyFingerprintValue(fingerprint: String) {
if (fingerprint.isBlank()) return
identityManager.setVerifiedFingerprint(fingerprint, false)
val current = _verifiedFingerprints.value.toMutableSet()
current.remove(fingerprint)
_verifiedFingerprints.value = current
}
private fun addVerificationSystemMessage(peerID: String, text: String) {
val msg = BitchatMessage(
sender = "system",
content = text,
timestamp = Date(),
isRelay = false,
isPrivate = true,
senderPeerID = peerID
)
messageManager.addPrivateMessageNoUnread(peerID, msg)
}
private fun resolvePeerDisplayName(peerID: String): String {
val nick = try { meshService.getPeerInfo(peerID)?.nickname } catch (_: Exception) { null }
return nick ?: peerID.take(8)
}
private fun sendVerificationNotification(title: String, body: String, peerID: String) {
notificationManager.showVerificationNotification(title, body, peerID)
}
private fun fingerprintFromNoiseHex(noiseHex: String): String? {
val bytes = noiseHex.dataFromHexString() ?: return null
return fingerprintFromNoiseBytes(bytes)
}
fun fingerprintFromNoiseBytes(bytes: ByteArray): String {
val hash = MessageDigest.getInstance("SHA-256").digest(bytes)
return hash.hexEncodedString()
}
private data class PendingVerification(
val noiseKeyHex: String,
val signKeyHex: String,
val nonceA: ByteArray,
val startedAtMs: Long,
val sent: Boolean
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as PendingVerification
if (startedAtMs != other.startedAtMs) return false
if (sent != other.sent) return false
if (noiseKeyHex != other.noiseKeyHex) return false
if (signKeyHex != other.signKeyHex) return false
if (!nonceA.contentEquals(other.nonceA)) return false
return true
}
override fun hashCode(): Int {
var result = startedAtMs.hashCode()
result = 31 * result + sent.hashCode()
result = 31 * result + noiseKeyHex.hashCode()
result = 31 * result + signKeyHex.hashCode()
result = 31 * result + nonceA.contentHashCode()
return result
}
}
}
@@ -0,0 +1,496 @@
package com.bitchat.android.ui
import android.graphics.Bitmap
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.camera.compose.CameraXViewfinder
import androidx.camera.core.CameraSelector
import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.core.SurfaceRequest
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.viewfinder.core.ImplementationMode
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.QrCode
import androidx.compose.material.icons.outlined.QrCodeScanner
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.core.graphics.createBitmap
import androidx.core.graphics.set
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.services.VerificationService
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import kotlinx.coroutines.flow.MutableStateFlow
import com.google.zxing.BarcodeFormat
import com.google.zxing.common.BitMatrix
import com.google.zxing.qrcode.QRCodeWriter
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VerificationSheet(
isPresented: Boolean,
onDismiss: () -> Unit,
viewModel: ChatViewModel,
modifier: Modifier = Modifier
) {
if (!isPresented) return
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val isDark = isSystemInDarkTheme()
val accent = if (isDark) Color.Green else Color(0xFF008000)
val boxColor = if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.06f)
var showingScanner by remember { mutableStateOf(false) }
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val npub = remember {
viewModel.getCurrentNpub()
}
val qrString = remember(nickname, npub) {
viewModel.buildMyQRString(nickname, npub)
}
ModalBottomSheet(
modifier = modifier.statusBarsPadding(),
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.background,
dragHandle = null
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
VerificationHeader(
accent = accent,
onClose = onDismiss
)
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (showingScanner) {
QRScannerPanel(
accent = accent,
boxColor = boxColor,
onScan = { code ->
val qr = VerificationService.verifyScannedQR(code)
if (qr != null && viewModel.beginQRVerification(qr)) {
showingScanner = false
}
}
)
} else {
MyQrPanel(
qrString = qrString,
accent = accent,
boxColor = boxColor,
)
}
}
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
ToggleVerificationModeButton(
showingScanner = showingScanner,
onToggle = { showingScanner = !showingScanner }
)
val peerID by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
val fingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
if (peerID != null) {
val fingerprint = viewModel.meshService.getPeerFingerprint(peerID!!)
if (fingerprint != null && fingerprints.contains(fingerprint)) {
Button(
onClick = { viewModel.unverifyFingerprint(peerID!!) },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
contentColor = MaterialTheme.colorScheme.onSurface
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.verify_remove),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
}
}
}
}
}
}
}
@Composable
private fun VerificationHeader(
accent: Color,
onClose: () -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.verify_title).uppercase(),
fontSize = 14.sp,
fontFamily = FontFamily.Monospace,
color = accent
)
CloseButton(onClick = onClose)
}
}
@Composable
private fun MyQrPanel(
qrString: String,
accent: Color,
boxColor: Color,
) {
Text(
text = stringResource(R.string.verify_my_qr_title),
fontSize = 16.sp,
fontFamily = FontFamily.Monospace,
color = accent,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
QRCodeCard(qrString = qrString, boxColor = boxColor)
}
@Composable
private fun ToggleVerificationModeButton(
showingScanner: Boolean,
onToggle: () -> Unit
) {
Button(
onClick = onToggle,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
contentColor = MaterialTheme.colorScheme.onSurface
),
modifier = Modifier.fillMaxWidth()
) {
if (showingScanner) {
Icon(
imageVector = Icons.Outlined.QrCode,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = stringResource(R.string.verify_show_my_qr),
fontFamily = FontFamily.Monospace,
fontSize = 13.sp
)
} else {
Icon(
imageVector = Icons.Outlined.QrCodeScanner,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = stringResource(R.string.verify_scan_someone),
fontFamily = FontFamily.Monospace,
fontSize = 13.sp
)
}
}
}
@Composable
private fun QRCodeCard(qrString: String, boxColor: Color) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(boxColor, RoundedCornerShape(12.dp))
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (qrString.isNotBlank()) {
QRCodeImage(data = qrString, size = 220.dp)
} else {
Box(
modifier = Modifier
.size(220.dp)
.background(Color.Transparent, RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.verify_qr_unavailable),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
SelectionContainer {
Text(
text = qrString,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
@Composable
private fun QRCodeImage(data: String, size: Dp) {
val sizePx = with(LocalDensity.current) { size.toPx().toInt() }
val bitmap = remember(data, sizePx) { generateQrBitmap(data, sizePx) }
if (bitmap != null) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.size(size)
)
}
}
private fun generateQrBitmap(data: String, sizePx: Int): Bitmap? {
if (data.isBlank() || sizePx <= 0) return null
return try {
val matrix = QRCodeWriter().encode(data, BarcodeFormat.QR_CODE, sizePx, sizePx)
bitmapFromMatrix(matrix)
} catch (_: Exception) {
null
}
}
private fun bitmapFromMatrix(matrix: BitMatrix): Bitmap {
val width = matrix.width
val height = matrix.height
val bitmap = createBitmap(width, height)
for (x in 0 until width) {
for (y in 0 until height) {
bitmap[x, y] =
if (matrix[x, y]) android.graphics.Color.BLACK else android.graphics.Color.WHITE
}
}
return bitmap
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
private fun QRScannerPanel(
onScan: (String) -> Unit,
accent: Color,
boxColor: Color,
modifier: Modifier = Modifier
) {
val permissionState = rememberPermissionState(android.Manifest.permission.CAMERA)
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var lastValid by remember { mutableStateOf<String?>(null) }
val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }
val cameraExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() }
val surfaceRequests = remember { MutableStateFlow<SurfaceRequest?>(null) }
val surfaceRequest by surfaceRequests.collectAsState(initial = null)
val mainHandler = remember { Handler(Looper.getMainLooper()) }
val onCodeState = rememberUpdatedState(onScan)
val analyzer = remember {
QRCodeAnalyzer { text ->
mainHandler.post {
if (text == lastValid) return@post
lastValid = text
onCodeState.value(text)
}
}
}
DisposableEffect(permissionState.status.isGranted) {
var cameraProvider: ProcessCameraProvider? = null
if (permissionState.status.isGranted) {
val executor = ContextCompat.getMainExecutor(context)
cameraProviderFuture.addListener(
{
val provider = cameraProviderFuture.get()
cameraProvider = provider
val preview = Preview.Builder()
.build()
.also { it.setSurfaceProvider { request -> surfaceRequests.value = request } }
val analysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also { it.setAnalyzer(cameraExecutor, analyzer) }
runCatching {
provider.unbindAll()
provider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
analysis
)
}.onFailure {
Log.w("VerificationSheet", "Failed to bind camera: ${it.message}")
}
},
executor
)
}
onDispose {
surfaceRequests.value = null
runCatching { cameraProvider?.unbindAll() }
}
}
DisposableEffect(Unit) {
onDispose { cameraExecutor.shutdown() }
}
Column(
modifier = modifier
.fillMaxWidth()
.background(boxColor, RoundedCornerShape(12.dp))
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(R.string.verify_scan_prompt_friend),
fontSize = 16.sp,
fontFamily = FontFamily.Monospace,
color = accent,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
if (permissionState.status.isGranted) {
surfaceRequest?.let { request ->
CameraXViewfinder(
surfaceRequest = request,
implementationMode = ImplementationMode.EMBEDDED,
modifier = Modifier
.size(220.dp)
.clipToBounds()
)
}
} else {
Text(
text = stringResource(R.string.verify_camera_permission),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
Button(
onClick = { permissionState.launchPermissionRequest() },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
contentColor = MaterialTheme.colorScheme.onSurface
)
) {
Text(
text = stringResource(R.string.verify_request_camera),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
}
}
}
}
private class QRCodeAnalyzer(
private val onCode: (String) -> Unit
) : ImageAnalysis.Analyzer {
private val scanner = BarcodeScanning.getClient(
BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
.build()
)
@ExperimentalGetImage
override fun analyze(imageProxy: ImageProxy) {
val mediaImage = imageProxy.image ?: run {
imageProxy.close()
return
}
val input = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
scanner.process(input)
.addOnSuccessListener { barcodes ->
val text = barcodes.firstOrNull()?.rawValue
if (!text.isNullOrBlank()) onCode(text)
}
.addOnCompleteListener { imageProxy.close() }
}
}
@@ -20,6 +20,7 @@ object DebugPreferenceManager {
// GCS keys (no migration/back-compat)
private const val KEY_GCS_MAX_BYTES = "gcs_max_filter_bytes"
private const val KEY_GCS_FPR = "gcs_filter_fpr_percent"
// Removed: persistent notification toggle is now governed by MeshServicePreferences.isBackgroundEnabled
private lateinit var prefs: SharedPreferences
@@ -100,4 +101,6 @@ object DebugPreferenceManager {
fun setGcsFprPercent(value: Double) {
if (ready()) prefs.edit().putLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(value)).apply()
}
// No longer storing persistent notification in debug prefs.
}
@@ -36,6 +36,11 @@ class DebugSettingsManager private constructor() {
private val _packetRelayEnabled = MutableStateFlow(true)
val packetRelayEnabled: StateFlow<Boolean> = _packetRelayEnabled.asStateFlow()
// Visibility of the debug sheet; gates heavy work
private val _debugSheetVisible = MutableStateFlow(false)
val debugSheetVisible: StateFlow<Boolean> = _debugSheetVisible.asStateFlow()
fun setDebugSheetVisible(visible: Boolean) { _debugSheetVisible.value = visible }
// Connection limit overrides (debug)
private val _maxConnectionsOverall = MutableStateFlow(8)
val maxConnectionsOverall: StateFlow<Int> = _maxConnectionsOverall.asStateFlow()
@@ -75,12 +80,63 @@ class DebugSettingsManager private constructor() {
// Timestamps to compute rolling window stats
private val relayTimestamps = ConcurrentLinkedQueue<Long>()
// Per-device and per-peer rolling timestamps for stacked graphs
private val perDeviceRelayTimestamps = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
private val perPeerRelayTimestamps = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
// Additional buckets to split incoming vs outgoing
private val incomingTimestamps = ConcurrentLinkedQueue<Long>()
private val outgoingTimestamps = ConcurrentLinkedQueue<Long>()
private val perDeviceIncoming = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
private val perDeviceOutgoing = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
private val perPeerIncoming = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
private val perPeerOutgoing = mutableMapOf<String, ConcurrentLinkedQueue<Long>>()
// Expose current per-second rates (updated when logging/pruning occurs)
private val _perDeviceLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perDeviceLastSecond: StateFlow<Map<String, Int>> = _perDeviceLastSecond.asStateFlow()
private val _perPeerLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perPeerLastSecond: StateFlow<Map<String, Int>> = _perPeerLastSecond.asStateFlow()
// New flows used by UI for incoming/outgoing stacked plots
private val _perDeviceIncomingLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perDeviceIncomingLastSecond: StateFlow<Map<String, Int>> = _perDeviceIncomingLastSecond.asStateFlow()
private val _perDeviceOutgoingLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perDeviceOutgoingLastSecond: StateFlow<Map<String, Int>> = _perDeviceOutgoingLastSecond.asStateFlow()
private val _perPeerIncomingLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perPeerIncomingLastSecond: StateFlow<Map<String, Int>> = _perPeerIncomingLastSecond.asStateFlow()
private val _perPeerOutgoingLastSecond: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perPeerOutgoingLastSecond: StateFlow<Map<String, Int>> = _perPeerOutgoingLastSecond.asStateFlow()
// Per-minute counts per key
private val _perDeviceIncomingLastMinute: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perDeviceIncomingLastMinute: StateFlow<Map<String, Int>> = _perDeviceIncomingLastMinute.asStateFlow()
private val _perDeviceOutgoingLastMinute: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perDeviceOutgoingLastMinute: StateFlow<Map<String, Int>> = _perDeviceOutgoingLastMinute.asStateFlow()
private val _perPeerIncomingLastMinute: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perPeerIncomingLastMinute: StateFlow<Map<String, Int>> = _perPeerIncomingLastMinute.asStateFlow()
private val _perPeerOutgoingLastMinute: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
val perPeerOutgoingLastMinute: StateFlow<Map<String, Int>> = _perPeerOutgoingLastMinute.asStateFlow()
// Totals per key (since app start)
private val deviceIncomingTotalsMap = mutableMapOf<String, Long>()
private val deviceOutgoingTotalsMap = mutableMapOf<String, Long>()
private val peerIncomingTotalsMap = mutableMapOf<String, Long>()
private val peerOutgoingTotalsMap = mutableMapOf<String, Long>()
private val _perDeviceIncomingTotalsFlow: MutableStateFlow<Map<String, Long>> = MutableStateFlow(emptyMap())
val perDeviceIncomingTotal: StateFlow<Map<String, Long>> = _perDeviceIncomingTotalsFlow.asStateFlow()
private val _perDeviceOutgoingTotalsFlow: MutableStateFlow<Map<String, Long>> = MutableStateFlow(emptyMap())
val perDeviceOutgoingTotal: StateFlow<Map<String, Long>> = _perDeviceOutgoingTotalsFlow.asStateFlow()
private val _perPeerIncomingTotalsFlow: MutableStateFlow<Map<String, Long>> = MutableStateFlow(emptyMap())
val perPeerIncomingTotal: StateFlow<Map<String, Long>> = _perPeerIncomingTotalsFlow.asStateFlow()
private val _perPeerOutgoingTotalsFlow: MutableStateFlow<Map<String, Long>> = MutableStateFlow(emptyMap())
val perPeerOutgoingTotal: StateFlow<Map<String, Long>> = _perPeerOutgoingTotalsFlow.asStateFlow()
// Internal data storage for managing debug data
private val debugMessageQueue = ConcurrentLinkedQueue<DebugMessage>()
private val scanResultsQueue = ConcurrentLinkedQueue<DebugScanResult>()
private fun updateRelayStatsFromTimestamps() {
if (!_debugSheetVisible.value) return
val now = System.currentTimeMillis()
// prune older than 15m
while (true) {
@@ -89,18 +145,84 @@ class DebugSettingsManager private constructor() {
relayTimestamps.poll()
} else break
}
// prune per-device and per-peer and compute 1s/60s rates
fun pruneAndCount1s(map: MutableMap<String, ConcurrentLinkedQueue<Long>>): Map<String, Int> {
val result = mutableMapOf<String, Int>()
val iterator = map.entries.iterator()
while (iterator.hasNext()) {
val (key, q) = iterator.next()
// prune this queue
while (true) {
val ts = q.peek() ?: break
if (now - ts > 15 * 60 * 1000L) {
q.poll()
} else break
}
// count last 1s only
val count1s = q.count { now - it <= 1_000L }
if (q.isEmpty()) {
// cleanup empty queues to prevent unbounded growth
iterator.remove()
}
if (count1s > 0) result[key] = count1s
}
return result
}
fun pruneAndCount60s(map: MutableMap<String, ConcurrentLinkedQueue<Long>>): Map<String, Int> {
val result = mutableMapOf<String, Int>()
map.forEach { (key, q) ->
val count60 = q.count { now - it <= 60_000L }
if (count60 > 0) result[key] = count60
}
return result
}
val perDevice1s = pruneAndCount1s(perDeviceRelayTimestamps)
val perPeer1s = pruneAndCount1s(perPeerRelayTimestamps)
_perDeviceLastSecond.value = perDevice1s
_perPeerLastSecond.value = perPeer1s
// Also compute incoming/outgoing per-key rates
_perDeviceIncomingLastSecond.value = pruneAndCount1s(perDeviceIncoming)
_perDeviceOutgoingLastSecond.value = pruneAndCount1s(perDeviceOutgoing)
_perPeerIncomingLastSecond.value = pruneAndCount1s(perPeerIncoming)
_perPeerOutgoingLastSecond.value = pruneAndCount1s(perPeerOutgoing)
_perDeviceIncomingLastMinute.value = pruneAndCount60s(perDeviceIncoming)
_perDeviceOutgoingLastMinute.value = pruneAndCount60s(perDeviceOutgoing)
_perPeerIncomingLastMinute.value = pruneAndCount60s(perPeerIncoming)
_perPeerOutgoingLastMinute.value = pruneAndCount60s(perPeerOutgoing)
val last1s = relayTimestamps.count { now - it <= 1_000L }
val last10s = relayTimestamps.count { now - it <= 10_000L }
val last1m = relayTimestamps.count { now - it <= 60_000L }
val last15m = relayTimestamps.size
val total = _relayStats.value.totalRelaysCount + 1
// And incoming/outgoing per-second counters
val last1sIncoming = incomingTimestamps.count { now - it <= 1_000L }
val last1sOutgoing = outgoingTimestamps.count { now - it <= 1_000L }
val last10sIncoming = incomingTimestamps.count { now - it <= 10_000L }
val last10sOutgoing = outgoingTimestamps.count { now - it <= 10_000L }
val last1mIncoming = incomingTimestamps.count { now - it <= 60_000L }
val last1mOutgoing = outgoingTimestamps.count { now - it <= 60_000L }
val last15mIncoming = incomingTimestamps.size
val last15mOutgoing = outgoingTimestamps.size
val totalIncoming = _relayStats.value.totalIncomingCount
val totalOutgoing = _relayStats.value.totalOutgoingCount
_relayStats.value = PacketRelayStats(
totalRelaysCount = total,
totalRelaysCount = totalIncoming + totalOutgoing,
lastSecondRelays = last1s,
last10SecondRelays = last10s,
lastMinuteRelays = last1m,
last15MinuteRelays = last15m,
lastResetTime = _relayStats.value.lastResetTime
lastResetTime = _relayStats.value.lastResetTime,
lastSecondIncoming = last1sIncoming,
lastSecondOutgoing = last1sOutgoing,
last10SecondIncoming = last10sIncoming,
last10SecondOutgoing = last10sOutgoing,
lastMinuteIncoming = last1mIncoming,
lastMinuteOutgoing = last1mOutgoing,
last15MinuteIncoming = last15mIncoming,
last15MinuteOutgoing = last15mOutgoing,
totalIncomingCount = totalIncoming,
totalOutgoingCount = totalOutgoing
)
}
@@ -336,11 +458,61 @@ class DebugSettingsManager private constructor() {
}
}
// Update rolling statistics only for relays
if (isRelay) {
relayTimestamps.offer(System.currentTimeMillis())
updateRelayStatsFromTimestamps()
// Do not update counters here; this path is for readable logs only.
}
// Explicit incoming/outgoing logging to avoid double counting
fun logIncoming(packetType: String, fromPeerID: String?, fromNickname: String?, fromDeviceAddress: String?) {
if (verboseLoggingEnabled.value) {
val who = fromNickname ?: fromPeerID ?: "unknown"
addDebugMessage(DebugMessage.PacketEvent("📥 Incoming $packetType from $who (${fromPeerID ?: "?"}, ${fromDeviceAddress ?: "?"})"))
}
val now = System.currentTimeMillis()
val visible = _debugSheetVisible.value
if (visible) incomingTimestamps.offer(now)
fromDeviceAddress?.let {
perDeviceIncoming.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now)
deviceIncomingTotalsMap[it] = (deviceIncomingTotalsMap[it] ?: 0L) + 1L
_perDeviceIncomingTotalsFlow.value = deviceIncomingTotalsMap.toMap()
}
fromPeerID?.let {
perPeerIncoming.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now)
peerIncomingTotalsMap[it] = (peerIncomingTotalsMap[it] ?: 0L) + 1L
_perPeerIncomingTotalsFlow.value = peerIncomingTotalsMap.toMap()
}
// bump totals
val cur = _relayStats.value
_relayStats.value = cur.copy(
totalIncomingCount = cur.totalIncomingCount + 1,
totalRelaysCount = cur.totalRelaysCount + 1
)
if (visible) updateRelayStatsFromTimestamps()
}
fun logOutgoing(packetType: String, toPeerID: String?, toNickname: String?, toDeviceAddress: String?, previousHopPeerID: String? = null) {
if (verboseLoggingEnabled.value) {
val who = toNickname ?: toPeerID ?: "unknown"
addDebugMessage(DebugMessage.PacketEvent("📤 Outgoing $packetType to $who (${toPeerID ?: "?"}, ${toDeviceAddress ?: "?"})"))
}
val now = System.currentTimeMillis()
val visible = _debugSheetVisible.value
if (visible) outgoingTimestamps.offer(now)
toDeviceAddress?.let {
perDeviceOutgoing.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now)
deviceOutgoingTotalsMap[it] = (deviceOutgoingTotalsMap[it] ?: 0L) + 1L
_perDeviceOutgoingTotalsFlow.value = deviceOutgoingTotalsMap.toMap()
}
(toPeerID ?: previousHopPeerID)?.let {
perPeerOutgoing.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now)
peerOutgoingTotalsMap[it] = (peerOutgoingTotalsMap[it] ?: 0L) + 1L
_perPeerOutgoingTotalsFlow.value = peerOutgoingTotalsMap.toMap()
}
val cur = _relayStats.value
_relayStats.value = cur.copy(
totalOutgoingCount = cur.totalOutgoingCount + 1,
totalRelaysCount = cur.totalRelaysCount + 1
)
if (visible) updateRelayStatsFromTimestamps()
}
// MARK: - Clear Data
@@ -407,5 +579,15 @@ data class PacketRelayStats(
val last10SecondRelays: Int = 0,
val lastMinuteRelays: Int = 0,
val last15MinuteRelays: Int = 0,
val lastResetTime: Date = Date()
val lastResetTime: Date = Date(),
val lastSecondIncoming: Int = 0,
val lastSecondOutgoing: Int = 0,
val last10SecondIncoming: Int = 0,
val last10SecondOutgoing: Int = 0,
val lastMinuteIncoming: Int = 0,
val lastMinuteOutgoing: Int = 0,
val last15MinuteIncoming: Int = 0,
val last15MinuteOutgoing: Int = 0,
val totalIncomingCount: Long = 0,
val totalOutgoingCount: Long = 0
)
@@ -3,9 +3,11 @@ package com.bitchat.android.ui.debug
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.BugReport
@@ -15,6 +17,7 @@ import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material.icons.filled.SettingsEthernet
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -31,6 +34,9 @@ import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
import androidx.compose.ui.platform.LocalContext
import com.bitchat.android.service.MeshServicePreferences
import com.bitchat.android.service.MeshForegroundService
@Composable
fun MeshTopologySection() {
@@ -113,7 +119,9 @@ fun MeshTopologySection() {
}
}
@OptIn(ExperimentalMaterial3Api::class)
private enum class GraphMode { OVERALL, PER_DEVICE, PER_PEER }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun DebugSettingsSheet(
isPresented: Boolean,
@@ -138,6 +146,8 @@ fun DebugSettingsSheet(
val seenCapacity by manager.seenPacketCapacity.collectAsState()
val gcsMaxBytes by manager.gcsMaxBytes.collectAsState()
val gcsFpr by manager.gcsFprPercent.collectAsState()
val context = LocalContext.current
// Persistent notification is now controlled solely by MeshServicePreferences.isBackgroundEnabled
// Push live connected devices from mesh service whenever sheet is visible
LaunchedEffect(isPresented) {
@@ -174,6 +184,11 @@ fun DebugSettingsSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
// Mark debug sheet visible/invisible to gate heavy work
LaunchedEffect(Unit) { DebugSettingsManager.getInstance().setDebugSheetVisible(true) }
DisposableEffect(Unit) {
onDispose { DebugSettingsManager.getInstance().setDebugSheetVisible(false) }
}
LazyColumn(
modifier = Modifier
.fillMaxWidth()
@@ -292,135 +307,260 @@ fun DebugSettingsSheet(
item {
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
// Persistent notification is controlled by About sheet (MeshServicePreferences.isBackgroundEnabled)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, tint = Color(0xFFFF9500))
Text(stringResource(R.string.debug_packet_relay), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.weight(1f))
Switch(checked = packetRelayEnabled, onCheckedChange = { manager.setPacketRelayEnabled(it) })
}
Text(stringResource(R.string.debug_since_start_fmt, relayStats.totalRelaysCount), fontFamily = FontFamily.Monospace, fontSize = 11.sp)
Text(stringResource(R.string.debug_relays_window_fmt, relayStats.last10SecondRelays, relayStats.lastMinuteRelays, relayStats.last15MinuteRelays), fontFamily = FontFamily.Monospace, fontSize = 11.sp)
// Realtime graph: per-second relays, full-width canvas, bottom-up bars, fast decay
var series by remember { mutableStateOf(List(60) { 0f }) }
LaunchedEffect(isPresented) {
// Removed aggregate labels; we will show per-direction compact labels below titles
// Toggle: overall vs per-connection vs per-peer
var graphMode by rememberSaveable { mutableStateOf(GraphMode.OVERALL) }
val perDeviceIncoming by manager.perDeviceIncomingLastSecond.collectAsState()
val perPeerIncoming by manager.perPeerIncomingLastSecond.collectAsState()
val perDeviceOutgoing by manager.perDeviceOutgoingLastSecond.collectAsState()
val perPeerOutgoing by manager.perPeerOutgoingLastSecond.collectAsState()
val perDeviceIncoming1m by manager.perDeviceIncomingLastMinute.collectAsState()
val perDeviceOutgoing1m by manager.perDeviceOutgoingLastMinute.collectAsState()
val perPeerIncoming1m by manager.perPeerIncomingLastMinute.collectAsState()
val perPeerOutgoing1m by manager.perPeerOutgoingLastMinute.collectAsState()
val perDeviceIncomingTotal by manager.perDeviceIncomingTotal.collectAsState()
val perDeviceOutgoingTotal by manager.perDeviceOutgoingTotal.collectAsState()
val perPeerIncomingTotal by manager.perPeerIncomingTotal.collectAsState()
val perPeerOutgoingTotal by manager.perPeerOutgoingTotal.collectAsState()
val nicknameMap = remember { mutableStateOf<Map<String, String?>>(emptyMap()) }
val devicePeerMap = remember { mutableStateOf<Map<String, String>>(emptyMap()) }
LaunchedEffect(Unit) {
try { nicknameMap.value = meshService.getPeerNicknames() } catch (_: Exception) { }
// Try to fetch device->peer map periodically for legend resolution
while (isPresented) {
val s = relayStats.lastSecondRelays.toFloat()
val last = series.lastOrNull() ?: 0f
// Faster decay and smoothing
val v = last * 0.5f + s * 0.5f
series = (series + v).takeLast(60)
kotlinx.coroutines.delay(400)
try { devicePeerMap.value = meshService.getDeviceAddressToPeerMapping() } catch (_: Exception) { }
kotlinx.coroutines.delay(1000)
}
}
val maxValRaw = series.maxOrNull() ?: 0f
val maxVal = if (maxValRaw > 0f) maxValRaw else 0f
val leftGutter = 40.dp
Box(Modifier.fillMaxWidth().height(56.dp)) {
// Graph canvas
androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) {
val axisPx = leftGutter.toPx() // reserved left gutter for labels
val barCount = series.size
val availW = (size.width - axisPx).coerceAtLeast(1f)
val w = availW / barCount
val h = size.height
// Baseline at bottom (y = 0)
drawLine(
color = Color(0x33888888),
start = androidx.compose.ui.geometry.Offset(axisPx, h - 1f),
end = androidx.compose.ui.geometry.Offset(size.width, h - 1f),
strokeWidth = 1f
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
// Mode selector
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = graphMode == GraphMode.OVERALL,
onClick = { graphMode = GraphMode.OVERALL },
label = { Text("Overall") }
)
// Bars from bottom-up; skip zeros entirely
series.forEachIndexed { i, value ->
if (value > 0f && maxVal > 0f) {
val ratio = (value / maxVal).coerceIn(0f, 1f)
val barHeight = (h * ratio).coerceAtLeast(0f)
if (barHeight > 0.5f) {
drawRect(
color = Color(0xFF00C851),
topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = h - barHeight),
size = androidx.compose.ui.geometry.Size(w, barHeight)
)
}
}
FilterChip(
selected = graphMode == GraphMode.PER_DEVICE,
onClick = { graphMode = GraphMode.PER_DEVICE },
label = { Text("Per Device") },
leadingIcon = { Icon(Icons.Filled.Devices, contentDescription = null) }
)
FilterChip(
selected = graphMode == GraphMode.PER_PEER,
onClick = { graphMode = GraphMode.PER_PEER },
label = { Text("Per Peer") },
leadingIcon = { Icon(Icons.Filled.SettingsEthernet, contentDescription = null) }
)
}
// Time series state
var overallSeriesIncoming by rememberSaveable { mutableStateOf(List(60) { 0f }) }
var overallSeriesOutgoing by rememberSaveable { mutableStateOf(List(60) { 0f }) }
var stackedKeysIncoming by rememberSaveable { mutableStateOf(listOf<String>()) }
var stackedKeysOutgoing by rememberSaveable { mutableStateOf(listOf<String>()) }
var stackedSeriesIncoming by rememberSaveable { mutableStateOf<Map<String, List<Float>>>(emptyMap()) }
var stackedSeriesOutgoing by rememberSaveable { mutableStateOf<Map<String, List<Float>>>(emptyMap()) }
var highlightedKey by rememberSaveable { mutableStateOf<String?>(null) }
// Color palette for stacked legend
val palette = remember {
listOf(
Color(0xFF00C851), Color(0xFF007AFF), Color(0xFFFF9500), Color(0xFFFF3B30),
Color(0xFF5AC8FA), Color(0xFFAF52DE), Color(0xFFFF2D55), Color(0xFF34C759),
Color(0xFFFFCC00), Color(0xFF5856D6)
)
}
val colorForKey = remember { mutableStateMapOf<String, Color>() }
fun stableColorFor(key: String): Color {
// Deterministic fallback color based on key hash using HSV palette
val h = (key.hashCode().toUInt().toInt() and 0x7FFFFFFF) % 360
return Color.hsv(h.toFloat(), 0.65f, 0.95f)
}
// Ensure colors are assigned for current keys before drawing
fun ensureColors(keys: List<String>) {
keys.forEachIndexed { idx, k ->
colorForKey.putIfAbsent(k, palette.getOrNull(idx) ?: stableColorFor(k))
}
}
// Y-axis ticks (min/max) in the left margin
Text("0", fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.BottomStart).padding(start = 4.dp, bottom = 2.dp))
Text("${maxVal.toInt()}", fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.TopStart).padding(start = 4.dp, top = 2.dp))
// Y-axis unit label (vertical)
Text("p/s", fontFamily = FontFamily.Monospace, fontSize = 9.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp).rotate(-90f))
}
}
}
}
@Composable
fun MeshTopologySection() {
val colorScheme = MaterialTheme.colorScheme
val graphService = remember { MeshGraphService.getInstance() }
val snapshot by graphService.graphState.collectAsState()
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF8E8E93))
Text("mesh topology", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
val nodes = snapshot.nodes
val edges = snapshot.edges
val empty = nodes.isEmpty()
if (empty) {
Text("no gossip yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
} else {
androidx.compose.foundation.Canvas(Modifier.fillMaxWidth().height(220.dp).background(colorScheme.surface.copy(alpha = 0.4f))) {
val w = size.width
val h = size.height
val cx = w / 2f
val cy = h / 2f
val radius = (minOf(w, h) * 0.36f)
val n = nodes.size
if (n == 1) {
// Single node centered
drawCircle(color = Color(0xFF00C851), radius = 12f, center = androidx.compose.ui.geometry.Offset(cx, cy))
} else {
// Circular layout
val positions = nodes.mapIndexed { i, node ->
val angle = (2 * Math.PI * i.toDouble()) / n
val x = cx + (radius * Math.cos(angle)).toFloat()
val y = cy + (radius * Math.sin(angle)).toFloat()
node.peerID to androidx.compose.ui.geometry.Offset(x, y)
}.toMap()
// Draw edges
edges.forEach { e ->
val p1 = positions[e.a]
val p2 = positions[e.b]
if (p1 != null && p2 != null) {
drawLine(color = Color(0xFF4A90E2), start = p1, end = p2, strokeWidth = 2f)
LaunchedEffect(isPresented, graphMode) {
while (isPresented) {
when (graphMode) {
GraphMode.OVERALL -> {
val sIn = relayStats.lastSecondIncoming.toFloat()
val sOut = relayStats.lastSecondOutgoing.toFloat()
overallSeriesIncoming = (overallSeriesIncoming + sIn).takeLast(60)
overallSeriesOutgoing = (overallSeriesOutgoing + sOut).takeLast(60)
}
GraphMode.PER_DEVICE -> {
val snapshotIn = perDeviceIncoming
val snapshotOut = perDeviceOutgoing
fun advance(base: Map<String, List<Float>>, snap: Map<String, Int>): Map<String, List<Float>> {
val next = mutableMapOf<String, List<Float>>()
val union = (base.keys + snap.keys).toSet()
union.forEach { k ->
val prev = base[k] ?: List(60) { 0f }
val s = (snap[k] ?: 0).toFloat()
next[k] = (prev + s).takeLast(60)
}
return next
}
// Advance and prune fully-stale series (all-zero in visible window)
stackedSeriesIncoming = advance(stackedSeriesIncoming, snapshotIn).filterValues { series -> series.any { it != 0f } }
stackedSeriesOutgoing = advance(stackedSeriesOutgoing, snapshotOut).filterValues { series -> series.any { it != 0f } }
stackedKeysIncoming = stackedSeriesIncoming.keys.sorted()
stackedKeysOutgoing = stackedSeriesOutgoing.keys.sorted()
}
GraphMode.PER_PEER -> {
val snapshotIn = perPeerIncoming
val snapshotOut = perPeerOutgoing
fun advance(base: Map<String, List<Float>>, snap: Map<String, Int>): Map<String, List<Float>> {
val next = mutableMapOf<String, List<Float>>()
val union = (base.keys + snap.keys).toSet()
union.forEach { k ->
val prev = base[k] ?: List(60) { 0f }
val s = (snap[k] ?: 0).toFloat()
next[k] = (prev + s).takeLast(60)
}
return next
}
stackedSeriesIncoming = advance(stackedSeriesIncoming, snapshotIn).filterValues { series -> series.any { it != 0f } }
stackedSeriesOutgoing = advance(stackedSeriesOutgoing, snapshotOut).filterValues { series -> series.any { it != 0f } }
stackedKeysIncoming = stackedSeriesIncoming.keys.sorted()
stackedKeysOutgoing = stackedSeriesOutgoing.keys.sorted()
}
}
kotlinx.coroutines.delay(1000)
}
}
}
// Helper functions moved to top-level composable below to avoid scope issues
// Draw nodes
nodes.forEach { node ->
val pos = positions[node.peerID] ?: androidx.compose.ui.geometry.Offset(cx, cy)
drawCircle(color = Color(0xFF00C851), radius = 10f, center = pos)
// Render two blocks: Incoming and Outgoing
Text("Incoming", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(
"${relayStats.lastSecondIncoming}/s • ${relayStats.lastMinuteIncoming}/m • ${relayStats.last15MinuteIncoming}/15m • total ${relayStats.totalIncomingCount}",
fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)
)
DrawGraphBlock(
title = "Incoming",
stackedKeys = stackedKeysIncoming,
stackedSeries = stackedSeriesIncoming,
overallSeries = if (graphMode == GraphMode.OVERALL) overallSeriesIncoming else null,
graphMode = graphMode,
highlightedKey = highlightedKey,
onToggleHighlight = { key -> highlightedKey = if (highlightedKey == key) null else key },
ensureColors = { keys -> ensureColors(keys) },
colorForKey = { k -> colorForKey[k] ?: stableColorFor(k) },
legendTitleFor = { key ->
when (graphMode) {
GraphMode.PER_PEER -> {
val nick = nicknameMap.value[key]
val prefix = key.take(6)
if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix
}
GraphMode.PER_DEVICE -> {
val device = key
val pid = connectedDevices.firstOrNull { it.deviceAddress == device }?.peerID
?: devicePeerMap.value[device]
if (pid != null) {
val nick = nicknameMap.value[pid]
val prefix = pid.take(6)
"$device (${if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix})"
} else device
}
else -> key
}
},
legendMetricsFor = { key ->
when (graphMode) {
GraphMode.PER_PEER -> {
val s = perPeerIncoming[key] ?: 0
val m = perPeerIncoming1m[key] ?: 0
val t = (perPeerIncomingTotal[key] ?: 0L)
"${s}/s • ${m}/m • total ${t}"
}
GraphMode.PER_DEVICE -> {
val s = perDeviceIncoming[key] ?: 0
val m = perDeviceIncoming1m[key] ?: 0
val t = (perDeviceIncomingTotal[key] ?: 0L)
"${s}/s • ${m}/m • total ${t}"
}
else -> ""
}
}
)
if (graphMode != GraphMode.OVERALL && stackedKeysIncoming.isNotEmpty()) { /* legend printed inside DrawGraphBlock */ }
Spacer(Modifier.height(8.dp))
Text("Outgoing", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(
"${relayStats.lastSecondOutgoing}/s • ${relayStats.lastMinuteOutgoing}/m • ${relayStats.last15MinuteOutgoing}/15m • total ${relayStats.totalOutgoingCount}",
fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)
)
DrawGraphBlock(
title = "Outgoing",
stackedKeys = stackedKeysOutgoing,
stackedSeries = stackedSeriesOutgoing,
overallSeries = if (graphMode == GraphMode.OVERALL) overallSeriesOutgoing else null,
graphMode = graphMode,
highlightedKey = highlightedKey,
onToggleHighlight = { key -> highlightedKey = if (highlightedKey == key) null else key },
ensureColors = { keys -> ensureColors(keys) },
colorForKey = { k -> colorForKey[k] ?: stableColorFor(k) },
legendTitleFor = { key ->
when (graphMode) {
GraphMode.PER_PEER -> {
val nick = nicknameMap.value[key]
val prefix = key.take(6)
if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix
}
GraphMode.PER_DEVICE -> {
val device = key
val pid = connectedDevices.firstOrNull { it.deviceAddress == device }?.peerID
?: devicePeerMap.value[device]
if (pid != null) {
val nick = nicknameMap.value[pid]
val prefix = pid.take(6)
"$device (${if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix})"
} else device
}
else -> key
}
},
legendMetricsFor = { key ->
when (graphMode) {
GraphMode.PER_PEER -> {
val s = perPeerOutgoing[key] ?: 0
val m = perPeerOutgoing1m[key] ?: 0
val t = (perPeerOutgoingTotal[key] ?: 0L)
"${s}/s • ${m}/m • total ${t}"
}
GraphMode.PER_DEVICE -> {
val s = perDeviceOutgoing[key] ?: 0
val m = perDeviceOutgoing1m[key] ?: 0
val t = (perDeviceOutgoingTotal[key] ?: 0L)
"${s}/s • ${m}/m • total ${t}"
}
else -> ""
}
}
)
if (graphMode != GraphMode.OVERALL && stackedKeysOutgoing.isNotEmpty()) { /* legend printed inside DrawGraphBlock */ }
}
}
}
// Label list for clarity under the canvas
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 140.dp)) {
items(nodes) { node ->
val label = "${node.peerID.take(8)}${node.nickname ?: "unknown"}"
Text(label, fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.85f))
}
}
}
}
}
}
// Connected devices
item {
@@ -443,9 +583,6 @@ fun MeshTopologySection() {
}
}
// Connected devices
item {
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
@@ -532,3 +669,146 @@ fun MeshTopologySection() {
}
}
}
@Composable
private fun DrawGraphBlock(
title: String,
stackedKeys: List<String>,
stackedSeries: Map<String, List<Float>>,
overallSeries: List<Float>?,
graphMode: GraphMode,
highlightedKey: String?,
onToggleHighlight: (String) -> Unit,
ensureColors: (List<String>) -> Unit,
colorForKey: (String) -> Color,
legendTitleFor: (String) -> String,
legendMetricsFor: (String) -> String
) {
val colorScheme = MaterialTheme.colorScheme
val leftGutter = 40.dp
Box(Modifier.fillMaxWidth().height(56.dp)) {
androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) {
val axisPx = leftGutter.toPx()
val barCount = 60
val availW = (size.width - axisPx).coerceAtLeast(1f)
val w = availW / barCount
val h = size.height
drawLine(
color = Color(0x33888888),
start = androidx.compose.ui.geometry.Offset(axisPx, h - 1f),
end = androidx.compose.ui.geometry.Offset(size.width, h - 1f),
strokeWidth = 1f
)
when (graphMode) {
GraphMode.OVERALL -> {
val maxValRaw = (overallSeries?.maxOrNull() ?: 0f)
val maxVal = if (maxValRaw > 0f) maxValRaw else 0f
(overallSeries ?: emptyList()).forEachIndexed { i, value ->
if (value > 0f && maxVal > 0f) {
val ratio = (value / maxVal).coerceIn(0f, 1f)
val barHeight = (h * ratio).coerceAtLeast(0f)
if (barHeight > 0.5f) {
drawRect(
color = Color(0xFF00C851),
topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = h - barHeight),
size = androidx.compose.ui.geometry.Size(w, barHeight)
)
}
}
}
}
else -> {
val indices = 0 until 60
val totals = indices.map { idx ->
stackedSeries.values.sumOf { it.getOrNull(idx)?.toDouble() ?: 0.0 }.toFloat()
}
val maxTotal = (totals.maxOrNull() ?: 0f)
val drawKeysBars = if (stackedKeys.isNotEmpty()) stackedKeys else stackedSeries.keys.sorted()
indices.forEach { i ->
var yTop = h
if (maxTotal > 0f) {
ensureColors(drawKeysBars)
drawKeysBars.forEach { k ->
val v = stackedSeries[k]?.getOrNull(i) ?: 0f
if (v > 0f) {
val ratio = (v / maxTotal).coerceIn(0f, 1f)
val segH = (h * ratio)
if (segH > 0.5f) {
val top = (yTop - segH)
val baseColor = colorForKey(k)
val c = if (highlightedKey == null || highlightedKey == k) baseColor else baseColor.copy(alpha = 0.35f)
drawRect(
color = c,
topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = top),
size = androidx.compose.ui.geometry.Size(w, segH)
)
yTop = top
}
}
}
}
}
}
}
}
Row(Modifier.fillMaxSize()) {
Box(Modifier.width(leftGutter).fillMaxHeight()) {
Text(
"p/s",
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp).rotate(-90f)
)
val topLabel = when (graphMode) {
GraphMode.OVERALL -> (overallSeries?.maxOrNull() ?: 0f).toInt().toString()
else -> {
val totals = (0 until 60).map { idx -> stackedSeries.values.sumOf { it.getOrNull(idx)?.toDouble() ?: 0.0 }.toFloat() }
(totals.maxOrNull() ?: 0f).toInt().toString()
}
}
Text(
topLabel,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.align(Alignment.TopEnd).padding(end = 4.dp)
)
Text(
"0",
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 4.dp)
)
}
Spacer(Modifier.weight(1f))
}
}
val drawKeys = if (stackedKeys.isNotEmpty()) stackedKeys else stackedSeries.keys.sorted()
if (graphMode != GraphMode.OVERALL && drawKeys.isNotEmpty()) {
Column(Modifier.fillMaxWidth()) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
drawKeys.forEach { key ->
val baseColor = colorForKey(key)
val dimmed = highlightedKey != null && highlightedKey != key
val swatchColor = if (dimmed) baseColor.copy(alpha = 0.35f) else baseColor
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.clickable { onToggleHighlight(key) }
) {
Box(Modifier.size(10.dp).background(swatchColor, RoundedCornerShape(2.dp)))
Column {
Text(legendTitleFor(key), fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.6f else 0.95f))
Text(legendMetricsFor(key), fontFamily = FontFamily.Monospace, fontSize = 9.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.45f else 0.75f))
}
}
}
}
}
}
}
@@ -58,6 +58,10 @@ object AppConstants {
const val HIGH_NONCE_WARNING_THRESHOLD: Long = 1_000_000_000L
}
object Verification {
const val QR_MAX_AGE_SECONDS: Long = 300L // 5 minutes
}
object Protocol {
const val COMPRESSION_THRESHOLD_BYTES: Int = 100
}
@@ -76,12 +80,12 @@ object AppConstants {
const val SCAN_ON_DURATION_NORMAL_MS: Long = 8_000L
const val SCAN_OFF_DURATION_NORMAL_MS: Long = 2_000L
const val SCAN_ON_DURATION_POWER_SAVE_MS: Long = 2_000L
const val SCAN_OFF_DURATION_POWER_SAVE_MS: Long = 8_000L
const val SCAN_OFF_DURATION_POWER_SAVE_MS: Long = 28_000L
const val SCAN_ON_DURATION_ULTRA_LOW_MS: Long = 1_000L
const val SCAN_OFF_DURATION_ULTRA_LOW_MS: Long = 10_000L
const val SCAN_OFF_DURATION_ULTRA_LOW_MS: Long = 29_000L
const val MAX_CONNECTIONS_NORMAL: Int = 8
const val MAX_CONNECTIONS_POWER_SAVE: Int = 4
const val MAX_CONNECTIONS_ULTRA_LOW: Int = 2
const val MAX_CONNECTIONS_POWER_SAVE: Int = 8
const val MAX_CONNECTIONS_ULTRA_LOW: Int = 4
}
object Nostr {
@@ -115,6 +119,8 @@ object AppConstants {
const val MESSAGE_DEDUP_TIMEOUT_MS: Long = 30_000L
const val SYSTEM_EVENT_DEDUP_TIMEOUT_MS: Long = 5_000L
const val ACTIVE_PEERS_NOTIFICATION_INTERVAL_MS: Long = 300_000L
const val ACTION_FORCE_FINISH: String = "com.bitchat.android.ACTION_FORCE_FINISH"
const val PERMISSION_FORCE_FINISH: String = "com.bitchat.android.permission.FORCE_FINISH"
}
object Media {