mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 22:25:19 +00:00
Wifi aware refactor mesh core (#711)
* wifi aware wip * wifi aware wip * starting to work * werk * dms work * wip * fix(wifi-aware): use bindSocket and scoped IPv6 instead of bindProcessToNetwork * Merge branch 'upstream/main' into fix/wifi-aware-socket-binding * Fix Wi-Fi Aware connectivity and UI integration post-merge - Replace bindProcessToNetwork with bindSocket for VPN compatibility. - Implement Scoped IPv6 address resolution (aware0) for mesh routing. - Bridge Wi-Fi Aware incoming messages to AppStateStore for UI visibility. - Fix syntax errors and variable name conflicts in Debug UI. * Enhance Wi-Fi Aware robustness and debug UI display - Clean up transport resources (sockets, server sockets, network callbacks) immediately on peer disconnection. - Implement resolveScopedAddress to show scoped IPv6 (e.g., %aware0) in Debug UI. - Fix Map type mismatch warning in ChatViewModel bridge. - Filter self-ID from peer cleanup tables to prevent recursive self-removal. * Share GossipSyncManager across transports to prevent redundant message synchronization - Registered BluetoothMeshService's GossipSyncManager as a singleton in MeshServiceHolder. - Modified WifiAwareMeshService to use the shared GossipSyncManager if available. - Added background cleanup for peer mappings on socket disconnection. - Fixed Kotlin type mismatch during nickname map merging. * Restore VPN acquisition logic and improve peer cleanup - Revert removal of NET_CAPABILITY_NOT_VPN to allow hardware handle acquisition while VPN is active. - Refactor handlePeerDisconnection to more reliably cleanup initial and routed IDs. - Switch cleanup logging to debug level to reduce log noise. * Fix wifi aware socket binding 2 (#536) * Background persistence (#505) * persistence step 1 * fix build * messages in the background work, notifications not yet * app state store * DM icon shows up * notification launches when app is closed! * keep ui updated * lifecycle fixes * extensive logging, maybe revert later * send nickname in announcement * quit in notification * setting in about sheet * fix quit bitchat * lifecycle fixes * power mode based on background state * stats for both direciotns * fix graph persistence * better counting * count per device * only compute when debug sheet is open? untested * fix read receipts * fix read receipts fully * fix unread badge if messages have been read in focus * foreground promotion fix * fix app kill in notification * adjust to new tor * nice * about sheet design * bump version 1.6.0 (#524) * Automated update of relay data - Sun Dec 14 06:06:53 UTC 2025 * bump targetSdk (#526) * Automated update of relay data - Sun Dec 21 06:06:56 UTC 2025 * Automated update of relay data - Sun Dec 28 06:07:12 UTC 2025 * Prevent quit notification from reappearing (#530) * shutdown sequence * Prevent quit notification from reappearing * Restrict force-finish broadcast * Cancel quit shutdown on relaunch * fix(wifi-aware): use bindSocket and scoped IPv6 instead of bindProcessToNetwork * Merge branch 'upstream/main' into fix/wifi-aware-socket-binding * Fix Wi-Fi Aware connectivity and UI integration post-merge - Replace bindProcessToNetwork with bindSocket for VPN compatibility. - Implement Scoped IPv6 address resolution (aware0) for mesh routing. - Bridge Wi-Fi Aware incoming messages to AppStateStore for UI visibility. - Fix syntax errors and variable name conflicts in Debug UI. * Enhance Wi-Fi Aware robustness and debug UI display - Clean up transport resources (sockets, server sockets, network callbacks) immediately on peer disconnection. - Implement resolveScopedAddress to show scoped IPv6 (e.g., %aware0) in Debug UI. - Fix Map type mismatch warning in ChatViewModel bridge. - Filter self-ID from peer cleanup tables to prevent recursive self-removal. * Share GossipSyncManager across transports to prevent redundant message synchronization - Registered BluetoothMeshService's GossipSyncManager as a singleton in MeshServiceHolder. - Modified WifiAwareMeshService to use the shared GossipSyncManager if available. - Added background cleanup for peer mappings on socket disconnection. - Fixed Kotlin type mismatch during nickname map merging. * Restore VPN acquisition logic and improve peer cleanup - Revert removal of NET_CAPABILITY_NOT_VPN to allow hardware handle acquisition while VPN is active. - Refactor handlePeerDisconnection to more reliably cleanup initial and routed IDs. - Switch cleanup logging to debug level to reduce log noise. --------- Co-authored-by: GitHub Action <action@github.com> Co-authored-by: aidenvalue <> * Fix wifi aware routing (#534) * Background persistence (#505) * persistence step 1 * fix build * messages in the background work, notifications not yet * app state store * DM icon shows up * notification launches when app is closed! * keep ui updated * lifecycle fixes * extensive logging, maybe revert later * send nickname in announcement * quit in notification * setting in about sheet * fix quit bitchat * lifecycle fixes * power mode based on background state * stats for both direciotns * fix graph persistence * better counting * count per device * only compute when debug sheet is open? untested * fix read receipts * fix read receipts fully * fix unread badge if messages have been read in focus * foreground promotion fix * fix app kill in notification * adjust to new tor * nice * about sheet design * bump version 1.6.0 (#524) * Automated update of relay data - Sun Dec 14 06:06:53 UTC 2025 * bump targetSdk (#526) * Automated update of relay data - Sun Dec 21 06:06:56 UTC 2025 * Automated update of relay data - Sun Dec 28 06:07:12 UTC 2025 * Prevent quit notification from reappearing (#530) * shutdown sequence * Prevent quit notification from reappearing * Restrict force-finish broadcast * Cancel quit shutdown on relaunch * fix(wifi-aware): use bindSocket and scoped IPv6 instead of bindProcessToNetwork * Merge branch 'upstream/main' into fix/wifi-aware-socket-binding * Fix Wi-Fi Aware connectivity and UI integration post-merge - Replace bindProcessToNetwork with bindSocket for VPN compatibility. - Implement Scoped IPv6 address resolution (aware0) for mesh routing. - Bridge Wi-Fi Aware incoming messages to AppStateStore for UI visibility. - Fix syntax errors and variable name conflicts in Debug UI. * Enhance Wi-Fi Aware robustness and debug UI display - Clean up transport resources (sockets, server sockets, network callbacks) immediately on peer disconnection. - Implement resolveScopedAddress to show scoped IPv6 (e.g., %aware0) in Debug UI. - Fix Map type mismatch warning in ChatViewModel bridge. - Filter self-ID from peer cleanup tables to prevent recursive self-removal. * Share GossipSyncManager across transports to prevent redundant message synchronization - Registered BluetoothMeshService's GossipSyncManager as a singleton in MeshServiceHolder. - Modified WifiAwareMeshService to use the shared GossipSyncManager if available. - Added background cleanup for peer mappings on socket disconnection. - Fixed Kotlin type mismatch during nickname map merging. * Restore VPN acquisition logic and improve peer cleanup - Revert removal of NET_CAPABILITY_NOT_VPN to allow hardware handle acquisition while VPN is active. - Refactor handlePeerDisconnection to more reliably cleanup initial and routed IDs. - Switch cleanup logging to debug level to reduce log noise. * fix wifi aware routing --------- Co-authored-by: GitHub Action <action@github.com> Co-authored-by: aidenvalue <> * unify connection tracker for ble and wifi (#537) * unify connection tracker for ble and wifi * cleanup nicely * Wifi aware skip bluetooth (#538) * unify connection tracker for ble and wifi * bluetooth optional * refactor mesh core * tests * share gossip * refresh peer list more often * patches: wifi aware mesh refactor core (#549) * Refactor WifiAware memory management and lifecycle handling * Fix Wi-Fi Aware reconnection by ensuring callback unregistration and session watchdog * fix(wifiaware): allow WifiAwareController to restart service if session drops * Enhance WiFi Aware logging with network request timeouts and detailed callback status * Ensure explicit release of WiFi Aware network callbacks on failure or disconnection --------- Co-authored-by: aidenvalue <> * fix: wifi aware mesh private dms (#561) * fix(wifi-aware): restore peer check and remove aggressive session restarts * synchronized access to sockets. * clear old peer socket `onClientConnected` if one was found. * address second round of review * fix: Restart publish session on termination in WifiAwareMeshService * fix: cleanup connection tracker resources when handling peer disconnection without active socket --------- Co-authored-by: aidenvalu3 <> * fix(mesh): address memory leaks and background persistence issues in Wi-Fi Aware - Move message persistence and background notifications for Wi-Fi Aware into the service layer via MeshCore hooks. - Fix memory leak in MainActivity by properly detaching WifiAwareMeshDelegate on pause. - Ensure BluetoothMeshService is initialized before WifiAwareMeshService to guarantee shared GossipSyncManager instance. - Deduplicate persistence logic from MainActivity delegate. * wifi aware working * more robust * fragments work * new files * wifi improvements * handshake works * improve * wifi mesh more robust * fix review * check for support * wifi aware fixes --------- Co-authored-by: aidenvalue <> Co-authored-by: GitHub Action <action@github.com> Co-authored-by: aidenvalu3 <erendentman@gmail.com> Co-authored-by: a1denvalu3 <43107113+a1denvalu3@users.noreply.github.com> Co-authored-by: CC <cc@ggg.local>
This commit is contained in:
co-authored by
aidenvalue <>
GitHub Action
aidenvalu3
a1denvalu3
CC
parent
6189f0cb84
commit
3a983c5767
@@ -5,6 +5,7 @@
|
||||
<!-- Internet permissions for Nostr relay connections -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
|
||||
<!-- Bluetooth permissions -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
@@ -20,6 +21,12 @@
|
||||
|
||||
<!-- Notification permissions -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- Wi‑Fi / Wi‑Fi Aware permissions -->
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
|
||||
<!-- Android 13+ runtime permission for Wi‑Fi operations (including Aware) -->
|
||||
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
|
||||
<!-- Signature permission for internal UI shutdown broadcasts -->
|
||||
<uses-permission android:name="com.bitchat.android.permission.FORCE_FINISH" />
|
||||
<!-- Foreground service and boot permissions for long-running background mesh -->
|
||||
@@ -52,6 +59,8 @@
|
||||
<!-- Hardware features -->
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
||||
<uses-feature android:name="android.hardware.bluetooth" android:required="true" />
|
||||
<!-- Device support hint for Wi‑Fi Aware (optional) -->
|
||||
<uses-feature android:name="android.hardware.wifi.aware" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
<permission
|
||||
|
||||
@@ -41,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 Wi‑Fi Aware controller with persisted default
|
||||
try {
|
||||
val enabled = com.bitchat.android.ui.debug.DebugPreferenceManager.getWifiAwareEnabled(true)
|
||||
com.bitchat.android.wifiaware.WifiAwareController.initialize(this, enabled)
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Initialize Geohash Registries for persistence
|
||||
try {
|
||||
com.bitchat.android.nostr.GeohashAliasRegistry.initialize(this)
|
||||
|
||||
@@ -19,6 +19,7 @@ import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.onboarding.BluetoothCheckScreen
|
||||
import com.bitchat.android.onboarding.BluetoothStatus
|
||||
import com.bitchat.android.onboarding.BluetoothStatusManager
|
||||
@@ -40,6 +41,7 @@ import com.bitchat.android.ui.ChatScreen
|
||||
import com.bitchat.android.ui.ChatViewModel
|
||||
import com.bitchat.android.ui.OrientationAwareActivity
|
||||
import com.bitchat.android.ui.theme.BitchatTheme
|
||||
import com.bitchat.android.wifiaware.WifiAwareController
|
||||
import com.bitchat.android.nostr.PoWPreferenceManager
|
||||
import com.bitchat.android.services.VerificationService
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -55,12 +57,13 @@ class MainActivity : OrientationAwareActivity() {
|
||||
|
||||
// Core mesh service - provided by the foreground service holder
|
||||
private lateinit var meshService: BluetoothMeshService
|
||||
private lateinit var unifiedMeshService: MeshService
|
||||
private val mainViewModel: MainViewModel by viewModels()
|
||||
private val chatViewModel: ChatViewModel by viewModels {
|
||||
object : ViewModelProvider.Factory {
|
||||
override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return ChatViewModel(application, meshService) as T
|
||||
return ChatViewModel(application, meshService, unifiedMeshService) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,6 +117,10 @@ class MainActivity : OrientationAwareActivity() {
|
||||
// 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)
|
||||
unifiedMeshService = com.bitchat.android.service.MeshServiceHolder.getUnifiedOrCreate(applicationContext)
|
||||
// Expose BLE mesh to Wi‑Fi Aware controller for cross-transport relays - DEPRECATED
|
||||
// Bridging is now handled by TransportBridgeService automatically
|
||||
|
||||
bluetoothStatusManager = BluetoothStatusManager(
|
||||
activity = this,
|
||||
context = this,
|
||||
@@ -164,6 +171,17 @@ class MainActivity : OrientationAwareActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the unified mesh delegate attached when Wi-Fi Aware starts after the UI.
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
WifiAwareController.running.collect { running ->
|
||||
if (running && lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
|
||||
unifiedMeshService.delegate = chatViewModel
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only start onboarding process if we're in the initial CHECKING state
|
||||
// This prevents restarting onboarding on configuration changes
|
||||
@@ -222,6 +240,10 @@ class MainActivity : OrientationAwareActivity() {
|
||||
onRetry = {
|
||||
checkBluetoothAndProceed()
|
||||
},
|
||||
onSkip = {
|
||||
mainViewModel.skipBluetoothCheck()
|
||||
checkLocationAndProceed()
|
||||
},
|
||||
isLoading = isBluetoothLoading
|
||||
)
|
||||
}
|
||||
@@ -355,6 +377,13 @@ class MainActivity : OrientationAwareActivity() {
|
||||
private fun checkBluetoothAndProceed() {
|
||||
// Log.d("MainActivity", "Checking Bluetooth status")
|
||||
|
||||
// Check if user has skipped Bluetooth check for this session
|
||||
if (mainViewModel.isBluetoothCheckSkipped.value) {
|
||||
Log.d("MainActivity", "Bluetooth check skipped by user, proceeding to location check")
|
||||
checkLocationAndProceed()
|
||||
return
|
||||
}
|
||||
|
||||
// For first-time users, skip Bluetooth check and go straight to permissions
|
||||
// We'll check Bluetooth after permissions are granted
|
||||
if (permissionManager.isFirstTimeLaunch()) {
|
||||
@@ -367,6 +396,12 @@ class MainActivity : OrientationAwareActivity() {
|
||||
bluetoothStatusManager.logBluetoothStatus()
|
||||
mainViewModel.updateBluetoothStatus(bluetoothStatusManager.checkBluetoothStatus())
|
||||
|
||||
val bleRequired = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
|
||||
if (!bleRequired) {
|
||||
// Skip BLE checks entirely when BLE is disabled in debug settings
|
||||
checkLocationAndProceed()
|
||||
return
|
||||
}
|
||||
when (mainViewModel.bluetoothStatus.value) {
|
||||
BluetoothStatus.ENABLED -> {
|
||||
// Bluetooth is enabled, check location services next
|
||||
@@ -472,6 +507,8 @@ class MainActivity : OrientationAwareActivity() {
|
||||
Log.d("MainActivity", "Location services enabled by user")
|
||||
mainViewModel.updateLocationLoading(false)
|
||||
mainViewModel.updateLocationStatus(LocationStatus.ENABLED)
|
||||
// Ensure Wi-Fi Aware starts now that location is enabled
|
||||
com.bitchat.android.wifiaware.WifiAwareController.startIfPossible()
|
||||
checkBatteryOptimizationAndProceed()
|
||||
}
|
||||
|
||||
@@ -540,8 +577,9 @@ class MainActivity : OrientationAwareActivity() {
|
||||
else -> BatteryOptimizationStatus.ENABLED
|
||||
}
|
||||
|
||||
val bleRequired2 = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
|
||||
when {
|
||||
currentBluetoothStatus != BluetoothStatus.ENABLED -> {
|
||||
bleRequired2 && currentBluetoothStatus != BluetoothStatus.ENABLED -> {
|
||||
// Bluetooth still disabled, but now we have permissions to enable it
|
||||
Log.d("MainActivity", "Permissions granted, but Bluetooth still disabled. Showing Bluetooth enable screen.")
|
||||
mainViewModel.updateBluetoothStatus(currentBluetoothStatus)
|
||||
@@ -674,9 +712,9 @@ class MainActivity : OrientationAwareActivity() {
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Set up mesh service delegate and start services
|
||||
meshService.delegate = chatViewModel
|
||||
meshService.startServices()
|
||||
// Set up unified mesh delegate and start enabled transports
|
||||
unifiedMeshService.delegate = chatViewModel
|
||||
unifiedMeshService.startServices()
|
||||
|
||||
Log.d("MainActivity", "Mesh service started successfully")
|
||||
|
||||
@@ -720,11 +758,12 @@ class MainActivity : OrientationAwareActivity() {
|
||||
// Check Bluetooth and Location status on resume and handle accordingly
|
||||
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
||||
// Reattach mesh delegate to new ChatViewModel instance after Activity recreation
|
||||
try { meshService.delegate = chatViewModel } catch (_: Exception) { }
|
||||
try { unifiedMeshService.delegate = chatViewModel } catch (_: Exception) { }
|
||||
|
||||
// Check if Bluetooth was disabled while app was backgrounded
|
||||
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
|
||||
if (currentBluetoothStatus != BluetoothStatus.ENABLED) {
|
||||
val bleRequired = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
|
||||
if (bleRequired && currentBluetoothStatus != BluetoothStatus.ENABLED && !mainViewModel.isBluetoothCheckSkipped.value) {
|
||||
Log.w("MainActivity", "Bluetooth disabled while app was backgrounded")
|
||||
mainViewModel.updateBluetoothStatus(currentBluetoothStatus)
|
||||
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
|
||||
@@ -739,6 +778,9 @@ class MainActivity : OrientationAwareActivity() {
|
||||
mainViewModel.updateLocationStatus(currentLocationStatus)
|
||||
mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK)
|
||||
mainViewModel.updateLocationLoading(false)
|
||||
} else {
|
||||
// If location is enabled, ensure Wi-Fi Aware starts if it was blocked by location earlier
|
||||
com.bitchat.android.wifiaware.WifiAwareController.startIfPossible()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -748,7 +790,7 @@ class MainActivity : OrientationAwareActivity() {
|
||||
// Only set background state if app is fully initialized
|
||||
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
||||
// Detach UI delegate so the foreground service can own DM notifications while UI is closed
|
||||
try { meshService.delegate = null } catch (_: Exception) { }
|
||||
try { unifiedMeshService.delegate = null } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@ class MainViewModel : ViewModel() {
|
||||
private val _isBatteryOptimizationLoading = MutableStateFlow(false)
|
||||
val isBatteryOptimizationLoading: StateFlow<Boolean> = _isBatteryOptimizationLoading.asStateFlow()
|
||||
|
||||
private val _isBluetoothCheckSkipped = MutableStateFlow(false)
|
||||
val isBluetoothCheckSkipped: StateFlow<Boolean> = _isBluetoothCheckSkipped.asStateFlow()
|
||||
|
||||
// Public update functions for MainActivity
|
||||
fun updateOnboardingState(state: OnboardingState) {
|
||||
_onboardingState.value = state
|
||||
@@ -67,4 +70,8 @@ class MainViewModel : ViewModel() {
|
||||
fun updateBatteryOptimizationLoading(loading: Boolean) {
|
||||
_isBatteryOptimizationLoading.value = loading
|
||||
}
|
||||
|
||||
fun skipBluetoothCheck() {
|
||||
_isBluetoothCheckSkipped.value = true
|
||||
}
|
||||
}
|
||||
@@ -88,22 +88,49 @@ class BluetoothConnectionManager(
|
||||
// Public property for address-peer mapping
|
||||
val addressPeerMap get() = connectionTracker.addressPeerMap
|
||||
|
||||
private fun isBleTransportEnabled(): Boolean {
|
||||
return try {
|
||||
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
|
||||
} catch (_: Exception) {
|
||||
try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
|
||||
}
|
||||
}
|
||||
|
||||
private fun isGattServerEnabled(): Boolean {
|
||||
return isBleTransportEnabled() &&
|
||||
(try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true })
|
||||
}
|
||||
|
||||
private fun isGattClientEnabled(): Boolean {
|
||||
return isBleTransportEnabled() &&
|
||||
(try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true })
|
||||
}
|
||||
|
||||
init {
|
||||
powerManager.delegate = this
|
||||
// Observe debug settings to enforce role state while active
|
||||
try {
|
||||
val dbg = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance()
|
||||
// Master transport enable/disable
|
||||
connectionScope.launch {
|
||||
dbg.bleEnabled.collect { enabled ->
|
||||
if (enabled) return@collect
|
||||
if (isActive) {
|
||||
disableTransport()
|
||||
}
|
||||
}
|
||||
}
|
||||
// Role enable/disable
|
||||
connectionScope.launch {
|
||||
dbg.gattServerEnabled.collect { enabled ->
|
||||
if (!isActive) return@collect
|
||||
if (enabled) startServer() else stopServer()
|
||||
if (enabled && isBleTransportEnabled()) startServer() else stopServer()
|
||||
}
|
||||
}
|
||||
connectionScope.launch {
|
||||
dbg.gattClientEnabled.collect { enabled ->
|
||||
if (!isActive) return@collect
|
||||
if (enabled) startClient() else stopClient()
|
||||
if (enabled && isBleTransportEnabled()) startClient() else stopClient()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +190,12 @@ class BluetoothConnectionManager(
|
||||
*/
|
||||
fun startServices(): Boolean {
|
||||
Log.i(TAG, "Starting power-optimized Bluetooth services...")
|
||||
|
||||
if (!isBleTransportEnabled()) {
|
||||
Log.i(TAG, "BLE transport disabled by debug settings; not starting Bluetooth services")
|
||||
disableTransport()
|
||||
return false
|
||||
}
|
||||
|
||||
if (!permissionManager.hasBluetoothPermissions()) {
|
||||
Log.e(TAG, "Missing Bluetooth permissions")
|
||||
@@ -197,9 +230,8 @@ class BluetoothConnectionManager(
|
||||
powerManager.start()
|
||||
|
||||
// Start server/client based on debug settings
|
||||
val dbg = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (_: Exception) { null }
|
||||
val startServer = dbg?.gattServerEnabled?.value != false
|
||||
val startClient = dbg?.gattClientEnabled?.value != false
|
||||
val startServer = isGattServerEnabled()
|
||||
val startClient = isGattClientEnabled()
|
||||
|
||||
if (startServer) {
|
||||
if (!serverManager.start()) {
|
||||
@@ -234,6 +266,19 @@ class BluetoothConnectionManager(
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable BLE without cancelling this manager's coroutine scope, so it can be re-enabled.
|
||||
*/
|
||||
fun disableTransport() {
|
||||
Log.i(TAG, "Disabling BLE transport")
|
||||
isActive = false
|
||||
connectionScope.launch {
|
||||
clientManager.stop()
|
||||
serverManager.stop()
|
||||
connectionTracker.stop()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all Bluetooth services with proper cleanup
|
||||
@@ -279,7 +324,7 @@ class BluetoothConnectionManager(
|
||||
* Automatically fragments large packets to fit within BLE MTU limits
|
||||
*/
|
||||
fun broadcastPacket(routed: RoutedPacket) {
|
||||
if (!isActive) return
|
||||
if (!isActive || !isBleTransportEnabled()) return
|
||||
|
||||
packetBroadcaster.broadcastPacket(
|
||||
routed,
|
||||
@@ -289,7 +334,7 @@ class BluetoothConnectionManager(
|
||||
}
|
||||
|
||||
fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
|
||||
if (!isActive) return false
|
||||
if (!isActive || !isBleTransportEnabled()) return false
|
||||
return packetBroadcaster.sendToPeer(
|
||||
peerID,
|
||||
routed,
|
||||
@@ -306,7 +351,7 @@ class BluetoothConnectionManager(
|
||||
* Send a packet directly to a specific peer, without broadcasting to others.
|
||||
*/
|
||||
fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean {
|
||||
if (!isActive) return false
|
||||
if (!isActive || !isBleTransportEnabled()) return false
|
||||
return packetBroadcaster.sendPacketToPeer(
|
||||
RoutedPacket(packet),
|
||||
peerID,
|
||||
@@ -317,9 +362,15 @@ class BluetoothConnectionManager(
|
||||
|
||||
|
||||
// Expose role controls for debug UI
|
||||
fun startServer() { connectionScope.launch { serverManager.start() } }
|
||||
fun startServer() {
|
||||
if (!isActive || !isBleTransportEnabled()) return
|
||||
connectionScope.launch { if (isGattServerEnabled()) serverManager.start() }
|
||||
}
|
||||
fun stopServer() { connectionScope.launch { serverManager.stop() } }
|
||||
fun startClient() { connectionScope.launch { clientManager.start() } }
|
||||
fun startClient() {
|
||||
if (!isActive || !isBleTransportEnabled()) return
|
||||
connectionScope.launch { if (isGattClientEnabled()) clientManager.start() }
|
||||
}
|
||||
fun stopClient() { connectionScope.launch { clientManager.stop() } }
|
||||
|
||||
// Inject nickname resolver for broadcaster logs
|
||||
@@ -347,7 +398,10 @@ class BluetoothConnectionManager(
|
||||
/**
|
||||
* Public: connect/disconnect helpers for debug UI
|
||||
*/
|
||||
fun connectToAddress(address: String): Boolean = clientManager.connectToAddress(address)
|
||||
fun connectToAddress(address: String): Boolean {
|
||||
if (!isActive || !isBleTransportEnabled()) return false
|
||||
return clientManager.connectToAddress(address)
|
||||
}
|
||||
fun disconnectAddress(address: String) { connectionTracker.disconnectDevice(address) }
|
||||
|
||||
|
||||
@@ -358,10 +412,10 @@ class BluetoothConnectionManager(
|
||||
clientManager.stop()
|
||||
serverManager.stop()
|
||||
delay(200)
|
||||
if (isActive) {
|
||||
if (isActive && isBleTransportEnabled()) {
|
||||
// Restart managers if service is active
|
||||
serverManager.start()
|
||||
clientManager.start()
|
||||
if (isGattServerEnabled()) serverManager.start()
|
||||
if (isGattClientEnabled()) clientManager.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -396,11 +450,17 @@ class BluetoothConnectionManager(
|
||||
Log.i(TAG, "Power mode changed to: $newMode")
|
||||
|
||||
connectionScope.launch {
|
||||
if (!isActive || !isBleTransportEnabled()) {
|
||||
serverManager.stop()
|
||||
clientManager.stop()
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Avoid rapid scan restarts by checking if we need to change scan behavior
|
||||
val wasUsingDutyCycle = powerManager.shouldUseDutyCycle()
|
||||
|
||||
// Update advertising with new power settings if server enabled
|
||||
val serverEnabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true }
|
||||
val serverEnabled = isGattServerEnabled()
|
||||
if (serverEnabled) {
|
||||
serverManager.restartAdvertising()
|
||||
} else {
|
||||
@@ -411,7 +471,7 @@ class BluetoothConnectionManager(
|
||||
val nowUsingDutyCycle = powerManager.shouldUseDutyCycle()
|
||||
if (wasUsingDutyCycle != nowUsingDutyCycle) {
|
||||
Log.d(TAG, "Duty cycle behavior changed (${wasUsingDutyCycle} -> ${nowUsingDutyCycle}), restarting scan")
|
||||
val clientEnabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }
|
||||
val clientEnabled = isGattClientEnabled()
|
||||
if (clientEnabled) {
|
||||
clientManager.restartScanning()
|
||||
} else {
|
||||
@@ -427,6 +487,10 @@ class BluetoothConnectionManager(
|
||||
}
|
||||
|
||||
override fun onScanStateChanged(shouldScan: Boolean) {
|
||||
if (!isActive || !isBleTransportEnabled()) {
|
||||
clientManager.onScanStateChanged(false)
|
||||
return
|
||||
}
|
||||
clientManager.onScanStateChanged(shouldScan)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,29 +16,22 @@ import java.util.concurrent.CopyOnWriteArrayList
|
||||
class BluetoothConnectionTracker(
|
||||
private val connectionScope: CoroutineScope,
|
||||
private val powerManager: PowerManager
|
||||
) {
|
||||
) : MeshConnectionTracker(connectionScope, TAG) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BluetoothConnectionTracker"
|
||||
private const val CONNECTION_RETRY_DELAY = com.bitchat.android.util.AppConstants.Mesh.CONNECTION_RETRY_DELAY_MS
|
||||
private const val MAX_CONNECTION_ATTEMPTS = com.bitchat.android.util.AppConstants.Mesh.MAX_CONNECTION_ATTEMPTS
|
||||
private const val CLEANUP_DELAY = com.bitchat.android.util.AppConstants.Mesh.CONNECTION_CLEANUP_DELAY_MS
|
||||
private const val CLEANUP_INTERVAL = com.bitchat.android.util.AppConstants.Mesh.CONNECTION_CLEANUP_INTERVAL_MS // 30 seconds
|
||||
}
|
||||
|
||||
// Connection tracking - reduced memory footprint
|
||||
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>()
|
||||
|
||||
// Connection attempt tracking with automatic cleanup
|
||||
private val pendingConnections = ConcurrentHashMap<String, ConnectionAttempt>()
|
||||
|
||||
// State management
|
||||
private var isActive = false
|
||||
|
||||
/**
|
||||
* Consolidated device connection information
|
||||
*/
|
||||
@@ -52,37 +45,28 @@ class BluetoothConnectionTracker(
|
||||
val peerID: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Connection attempt tracking with automatic expiry
|
||||
*/
|
||||
data class ConnectionAttempt(
|
||||
val attempts: Int,
|
||||
val lastAttempt: Long = System.currentTimeMillis()
|
||||
) {
|
||||
fun isExpired(): Boolean =
|
||||
System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY * 2
|
||||
|
||||
fun shouldRetry(): Boolean =
|
||||
attempts < MAX_CONNECTION_ATTEMPTS &&
|
||||
System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY
|
||||
override fun start() {
|
||||
super.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the connection tracker
|
||||
*/
|
||||
fun start() {
|
||||
isActive = true
|
||||
startPeriodicCleanup()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the connection tracker
|
||||
*/
|
||||
fun stop() {
|
||||
isActive = false
|
||||
override fun stop() {
|
||||
super.stop()
|
||||
cleanupAllConnections()
|
||||
clearAllConnections()
|
||||
}
|
||||
|
||||
// Abstract implementations
|
||||
override fun isConnected(id: String): Boolean = connectedDevices.containsKey(id)
|
||||
|
||||
override fun disconnect(id: String) {
|
||||
connectedDevices[id]?.gatt?.let {
|
||||
try { it.disconnect() } catch (_: Exception) { }
|
||||
}
|
||||
cleanupDeviceConnection(id)
|
||||
Log.d(TAG, "Requested disconnect for $id")
|
||||
}
|
||||
|
||||
override fun getConnectionCount(): Int = connectedDevices.size
|
||||
|
||||
/**
|
||||
* Add a device connection
|
||||
@@ -90,7 +74,9 @@ class BluetoothConnectionTracker(
|
||||
fun addDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) {
|
||||
Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}")
|
||||
connectedDevices[deviceAddress] = deviceConn
|
||||
pendingConnections.remove(deviceAddress)
|
||||
removePendingConnection(deviceAddress)
|
||||
// Mark as awaiting first ANNOUNCE on this connection
|
||||
firstAnnounceSeen[deviceAddress] = false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,9 +149,7 @@ class BluetoothConnectionTracker(
|
||||
/**
|
||||
* Check if device is already connected
|
||||
*/
|
||||
fun isDeviceConnected(deviceAddress: String): Boolean {
|
||||
return connectedDevices.containsKey(deviceAddress)
|
||||
}
|
||||
fun isDeviceConnected(deviceAddress: String): Boolean = isConnected(deviceAddress)
|
||||
|
||||
/**
|
||||
* Check if a peer is already connected (by PeerID)
|
||||
@@ -175,63 +159,15 @@ class BluetoothConnectionTracker(
|
||||
return connectedDevices.values.any { it.peerID == peerID }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connection attempt is allowed
|
||||
*/
|
||||
fun isConnectionAttemptAllowed(deviceAddress: String): Boolean {
|
||||
val existingAttempt = pendingConnections[deviceAddress]
|
||||
return existingAttempt?.let {
|
||||
it.isExpired() || it.shouldRetry()
|
||||
} ?: true
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a pending connection attempt
|
||||
*/
|
||||
fun addPendingConnection(deviceAddress: String): Boolean {
|
||||
Log.d(TAG, "Tracker: Adding pending connection for $deviceAddress")
|
||||
synchronized(pendingConnections) {
|
||||
// Double-check inside synchronized block
|
||||
val currentAttempt = pendingConnections[deviceAddress]
|
||||
if (currentAttempt != null && !currentAttempt.isExpired() && !currentAttempt.shouldRetry()) {
|
||||
Log.d(TAG, "Tracker: Connection attempt already in progress for $deviceAddress")
|
||||
return false
|
||||
}
|
||||
if (currentAttempt != null) {
|
||||
Log.d(TAG, "Tracker: current attempt: $currentAttempt")
|
||||
}
|
||||
|
||||
// Update connection attempt atomically
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect a specific device (by MAC address)
|
||||
*/
|
||||
fun disconnectDevice(deviceAddress: String) {
|
||||
connectedDevices[deviceAddress]?.gatt?.let {
|
||||
try { it.disconnect() } catch (_: Exception) { }
|
||||
}
|
||||
cleanupDeviceConnection(deviceAddress)
|
||||
Log.d(TAG, "Requested disconnect for $deviceAddress")
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a pending connection
|
||||
*/
|
||||
fun removePendingConnection(deviceAddress: String) {
|
||||
pendingConnections.remove(deviceAddress)
|
||||
}
|
||||
fun disconnectDevice(deviceAddress: String) = disconnect(deviceAddress)
|
||||
|
||||
/**
|
||||
* Get connected device count
|
||||
*/
|
||||
fun getConnectedDeviceCount(): Int = connectedDevices.size
|
||||
fun getConnectedDeviceCount(): Int = getConnectionCount()
|
||||
|
||||
/**
|
||||
* Check if connection limit is reached
|
||||
@@ -301,6 +237,7 @@ class BluetoothConnectionTracker(
|
||||
subscribedDevices.removeAll { it.address == deviceAddress }
|
||||
addressPeerMap.remove(deviceAddress)
|
||||
}
|
||||
firstAnnounceSeen.remove(deviceAddress)
|
||||
Log.d(TAG, "Cleaned up device connection for $deviceAddress")
|
||||
}
|
||||
|
||||
@@ -334,36 +271,21 @@ class BluetoothConnectionTracker(
|
||||
addressPeerMap.clear()
|
||||
pendingConnections.clear()
|
||||
scanRSSI.clear()
|
||||
firstAnnounceSeen.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic cleanup of expired connections
|
||||
* Mark that we have received the first ANNOUNCE over this device connection.
|
||||
*/
|
||||
private fun startPeriodicCleanup() {
|
||||
connectionScope.launch {
|
||||
while (isActive) {
|
||||
delay(CLEANUP_INTERVAL)
|
||||
|
||||
if (!isActive) break
|
||||
|
||||
try {
|
||||
// Clean up expired pending connections
|
||||
val expiredConnections = pendingConnections.filter { it.value.isExpired() }
|
||||
expiredConnections.keys.forEach { pendingConnections.remove(it) }
|
||||
|
||||
// Log cleanup if any
|
||||
if (expiredConnections.isNotEmpty()) {
|
||||
Log.d(TAG, "Cleaned up ${expiredConnections.size} expired connection attempts")
|
||||
}
|
||||
|
||||
// Log current state
|
||||
Log.d(TAG, "Periodic cleanup: ${connectedDevices.size} connections, ${pendingConnections.size} pending")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error in periodic cleanup: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -32,6 +32,11 @@ class BluetoothGattClientManager(
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BluetoothGattClientManager"
|
||||
// Self-healing scan recovery tuning
|
||||
private const val SCAN_RETRY_BASE_MS = 3_000L // base backoff for transient scan failures
|
||||
private const val SCAN_MAX_RETRY_DELAY_MS = 30_000L // cap on backoff delay
|
||||
private const val SCAN_WATCHDOG_INTERVAL_MS = 30_000L // how often to verify the scanner is alive
|
||||
private const val SCAN_STALE_RESULT_MS = 120_000L // force a scan restart if no results for this long
|
||||
}
|
||||
|
||||
// Core Bluetooth components
|
||||
@@ -39,11 +44,28 @@ class BluetoothGattClientManager(
|
||||
context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
|
||||
private val bleScanner: BluetoothLeScanner? = bluetoothAdapter?.bluetoothLeScanner
|
||||
|
||||
private fun isBleTransportEnabled(): Boolean {
|
||||
return try {
|
||||
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
|
||||
} catch (_: Exception) {
|
||||
try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
|
||||
}
|
||||
}
|
||||
|
||||
private fun isClientRoleEnabled(): Boolean {
|
||||
return isBleTransportEnabled() &&
|
||||
(try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Public: Connect to a device by MAC address (for debug UI)
|
||||
*/
|
||||
fun connectToAddress(deviceAddress: String): Boolean {
|
||||
if (!isClientRoleEnabled()) {
|
||||
Log.i(TAG, "connectToAddress skipped: BLE client disabled")
|
||||
return false
|
||||
}
|
||||
val device = bluetoothAdapter?.getRemoteDevice(deviceAddress)
|
||||
return if (device != null) {
|
||||
val rssi = connectionTracker.getBestRSSI(deviceAddress) ?: -50
|
||||
@@ -61,8 +83,16 @@ class BluetoothGattClientManager(
|
||||
// Scan rate limiting to prevent "scanning too frequently" errors
|
||||
private var lastScanStartTime = 0L
|
||||
private var lastScanStopTime = 0L
|
||||
private var isCurrentlyScanning = false
|
||||
@Volatile private var isCurrentlyScanning = false
|
||||
private val scanRateLimit = 5000L // Minimum 5 seconds between scan start attempts
|
||||
|
||||
// Self-healing scan state.
|
||||
// scanningDesired distinguishes "we want to be scanning but it isn't running" (a fault to recover
|
||||
// from) from "scanning is intentionally off" (e.g. duty-cycle OFF window or client disabled).
|
||||
@Volatile private var scanningDesired = false
|
||||
@Volatile private var lastScanResultTime = 0L
|
||||
private var scanRetryCount = 0
|
||||
private var scanWatchdogJob: Job? = null
|
||||
|
||||
// RSSI monitoring state
|
||||
private var rssiMonitoringJob: Job? = null
|
||||
@@ -75,12 +105,10 @@ class BluetoothGattClientManager(
|
||||
*/
|
||||
fun start(): Boolean {
|
||||
// Respect debug setting
|
||||
try {
|
||||
if (!com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value) {
|
||||
Log.i(TAG, "Client start skipped: GATT Client disabled in debug settings")
|
||||
return false
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
if (!isClientRoleEnabled()) {
|
||||
Log.i(TAG, "Client start skipped: BLE/GATT Client disabled in debug settings")
|
||||
return false
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
Log.d(TAG, "GATT client already active; start is a no-op")
|
||||
@@ -106,12 +134,16 @@ class BluetoothGattClientManager(
|
||||
connectionScope.launch {
|
||||
if (powerManager.shouldUseDutyCycle()) {
|
||||
Log.i(TAG, "Using power-aware duty cycling")
|
||||
// Duty cycle drives onScanStateChanged(true/false); scanningDesired follows that.
|
||||
} else {
|
||||
scanningDesired = true
|
||||
startScanning()
|
||||
}
|
||||
|
||||
// Start RSSI monitoring
|
||||
startRSSIMonitoring()
|
||||
// Start the scan watchdog so a silently-dead or wedged scanner self-heals.
|
||||
startScanWatchdog()
|
||||
}
|
||||
|
||||
return true
|
||||
@@ -121,6 +153,8 @@ class BluetoothGattClientManager(
|
||||
* Stop client manager
|
||||
*/
|
||||
fun stop() {
|
||||
scanningDesired = false
|
||||
stopScanWatchdog()
|
||||
if (!isActive) {
|
||||
// Idempotent stop
|
||||
stopScanning()
|
||||
@@ -150,7 +184,8 @@ class BluetoothGattClientManager(
|
||||
* Handle scan state changes from power manager
|
||||
*/
|
||||
fun onScanStateChanged(shouldScan: Boolean) {
|
||||
val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }
|
||||
val enabled = isClientRoleEnabled()
|
||||
scanningDesired = shouldScan && enabled
|
||||
if (shouldScan && enabled) {
|
||||
startScanning()
|
||||
} else {
|
||||
@@ -199,7 +234,7 @@ class BluetoothGattClientManager(
|
||||
@Suppress("DEPRECATION")
|
||||
private fun startScanning() {
|
||||
// Respect debug setting
|
||||
val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }
|
||||
val enabled = isClientRoleEnabled()
|
||||
if (!permissionManager.hasBluetoothPermissions() || bleScanner == null || !isActive || !enabled) return
|
||||
|
||||
// Rate limit scan starts to prevent "scanning too frequently" errors
|
||||
@@ -217,7 +252,7 @@ class BluetoothGattClientManager(
|
||||
// Schedule delayed scan start
|
||||
connectionScope.launch {
|
||||
delay(remainingWait)
|
||||
if (isActive && !isCurrentlyScanning) {
|
||||
if (isActive && !isCurrentlyScanning && isClientRoleEnabled()) {
|
||||
startScanning()
|
||||
}
|
||||
}
|
||||
@@ -251,22 +286,39 @@ class BluetoothGattClientManager(
|
||||
lastScanStopTime = System.currentTimeMillis()
|
||||
|
||||
when (errorCode) {
|
||||
1 -> Log.e(TAG, "SCAN_FAILED_ALREADY_STARTED")
|
||||
2 -> Log.e(TAG, "SCAN_FAILED_APPLICATION_REGISTRATION_FAILED")
|
||||
3 -> Log.e(TAG, "SCAN_FAILED_INTERNAL_ERROR")
|
||||
4 -> Log.e(TAG, "SCAN_FAILED_FEATURE_UNSUPPORTED")
|
||||
5 -> Log.e(TAG, "SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES")
|
||||
1 -> {
|
||||
// Already started: the stack thinks a scan is running. Re-arm from a clean
|
||||
// state so we don't stay wedged (stop then restart with backoff).
|
||||
Log.e(TAG, "SCAN_FAILED_ALREADY_STARTED")
|
||||
stopScanning()
|
||||
scheduleScanRestart("already-started", SCAN_RETRY_BASE_MS)
|
||||
}
|
||||
2 -> {
|
||||
// App registration failed: common transient stack fault. Previously had NO
|
||||
// retry, which left discovery dead until a manual BLE toggle.
|
||||
Log.e(TAG, "SCAN_FAILED_APPLICATION_REGISTRATION_FAILED")
|
||||
scheduleScanRestart("registration-failed", SCAN_RETRY_BASE_MS)
|
||||
}
|
||||
3 -> {
|
||||
Log.e(TAG, "SCAN_FAILED_INTERNAL_ERROR")
|
||||
scheduleScanRestart("internal-error", SCAN_RETRY_BASE_MS)
|
||||
}
|
||||
4 -> Log.e(TAG, "SCAN_FAILED_FEATURE_UNSUPPORTED") // permanent: don't retry
|
||||
5 -> {
|
||||
// Out of hardware resources: back off longer so other scanners/connections
|
||||
// can free up before we try again.
|
||||
Log.e(TAG, "SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES")
|
||||
scheduleScanRestart("out-of-resources", SCAN_RETRY_BASE_MS * 3)
|
||||
}
|
||||
6 -> {
|
||||
Log.e(TAG, "SCAN_FAILED_SCANNING_TOO_FREQUENTLY")
|
||||
Log.w(TAG, "Scan failed due to rate limiting - will retry after delay")
|
||||
connectionScope.launch {
|
||||
delay(10000) // Wait 10 seconds before retrying
|
||||
if (isActive) {
|
||||
startScanning()
|
||||
}
|
||||
}
|
||||
scheduleScanRestart("too-frequently", 10_000L)
|
||||
}
|
||||
else -> {
|
||||
Log.e(TAG, "Unknown scan failure code: $errorCode")
|
||||
scheduleScanRestart("unknown-$errorCode", SCAN_RETRY_BASE_MS)
|
||||
}
|
||||
else -> Log.e(TAG, "Unknown scan failure code: $errorCode")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -304,6 +356,76 @@ class BluetoothGattClientManager(
|
||||
lastScanStopTime = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a scan restart with incremental backoff. Used to recover from transient scan
|
||||
* failures that previously had no retry path (codes 2/3/5), leaving discovery dead until a
|
||||
* manual BLE toggle.
|
||||
*/
|
||||
private fun scheduleScanRestart(reason: String, baseDelayMs: Long) {
|
||||
scanRetryCount++
|
||||
val delayMs = (baseDelayMs * scanRetryCount).coerceAtMost(SCAN_MAX_RETRY_DELAY_MS)
|
||||
Log.w(TAG, "Scheduling scan restart in ${delayMs}ms (attempt $scanRetryCount, reason=$reason)")
|
||||
connectionScope.launch {
|
||||
delay(delayMs)
|
||||
if (isActive && scanningDesired && isClientRoleEnabled() && !isCurrentlyScanning) {
|
||||
startScanning()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodic watchdog that self-heals the scanner. Android can stop a scan without ever invoking
|
||||
* onScanFailed (internal stack reset, Doze, background throttling), which leaves the app
|
||||
* believing it is scanning while it is not. This re-arms the scanner in those cases.
|
||||
*/
|
||||
private fun startScanWatchdog() {
|
||||
scanWatchdogJob?.cancel()
|
||||
scanWatchdogJob = connectionScope.launch {
|
||||
while (isActive) {
|
||||
delay(SCAN_WATCHDOG_INTERVAL_MS)
|
||||
try {
|
||||
// Only act when we are supposed to be scanning. Honors duty-cycle OFF windows
|
||||
// and the client-disabled state via scanningDesired.
|
||||
if (!isActive || !scanningDesired || !isClientRoleEnabled()) continue
|
||||
if (!permissionManager.hasBluetoothPermissions() || bluetoothAdapter?.isEnabled != true) continue
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
if (!isCurrentlyScanning) {
|
||||
Log.w(TAG, "Watchdog: scan desired but not running -> restarting scan")
|
||||
startScanning()
|
||||
} else if (lastScanResultTime > 0L &&
|
||||
now - lastScanResultTime > SCAN_STALE_RESULT_MS &&
|
||||
now - lastScanStartTime > SCAN_STALE_RESULT_MS) {
|
||||
// We think we're scanning but haven't seen anything for a long time. The scan
|
||||
// may have silently died (flag wedged true). Force a clean re-arm.
|
||||
Log.w(TAG, "Watchdog: no scan results for ${(now - lastScanResultTime) / 1000}s -> forcing scan restart")
|
||||
forceRestartScan()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Scan watchdog error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopScanWatchdog() {
|
||||
scanWatchdogJob?.cancel()
|
||||
scanWatchdogJob = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a clean scan restart, clearing a possibly-wedged isCurrentlyScanning flag.
|
||||
*/
|
||||
private fun forceRestartScan() {
|
||||
stopScanning()
|
||||
connectionScope.launch {
|
||||
delay(500)
|
||||
if (isActive && scanningDesired && isClientRoleEnabled() && !isCurrentlyScanning) {
|
||||
startScanning()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle scan result and initiate connection if appropriate
|
||||
@@ -320,6 +442,10 @@ class BluetoothGattClientManager(
|
||||
return
|
||||
}
|
||||
|
||||
// Proof the scanner is alive and finding our network: refresh liveness and clear backoff.
|
||||
lastScanResultTime = System.currentTimeMillis()
|
||||
scanRetryCount = 0
|
||||
|
||||
// Try to extract peerID from Service Data (if available) for stable identity
|
||||
val serviceData = scanRecord?.getServiceData(ParcelUuid(AppConstants.Mesh.Gatt.SERVICE_UUID))
|
||||
val peerID = if (serviceData != null && serviceData.size >= 8) {
|
||||
@@ -402,6 +528,7 @@ class BluetoothGattClientManager(
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
private fun connectToDevice(device: BluetoothDevice, rssi: Int, peerID: String? = null) {
|
||||
if (!isClientRoleEnabled()) return
|
||||
if (!permissionManager.hasBluetoothPermissions()) return
|
||||
|
||||
val deviceAddress = device.address
|
||||
@@ -562,7 +689,7 @@ class BluetoothGattClientManager(
|
||||
*/
|
||||
fun restartScanning() {
|
||||
// Respect debug setting
|
||||
val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }
|
||||
val enabled = isClientRoleEnabled()
|
||||
if (!isActive || !enabled) return
|
||||
|
||||
connectionScope.launch {
|
||||
|
||||
@@ -30,6 +30,9 @@ class BluetoothGattServerManager(
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BluetoothGattServerManager"
|
||||
// Self-healing advertising recovery tuning
|
||||
private const val ADVERTISE_RETRY_BASE_MS = 3_000L // base backoff for transient advertise failures
|
||||
private const val ADVERTISE_MAX_RETRY_DELAY_MS = 30_000L // cap on backoff delay
|
||||
}
|
||||
|
||||
// Core Bluetooth components
|
||||
@@ -42,10 +45,24 @@ class BluetoothGattServerManager(
|
||||
private var gattServer: BluetoothGattServer? = null
|
||||
private var characteristic: BluetoothGattCharacteristic? = null
|
||||
private var advertiseCallback: AdvertiseCallback? = null
|
||||
private var advertiseRetryCount = 0
|
||||
|
||||
// State management
|
||||
private var isActive = false
|
||||
|
||||
private fun isBleTransportEnabled(): Boolean {
|
||||
return try {
|
||||
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
|
||||
} catch (_: Exception) {
|
||||
try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
|
||||
}
|
||||
}
|
||||
|
||||
private fun isServerRoleEnabled(): Boolean {
|
||||
return isBleTransportEnabled() &&
|
||||
(try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect a specific device (used by ConnectionManager to enforce overall limits)
|
||||
*/
|
||||
@@ -62,12 +79,10 @@ class BluetoothGattServerManager(
|
||||
*/
|
||||
fun start(): Boolean {
|
||||
// Respect debug setting
|
||||
try {
|
||||
if (!com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value) {
|
||||
Log.i(TAG, "Server start skipped: GATT Server disabled in debug settings")
|
||||
return false
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
if (!isServerRoleEnabled()) {
|
||||
Log.i(TAG, "Server start skipped: BLE/GATT Server disabled in debug settings")
|
||||
return false
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
Log.d(TAG, "GATT server already active; start is a no-op")
|
||||
@@ -322,7 +337,7 @@ class BluetoothGattServerManager(
|
||||
@Suppress("DEPRECATION")
|
||||
private fun startAdvertising() {
|
||||
// Respect debug setting
|
||||
val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true }
|
||||
val enabled = isServerRoleEnabled()
|
||||
|
||||
// Guard conditions – never throw here to avoid crashing the app from a background coroutine
|
||||
if (!permissionManager.hasBluetoothPermissions()) {
|
||||
@@ -374,6 +389,7 @@ class BluetoothGattServerManager(
|
||||
|
||||
advertiseCallback = object : AdvertiseCallback() {
|
||||
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
|
||||
advertiseRetryCount = 0
|
||||
val mode = try {
|
||||
powerManager.getPowerInfo().split("Current Mode: ")[1].split("\n")[0]
|
||||
} catch (_: Exception) { "unknown" }
|
||||
@@ -382,6 +398,28 @@ class BluetoothGattServerManager(
|
||||
|
||||
override fun onStartFailure(errorCode: Int) {
|
||||
Log.e(TAG, "Advertising failed: $errorCode")
|
||||
// Previously this only logged, so if advertising failed this device became
|
||||
// undiscoverable until a manual BLE toggle. Retry transient failures with backoff.
|
||||
when (errorCode) {
|
||||
ADVERTISE_FAILED_ALREADY_STARTED ->
|
||||
Log.w(TAG, "ADVERTISE_FAILED_ALREADY_STARTED - already advertising, no retry")
|
||||
ADVERTISE_FAILED_DATA_TOO_LARGE ->
|
||||
Log.e(TAG, "ADVERTISE_FAILED_DATA_TOO_LARGE - config issue, not retrying")
|
||||
ADVERTISE_FAILED_FEATURE_UNSUPPORTED ->
|
||||
Log.e(TAG, "ADVERTISE_FAILED_FEATURE_UNSUPPORTED - unsupported, not retrying")
|
||||
ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> {
|
||||
Log.w(TAG, "ADVERTISE_FAILED_TOO_MANY_ADVERTISERS - will retry after backoff")
|
||||
scheduleAdvertiseRestart("too-many-advertisers")
|
||||
}
|
||||
ADVERTISE_FAILED_INTERNAL_ERROR -> {
|
||||
Log.w(TAG, "ADVERTISE_FAILED_INTERNAL_ERROR - will retry after backoff")
|
||||
scheduleAdvertiseRestart("internal-error")
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Unknown advertise failure $errorCode - will retry after backoff")
|
||||
scheduleAdvertiseRestart("unknown-$errorCode")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,12 +445,29 @@ class BluetoothGattServerManager(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule an advertising restart with incremental backoff after a transient failure.
|
||||
*/
|
||||
private fun scheduleAdvertiseRestart(reason: String) {
|
||||
advertiseRetryCount++
|
||||
val delayMs = (ADVERTISE_RETRY_BASE_MS * advertiseRetryCount).coerceAtMost(ADVERTISE_MAX_RETRY_DELAY_MS)
|
||||
Log.w(TAG, "Scheduling advertising restart in ${delayMs}ms (attempt $advertiseRetryCount, reason=$reason)")
|
||||
connectionScope.launch {
|
||||
delay(delayMs)
|
||||
if (isActive && isServerRoleEnabled()) {
|
||||
stopAdvertising()
|
||||
delay(100)
|
||||
startAdvertising()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart advertising (for power mode changes)
|
||||
*/
|
||||
fun restartAdvertising() {
|
||||
// Respect debug setting
|
||||
val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true }
|
||||
val enabled = isServerRoleEnabled()
|
||||
if (!isActive || !enabled) {
|
||||
stopAdvertising()
|
||||
return
|
||||
|
||||
@@ -16,6 +16,7 @@ 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 com.bitchat.android.service.TransportBridgeService
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.*
|
||||
import kotlin.math.sign
|
||||
@@ -34,7 +35,7 @@ import kotlin.random.Random
|
||||
* - BluetoothConnectionManager: BLE connections and GATT operations
|
||||
* - PacketProcessor: Incoming packet routing
|
||||
*/
|
||||
class BluetoothMeshService(private val context: Context) {
|
||||
class BluetoothMeshService(private val context: Context) : TransportBridgeService.TransportLayer {
|
||||
private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } }
|
||||
|
||||
companion object {
|
||||
@@ -70,6 +71,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
|
||||
// Coroutines
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var announceJob: Job? = null
|
||||
// Tracks whether this instance has been terminated via stopServices()
|
||||
private var terminated = false
|
||||
|
||||
@@ -99,17 +101,11 @@ class BluetoothMeshService(private val context: Context) {
|
||||
}
|
||||
)
|
||||
|
||||
// Wire sync manager delegate
|
||||
gossipSyncManager.delegate = object : GossipSyncManager.Delegate {
|
||||
override fun sendPacket(packet: BitchatPacket) {
|
||||
connectionManager.broadcastPacket(RoutedPacket(packet))
|
||||
}
|
||||
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
|
||||
connectionManager.sendPacketToPeer(peerID, packet)
|
||||
}
|
||||
override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket {
|
||||
return signPacketBeforeBroadcast(packet)
|
||||
}
|
||||
com.bitchat.android.service.MeshServiceHolder.setGossipManager(gossipSyncManager) { packet ->
|
||||
signPacketBeforeBroadcast(packet)
|
||||
}
|
||||
if (isBleTransportEnabled()) {
|
||||
TransportBridgeService.register("BLE", this)
|
||||
}
|
||||
|
||||
// Inject dynamic direct connection check into PeerManager
|
||||
@@ -120,6 +116,30 @@ class BluetoothMeshService(private val context: Context) {
|
||||
|
||||
Log.d(TAG, "Delegates set up; GossipSyncManager initialized")
|
||||
}
|
||||
|
||||
override fun send(packet: RoutedPacket) {
|
||||
if (!isBleTransportEnabled()) return
|
||||
connectionManager.broadcastPacket(packet)
|
||||
}
|
||||
|
||||
override fun sendToPeer(peerID: String, packet: BitchatPacket) {
|
||||
if (!isBleTransportEnabled()) return
|
||||
connectionManager.sendPacketToPeer(peerID, packet)
|
||||
}
|
||||
|
||||
private fun broadcastRoutedPacket(routed: RoutedPacket) {
|
||||
if (!isBleTransportEnabled()) return
|
||||
connectionManager.broadcastPacket(routed)
|
||||
TransportBridgeService.broadcast("BLE", routed)
|
||||
}
|
||||
|
||||
private fun isBleTransportEnabled(): Boolean {
|
||||
return try {
|
||||
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
|
||||
} catch (_: Exception) {
|
||||
try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic debug logging every 10 seconds
|
||||
@@ -146,7 +166,8 @@ class BluetoothMeshService(private val context: Context) {
|
||||
* Send broadcast announcement every 30 seconds
|
||||
*/
|
||||
private fun sendPeriodicBroadcastAnnounce() {
|
||||
serviceScope.launch {
|
||||
announceJob?.cancel()
|
||||
announceJob = serviceScope.launch {
|
||||
Log.d(TAG, "Starting periodic announce loop")
|
||||
while (isActive) {
|
||||
try {
|
||||
@@ -175,7 +196,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
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) { }
|
||||
try { com.bitchat.android.services.AppStateStore.setTransportPeers("BLE", peerIDs) } catch (_: Exception) { }
|
||||
// Then notify UI delegate if attached
|
||||
delegate?.didUpdatePeerList(peerIDs)
|
||||
}
|
||||
@@ -221,7 +242,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
)
|
||||
// Sign the handshake response
|
||||
val signedPacket = signPacketBeforeBroadcast(responsePacket)
|
||||
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
Log.d(TAG, "Sent Noise handshake response to $peerID (${response.size} bytes)")
|
||||
}
|
||||
|
||||
@@ -241,7 +262,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
}
|
||||
|
||||
override fun sendPacket(packet: BitchatPacket) {
|
||||
connectionManager.broadcastPacket(RoutedPacket(packet))
|
||||
broadcastRoutedPacket(RoutedPacket(packet))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,11 +305,11 @@ class BluetoothMeshService(private val context: Context) {
|
||||
override fun sendPacket(packet: BitchatPacket) {
|
||||
// Sign the packet before broadcasting
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
}
|
||||
|
||||
override fun relayPacket(routed: RoutedPacket) {
|
||||
connectionManager.broadcastPacket(routed)
|
||||
broadcastRoutedPacket(routed)
|
||||
}
|
||||
|
||||
override fun getBroadcastRecipient(): ByteArray {
|
||||
@@ -335,7 +356,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
|
||||
// Sign the handshake packet before broadcasting
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
Log.d(TAG, "Initiated Noise handshake with $peerID (${handshakeData.size} bytes)")
|
||||
} else {
|
||||
Log.w(TAG, "Failed to generate Noise handshake data for $peerID")
|
||||
@@ -538,11 +559,13 @@ class BluetoothMeshService(private val context: Context) {
|
||||
}
|
||||
|
||||
override fun relayPacket(routed: RoutedPacket) {
|
||||
connectionManager.broadcastPacket(routed)
|
||||
broadcastRoutedPacket(routed)
|
||||
}
|
||||
|
||||
override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
|
||||
return connectionManager.sendToPeer(peerID, routed)
|
||||
val sentOverBle = connectionManager.sendToPeer(peerID, routed)
|
||||
TransportBridgeService.sendToPeer("BLE", peerID, routed.packet)
|
||||
return sentOverBle
|
||||
}
|
||||
|
||||
override fun handleRequestSync(routed: RoutedPacket) {
|
||||
@@ -625,6 +648,15 @@ class BluetoothMeshService(private val context: Context) {
|
||||
Log.w(TAG, "Mesh service already active, ignoring duplicate start request")
|
||||
return
|
||||
}
|
||||
if (!isBleTransportEnabled()) {
|
||||
Log.i(TAG, "BLE transport disabled by debug settings; not starting mesh service")
|
||||
connectionManager.disableTransport()
|
||||
TransportBridgeService.unregister("BLE")
|
||||
com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("BLE")
|
||||
try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { }
|
||||
try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("BLE") } catch (_: Exception) { }
|
||||
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")
|
||||
@@ -635,17 +667,42 @@ class BluetoothMeshService(private val context: Context) {
|
||||
|
||||
if (connectionManager.startServices()) {
|
||||
isActive = true
|
||||
TransportBridgeService.register("BLE", this)
|
||||
|
||||
// Start periodic announcements for peer discovery and connectivity
|
||||
sendPeriodicBroadcastAnnounce()
|
||||
Log.d(TAG, "Started periodic broadcast announcements (every 30 seconds)")
|
||||
// Start periodic syncs
|
||||
gossipSyncManager.start()
|
||||
com.bitchat.android.service.MeshServiceHolder.startSharedGossip("BLE")
|
||||
Log.d(TAG, "GossipSyncManager started")
|
||||
} else {
|
||||
Log.e(TAG, "Failed to start Bluetooth services")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the debug master transport toggle without destroying this mesh instance.
|
||||
*/
|
||||
fun setBleTransportEnabled(enabled: Boolean) {
|
||||
if (enabled) {
|
||||
startServices()
|
||||
} else {
|
||||
pauseServicesForTransportDisable()
|
||||
}
|
||||
}
|
||||
|
||||
private fun pauseServicesForTransportDisable() {
|
||||
Log.i(TAG, "Disabling BLE mesh transport")
|
||||
isActive = false
|
||||
announceJob?.cancel()
|
||||
announceJob = null
|
||||
com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("BLE")
|
||||
TransportBridgeService.unregister("BLE")
|
||||
try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { }
|
||||
try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("BLE") } catch (_: Exception) { }
|
||||
connectionManager.disableTransport()
|
||||
try { peerManager.refreshPeerList() } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all mesh services
|
||||
@@ -658,6 +715,11 @@ class BluetoothMeshService(private val context: Context) {
|
||||
|
||||
Log.i(TAG, "Stopping Bluetooth mesh service")
|
||||
isActive = false
|
||||
announceJob?.cancel()
|
||||
announceJob = null
|
||||
TransportBridgeService.unregister("BLE")
|
||||
try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { }
|
||||
try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("BLE") } catch (_: Exception) { }
|
||||
|
||||
// Send leave announcement
|
||||
sendLeaveAnnouncement()
|
||||
@@ -667,7 +729,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
delay(200) // Give leave message time to send
|
||||
|
||||
// Stop all components
|
||||
gossipSyncManager.stop()
|
||||
com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("BLE")
|
||||
Log.d(TAG, "GossipSyncManager stopped")
|
||||
connectionManager.stopServices()
|
||||
Log.d(TAG, "BluetoothConnectionManager stop requested")
|
||||
@@ -717,7 +779,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
|
||||
// Sign the packet before broadcasting
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
// Track our own broadcast message for sync
|
||||
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
|
||||
}
|
||||
@@ -749,7 +811,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
val signed = signPacketBeforeBroadcast(packet)
|
||||
// Use a stable transferId based on the file TLV payload for progress tracking
|
||||
val transferId = sha256Hex(payload)
|
||||
connectionManager.broadcastPacket(RoutedPacket(signed, transferId = transferId))
|
||||
broadcastRoutedPacket(RoutedPacket(signed, transferId = transferId))
|
||||
try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -793,7 +855,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
|
||||
// Create NOISE_ENCRYPTED packet (not FILE_TRANSFER!)
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
version = if (encrypted.size > 0xFFFF) 2u else 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = hexStringToByteArray(recipientPeerID),
|
||||
@@ -807,7 +869,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
val signed = signPacketBeforeBroadcast(packet)
|
||||
// Use a stable transferId based on the unencrypted file TLV payload for progress tracking
|
||||
val transferId = sha256Hex(filePayload)
|
||||
connectionManager.broadcastPacket(RoutedPacket(signed, transferId = transferId))
|
||||
broadcastRoutedPacket(RoutedPacket(signed, transferId = transferId))
|
||||
Log.d(TAG, "✅ Sent encrypted file to $recipientPeerID")
|
||||
|
||||
} catch (e: Exception) {
|
||||
@@ -887,7 +949,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
|
||||
// Sign the packet before broadcasting
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
Log.d(TAG, "📤 Sent encrypted private message to $recipientPeerID (${encrypted.size} bytes)")
|
||||
|
||||
// FIXED: Don't send didReceiveMessage for our own sent messages
|
||||
@@ -958,7 +1020,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
|
||||
// Sign the packet before broadcasting
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID")
|
||||
|
||||
// Persist as read after successful send
|
||||
@@ -1006,7 +1068,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
)
|
||||
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
||||
broadcastRoutedPacket(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}")
|
||||
@@ -1070,7 +1132,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
announcePacket.copy(signature = signature)
|
||||
} ?: announcePacket
|
||||
|
||||
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
Log.d(TAG, "Sent iOS-compatible signed TLV announce (${tlvPayload.size} bytes)")
|
||||
// Track announce for sync
|
||||
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
|
||||
@@ -1133,7 +1195,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
packet.copy(signature = signature)
|
||||
} ?: packet
|
||||
|
||||
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
peerManager.markPeerAsAnnouncedTo(peerID)
|
||||
Log.d(TAG, "Sent iOS-compatible signed TLV peer announce to $peerID (${tlvPayload.size} bytes)")
|
||||
|
||||
@@ -1148,8 +1210,14 @@ class BluetoothMeshService(private val context: Context) {
|
||||
return try {
|
||||
// Prefer verified peers that are currently marked as direct
|
||||
val verified = peerManager.getVerifiedPeers()
|
||||
val direct = verified.filter { it.value.isDirectConnection }.keys.toList()
|
||||
direct.take(10)
|
||||
val direct = verified.filter { it.value.isDirectConnection }.keys.toSet()
|
||||
// Publish this transport's direct peers and gossip the cross-transport union so a
|
||||
// node connected via multiple transports advertises a complete neighbor list.
|
||||
try { com.bitchat.android.services.AppStateStore.setTransportDirectPeers("BLE", direct) } catch (_: Exception) { }
|
||||
val union = try {
|
||||
com.bitchat.android.services.AppStateStore.getDirectPeers().ifEmpty { direct }
|
||||
} catch (_: Exception) { direct }
|
||||
union.distinct().take(10)
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
@@ -1168,7 +1236,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
|
||||
// Sign the packet before broadcasting
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1415,18 +1483,10 @@ class BluetoothMeshService(private val context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegate interface for mesh service callbacks (maintains exact same interface)
|
||||
* Delegate interface for BLE mesh callbacks. Extends the shared mesh delegate so
|
||||
* transport-agnostic facades can receive the same callback stream.
|
||||
*/
|
||||
interface BluetoothMeshDelegate {
|
||||
fun didReceiveMessage(message: BitchatMessage)
|
||||
fun didUpdatePeerList(peers: List<String>)
|
||||
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
|
||||
// registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager
|
||||
interface BluetoothMeshDelegate : MeshDelegate {
|
||||
override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long)
|
||||
override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long)
|
||||
}
|
||||
|
||||
@@ -18,8 +18,6 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.Job
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.channels.actor
|
||||
|
||||
/**
|
||||
@@ -117,7 +115,7 @@ class BluetoothPacketBroadcaster(
|
||||
|
||||
// Actor scope for the broadcaster
|
||||
private val broadcasterScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val transferJobs = ConcurrentHashMap<String, Job>()
|
||||
private val fragmentingSender = FragmentingPacketSender(connectionScope, fragmentManager, TAG)
|
||||
|
||||
// SERIALIZATION: Actor to serialize all broadcast operations
|
||||
@OptIn(kotlinx.coroutines.ObsoleteCoroutinesApi::class)
|
||||
@@ -139,71 +137,14 @@ class BluetoothPacketBroadcaster(
|
||||
gattServer: BluetoothGattServer?,
|
||||
characteristic: BluetoothGattCharacteristic?
|
||||
) {
|
||||
val packet = routed.packet
|
||||
val isFile = packet.type == MessageType.FILE_TRANSFER.value
|
||||
if (isFile) {
|
||||
Log.d(TAG, "📤 Broadcasting FILE_TRANSFER: ${packet.payload.size} bytes")
|
||||
}
|
||||
// Prefer caller-provided transferId (e.g., for encrypted media), else derive for FILE_TRANSFER
|
||||
val transferId = routed.transferId ?: (if (isFile) sha256Hex(packet.payload) else null)
|
||||
// Check if we need to fragment
|
||||
if (fragmentManager != null) {
|
||||
val fragments = try {
|
||||
fragmentManager.createFragments(packet)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Fragment creation failed: ${e.message}", e)
|
||||
if (isFile) {
|
||||
Log.e(TAG, "❌ File fragmentation failed for ${packet.payload.size} byte file")
|
||||
}
|
||||
return
|
||||
}
|
||||
if (fragments.size > 1) {
|
||||
if (isFile) {
|
||||
Log.d(TAG, "🔀 File needs ${fragments.size} fragments")
|
||||
}
|
||||
Log.d(TAG, "Fragmenting packet into ${fragments.size} fragments")
|
||||
if (transferId != null) {
|
||||
TransferProgressManager.start(transferId, fragments.size)
|
||||
}
|
||||
val job = connectionScope.launch {
|
||||
var sent = 0
|
||||
fragments.forEach { fragment ->
|
||||
if (!isActive) return@launch
|
||||
// If cancelled, stop sending remaining fragments
|
||||
if (transferId != null && transferJobs[transferId]?.isCancelled == true) return@launch
|
||||
broadcastSinglePacket(RoutedPacket(fragment, transferId = transferId), gattServer, characteristic)
|
||||
// 20ms delay between fragments
|
||||
delay(20)
|
||||
if (transferId != null) {
|
||||
sent += 1
|
||||
TransferProgressManager.progress(transferId, sent, fragments.size)
|
||||
if (sent == fragments.size) TransferProgressManager.complete(transferId, fragments.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (transferId != null) {
|
||||
transferJobs[transferId] = job
|
||||
job.invokeOnCompletion { transferJobs.remove(transferId) }
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Send single packet if no fragmentation needed
|
||||
if (transferId != null) {
|
||||
TransferProgressManager.start(transferId, 1)
|
||||
}
|
||||
broadcastSinglePacket(routed, gattServer, characteristic)
|
||||
if (transferId != null) {
|
||||
TransferProgressManager.progress(transferId, 1, 1)
|
||||
TransferProgressManager.complete(transferId, 1)
|
||||
fragmentingSender.send(routed, "BLE broadcast") { packet ->
|
||||
broadcastSinglePacket(packet, gattServer, characteristic)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelTransfer(transferId: String): Boolean {
|
||||
val job = transferJobs.remove(transferId) ?: return false
|
||||
job.cancel()
|
||||
return true
|
||||
return fragmentingSender.cancelTransfer(transferId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,6 +156,18 @@ class BluetoothPacketBroadcaster(
|
||||
targetPeerID: String,
|
||||
gattServer: BluetoothGattServer?,
|
||||
characteristic: BluetoothGattCharacteristic?
|
||||
): Boolean {
|
||||
if (!hasPeerConnection(targetPeerID)) return false
|
||||
return fragmentingSender.send(routed, "BLE peer ${targetPeerID.take(8)}") { packet ->
|
||||
sendSinglePacketToPeer(packet, targetPeerID, gattServer, characteristic)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendSinglePacketToPeer(
|
||||
routed: RoutedPacket,
|
||||
targetPeerID: String,
|
||||
gattServer: BluetoothGattServer?,
|
||||
characteristic: BluetoothGattCharacteristic?
|
||||
): Boolean {
|
||||
val packet = routed.packet
|
||||
val data = packet.toBinaryData() ?: return false
|
||||
@@ -222,11 +175,6 @@ class BluetoothPacketBroadcaster(
|
||||
if (isFile) {
|
||||
Log.d(TAG, "📤 Broadcasting FILE_TRANSFER: ${packet.payload.size} bytes")
|
||||
}
|
||||
// Prefer caller-provided transferId (e.g., for encrypted media), else derive for FILE_TRANSFER
|
||||
val transferId = routed.transferId ?: (if (isFile) sha256Hex(packet.payload) else null)
|
||||
if (transferId != null) {
|
||||
TransferProgressManager.start(transferId, 1)
|
||||
}
|
||||
val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString()
|
||||
val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
|
||||
val incomingAddr = routed.relayAddress
|
||||
@@ -241,10 +189,6 @@ class BluetoothPacketBroadcaster(
|
||||
if (serverTarget != null) {
|
||||
if (notifyDevice(serverTarget, data, gattServer, characteristic)) {
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, serverTarget.address, packet.ttl, packet.version, routeInfo)
|
||||
if (transferId != null) {
|
||||
TransferProgressManager.progress(transferId, 1, 1)
|
||||
TransferProgressManager.complete(transferId, 1)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -255,10 +199,6 @@ class BluetoothPacketBroadcaster(
|
||||
if (clientTarget != null) {
|
||||
if (writeToDeviceConn(clientTarget, data)) {
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, clientTarget.device.address, packet.ttl, packet.version, routeInfo)
|
||||
if (transferId != null) {
|
||||
TransferProgressManager.progress(transferId, 1, 1)
|
||||
TransferProgressManager.complete(transferId, 1)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -266,12 +206,6 @@ class BluetoothPacketBroadcaster(
|
||||
return false
|
||||
}
|
||||
|
||||
private fun sha256Hex(bytes: ByteArray): String = try {
|
||||
val md = java.security.MessageDigest.getInstance("SHA-256")
|
||||
md.update(bytes)
|
||||
md.digest().joinToString("") { "%02x".format(it) }
|
||||
} catch (_: Exception) { bytes.size.toString(16) }
|
||||
|
||||
|
||||
/**
|
||||
* Public entry point for broadcasting - submits request to actor for serialization
|
||||
@@ -303,34 +237,19 @@ class BluetoothPacketBroadcaster(
|
||||
gattServer: BluetoothGattServer?,
|
||||
characteristic: BluetoothGattCharacteristic?
|
||||
): Boolean {
|
||||
val packet = routed.packet
|
||||
val data = packet.toBinaryData() ?: return false
|
||||
val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString()
|
||||
val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
|
||||
val incomingAddr = routed.relayAddress
|
||||
val incomingPeer = incomingAddr?.let { connectionTracker.addressPeerMap[it] }
|
||||
val senderNick = senderPeerID.let { pid -> nicknameResolver?.invoke(pid) }
|
||||
|
||||
// Try server-side connections first
|
||||
val targetDevice = connectionTracker.getSubscribedDevices()
|
||||
.firstOrNull { connectionTracker.addressPeerMap[it.address] == targetPeerID }
|
||||
if (targetDevice != null) {
|
||||
if (notifyDevice(targetDevice, data, gattServer, characteristic)) {
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, targetDevice.address, packet.ttl)
|
||||
return true
|
||||
}
|
||||
if (!hasPeerConnection(targetPeerID)) return false
|
||||
return fragmentingSender.send(routed, "BLE peer ${targetPeerID.take(8)}") { packet ->
|
||||
sendSinglePacketToPeer(packet, targetPeerID, gattServer, characteristic)
|
||||
}
|
||||
}
|
||||
|
||||
// Try client-side connections next
|
||||
val targetConn = connectionTracker.getConnectedDevices().values
|
||||
.firstOrNull { connectionTracker.addressPeerMap[it.device.address] == targetPeerID }
|
||||
if (targetConn != null) {
|
||||
if (writeToDeviceConn(targetConn, data)) {
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, targetConn.device.address, packet.ttl)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
private fun hasPeerConnection(targetPeerID: String): Boolean {
|
||||
val hasServerTarget = connectionTracker.getSubscribedDevices()
|
||||
.any { connectionTracker.addressPeerMap[it.address] == targetPeerID }
|
||||
if (hasServerTarget) return true
|
||||
|
||||
return connectionTracker.getConnectedDevices().values
|
||||
.any { connectionTracker.addressPeerMap[it.device.address] == targetPeerID }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import android.util.Log
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Shared transport send wrapper that applies bitchat packet fragmentation and
|
||||
* transfer progress before a transport writes packets to its concrete medium.
|
||||
*/
|
||||
class FragmentingPacketSender(
|
||||
private val scope: CoroutineScope,
|
||||
private val fragmentManager: FragmentManager?,
|
||||
private val logTag: String,
|
||||
private val interFragmentDelayMs: Long = 20L
|
||||
) {
|
||||
private val transferJobs = ConcurrentHashMap<String, Job>()
|
||||
|
||||
fun send(
|
||||
routed: RoutedPacket,
|
||||
description: String,
|
||||
sendSingle: (RoutedPacket) -> Boolean
|
||||
): Boolean {
|
||||
val transferId = transferIdFor(routed)
|
||||
val packets = packetsForTransport(routed.packet) ?: return false
|
||||
val total = packets.size
|
||||
|
||||
if (total <= 1) {
|
||||
if (transferId != null) {
|
||||
TransferProgressManager.start(transferId, 1)
|
||||
}
|
||||
val sent = sendSingle(routed.copy(packet = packets.first(), transferId = transferId))
|
||||
if (sent && transferId != null) {
|
||||
TransferProgressManager.progress(transferId, 1, 1)
|
||||
TransferProgressManager.complete(transferId, 1)
|
||||
}
|
||||
return sent
|
||||
}
|
||||
|
||||
Log.d(logTag, "Fragmenting packet type ${routed.packet.type} into $total fragments for $description")
|
||||
if (transferId != null) {
|
||||
TransferProgressManager.start(transferId, total)
|
||||
}
|
||||
|
||||
val job = scope.launch(start = CoroutineStart.LAZY) {
|
||||
var sent = 0
|
||||
for (packet in packets) {
|
||||
if (!isActive) return@launch
|
||||
if (transferId != null && transferJobs[transferId]?.isCancelled == true) return@launch
|
||||
|
||||
val fragment = routed.copy(packet = packet, transferId = transferId)
|
||||
val delivered = try {
|
||||
sendSingle(fragment)
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Fragment send failed for $description: ${e.message}", e)
|
||||
false
|
||||
}
|
||||
|
||||
if (!delivered) {
|
||||
Log.w(logTag, "Stopping fragmented send for $description after $sent/$total fragments")
|
||||
return@launch
|
||||
}
|
||||
|
||||
sent += 1
|
||||
if (transferId != null) {
|
||||
TransferProgressManager.progress(transferId, sent, total)
|
||||
}
|
||||
if (sent < total) {
|
||||
delay(interFragmentDelayMs)
|
||||
}
|
||||
}
|
||||
|
||||
if (transferId != null) {
|
||||
TransferProgressManager.complete(transferId, total)
|
||||
}
|
||||
}
|
||||
|
||||
if (transferId != null) {
|
||||
transferJobs[transferId] = job
|
||||
job.invokeOnCompletion { transferJobs.remove(transferId, job) }
|
||||
}
|
||||
job.start()
|
||||
return true
|
||||
}
|
||||
|
||||
fun cancelTransfer(transferId: String): Boolean {
|
||||
val job = transferJobs.remove(transferId) ?: return false
|
||||
job.cancel()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun packetsForTransport(packet: BitchatPacket): List<BitchatPacket>? {
|
||||
if (packet.type == MessageType.FRAGMENT.value) {
|
||||
return listOf(packet)
|
||||
}
|
||||
|
||||
val manager = fragmentManager ?: return listOf(packet)
|
||||
return try {
|
||||
val fragments = manager.createFragments(packet)
|
||||
if (fragments.isEmpty()) {
|
||||
Log.e(logTag, "Fragment manager returned no packets for packet type ${packet.type}")
|
||||
null
|
||||
} else {
|
||||
fragments
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Fragment creation failed for packet type ${packet.type}: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun transferIdFor(routed: RoutedPacket): String? {
|
||||
routed.transferId?.let { return it }
|
||||
val packet = routed.packet
|
||||
return if (packet.type == MessageType.FILE_TRANSFER.value) {
|
||||
sha256Hex(packet.payload)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun sha256Hex(bytes: ByteArray): String = try {
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
md.update(bytes)
|
||||
md.digest().joinToString("") { "%02x".format(it) }
|
||||
} catch (_: Exception) {
|
||||
bytes.size.toString(16)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Abstract base tracker for mesh connections (BLE, Wi-Fi Aware, etc.)
|
||||
* Encapsulates common state machine logic:
|
||||
* - Connection attempt tracking (retries, backoff)
|
||||
* - Pending connection management
|
||||
* - Automatic cleanup of expired attempts
|
||||
*/
|
||||
abstract class MeshConnectionTracker(
|
||||
private val scope: CoroutineScope,
|
||||
protected val tag: String
|
||||
) {
|
||||
companion object {
|
||||
const val CONNECTION_RETRY_DELAY = 5_000L
|
||||
const val MAX_CONNECTION_ATTEMPTS = 3
|
||||
const val CLEANUP_INTERVAL = 30_000L
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection attempt tracking with automatic expiry
|
||||
*/
|
||||
protected data class ConnectionAttempt(
|
||||
val attempts: Int,
|
||||
val lastAttempt: Long = System.currentTimeMillis()
|
||||
) {
|
||||
fun isExpired(): Boolean =
|
||||
System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY * 2
|
||||
|
||||
fun shouldRetry(): Boolean =
|
||||
attempts < MAX_CONNECTION_ATTEMPTS &&
|
||||
System.currentTimeMillis() - lastAttempt > CONNECTION_RETRY_DELAY
|
||||
}
|
||||
|
||||
// Tracks in-progress or failed attempts
|
||||
protected val pendingConnections = ConcurrentHashMap<String, ConnectionAttempt>()
|
||||
|
||||
private var isActive = false
|
||||
|
||||
/**
|
||||
* Start the tracker and its cleanup loop
|
||||
*/
|
||||
open fun start() {
|
||||
isActive = true
|
||||
startPeriodicCleanup()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the tracker
|
||||
*/
|
||||
open fun stop() {
|
||||
isActive = false
|
||||
pendingConnections.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a connection attempt is allowed for this peer/address
|
||||
*/
|
||||
fun isConnectionAttemptAllowed(id: String): Boolean {
|
||||
// If already connected, usually no need to retry (subclasses can override logic if needed,
|
||||
// but typically the caller checks isConnected() first).
|
||||
|
||||
val existingAttempt = pendingConnections[id]
|
||||
return existingAttempt?.let {
|
||||
it.isExpired() || it.shouldRetry()
|
||||
} ?: true
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a new connection attempt.
|
||||
* Returns true if the attempt was recorded (allowed), false if skipped.
|
||||
*/
|
||||
fun addPendingConnection(id: String): Boolean {
|
||||
synchronized(pendingConnections) {
|
||||
val currentAttempt = pendingConnections[id]
|
||||
|
||||
// If strictly not allowed right now, reject
|
||||
if (currentAttempt != null && !currentAttempt.isExpired() && !currentAttempt.shouldRetry()) {
|
||||
Log.d(tag, "Connection attempt already in progress for $id")
|
||||
return false
|
||||
}
|
||||
|
||||
// Update attempt count
|
||||
// Reset to 1 if expired, otherwise increment
|
||||
val attempts = if (currentAttempt?.isExpired() == true) 1 else (currentAttempt?.attempts ?: 0) + 1
|
||||
pendingConnections[id] = ConnectionAttempt(attempts)
|
||||
Log.d(tag, "Added pending connection for $id (attempts: $attempts)")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a pending attempt (e.g., on success or fatal error)
|
||||
*/
|
||||
fun removePendingConnection(id: String) {
|
||||
pendingConnections.remove(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract: Subclasses must define what "connected" means
|
||||
*/
|
||||
abstract fun isConnected(id: String): Boolean
|
||||
|
||||
/**
|
||||
* Abstract: Subclasses must implement disconnect logic
|
||||
*/
|
||||
abstract fun disconnect(id: String)
|
||||
|
||||
/**
|
||||
* Abstract: Subclasses report their active connection count
|
||||
*/
|
||||
abstract fun getConnectionCount(): Int
|
||||
|
||||
private fun startPeriodicCleanup() {
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
try {
|
||||
delay(CLEANUP_INTERVAL)
|
||||
if (!isActive) break
|
||||
|
||||
// Clean up expired pending connections
|
||||
val expired = pendingConnections.filter { it.value.isExpired() }
|
||||
expired.keys.forEach { pendingConnections.remove(it) }
|
||||
|
||||
if (expired.isNotEmpty()) {
|
||||
Log.d(tag, "Cleaned up ${expired.size} expired connection attempts")
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
Log.w(tag, "Error in periodic cleanup: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,843 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bitchat.android.crypto.EncryptionService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
import com.bitchat.android.model.IdentityAnnouncement
|
||||
import com.bitchat.android.model.NoisePayload
|
||||
import com.bitchat.android.model.NoisePayloadType
|
||||
import com.bitchat.android.model.PrivateMessagePacket
|
||||
import com.bitchat.android.model.RequestSyncPacket
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.protocol.SpecialRecipients
|
||||
import com.bitchat.android.service.TransportBridgeService
|
||||
import com.bitchat.android.sync.GossipSyncManager
|
||||
import com.bitchat.android.util.toHexString
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Shared mesh coordinator that wires all mesh-layer components and provides common APIs
|
||||
* for send/receive operations across transports.
|
||||
*/
|
||||
class MeshCore(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val transport: MeshTransport,
|
||||
private val encryptionService: EncryptionService,
|
||||
val myPeerID: String,
|
||||
private val maxTtl: UByte,
|
||||
sharedGossipManager: GossipSyncManager?,
|
||||
gossipConfigProvider: GossipSyncManager.ConfigProvider,
|
||||
private val hooks: Hooks = Hooks()
|
||||
) {
|
||||
data class Hooks(
|
||||
val onMessageReceived: ((BitchatMessage) -> Unit)? = null,
|
||||
val onPeerIdBindingUpdated: ((String, String, ByteArray, String?) -> Unit)? = null,
|
||||
val onAnnounceProcessed: ((RoutedPacket, Boolean) -> Unit)? = null,
|
||||
val readReceiptInterceptor: ((String, String) -> Boolean)? = null,
|
||||
val onReadReceiptSent: ((String) -> Unit)? = null,
|
||||
val announcementNicknameProvider: (() -> String?)? = null,
|
||||
val leavePayloadProvider: (() -> ByteArray)? = null
|
||||
)
|
||||
|
||||
private val peerManager = PeerManager()
|
||||
val fragmentManager = FragmentManager()
|
||||
private val securityManager = SecurityManager(encryptionService, myPeerID)
|
||||
private val storeForwardManager = StoreForwardManager()
|
||||
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
|
||||
private val packetProcessor = PacketProcessor(myPeerID)
|
||||
private val directPeers = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
val gossipSyncManager: GossipSyncManager =
|
||||
sharedGossipManager ?: GossipSyncManager(myPeerID = myPeerID, scope = scope, configProvider = gossipConfigProvider)
|
||||
private val ownsGossipManager: Boolean = sharedGossipManager == null
|
||||
|
||||
var delegate: MeshDelegate? = null
|
||||
|
||||
private var announceJob: Job? = null
|
||||
private var isActive = false
|
||||
|
||||
init {
|
||||
messageHandler.packetProcessor = packetProcessor
|
||||
peerManager.isPeerDirectlyConnected = { peerID -> directPeers.contains(peerID) }
|
||||
setupDelegates()
|
||||
|
||||
if (sharedGossipManager == null) {
|
||||
gossipSyncManager.delegate = object : GossipSyncManager.Delegate {
|
||||
override fun sendPacket(packet: BitchatPacket) {
|
||||
dispatchGlobal(RoutedPacket(packet))
|
||||
}
|
||||
|
||||
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
|
||||
transport.sendPacketToPeer(peerID, packet)
|
||||
TransportBridgeService.sendToPeer(transport.id, peerID, packet)
|
||||
}
|
||||
|
||||
override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket {
|
||||
return signPacketBeforeBroadcast(packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startCore() {
|
||||
if (isActive) return
|
||||
isActive = true
|
||||
startPeriodicBroadcastAnnounce()
|
||||
if (ownsGossipManager) {
|
||||
gossipSyncManager.start()
|
||||
}
|
||||
}
|
||||
|
||||
fun stopCore() {
|
||||
if (!isActive) return
|
||||
isActive = false
|
||||
announceJob?.cancel()
|
||||
announceJob = null
|
||||
if (ownsGossipManager) {
|
||||
gossipSyncManager.stop()
|
||||
}
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
peerManager.shutdown()
|
||||
fragmentManager.shutdown()
|
||||
securityManager.shutdown()
|
||||
storeForwardManager.shutdown()
|
||||
messageHandler.shutdown()
|
||||
packetProcessor.shutdown()
|
||||
}
|
||||
|
||||
fun processIncoming(packet: BitchatPacket, peerID: String?, relayAddress: String?) {
|
||||
packetProcessor.processPacket(RoutedPacket(packet, peerID, relayAddress))
|
||||
}
|
||||
|
||||
fun sendFromBridge(packet: RoutedPacket) {
|
||||
transport.broadcastPacket(packet)
|
||||
}
|
||||
|
||||
private fun dispatchGlobal(routed: RoutedPacket) {
|
||||
transport.broadcastPacket(routed)
|
||||
TransportBridgeService.broadcast(transport.id, routed)
|
||||
}
|
||||
|
||||
private fun startPeriodicBroadcastAnnounce() {
|
||||
announceJob?.cancel()
|
||||
announceJob = scope.launch {
|
||||
while (isActive) {
|
||||
try {
|
||||
delay(30_000)
|
||||
sendBroadcastAnnounce()
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupDelegates() {
|
||||
peerManager.delegate = object : PeerManagerDelegate {
|
||||
override fun onPeerListUpdated(peerIDs: List<String>) {
|
||||
try { com.bitchat.android.services.AppStateStore.setTransportPeers(transport.id, peerIDs) } catch (_: Exception) { }
|
||||
delegate?.didUpdatePeerList(peerIDs)
|
||||
}
|
||||
|
||||
override fun onPeerRemoved(peerID: String) {
|
||||
try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { }
|
||||
try { encryptionService.removePeer(peerID) } catch (_: Exception) { }
|
||||
try { peerManager.refreshPeerList() } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
securityManager.delegate = object : SecurityManagerDelegate {
|
||||
override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) {
|
||||
scope.launch {
|
||||
delay(100)
|
||||
sendAnnouncementToPeer(peerID)
|
||||
delay(1000)
|
||||
storeForwardManager.sendCachedMessages(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendHandshakeResponse(peerID: String, response: ByteArray) {
|
||||
val responsePacket = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_HANDSHAKE.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = MeshPacketUtils.hexStringToByteArray(peerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = response,
|
||||
ttl = maxTtl
|
||||
)
|
||||
dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(responsePacket)))
|
||||
}
|
||||
|
||||
override fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID)
|
||||
}
|
||||
|
||||
storeForwardManager.delegate = object : StoreForwardManagerDelegate {
|
||||
override fun isFavorite(peerID: String): Boolean {
|
||||
return delegate?.isFavorite(peerID) ?: false
|
||||
}
|
||||
|
||||
override fun isPeerOnline(peerID: String): Boolean {
|
||||
return peerManager.isPeerActive(peerID)
|
||||
}
|
||||
|
||||
override fun sendPacket(packet: BitchatPacket) {
|
||||
dispatchGlobal(RoutedPacket(packet))
|
||||
}
|
||||
}
|
||||
|
||||
messageHandler.delegate = object : MessageHandlerDelegate {
|
||||
override fun addOrUpdatePeer(peerID: String, nickname: String): Boolean {
|
||||
return peerManager.addOrUpdatePeer(peerID, nickname)
|
||||
}
|
||||
|
||||
override fun removePeer(peerID: String) {
|
||||
peerManager.removePeer(peerID)
|
||||
}
|
||||
|
||||
override fun updatePeerNickname(peerID: String, nickname: String) {
|
||||
peerManager.addOrUpdatePeer(peerID, nickname)
|
||||
}
|
||||
|
||||
override fun getPeerNickname(peerID: String): String? {
|
||||
return peerManager.getPeerNickname(peerID)
|
||||
}
|
||||
|
||||
override fun getNetworkSize(): Int {
|
||||
return peerManager.getActivePeerCount()
|
||||
}
|
||||
|
||||
override fun getMyNickname(): String? {
|
||||
return delegate?.getNickname()
|
||||
}
|
||||
|
||||
override fun getPeerInfo(peerID: String): PeerInfo? {
|
||||
return peerManager.getPeerInfo(peerID)
|
||||
}
|
||||
|
||||
override fun updatePeerInfo(
|
||||
peerID: String,
|
||||
nickname: String,
|
||||
noisePublicKey: ByteArray,
|
||||
signingPublicKey: ByteArray,
|
||||
isVerified: Boolean
|
||||
): Boolean {
|
||||
return peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified)
|
||||
}
|
||||
|
||||
override fun sendPacket(packet: BitchatPacket) {
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
dispatchGlobal(RoutedPacket(signedPacket))
|
||||
}
|
||||
|
||||
override fun relayPacket(routed: RoutedPacket) {
|
||||
dispatchGlobal(routed)
|
||||
}
|
||||
|
||||
override fun getBroadcastRecipient(): ByteArray {
|
||||
return SpecialRecipients.BROADCAST
|
||||
}
|
||||
|
||||
override fun verifySignature(packet: BitchatPacket, peerID: String): Boolean {
|
||||
return securityManager.verifySignature(packet, peerID)
|
||||
}
|
||||
|
||||
override fun encryptForPeer(data: ByteArray, recipientPeerID: String): ByteArray? {
|
||||
return securityManager.encryptForPeer(data, recipientPeerID)
|
||||
}
|
||||
|
||||
override fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray? {
|
||||
return securityManager.decryptFromPeer(encryptedData, senderPeerID)
|
||||
}
|
||||
|
||||
override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean {
|
||||
return encryptionService.verifyEd25519Signature(signature, data, publicKey)
|
||||
}
|
||||
|
||||
override fun hasNoiseSession(peerID: String): Boolean {
|
||||
return encryptionService.hasEstablishedSession(peerID)
|
||||
}
|
||||
|
||||
override fun initiateNoiseHandshake(peerID: String) {
|
||||
this@MeshCore.initiateNoiseHandshake(peerID)
|
||||
}
|
||||
|
||||
override fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray? {
|
||||
return try {
|
||||
encryptionService.processHandshakeMessage(payload, peerID)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun updatePeerIDBinding(
|
||||
newPeerID: String,
|
||||
nickname: String,
|
||||
publicKey: ByteArray,
|
||||
previousPeerID: String?
|
||||
) {
|
||||
peerManager.addOrUpdatePeer(newPeerID, nickname)
|
||||
val fingerprint = peerManager.storeFingerprintForPeer(newPeerID, publicKey)
|
||||
previousPeerID?.let { peerManager.removePeer(it) }
|
||||
Log.d("MeshCore", "Updated peer ID binding: $newPeerID fp=${fingerprint.take(16)}")
|
||||
hooks.onPeerIdBindingUpdated?.invoke(newPeerID, nickname, publicKey, previousPeerID)
|
||||
}
|
||||
|
||||
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
|
||||
return delegate?.decryptChannelMessage(encryptedContent, channel)
|
||||
}
|
||||
|
||||
override fun onMessageReceived(message: BitchatMessage) {
|
||||
hooks.onMessageReceived?.invoke(message)
|
||||
delegate?.didReceiveMessage(message)
|
||||
}
|
||||
|
||||
override fun onChannelLeave(channel: String, fromPeer: String) {
|
||||
delegate?.didReceiveChannelLeave(channel, fromPeer)
|
||||
}
|
||||
|
||||
override fun onDeliveryAckReceived(messageID: String, peerID: String) {
|
||||
delegate?.didReceiveDeliveryAck(messageID, peerID)
|
||||
}
|
||||
|
||||
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.delegate = object : PacketProcessorDelegate {
|
||||
override fun validatePacketSecurity(packet: BitchatPacket, peerID: String): Boolean {
|
||||
return securityManager.validatePacket(packet, peerID)
|
||||
}
|
||||
|
||||
override fun updatePeerLastSeen(peerID: String) {
|
||||
peerManager.updatePeerLastSeen(peerID)
|
||||
}
|
||||
|
||||
override fun getPeerNickname(peerID: String): String? {
|
||||
return peerManager.getPeerNickname(peerID)
|
||||
}
|
||||
|
||||
override fun getNetworkSize(): Int {
|
||||
return peerManager.getActivePeerCount()
|
||||
}
|
||||
|
||||
override fun getBroadcastRecipient(): ByteArray {
|
||||
return SpecialRecipients.BROADCAST
|
||||
}
|
||||
|
||||
override fun handleNoiseHandshake(routed: RoutedPacket): Boolean {
|
||||
return runBlocking { securityManager.handleNoiseHandshake(routed) }
|
||||
}
|
||||
|
||||
override fun handleNoiseEncrypted(routed: RoutedPacket) {
|
||||
scope.launch { messageHandler.handleNoiseEncrypted(routed) }
|
||||
}
|
||||
|
||||
override fun handleAnnounce(routed: RoutedPacket) {
|
||||
scope.launch {
|
||||
val isFirst = messageHandler.handleAnnounce(routed)
|
||||
hooks.onAnnounceProcessed?.invoke(routed, isFirst)
|
||||
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleMessage(routed: RoutedPacket) {
|
||||
scope.launch { messageHandler.handleMessage(routed) }
|
||||
try {
|
||||
val pkt = routed.packet
|
||||
val isBroadcast = (pkt.recipientID == null || pkt.recipientID.contentEquals(SpecialRecipients.BROADCAST))
|
||||
if (isBroadcast && pkt.type == MessageType.MESSAGE.value) {
|
||||
gossipSyncManager.onPublicPacketSeen(pkt)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override fun handleLeave(routed: RoutedPacket) {
|
||||
scope.launch { messageHandler.handleLeave(routed) }
|
||||
}
|
||||
|
||||
override fun handleFragment(packet: BitchatPacket): BitchatPacket? {
|
||||
try {
|
||||
val isBroadcast = (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST))
|
||||
if (isBroadcast && packet.type == MessageType.FRAGMENT.value) {
|
||||
gossipSyncManager.onPublicPacketSeen(packet)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
return fragmentManager.handleFragment(packet)
|
||||
}
|
||||
|
||||
override fun sendAnnouncementToPeer(peerID: String) {
|
||||
this@MeshCore.sendAnnouncementToPeer(peerID)
|
||||
}
|
||||
|
||||
override fun sendCachedMessages(peerID: String) {
|
||||
storeForwardManager.sendCachedMessages(peerID)
|
||||
}
|
||||
|
||||
override fun relayPacket(routed: RoutedPacket) {
|
||||
dispatchGlobal(routed)
|
||||
}
|
||||
|
||||
override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
|
||||
val sent = transport.sendPacketToPeer(peerID, routed.packet)
|
||||
TransportBridgeService.sendToPeer(transport.id, peerID, routed.packet)
|
||||
return sent
|
||||
}
|
||||
|
||||
override fun handleRequestSync(routed: RoutedPacket) {
|
||||
val fromPeer = routed.peerID ?: return
|
||||
val req = RequestSyncPacket.decode(routed.packet.payload) ?: return
|
||||
gossipSyncManager.handleRequestSync(fromPeer, req)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(content: String, mentions: List<String> = emptyList(), channel: String? = null) {
|
||||
if (content.isEmpty()) return
|
||||
scope.launch {
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.MESSAGE.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = SpecialRecipients.BROADCAST,
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = content.toByteArray(Charsets.UTF_8),
|
||||
signature = null,
|
||||
ttl = maxTtl
|
||||
)
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
dispatchGlobal(RoutedPacket(signedPacket))
|
||||
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
fun sendFileBroadcast(file: BitchatFilePacket) {
|
||||
try {
|
||||
val payload = file.encode() ?: return
|
||||
scope.launch {
|
||||
val packet = BitchatPacket(
|
||||
version = 2u,
|
||||
type = MessageType.FILE_TRANSFER.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = SpecialRecipients.BROADCAST,
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
signature = null,
|
||||
ttl = maxTtl
|
||||
)
|
||||
val signed = signPacketBeforeBroadcast(packet)
|
||||
val transferId = MeshPacketUtils.sha256Hex(payload)
|
||||
dispatchGlobal(RoutedPacket(signed, transferId = transferId))
|
||||
try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("MeshCore", "sendFileBroadcast failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) {
|
||||
try {
|
||||
scope.launch {
|
||||
if (!encryptionService.hasEstablishedSession(recipientPeerID)) {
|
||||
initiateNoiseHandshake(recipientPeerID)
|
||||
return@launch
|
||||
}
|
||||
val tlv = file.encode() ?: return@launch
|
||||
val np = NoisePayload(type = NoisePayloadType.FILE_TRANSFER, data = tlv).encode()
|
||||
val enc = encryptionService.encrypt(np, recipientPeerID)
|
||||
val packet = BitchatPacket(
|
||||
version = if (enc.size > 0xFFFF) 2u else 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = enc,
|
||||
signature = null,
|
||||
ttl = maxTtl
|
||||
)
|
||||
val signed = signPacketBeforeBroadcast(packet)
|
||||
val transferId = MeshPacketUtils.sha256Hex(tlv)
|
||||
dispatchGlobal(RoutedPacket(signed, transferId = transferId))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("MeshCore", "sendFilePrivate failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelFileTransfer(transferId: String): Boolean {
|
||||
return transport.cancelTransfer(transferId)
|
||||
}
|
||||
|
||||
fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) {
|
||||
if (content.isEmpty() || recipientPeerID.isEmpty()) return
|
||||
scope.launch {
|
||||
val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString()
|
||||
|
||||
if (encryptionService.hasEstablishedSession(recipientPeerID)) {
|
||||
try {
|
||||
val privateMessage = PrivateMessagePacket(messageID = finalMessageID, content = content)
|
||||
val tlvData = privateMessage.encode() ?: return@launch
|
||||
val messagePayload = NoisePayload(
|
||||
type = NoisePayloadType.PRIVATE_MESSAGE,
|
||||
data = tlvData
|
||||
)
|
||||
val encrypted = encryptionService.encrypt(messagePayload.encode(), recipientPeerID)
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = encrypted,
|
||||
signature = null,
|
||||
ttl = maxTtl
|
||||
)
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
dispatchGlobal(RoutedPacket(signedPacket))
|
||||
} catch (e: Exception) {
|
||||
Log.e("MeshCore", "Failed to encrypt private message: ${e.message}")
|
||||
}
|
||||
} else {
|
||||
initiateNoiseHandshake(recipientPeerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) {
|
||||
scope.launch {
|
||||
if (hooks.readReceiptInterceptor?.invoke(messageID, recipientPeerID) == true) return@launch
|
||||
try {
|
||||
val payload = NoisePayload(
|
||||
type = NoisePayloadType.READ_RECEIPT,
|
||||
data = messageID.toByteArray(Charsets.UTF_8)
|
||||
).encode()
|
||||
val enc = encryptionService.encrypt(payload, recipientPeerID)
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = enc,
|
||||
signature = null,
|
||||
ttl = maxTtl
|
||||
)
|
||||
dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet)))
|
||||
hooks.onReadReceiptSent?.invoke(messageID)
|
||||
} catch (e: Exception) {
|
||||
Log.e("MeshCore", "Failed to send read receipt: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
|
||||
val payload = NoisePayload(
|
||||
type = NoisePayloadType.VERIFY_CHALLENGE,
|
||||
data = com.bitchat.android.services.VerificationService.buildVerifyChallenge(noiseKeyHex, nonceA)
|
||||
)
|
||||
sendNoisePayloadToPeer(payload, peerID)
|
||||
}
|
||||
|
||||
fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
|
||||
val tlv = com.bitchat.android.services.VerificationService.buildVerifyResponse(noiseKeyHex, nonceA) ?: return
|
||||
val payload = NoisePayload(
|
||||
type = NoisePayloadType.VERIFY_RESPONSE,
|
||||
data = tlv
|
||||
)
|
||||
sendNoisePayloadToPeer(payload, peerID)
|
||||
}
|
||||
|
||||
private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String) {
|
||||
scope.launch {
|
||||
try {
|
||||
val encrypted = encryptionService.encrypt(payload.encode(), recipientPeerID)
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = encrypted,
|
||||
signature = null,
|
||||
ttl = maxTtl
|
||||
)
|
||||
dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet)))
|
||||
} catch (e: Exception) {
|
||||
Log.e("MeshCore", "Failed to send Noise payload to $recipientPeerID: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendBroadcastAnnounce() {
|
||||
scope.launch {
|
||||
val nickname = hooks.announcementNicknameProvider?.invoke()
|
||||
?: delegate?.getNickname()
|
||||
?: myPeerID
|
||||
val staticKey = encryptionService.getStaticPublicKey() ?: run {
|
||||
Log.e("MeshCore", "No static public key available for announcement")
|
||||
return@launch
|
||||
}
|
||||
val signingKey = encryptionService.getSigningPublicKey() ?: run {
|
||||
Log.e("MeshCore", "No signing public key available for announcement")
|
||||
return@launch
|
||||
}
|
||||
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
|
||||
val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return@launch
|
||||
val announcePacket = BitchatPacket(
|
||||
type = MessageType.ANNOUNCE.value,
|
||||
ttl = maxTtl,
|
||||
senderID = myPeerID,
|
||||
payload = tlvPayload
|
||||
)
|
||||
val signedPacket = signPacketBeforeBroadcast(announcePacket)
|
||||
dispatchGlobal(RoutedPacket(signedPacket))
|
||||
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
fun sendAnnouncementToPeer(peerID: String) {
|
||||
if (peerManager.hasAnnouncedToPeer(peerID)) return
|
||||
val nickname = hooks.announcementNicknameProvider?.invoke()
|
||||
?: delegate?.getNickname()
|
||||
?: myPeerID
|
||||
val staticKey = encryptionService.getStaticPublicKey() ?: return
|
||||
val signingKey = encryptionService.getSigningPublicKey() ?: return
|
||||
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
|
||||
val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.ANNOUNCE.value,
|
||||
ttl = maxTtl,
|
||||
senderID = myPeerID,
|
||||
payload = tlvPayload
|
||||
)
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
dispatchGlobal(RoutedPacket(signedPacket))
|
||||
peerManager.markPeerAsAnnouncedTo(peerID)
|
||||
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
private fun buildAnnouncementPayload(announcement: IdentityAnnouncement, nickname: String): ByteArray? {
|
||||
var tlvPayload = announcement.encode() ?: return null
|
||||
val directPeersForGossip = getDirectPeerIDsForGossip()
|
||||
try {
|
||||
if (directPeersForGossip.isNotEmpty()) {
|
||||
tlvPayload += com.bitchat.android.services.meshgraph.GossipTLV.encodeNeighbors(directPeersForGossip)
|
||||
}
|
||||
com.bitchat.android.services.meshgraph.MeshGraphService.getInstance()
|
||||
.updateFromAnnouncement(myPeerID, nickname, directPeersForGossip, System.currentTimeMillis().toULong())
|
||||
} catch (_: Exception) { }
|
||||
return tlvPayload
|
||||
}
|
||||
|
||||
private fun getDirectPeerIDsForGossip(): List<String> {
|
||||
return try {
|
||||
val verifiedDirect = peerManager.getVerifiedPeers()
|
||||
.filter { it.value.isDirectConnection }
|
||||
.keys
|
||||
val localDirect = (verifiedDirect + directPeers).toSet()
|
||||
// Publish this transport's direct peers and gossip the cross-transport union so a
|
||||
// node connected via multiple transports advertises a complete neighbor list.
|
||||
try { com.bitchat.android.services.AppStateStore.setTransportDirectPeers(transport.id, localDirect) } catch (_: Exception) { }
|
||||
val union = try {
|
||||
com.bitchat.android.services.AppStateStore.getDirectPeers().ifEmpty { localDirect }
|
||||
} catch (_: Exception) { localDirect }
|
||||
union.distinct().take(10)
|
||||
} catch (_: Exception) {
|
||||
directPeers.toList().take(10)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendLeaveAnnouncement() {
|
||||
val payload = hooks.leavePayloadProvider?.invoke() ?: byteArrayOf()
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.LEAVE.value,
|
||||
ttl = maxTtl,
|
||||
senderID = myPeerID,
|
||||
payload = payload
|
||||
)
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
dispatchGlobal(RoutedPacket(signedPacket))
|
||||
}
|
||||
|
||||
fun getPeerNicknames(): Map<String, String> = peerManager.getAllPeerNicknames()
|
||||
|
||||
fun getPeerRSSI(): Map<String, Int> = peerManager.getAllPeerRSSI()
|
||||
|
||||
fun getPeerNickname(peerID: String): String? = peerManager.getPeerNickname(peerID)
|
||||
|
||||
fun addOrUpdatePeer(peerID: String, nickname: String): Boolean {
|
||||
return peerManager.addOrUpdatePeer(peerID, nickname)
|
||||
}
|
||||
|
||||
fun removePeer(peerID: String) {
|
||||
peerManager.removePeer(peerID)
|
||||
}
|
||||
|
||||
fun setDirectConnection(peerID: String, isDirect: Boolean) {
|
||||
if (isDirect) {
|
||||
directPeers.add(peerID)
|
||||
} else {
|
||||
directPeers.remove(peerID)
|
||||
}
|
||||
peerManager.refreshPeerList()
|
||||
}
|
||||
|
||||
fun updatePeerRSSI(peerID: String, rssi: Int) {
|
||||
peerManager.updatePeerRSSI(peerID, rssi)
|
||||
}
|
||||
|
||||
fun getDebugInfoWithDeviceAddresses(deviceMap: Map<String, String>): String {
|
||||
return peerManager.getDebugInfoWithDeviceAddresses(deviceMap)
|
||||
}
|
||||
|
||||
fun getFingerprintDebugInfo(): String {
|
||||
return peerManager.getFingerprintDebugInfo()
|
||||
}
|
||||
|
||||
fun hasEstablishedSession(peerID: String): Boolean {
|
||||
return encryptionService.hasEstablishedSession(peerID)
|
||||
}
|
||||
|
||||
fun getSessionState(peerID: String): com.bitchat.android.noise.NoiseSession.NoiseSessionState {
|
||||
return encryptionService.getSessionState(peerID)
|
||||
}
|
||||
|
||||
fun initiateNoiseHandshake(peerID: String) {
|
||||
scope.launch {
|
||||
try {
|
||||
val handshakeData = encryptionService.initiateHandshake(peerID) ?: return@launch
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_HANDSHAKE.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = MeshPacketUtils.hexStringToByteArray(peerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = handshakeData,
|
||||
ttl = maxTtl
|
||||
)
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
dispatchGlobal(RoutedPacket(signedPacket))
|
||||
} catch (e: Exception) {
|
||||
Log.e("MeshCore", "Failed to initiate Noise handshake with $peerID: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getPeerFingerprint(peerID: String): String? = peerManager.getFingerprintForPeer(peerID)
|
||||
|
||||
fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID)
|
||||
|
||||
fun updatePeerInfo(
|
||||
peerID: String,
|
||||
nickname: String,
|
||||
noisePublicKey: ByteArray,
|
||||
signingPublicKey: ByteArray,
|
||||
isVerified: Boolean
|
||||
): Boolean = peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified)
|
||||
|
||||
fun getIdentityFingerprint(): String = encryptionService.getIdentityFingerprint()
|
||||
|
||||
fun getStaticNoisePublicKey(): ByteArray? = encryptionService.getStaticPublicKey()
|
||||
|
||||
fun shouldShowEncryptionIcon(peerID: String): Boolean = encryptionService.hasEstablishedSession(peerID)
|
||||
|
||||
fun getEncryptedPeers(): List<String> = emptyList()
|
||||
|
||||
fun getActivePeerCount(): Int = try { peerManager.getActivePeerCount() } catch (_: Exception) { 0 }
|
||||
|
||||
fun refreshPeerList() {
|
||||
try { peerManager.refreshPeerList() } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
fun getDeviceAddressForPeer(peerID: String): String? = transport.getDeviceAddressForPeer(peerID)
|
||||
|
||||
fun getDeviceAddressToPeerMapping(): Map<String, String> = transport.getDeviceAddressToPeerMapping()
|
||||
|
||||
fun getDebugStatus(
|
||||
transportInfo: String,
|
||||
deviceMap: Map<String, String>,
|
||||
extraLines: List<String> = emptyList(),
|
||||
title: String? = null
|
||||
): String {
|
||||
return buildString {
|
||||
appendLine("=== ${title ?: "${transport.id} Mesh Debug Status"} ===")
|
||||
appendLine("My Peer ID: $myPeerID")
|
||||
if (extraLines.isNotEmpty()) {
|
||||
extraLines.forEach { appendLine(it) }
|
||||
}
|
||||
appendLine(transportInfo)
|
||||
appendLine(peerManager.getDebugInfo(deviceMap))
|
||||
appendLine(fragmentManager.getDebugInfo())
|
||||
appendLine(securityManager.getDebugInfo())
|
||||
appendLine(storeForwardManager.getDebugInfo())
|
||||
appendLine(messageHandler.getDebugInfo())
|
||||
appendLine(packetProcessor.getDebugInfo())
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAllInternalData() {
|
||||
fragmentManager.clearAllFragments()
|
||||
storeForwardManager.clearAllCache()
|
||||
securityManager.clearAllData()
|
||||
peerManager.clearAllPeers()
|
||||
peerManager.clearAllFingerprints()
|
||||
}
|
||||
|
||||
fun clearAllEncryptionData() {
|
||||
encryptionService.clearPersistentIdentity()
|
||||
}
|
||||
|
||||
private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket {
|
||||
return try {
|
||||
val withRoute = try {
|
||||
val recipient = packet.recipientID
|
||||
if (recipient != null && !recipient.contentEquals(SpecialRecipients.BROADCAST)) {
|
||||
val destination = recipient.toHexString()
|
||||
val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath(myPeerID, destination)
|
||||
if (path != null && path.size >= 3) {
|
||||
val intermediates = path.subList(1, path.size - 1)
|
||||
packet.copy(
|
||||
route = intermediates.map { MeshPacketUtils.hexStringToByteArray(it) },
|
||||
version = 2u
|
||||
)
|
||||
} else {
|
||||
packet.copy(route = null)
|
||||
}
|
||||
} else {
|
||||
packet
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
packet
|
||||
}
|
||||
|
||||
val packetDataForSigning = withRoute.toBinaryDataForSigning() ?: return withRoute
|
||||
val signature = encryptionService.signData(packetDataForSigning)
|
||||
if (signature != null) {
|
||||
withRoute.copy(signature = signature)
|
||||
} else {
|
||||
withRoute
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
packet
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
|
||||
/**
|
||||
* Shared mesh delegate interface for transport-agnostic callbacks.
|
||||
*/
|
||||
interface MeshDelegate {
|
||||
fun didReceiveMessage(message: BitchatMessage)
|
||||
fun didUpdatePeerList(peers: List<String>)
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
/**
|
||||
* Shared helpers for mesh packet handling.
|
||||
*/
|
||||
object MeshPacketUtils {
|
||||
/**
|
||||
* Convert hex string peer ID to binary data (8 bytes), matching iOS behavior.
|
||||
*/
|
||||
fun hexStringToByteArray(hexString: String): ByteArray {
|
||||
val result = ByteArray(8) { 0 }
|
||||
var tempID = hexString
|
||||
var index = 0
|
||||
|
||||
while (tempID.length >= 2 && index < 8) {
|
||||
val hexByte = tempID.substring(0, 2)
|
||||
val byte = hexByte.toIntOrNull(16)?.toByte()
|
||||
if (byte != null) {
|
||||
result[index] = byte
|
||||
}
|
||||
tempID = tempID.substring(2)
|
||||
index++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash payloads to a stable hex ID for transfer tracking.
|
||||
*/
|
||||
fun sha256Hex(bytes: ByteArray): String = try {
|
||||
val md = java.security.MessageDigest.getInstance("SHA-256")
|
||||
md.update(bytes)
|
||||
md.digest().joinToString("") { "%02x".format(it) }
|
||||
} catch (_: Exception) {
|
||||
bytes.size.toString(16)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
|
||||
/**
|
||||
* Transport-agnostic mesh service API for UI and routing layers.
|
||||
*/
|
||||
interface MeshService {
|
||||
val myPeerID: String
|
||||
var delegate: MeshDelegate?
|
||||
|
||||
fun startServices()
|
||||
fun stopServices()
|
||||
|
||||
fun sendMessage(content: String, mentions: List<String> = emptyList(), channel: String? = null)
|
||||
fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null)
|
||||
fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String)
|
||||
fun sendDeliveryAck(messageID: String, recipientPeerID: String) {}
|
||||
fun sendFavoriteNotification(peerID: String, isFavorite: Boolean) {}
|
||||
fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray)
|
||||
fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray)
|
||||
fun sendFileBroadcast(file: BitchatFilePacket)
|
||||
fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket)
|
||||
fun cancelFileTransfer(transferId: String): Boolean
|
||||
|
||||
fun sendBroadcastAnnounce()
|
||||
fun sendAnnouncementToPeer(peerID: String)
|
||||
|
||||
fun getPeerNicknames(): Map<String, String>
|
||||
fun getPeerRSSI(): Map<String, Int>
|
||||
fun getActivePeerCount(): Int
|
||||
fun hasEstablishedSession(peerID: String): Boolean
|
||||
fun getSessionState(peerID: String): com.bitchat.android.noise.NoiseSession.NoiseSessionState
|
||||
fun initiateNoiseHandshake(peerID: String)
|
||||
fun getPeerFingerprint(peerID: String): String?
|
||||
fun getPeerInfo(peerID: String): PeerInfo?
|
||||
fun updatePeerInfo(
|
||||
peerID: String,
|
||||
nickname: String,
|
||||
noisePublicKey: ByteArray,
|
||||
signingPublicKey: ByteArray,
|
||||
isVerified: Boolean
|
||||
): Boolean
|
||||
fun getIdentityFingerprint(): String
|
||||
fun getStaticNoisePublicKey(): ByteArray?
|
||||
fun shouldShowEncryptionIcon(peerID: String): Boolean
|
||||
fun getEncryptedPeers(): List<String>
|
||||
|
||||
fun getDeviceAddressForPeer(peerID: String): String?
|
||||
fun getDeviceAddressToPeerMapping(): Map<String, String>
|
||||
fun printDeviceAddressesForPeers(): String
|
||||
fun getDebugStatus(): String
|
||||
|
||||
fun clearAllInternalData()
|
||||
fun clearAllEncryptionData()
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
|
||||
/**
|
||||
* Transport abstraction used by MeshCore to send packets via a specific medium.
|
||||
*/
|
||||
interface MeshTransport {
|
||||
val id: String
|
||||
|
||||
fun broadcastPacket(routed: RoutedPacket)
|
||||
|
||||
fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean
|
||||
|
||||
fun cancelTransfer(transferId: String): Boolean = false
|
||||
|
||||
fun getDeviceAddressForPeer(peerID: String): String? = null
|
||||
|
||||
fun getDeviceAddressToPeerMapping(): Map<String, String> = emptyMap()
|
||||
|
||||
fun getTransportDebugInfo(): String = ""
|
||||
}
|
||||
@@ -21,6 +21,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MessageHandler"
|
||||
private const val ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS = 10 * 60 * 1000L
|
||||
}
|
||||
|
||||
// Delegate for callbacks
|
||||
@@ -220,12 +221,15 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
|
||||
if (peerID == myPeerID) return false
|
||||
|
||||
// Ignore stale announcements older than STALE_PEER_TIMEOUT
|
||||
// Peers use wall-clock packet timestamps; tolerate moderate device clock skew
|
||||
// during identity learning, or later signed messages cannot be verified.
|
||||
val now = System.currentTimeMillis()
|
||||
val age = now - packet.timestamp.toLong()
|
||||
if (age > com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS) {
|
||||
Log.w(TAG, "Ignoring stale ANNOUNCE from ${peerID.take(8)} (age=${age}ms > ${com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS}ms)")
|
||||
val clockSkewMs = kotlin.math.abs(now - packet.timestamp.toLong())
|
||||
if (clockSkewMs > ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS) {
|
||||
Log.w(TAG, "Ignoring ANNOUNCE from ${peerID.take(8)} with excessive clock skew (${clockSkewMs}ms > ${ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS}ms)")
|
||||
return false
|
||||
} else if (clockSkewMs > com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS) {
|
||||
Log.w(TAG, "Accepting ANNOUNCE from ${peerID.take(8)} within clock skew tolerance (${clockSkewMs}ms)")
|
||||
}
|
||||
|
||||
// Try to decode as iOS-compatible IdentityAnnouncement with TLV format
|
||||
|
||||
@@ -110,10 +110,21 @@ class PeerManager {
|
||||
isVerified: Boolean
|
||||
): Boolean {
|
||||
if (peerID == "unknown") return false
|
||||
|
||||
fun keysMatch(a: ByteArray?, b: ByteArray?): Boolean {
|
||||
if (a == null && b == null) return true
|
||||
if (a == null || b == null) return false
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val existingPeer = peers[peerID]
|
||||
val isNewPeer = existingPeer == null
|
||||
val wasVerified = existingPeer?.isVerifiedNickname == true
|
||||
val nicknameChanged = existingPeer != null && existingPeer.nickname != nickname
|
||||
val noiseKeyChanged = existingPeer != null && !keysMatch(existingPeer.noisePublicKey, noisePublicKey)
|
||||
val signingKeyChanged = existingPeer != null && !keysMatch(existingPeer.signingPublicKey, signingPublicKey)
|
||||
val connectedChanged = existingPeer != null && existingPeer.isConnected != true
|
||||
|
||||
// Update or create peer info
|
||||
val peerInfo = PeerInfo(
|
||||
@@ -133,18 +144,27 @@ class PeerManager {
|
||||
// No legacy maps; peers map is the single source of truth
|
||||
// Maintain announcedPeers for first-time announce semantics
|
||||
|
||||
val shouldNotify = when {
|
||||
isNewPeer && isVerified -> true
|
||||
wasVerified != isVerified -> true
|
||||
nicknameChanged || noiseKeyChanged || signingKeyChanged || connectedChanged -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
if (isNewPeer && isVerified) {
|
||||
announcedPeers.add(peerID)
|
||||
notifyPeerListUpdate()
|
||||
Log.d(TAG, "🆕 New verified peer: $nickname ($peerID)")
|
||||
return true
|
||||
} else if (isVerified) {
|
||||
Log.d(TAG, "🔄 Updated verified peer: $nickname ($peerID)")
|
||||
} else {
|
||||
Log.d(TAG, "⚠️ Unverified peer announcement from: $nickname ($peerID)")
|
||||
}
|
||||
|
||||
if (shouldNotify) {
|
||||
notifyPeerListUpdate()
|
||||
}
|
||||
|
||||
return false
|
||||
return isNewPeer && isVerified
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,14 +199,6 @@ class PeerManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a peer list update notification.
|
||||
* Call this when connection state changes to refresh UI badges.
|
||||
*/
|
||||
fun refreshPeerList() {
|
||||
notifyPeerListUpdate()
|
||||
}
|
||||
|
||||
// MARK: - Legacy Methods (maintained for compatibility)
|
||||
|
||||
/**
|
||||
@@ -414,6 +426,10 @@ class PeerManager {
|
||||
val peerList = getActivePeerIDs()
|
||||
delegate?.onPeerListUpdated(peerList)
|
||||
}
|
||||
|
||||
fun refreshPeerList() {
|
||||
notifyPeerListUpdate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic cleanup of stale peers
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.noise.NoiseSession
|
||||
import com.bitchat.android.wifiaware.WifiAwareController
|
||||
|
||||
/**
|
||||
* Feature-facing mesh service that hides local transport selection from the rest of the app.
|
||||
*
|
||||
* BLE remains the canonical origin for broadcast packets when it is enabled so existing BLE mesh
|
||||
* behavior and bridge semantics stay intact. Addressed Noise traffic is routed over whichever
|
||||
* local transport already has the peer/session, falling back to a connected transport handshake.
|
||||
*/
|
||||
class UnifiedMeshService(
|
||||
private val context: Context,
|
||||
private val bluetooth: BluetoothMeshService
|
||||
) : MeshService, BluetoothMeshDelegate {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "UnifiedMeshService"
|
||||
}
|
||||
|
||||
override val myPeerID: String
|
||||
get() = bluetooth.myPeerID
|
||||
|
||||
override var delegate: MeshDelegate? = null
|
||||
set(value) {
|
||||
field = value
|
||||
refreshDelegates()
|
||||
}
|
||||
|
||||
fun refreshDelegates() {
|
||||
try { bluetooth.delegate = if (delegate != null) this else null } catch (_: Exception) { }
|
||||
try { wifiService()?.delegate = if (delegate != null) this else null } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override fun startServices() {
|
||||
if (isBleEnabled()) {
|
||||
try { bluetooth.startServices() } catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to start BLE transport: ${e.message}")
|
||||
}
|
||||
} else {
|
||||
try { bluetooth.setBleTransportEnabled(false) } catch (_: Exception) { }
|
||||
}
|
||||
try { WifiAwareController.startIfPossible() } catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to start Wi-Fi Aware transport: ${e.message}")
|
||||
}
|
||||
refreshDelegates()
|
||||
}
|
||||
|
||||
override fun stopServices() {
|
||||
try { bluetooth.stopServices() } catch (_: Exception) { }
|
||||
try { WifiAwareController.stop() } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override fun sendMessage(content: String, mentions: List<String>, channel: String?) {
|
||||
when {
|
||||
isBleEnabled() -> bluetooth.sendMessage(content, mentions, channel)
|
||||
else -> wifiService()?.sendMessage(content, mentions, channel)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendPrivateMessage(
|
||||
content: String,
|
||||
recipientPeerID: String,
|
||||
recipientNickname: String,
|
||||
messageID: String?
|
||||
) {
|
||||
when {
|
||||
isBleReady(recipientPeerID) -> bluetooth.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID)
|
||||
isWifiReady(recipientPeerID) -> wifiService()?.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID)
|
||||
isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) ->
|
||||
bluetooth.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID)
|
||||
else -> wifiService()?.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) {
|
||||
when {
|
||||
isBleReady(recipientPeerID) -> bluetooth.sendReadReceipt(messageID, recipientPeerID, readerNickname)
|
||||
isWifiReady(recipientPeerID) -> wifiService()?.sendReadReceipt(messageID, recipientPeerID, readerNickname)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendFavoriteNotification(peerID: String, isFavorite: Boolean) {
|
||||
val myNpub = try {
|
||||
com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)?.npub
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
val content = if (isFavorite) "[FAVORITED]:${myNpub ?: ""}" else "[UNFAVORITED]:${myNpub ?: ""}"
|
||||
val nickname = getPeerNicknames()[peerID] ?: peerID
|
||||
if (hasEstablishedSession(peerID)) {
|
||||
sendPrivateMessage(content, peerID, nickname, java.util.UUID.randomUUID().toString())
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
|
||||
when {
|
||||
isBleReady(peerID) -> bluetooth.sendVerifyChallenge(peerID, noiseKeyHex, nonceA)
|
||||
isWifiReady(peerID) -> wifiService()?.sendVerifyChallenge(peerID, noiseKeyHex, nonceA)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
|
||||
when {
|
||||
isBleReady(peerID) -> bluetooth.sendVerifyResponse(peerID, noiseKeyHex, nonceA)
|
||||
isWifiReady(peerID) -> wifiService()?.sendVerifyResponse(peerID, noiseKeyHex, nonceA)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendFileBroadcast(file: BitchatFilePacket) {
|
||||
when {
|
||||
isBleEnabled() -> bluetooth.sendFileBroadcast(file)
|
||||
else -> wifiService()?.sendFileBroadcast(file)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) {
|
||||
when {
|
||||
isBleReady(recipientPeerID) -> bluetooth.sendFilePrivate(recipientPeerID, file)
|
||||
isWifiReady(recipientPeerID) -> wifiService()?.sendFilePrivate(recipientPeerID, file)
|
||||
isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) ->
|
||||
bluetooth.sendFilePrivate(recipientPeerID, file)
|
||||
else -> wifiService()?.sendFilePrivate(recipientPeerID, file)
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancelFileTransfer(transferId: String): Boolean {
|
||||
val bleCancelled = try { bluetooth.cancelFileTransfer(transferId) } catch (_: Exception) { false }
|
||||
val wifiCancelled = try { wifiService()?.cancelFileTransfer(transferId) == true } catch (_: Exception) { false }
|
||||
return bleCancelled || wifiCancelled
|
||||
}
|
||||
|
||||
override fun sendBroadcastAnnounce() {
|
||||
if (isBleEnabled()) {
|
||||
try { bluetooth.sendBroadcastAnnounce() } catch (_: Exception) { }
|
||||
}
|
||||
try { wifiService()?.sendBroadcastAnnounce() } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override fun sendAnnouncementToPeer(peerID: String) {
|
||||
when {
|
||||
isBleConnected(peerID) || (isBleEnabled() && !isWifiConnected(peerID)) -> bluetooth.sendAnnouncementToPeer(peerID)
|
||||
else -> wifiService()?.sendAnnouncementToPeer(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPeerNicknames(): Map<String, String> {
|
||||
val merged = linkedMapOf<String, String>()
|
||||
try { merged.putAll(wifiService()?.getPeerNicknames().orEmpty()) } catch (_: Exception) { }
|
||||
try { merged.putAll(bluetooth.getPeerNicknames()) } catch (_: Exception) { }
|
||||
return merged
|
||||
}
|
||||
|
||||
override fun getPeerRSSI(): Map<String, Int> {
|
||||
val merged = linkedMapOf<String, Int>()
|
||||
try { merged.putAll(wifiService()?.getPeerRSSI().orEmpty()) } catch (_: Exception) { }
|
||||
try { merged.putAll(bluetooth.getPeerRSSI()) } catch (_: Exception) { }
|
||||
return merged
|
||||
}
|
||||
|
||||
override fun getActivePeerCount(): Int {
|
||||
return mergedPeerIDs().filter { it != myPeerID }.distinct().size
|
||||
}
|
||||
|
||||
override fun hasEstablishedSession(peerID: String): Boolean {
|
||||
return isBleReady(peerID) || isWifiReady(peerID)
|
||||
}
|
||||
|
||||
override fun getSessionState(peerID: String): NoiseSession.NoiseSessionState {
|
||||
val bleState = try { bluetooth.getSessionState(peerID) } catch (_: Exception) { NoiseSession.NoiseSessionState.Uninitialized }
|
||||
val wifiState = try { wifiService()?.getSessionState(peerID) } catch (_: Exception) { null }
|
||||
return when {
|
||||
bleState is NoiseSession.NoiseSessionState.Established -> bleState
|
||||
wifiState is NoiseSession.NoiseSessionState.Established -> wifiState
|
||||
bleState is NoiseSession.NoiseSessionState.Handshaking -> bleState
|
||||
wifiState is NoiseSession.NoiseSessionState.Handshaking -> wifiState
|
||||
bleState !is NoiseSession.NoiseSessionState.Uninitialized -> bleState
|
||||
wifiState != null -> wifiState
|
||||
else -> bleState
|
||||
}
|
||||
}
|
||||
|
||||
override fun initiateNoiseHandshake(peerID: String) {
|
||||
when {
|
||||
isBleConnected(peerID) -> bluetooth.initiateNoiseHandshake(peerID)
|
||||
isWifiConnected(peerID) -> wifiService()?.initiateNoiseHandshake(peerID)
|
||||
isBleEnabled() -> bluetooth.initiateNoiseHandshake(peerID)
|
||||
else -> wifiService()?.initiateNoiseHandshake(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPeerFingerprint(peerID: String): String? {
|
||||
return try { bluetooth.getPeerFingerprint(peerID) } catch (_: Exception) { null }
|
||||
?: try { wifiService()?.getPeerFingerprint(peerID) } catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
override fun getPeerInfo(peerID: String): PeerInfo? {
|
||||
val ble = try { bluetooth.getPeerInfo(peerID) } catch (_: Exception) { null }
|
||||
val wifi = try { wifiService()?.getPeerInfo(peerID) } catch (_: Exception) { null }
|
||||
return when {
|
||||
ble?.isConnected == true && hasEstablishedSessionOnBluetooth(peerID) -> ble
|
||||
wifi?.isConnected == true && wifiService()?.hasEstablishedSession(peerID) == true -> wifi
|
||||
ble?.isConnected == true -> ble
|
||||
wifi?.isConnected == true -> wifi
|
||||
else -> ble ?: wifi
|
||||
}
|
||||
}
|
||||
|
||||
override fun updatePeerInfo(
|
||||
peerID: String,
|
||||
nickname: String,
|
||||
noisePublicKey: ByteArray,
|
||||
signingPublicKey: ByteArray,
|
||||
isVerified: Boolean
|
||||
): Boolean {
|
||||
val bleUpdated = try {
|
||||
bluetooth.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
val wifiUpdated = try {
|
||||
wifiService()?.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) == true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
return bleUpdated || wifiUpdated
|
||||
}
|
||||
|
||||
override fun getIdentityFingerprint(): String = bluetooth.getIdentityFingerprint()
|
||||
|
||||
override fun getStaticNoisePublicKey(): ByteArray? {
|
||||
return bluetooth.getStaticNoisePublicKey() ?: wifiService()?.getStaticNoisePublicKey()
|
||||
}
|
||||
|
||||
override fun shouldShowEncryptionIcon(peerID: String): Boolean {
|
||||
return hasEstablishedSession(peerID)
|
||||
}
|
||||
|
||||
override fun getEncryptedPeers(): List<String> {
|
||||
val encrypted = linkedSetOf<String>()
|
||||
try { encrypted.addAll(bluetooth.getEncryptedPeers()) } catch (_: Exception) { }
|
||||
try { encrypted.addAll(wifiService()?.getEncryptedPeers().orEmpty()) } catch (_: Exception) { }
|
||||
mergedPeerIDs().filterTo(encrypted) { hasEstablishedSession(it) }
|
||||
return encrypted.toList()
|
||||
}
|
||||
|
||||
override fun getDeviceAddressForPeer(peerID: String): String? {
|
||||
return try { bluetooth.getDeviceAddressForPeer(peerID) } catch (_: Exception) { null }
|
||||
?: try { wifiService()?.getDeviceAddressForPeer(peerID) } catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
override fun getDeviceAddressToPeerMapping(): Map<String, String> {
|
||||
val merged = linkedMapOf<String, String>()
|
||||
try { merged.putAll(wifiService()?.getDeviceAddressToPeerMapping().orEmpty()) } catch (_: Exception) { }
|
||||
try { merged.putAll(bluetooth.getDeviceAddressToPeerMapping()) } catch (_: Exception) { }
|
||||
return merged
|
||||
}
|
||||
|
||||
override fun printDeviceAddressesForPeers(): String {
|
||||
return buildString {
|
||||
appendLine(bluetooth.printDeviceAddressesForPeers())
|
||||
wifiService()?.let {
|
||||
appendLine()
|
||||
appendLine(it.printDeviceAddressesForPeers())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getDebugStatus(): String {
|
||||
return buildString {
|
||||
appendLine("=== Unified Mesh Service Debug Status ===")
|
||||
appendLine("My Peer ID: $myPeerID")
|
||||
appendLine("Merged Peers: ${mergedPeerIDs().joinToString(", ")}")
|
||||
appendLine()
|
||||
appendLine(bluetooth.getDebugStatus())
|
||||
wifiService()?.let {
|
||||
appendLine()
|
||||
appendLine(it.getDebugStatus())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun clearAllInternalData() {
|
||||
try { bluetooth.clearAllInternalData() } catch (_: Exception) { }
|
||||
try { wifiService()?.clearAllInternalData() } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override fun clearAllEncryptionData() {
|
||||
try { bluetooth.clearAllEncryptionData() } catch (_: Exception) { }
|
||||
try { wifiService()?.clearAllEncryptionData() } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override fun didReceiveMessage(message: BitchatMessage) {
|
||||
delegate?.didReceiveMessage(message)
|
||||
}
|
||||
|
||||
override fun didUpdatePeerList(peers: List<String>) {
|
||||
delegate?.didUpdatePeerList(mergedPeerIDs().ifEmpty { peers.distinct() })
|
||||
}
|
||||
|
||||
override fun didReceiveChannelLeave(channel: String, fromPeer: String) {
|
||||
delegate?.didReceiveChannelLeave(channel, fromPeer)
|
||||
}
|
||||
|
||||
override fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) {
|
||||
delegate?.didReceiveDeliveryAck(messageID, recipientPeerID)
|
||||
}
|
||||
|
||||
override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) {
|
||||
delegate?.didReceiveReadReceipt(messageID, recipientPeerID)
|
||||
}
|
||||
|
||||
override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
delegate?.didReceiveVerifyChallenge(peerID, payload, timestampMs)
|
||||
}
|
||||
|
||||
override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
|
||||
}
|
||||
|
||||
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
|
||||
return delegate?.decryptChannelMessage(encryptedContent, channel)
|
||||
}
|
||||
|
||||
override fun getNickname(): String? = delegate?.getNickname()
|
||||
|
||||
override fun isFavorite(peerID: String): Boolean = delegate?.isFavorite(peerID) ?: false
|
||||
|
||||
private fun mergedPeerIDs(): List<String> {
|
||||
val ids = linkedSetOf<String>()
|
||||
try { ids.addAll(com.bitchat.android.services.AppStateStore.peers.value) } catch (_: Exception) { }
|
||||
try { ids.addAll(bluetooth.getPeerNicknames().keys) } catch (_: Exception) { }
|
||||
try { ids.addAll(wifiService()?.getPeerNicknames()?.keys.orEmpty()) } catch (_: Exception) { }
|
||||
return ids.toList()
|
||||
}
|
||||
|
||||
private fun wifiService(): MeshService? {
|
||||
return try {
|
||||
WifiAwareController.getService()?.also { service ->
|
||||
if (delegate != null && service.delegate !== this) {
|
||||
service.delegate = this
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun isBleEnabled(): Boolean {
|
||||
return try {
|
||||
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
|
||||
} catch (_: Exception) {
|
||||
try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
|
||||
}
|
||||
}
|
||||
|
||||
private fun isBleConnected(peerID: String): Boolean {
|
||||
return try { bluetooth.getPeerInfo(peerID)?.isConnected == true } catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
private fun isWifiConnected(peerID: String): Boolean {
|
||||
return try { wifiService()?.getPeerInfo(peerID)?.isConnected == true } catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
private fun isBleReady(peerID: String): Boolean {
|
||||
return isBleConnected(peerID) && hasEstablishedSessionOnBluetooth(peerID)
|
||||
}
|
||||
|
||||
private fun isWifiReady(peerID: String): Boolean {
|
||||
return try {
|
||||
val wifi = wifiService()
|
||||
wifi?.getPeerInfo(peerID)?.isConnected == true && wifi.hasEstablishedSession(peerID)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasEstablishedSessionOnBluetooth(peerID: String): Boolean {
|
||||
return try { bluetooth.hasEstablishedSession(peerID) } catch (_: Exception) { false }
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,8 @@ class NoiseEncryptionService(private val context: Context) {
|
||||
|
||||
private fun initializeSessionManager() {
|
||||
// Create new session manager with current keys
|
||||
sessionManager = NoiseSessionManager(staticIdentityPrivateKey, staticIdentityPublicKey)
|
||||
val localPeerID = calculateFingerprint(staticIdentityPublicKey).take(16)
|
||||
sessionManager = NoiseSessionManager(staticIdentityPrivateKey, staticIdentityPublicKey, localPeerID)
|
||||
|
||||
// Set up session callbacks
|
||||
sessionManager.onSessionEstablished = { peerID, remoteStaticKey ->
|
||||
|
||||
@@ -153,6 +153,9 @@ class NoiseSession(
|
||||
// Session state
|
||||
private var state: NoiseSessionState = NoiseSessionState.Uninitialized
|
||||
private val creationTime = System.currentTimeMillis()
|
||||
private var handshakeStartMs: Long? = null
|
||||
private var lastHandshakeActivityMs: Long? = null
|
||||
private var handshakeMessage1: ByteArray? = null
|
||||
|
||||
// Session counters
|
||||
private var currentPattern = 0;
|
||||
@@ -195,6 +198,15 @@ class NoiseSession(
|
||||
fun isEstablished(): Boolean = state is NoiseSessionState.Established
|
||||
fun isHandshaking(): Boolean = state is NoiseSessionState.Handshaking
|
||||
fun getCreationTime(): Long = creationTime
|
||||
fun isInitiatorRole(): Boolean = isInitiator
|
||||
fun getHandshakeStartMs(): Long? = handshakeStartMs
|
||||
fun getLastHandshakeActivityMs(): Long? = lastHandshakeActivityMs
|
||||
|
||||
internal fun getHandshakeMessage1(): ByteArray? = handshakeMessage1?.clone()
|
||||
|
||||
internal fun setLastHandshakeActivityForTest(timestampMs: Long) {
|
||||
lastHandshakeActivityMs = timestampMs
|
||||
}
|
||||
|
||||
init {
|
||||
try {
|
||||
@@ -317,19 +329,25 @@ class NoiseSession(
|
||||
// Initialize handshake as initiator
|
||||
initializeNoiseHandshake(HandshakeState.INITIATOR)
|
||||
state = NoiseSessionState.Handshaking
|
||||
if (handshakeStartMs == null) {
|
||||
handshakeStartMs = System.currentTimeMillis()
|
||||
}
|
||||
lastHandshakeActivityMs = System.currentTimeMillis()
|
||||
|
||||
val messageBuffer = ByteArray(XX_MESSAGE_1_SIZE)
|
||||
val handshakeStateLocal = handshakeState ?: throw IllegalStateException("Handshake state is null")
|
||||
val messageLength = handshakeStateLocal.writeMessage(messageBuffer, 0, null, 0, 0)
|
||||
currentPattern++
|
||||
val firstMessage = messageBuffer.copyOf(messageLength)
|
||||
handshakeMessage1 = firstMessage
|
||||
|
||||
// Validate message size matches XX pattern expectations
|
||||
if (firstMessage.size != XX_MESSAGE_1_SIZE) {
|
||||
Log.w(TAG, "Warning: XX message 1 size ${firstMessage.size} != expected $XX_MESSAGE_1_SIZE")
|
||||
}
|
||||
|
||||
Log.d(TAG, "Sending XX handshake message 1 to $peerID (${firstMessage.size} bytes) currentPattern: $currentPattern")
|
||||
val ePrefix = firstMessage.take(4).toByteArray().toHexString()
|
||||
Log.d(TAG, "Sending XX handshake message 1 to $peerID (${firstMessage.size} bytes) e_prefix=$ePrefix currentPattern: $currentPattern")
|
||||
return firstMessage
|
||||
} catch (e: Exception) {
|
||||
state = NoiseSessionState.Failed(e)
|
||||
@@ -344,19 +362,24 @@ class NoiseSession(
|
||||
*/
|
||||
@Synchronized
|
||||
fun processHandshakeMessage(message: ByteArray): ByteArray? {
|
||||
Log.d(TAG, "Processing handshake message from $peerID (${message.size} bytes)")
|
||||
val inputPrefix = message.take(4).toByteArray().toHexString()
|
||||
Log.d(TAG, "Processing handshake message from $peerID (${message.size} bytes) prefix=$inputPrefix")
|
||||
|
||||
try {
|
||||
// Initialize as responder if receiving first message
|
||||
if (state == NoiseSessionState.Uninitialized && !isInitiator) {
|
||||
initializeNoiseHandshake(HandshakeState.RESPONDER)
|
||||
state = NoiseSessionState.Handshaking
|
||||
if (handshakeStartMs == null) {
|
||||
handshakeStartMs = System.currentTimeMillis()
|
||||
}
|
||||
Log.d(TAG, "Initialized as RESPONDER for XX handshake with $peerID")
|
||||
}
|
||||
|
||||
if (state != NoiseSessionState.Handshaking) {
|
||||
throw IllegalStateException("Invalid state for handshake: $state")
|
||||
}
|
||||
lastHandshakeActivityMs = System.currentTimeMillis()
|
||||
|
||||
val handshakeStateLocal = handshakeState ?: throw IllegalStateException("Handshake state is null")
|
||||
|
||||
@@ -366,7 +389,8 @@ class NoiseSession(
|
||||
// Read the incoming message - the Noise library will handle validation
|
||||
val payloadLength = handshakeStateLocal.readMessage(message, 0, message.size, payloadBuffer, 0)
|
||||
currentPattern++
|
||||
Log.d(TAG, "Read handshake message, payload length: $payloadLength currentPattern: $currentPattern")
|
||||
val readPrefix = message.take(4).toByteArray().toHexString()
|
||||
Log.d(TAG, "Read handshake message, payload length: $payloadLength prefix=$readPrefix currentPattern: $currentPattern")
|
||||
|
||||
// Check what action the handshake state wants us to take next
|
||||
val action = handshakeStateLocal.getAction()
|
||||
|
||||
@@ -8,11 +8,14 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
*/
|
||||
class NoiseSessionManager(
|
||||
private val localStaticPrivateKey: ByteArray,
|
||||
private val localStaticPublicKey: ByteArray
|
||||
private val localStaticPublicKey: ByteArray,
|
||||
private val localPeerID: String
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NoiseSessionManager"
|
||||
private const val HANDSHAKE_TIMEOUT_MS = 20_000L
|
||||
private const val HANDSHAKE_MESSAGE_1_SIZE = 32
|
||||
}
|
||||
|
||||
private val sessions = ConcurrentHashMap<String, NoiseSession>()
|
||||
@@ -51,11 +54,30 @@ class NoiseSessionManager(
|
||||
/**
|
||||
* SIMPLIFIED: Initiate handshake - no tie breaker, just start
|
||||
*/
|
||||
fun initiateHandshake(peerID: String): ByteArray {
|
||||
fun initiateHandshake(peerID: String): ByteArray? {
|
||||
Log.d(TAG, "initiateHandshake($peerID)")
|
||||
|
||||
// Remove any existing session first
|
||||
removeSession(peerID)
|
||||
val now = System.currentTimeMillis()
|
||||
val existing = getSession(peerID)
|
||||
if (existing != null) {
|
||||
when {
|
||||
existing.isEstablished() -> {
|
||||
Log.d(TAG, "Handshake already established with $peerID, skipping initiate")
|
||||
return null
|
||||
}
|
||||
existing.isHandshaking() -> {
|
||||
if (!isHandshakeStale(existing, now)) {
|
||||
Log.d(TAG, "Handshake already in progress with $peerID, not restarting")
|
||||
return null
|
||||
}
|
||||
Log.d(TAG, "Handshake with $peerID is stale; restarting")
|
||||
removeSession(peerID)
|
||||
}
|
||||
else -> {
|
||||
removeSession(peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create new session as initiator
|
||||
val session = NoiseSession(
|
||||
@@ -85,6 +107,23 @@ class NoiseSessionManager(
|
||||
|
||||
try {
|
||||
var session = getSession(peerID)
|
||||
|
||||
// Collision handling: both sides initiated and we received message 1
|
||||
if (session != null &&
|
||||
session.isHandshaking() &&
|
||||
session.isInitiatorRole() &&
|
||||
message.size == HANDSHAKE_MESSAGE_1_SIZE
|
||||
) {
|
||||
val shouldYield = localPeerID > peerID
|
||||
if (shouldYield) {
|
||||
Log.d(TAG, "Handshake collision with $peerID; yielding to responder role")
|
||||
removeSession(peerID)
|
||||
session = null
|
||||
} else {
|
||||
Log.d(TAG, "Handshake collision with $peerID; keeping initiator role")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// If no session exists, create one as responder
|
||||
if (session == null) {
|
||||
@@ -119,6 +158,12 @@ class NoiseSessionManager(
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun isHandshakeStale(session: NoiseSession, nowMs: Long): Boolean {
|
||||
val lastActivity = session.getLastHandshakeActivityMs() ?: session.getHandshakeStartMs()
|
||||
if (lastActivity == null) return false
|
||||
return (nowMs - lastActivity) > HANDSHAKE_TIMEOUT_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* SIMPLIFIED: Encrypt data
|
||||
|
||||
@@ -26,6 +26,7 @@ fun BluetoothCheckScreen(
|
||||
status: BluetoothStatus,
|
||||
onEnableBluetooth: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onSkip: () -> Unit,
|
||||
isLoading: Boolean = false
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
@@ -39,13 +40,15 @@ fun BluetoothCheckScreen(
|
||||
BluetoothDisabledContent(
|
||||
onEnableBluetooth = onEnableBluetooth,
|
||||
onRetry = onRetry,
|
||||
onSkip = onSkip,
|
||||
colorScheme = colorScheme,
|
||||
isLoading = isLoading
|
||||
)
|
||||
}
|
||||
BluetoothStatus.NOT_SUPPORTED -> {
|
||||
BluetoothNotSupportedContent(
|
||||
colorScheme = colorScheme
|
||||
colorScheme = colorScheme,
|
||||
onSkip = onSkip
|
||||
)
|
||||
}
|
||||
BluetoothStatus.ENABLED -> {
|
||||
@@ -61,6 +64,7 @@ fun BluetoothCheckScreen(
|
||||
private fun BluetoothDisabledContent(
|
||||
onEnableBluetooth: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onSkip: () -> Unit,
|
||||
colorScheme: ColorScheme,
|
||||
isLoading: Boolean
|
||||
) {
|
||||
@@ -77,7 +81,7 @@ private fun BluetoothDisabledContent(
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.bluetooth_required),
|
||||
text = stringResource(R.string.bluetooth_recommended),
|
||||
style = MaterialTheme.typography.headlineSmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
@@ -141,20 +145,17 @@ private fun BluetoothDisabledContent(
|
||||
)
|
||||
}
|
||||
|
||||
//Since we are automatically checking bluetooth state -- commented
|
||||
|
||||
// OutlinedButton(
|
||||
// onClick = onRetry,
|
||||
// modifier = Modifier.fillMaxWidth()
|
||||
// ) {
|
||||
// Text(
|
||||
// text = "Check Again",
|
||||
// style = MaterialTheme.typography.bodyMedium.copy(
|
||||
// fontFamily = FontFamily.Monospace
|
||||
// ),
|
||||
// modifier = Modifier.padding(vertical = 4.dp)
|
||||
// )
|
||||
// }
|
||||
TextButton(
|
||||
onClick = onSkip,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.skip),
|
||||
style = MaterialTheme.typography.labelLarge.copy(
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,7 +163,8 @@ private fun BluetoothDisabledContent(
|
||||
|
||||
@Composable
|
||||
private fun BluetoothNotSupportedContent(
|
||||
colorScheme: ColorScheme
|
||||
colorScheme: ColorScheme,
|
||||
onSkip: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
@@ -209,6 +211,16 @@ private fun BluetoothNotSupportedContent(
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onSkip,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = colorScheme.secondary
|
||||
)
|
||||
) {
|
||||
Text(text = stringResource(R.string.continue_btn))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -271,6 +271,7 @@ class OnboardingCoordinator(
|
||||
permission.contains("BLUETOOTH") -> "Bluetooth/Nearby Devices"
|
||||
permission.contains("BACKGROUND") -> "Background Location"
|
||||
permission.contains("LOCATION") -> "Location (for Bluetooth scanning)"
|
||||
permission.contains("NEARBY_WIFI") -> "Nearby Wi‑Fi Devices (for Wi‑Fi Aware)"
|
||||
permission.contains("NOTIFICATION") -> "Notifications"
|
||||
else -> permission.substringAfterLast(".")
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.material.icons.filled.Notifications
|
||||
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.Wifi
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
@@ -218,6 +219,7 @@ private fun getPermissionIcon(permissionType: PermissionType): ImageVector {
|
||||
PermissionType.BACKGROUND_LOCATION -> Icons.Filled.LocationOn
|
||||
PermissionType.MICROPHONE -> Icons.Filled.Mic
|
||||
PermissionType.NOTIFICATIONS -> Icons.Filled.Notifications
|
||||
PermissionType.WIFI_AWARE -> Icons.Filled.Wifi
|
||||
PermissionType.BATTERY_OPTIMIZATION -> Icons.Filled.Power
|
||||
PermissionType.OTHER -> Icons.Filled.Settings
|
||||
}
|
||||
|
||||
@@ -23,6 +23,22 @@ class PermissionManager(private val context: Context) {
|
||||
|
||||
private val sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
private fun shouldRequireWifiAwarePermission(): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return false
|
||||
val enabled = try {
|
||||
com.bitchat.android.ui.debug.DebugPreferenceManager.getWifiAwareEnabled(true)
|
||||
} catch (_: Exception) {
|
||||
true
|
||||
}
|
||||
if (!enabled) return false
|
||||
|
||||
return try {
|
||||
com.bitchat.android.wifiaware.WifiAwareSupport.isSupported(context)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is the first time the user is launching the app
|
||||
*/
|
||||
@@ -69,6 +85,11 @@ class PermissionManager(private val context: Context) {
|
||||
Manifest.permission.ACCESS_FINE_LOCATION
|
||||
))
|
||||
|
||||
// Wi‑Fi Aware: Android 13+ requires NEARBY_WIFI_DEVICES runtime permission
|
||||
if (shouldRequireWifiAwarePermission()) {
|
||||
permissions.add(Manifest.permission.NEARBY_WIFI_DEVICES)
|
||||
}
|
||||
|
||||
// Notification permission intentionally excluded to keep it optional
|
||||
|
||||
return permissions
|
||||
@@ -209,6 +230,20 @@ class PermissionManager(private val context: Context) {
|
||||
)
|
||||
)
|
||||
|
||||
// Wi‑Fi Aware category (Android 13+)
|
||||
if (shouldRequireWifiAwarePermission()) {
|
||||
val wifiAwarePermissions = listOf(Manifest.permission.NEARBY_WIFI_DEVICES)
|
||||
categories.add(
|
||||
PermissionCategory(
|
||||
type = PermissionType.WIFI_AWARE,
|
||||
description = "Enable Wi‑Fi Aware to discover and connect to nearby bitchat users over Wi‑Fi.",
|
||||
permissions = wifiAwarePermissions,
|
||||
isGranted = wifiAwarePermissions.all { isPermissionGranted(it) },
|
||||
systemDescription = "Allow bitchat to discover nearby Wi‑Fi devices"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (needsBackgroundLocationPermission()) {
|
||||
val backgroundPermission = listOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
|
||||
categories.add(
|
||||
@@ -308,6 +343,7 @@ enum class PermissionType(val nameValue: String) {
|
||||
BACKGROUND_LOCATION("Background Location"),
|
||||
MICROPHONE("Microphone"),
|
||||
NOTIFICATIONS("Notifications"),
|
||||
WIFI_AWARE("Wi‑Fi Aware"),
|
||||
BATTERY_OPTIMIZATION("Battery Optimization"),
|
||||
OTHER("Other")
|
||||
}
|
||||
|
||||
@@ -255,6 +255,10 @@ object BinaryProtocol {
|
||||
if (packet.version >= 2u.toUByte()) {
|
||||
buffer.putInt(payloadDataSize) // 4 bytes for v2+
|
||||
} else {
|
||||
if (payloadDataSize > 0xFFFF || (originalPayloadSize ?: 0) > 0xFFFF) {
|
||||
Log.w("BinaryProtocol", "Cannot encode oversized v1 packet payload: $payloadDataSize bytes")
|
||||
return null
|
||||
}
|
||||
buffer.putShort(payloadDataSize.toShort()) // 2 bytes for v1
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ 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.mesh.MeshService
|
||||
import com.bitchat.android.net.ArtiTorManager
|
||||
import com.bitchat.android.net.TorMode
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -39,7 +39,7 @@ object AppShutdownCoordinator {
|
||||
|
||||
fun requestFullShutdownAndKill(
|
||||
app: Application,
|
||||
mesh: BluetoothMeshService?,
|
||||
mesh: MeshService?,
|
||||
notificationManager: NotificationManagerCompat,
|
||||
stopForeground: () -> Unit,
|
||||
stopService: () -> Unit
|
||||
|
||||
@@ -115,6 +115,8 @@ class MeshForegroundService : Service() {
|
||||
private var updateJob: Job? = null
|
||||
private val meshService: BluetoothMeshService?
|
||||
get() = MeshServiceHolder.meshService
|
||||
private val unifiedMeshService: com.bitchat.android.mesh.MeshService?
|
||||
get() = MeshServiceHolder.unifiedMeshService
|
||||
private val serviceJob = Job()
|
||||
private val scope = CoroutineScope(Dispatchers.Default + serviceJob)
|
||||
private var isInForeground: Boolean = false
|
||||
@@ -134,6 +136,7 @@ class MeshForegroundService : Service() {
|
||||
Log.i("MeshForegroundService", "Created new BluetoothMeshService via holder")
|
||||
MeshServiceHolder.attach(created)
|
||||
}
|
||||
MeshServiceHolder.getUnifiedOrCreate(applicationContext)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
@@ -147,7 +150,7 @@ class MeshForegroundService : Service() {
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> {
|
||||
// Stop FGS and mesh cleanly
|
||||
try { meshService?.stopServices() } catch (_: Exception) { }
|
||||
try { unifiedMeshService?.stopServices() ?: meshService?.stopServices() } catch (_: Exception) { }
|
||||
try { MeshServiceHolder.clear() } catch (_: Exception) { }
|
||||
try { stopForeground(true) } catch (_: Exception) { }
|
||||
notificationManager.cancel(NOTIFICATION_ID)
|
||||
@@ -165,7 +168,7 @@ class MeshForegroundService : Service() {
|
||||
// Fully stop all background activity, stop Tor (without changing setting), then kill the app
|
||||
AppShutdownCoordinator.requestFullShutdownAndKill(
|
||||
app = application,
|
||||
mesh = meshService,
|
||||
mesh = unifiedMeshService,
|
||||
notificationManager = notificationManager,
|
||||
stopForeground = {
|
||||
try { stopForeground(true) } catch (_: Exception) { }
|
||||
@@ -178,7 +181,7 @@ class MeshForegroundService : Service() {
|
||||
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)
|
||||
val n = buildNotification(getUnifiedActivePeerCount())
|
||||
startForegroundCompat(n)
|
||||
isInForeground = true
|
||||
} else {
|
||||
@@ -193,7 +196,7 @@ class MeshForegroundService : Service() {
|
||||
|
||||
// Promote exactly once when eligible, otherwise stay background (or stop)
|
||||
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
|
||||
val notification = buildNotification(meshService?.getActivePeerCount() ?: 0)
|
||||
val notification = buildNotification(getUnifiedActivePeerCount())
|
||||
startForegroundCompat(notification)
|
||||
isInForeground = true
|
||||
}
|
||||
@@ -226,6 +229,21 @@ class MeshForegroundService : Service() {
|
||||
|
||||
private fun ensureMeshStarted() {
|
||||
if (isShuttingDown) return
|
||||
try {
|
||||
com.bitchat.android.wifiaware.WifiAwareController.startIfPossible()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("MeshForegroundService", "Failed to ensure Wi-Fi Aware transport: ${e.message}")
|
||||
}
|
||||
|
||||
val bleEnabled = try {
|
||||
com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true)
|
||||
} catch (_: Exception) {
|
||||
true
|
||||
}
|
||||
if (!bleEnabled) {
|
||||
try { meshService?.setBleTransportEnabled(false) } catch (_: Exception) { }
|
||||
return
|
||||
}
|
||||
if (!hasBluetoothPermissions()) return
|
||||
try {
|
||||
android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started")
|
||||
@@ -241,7 +259,7 @@ class MeshForegroundService : Service() {
|
||||
notificationManager.cancel(NOTIFICATION_ID)
|
||||
return
|
||||
}
|
||||
val count = meshService?.getActivePeerCount() ?: 0
|
||||
val count = getUnifiedActivePeerCount()
|
||||
val notification = buildNotification(count)
|
||||
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) {
|
||||
notificationManager.notify(NOTIFICATION_ID, notification)
|
||||
@@ -261,6 +279,14 @@ class MeshForegroundService : Service() {
|
||||
return hasBluetoothPermissions() && hasNotificationPermission()
|
||||
}
|
||||
|
||||
private fun getUnifiedActivePeerCount(): Int {
|
||||
return try {
|
||||
unifiedMeshService?.getActivePeerCount() ?: meshService?.getActivePeerCount() ?: 0
|
||||
} catch (_: Exception) {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
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 &&
|
||||
|
||||
@@ -2,6 +2,10 @@ package com.bitchat.android.service
|
||||
|
||||
import android.content.Context
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.mesh.UnifiedMeshService
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.sync.GossipSyncManager
|
||||
|
||||
/**
|
||||
* Process-wide holder to share a single BluetoothMeshService instance
|
||||
@@ -9,10 +13,69 @@ import com.bitchat.android.mesh.BluetoothMeshService
|
||||
*/
|
||||
object MeshServiceHolder {
|
||||
private const val TAG = "MeshServiceHolder"
|
||||
@Volatile
|
||||
var sharedGossipSyncManager: GossipSyncManager? = null
|
||||
private set
|
||||
|
||||
private val activeGossipOwners = mutableSetOf<String>()
|
||||
|
||||
@Synchronized
|
||||
fun setGossipManager(
|
||||
mgr: GossipSyncManager,
|
||||
signer: (BitchatPacket) -> BitchatPacket
|
||||
) {
|
||||
val previous = sharedGossipSyncManager
|
||||
if (previous !== mgr) {
|
||||
try { previous?.stop() } catch (_: Exception) { }
|
||||
}
|
||||
sharedGossipSyncManager = mgr
|
||||
mgr.delegate = TransportGossipDelegate(signer)
|
||||
if (activeGossipOwners.isNotEmpty()) {
|
||||
mgr.start()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun startSharedGossip(owner: String) {
|
||||
val wasIdle = activeGossipOwners.isEmpty()
|
||||
activeGossipOwners.add(owner)
|
||||
if (wasIdle) {
|
||||
sharedGossipSyncManager?.start()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stopSharedGossip(owner: String) {
|
||||
activeGossipOwners.remove(owner)
|
||||
if (activeGossipOwners.isEmpty()) {
|
||||
sharedGossipSyncManager?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private class TransportGossipDelegate(
|
||||
private val signer: (BitchatPacket) -> BitchatPacket
|
||||
) : GossipSyncManager.Delegate {
|
||||
override fun sendPacket(packet: BitchatPacket) {
|
||||
TransportBridgeService.broadcastFromLocal(RoutedPacket(packet))
|
||||
}
|
||||
|
||||
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
|
||||
TransportBridgeService.sendToPeerFromLocal(peerID, packet)
|
||||
}
|
||||
|
||||
override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket {
|
||||
return signer(packet)
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var meshService: BluetoothMeshService? = null
|
||||
private set
|
||||
|
||||
@Volatile
|
||||
var unifiedMeshService: UnifiedMeshService? = null
|
||||
private set
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreate(context: Context): BluetoothMeshService {
|
||||
val existing = meshService
|
||||
@@ -31,18 +94,35 @@ object MeshServiceHolder {
|
||||
val created = BluetoothMeshService(context.applicationContext)
|
||||
android.util.Log.i(TAG, "Created new BluetoothMeshService (replacement)")
|
||||
meshService = created
|
||||
unifiedMeshService = null
|
||||
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
|
||||
unifiedMeshService = null
|
||||
created
|
||||
}
|
||||
}
|
||||
val created = BluetoothMeshService(context.applicationContext)
|
||||
android.util.Log.i(TAG, "Created new BluetoothMeshService (no existing instance)")
|
||||
meshService = created
|
||||
unifiedMeshService = null
|
||||
return created
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getUnifiedOrCreate(context: Context): UnifiedMeshService {
|
||||
val bluetooth = getOrCreate(context)
|
||||
val existing = unifiedMeshService
|
||||
if (existing != null) {
|
||||
existing.refreshDelegates()
|
||||
return existing
|
||||
}
|
||||
val created = UnifiedMeshService(context.applicationContext, bluetooth)
|
||||
unifiedMeshService = created
|
||||
android.util.Log.i(TAG, "Created new UnifiedMeshService")
|
||||
return created
|
||||
}
|
||||
|
||||
@@ -50,11 +130,16 @@ object MeshServiceHolder {
|
||||
fun attach(service: BluetoothMeshService) {
|
||||
android.util.Log.d(TAG, "Attaching BluetoothMeshService to holder")
|
||||
meshService = service
|
||||
unifiedMeshService = null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clear() {
|
||||
android.util.Log.d(TAG, "Clearing BluetoothMeshService from holder")
|
||||
try { sharedGossipSyncManager?.stop() } catch (_: Exception) { }
|
||||
sharedGossipSyncManager = null
|
||||
activeGossipOwners.clear()
|
||||
meshService = null
|
||||
unifiedMeshService = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package com.bitchat.android.service
|
||||
|
||||
import android.util.Log
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.util.toHexString
|
||||
import java.security.MessageDigest
|
||||
import java.util.Collections
|
||||
import java.util.LinkedHashMap
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Central bridge for routing packets between different transport layers
|
||||
* (e.g., Bluetooth LE <-> Wi-Fi Aware).
|
||||
*
|
||||
* Allows a packet received on one transport to be seamlessly relayed
|
||||
* to all other active transports, effectively bridging separate meshes.
|
||||
*/
|
||||
object TransportBridgeService {
|
||||
private const val TAG = "TransportBridgeService"
|
||||
private const val MAX_SEEN_PACKETS = 4096
|
||||
private const val SEEN_PACKET_TTL_MS = 5 * 60 * 1000L
|
||||
|
||||
/**
|
||||
* Interface that any transport layer (BLE, WiFi, Tor, etc.) must implement
|
||||
* to receive bridged packets.
|
||||
*/
|
||||
interface TransportLayer {
|
||||
/**
|
||||
* Send a packet out via this transport.
|
||||
*/
|
||||
fun send(packet: RoutedPacket)
|
||||
|
||||
/**
|
||||
* Send a packet to a specific peer via this transport (optional).
|
||||
*/
|
||||
fun sendToPeer(peerID: String, packet: BitchatPacket) { }
|
||||
}
|
||||
|
||||
private val transports = ConcurrentHashMap<String, TransportLayer>()
|
||||
private val seenPackets = Collections.synchronizedMap(
|
||||
object : LinkedHashMap<String, Long>(MAX_SEEN_PACKETS, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Long>?): Boolean {
|
||||
return size > MAX_SEEN_PACKETS
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Register a transport layer to receive bridged packets.
|
||||
* @param id Unique identifier (e.g., "BLE", "WIFI")
|
||||
* @param layer The transport implementation
|
||||
*/
|
||||
fun register(id: String, layer: TransportLayer) {
|
||||
Log.i(TAG, "Registering transport layer: $id")
|
||||
transports[id] = layer
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a transport layer.
|
||||
*/
|
||||
fun unregister(id: String) {
|
||||
Log.i(TAG, "Unregistering transport layer: $id")
|
||||
transports.remove(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast a packet from a specific source transport to ALL other registered transports.
|
||||
*
|
||||
* @param sourceId The ID of the transport initiating the broadcast (e.g., "BLE").
|
||||
* The packet will NOT be sent back to this source.
|
||||
* @param packet The packet to bridge.
|
||||
*/
|
||||
fun broadcast(sourceId: String, packet: RoutedPacket) {
|
||||
val targets = transports.filterKeys { it != sourceId }
|
||||
if (targets.isEmpty()) return
|
||||
val forwardedPacket = prepareForwardedPacket("broadcast", packet.packet) ?: return
|
||||
val forwarded = packet.copy(packet = forwardedPacket)
|
||||
|
||||
// Log.v(TAG, "Bridging packet type ${packet.packet.type} from $sourceId to ${targets.keys}")
|
||||
|
||||
targets.forEach { (id, layer) ->
|
||||
try {
|
||||
layer.send(forwarded)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to bridge packet to $id: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a packet to a specific peer across all other transports.
|
||||
*/
|
||||
fun sendToPeer(sourceId: String, peerID: String, packet: BitchatPacket) {
|
||||
val targets = transports.filterKeys { it != sourceId }
|
||||
if (targets.isEmpty()) return
|
||||
val forwardedPacket = prepareForwardedPacket("peer:$peerID", packet) ?: return
|
||||
|
||||
targets.forEach { (id, layer) ->
|
||||
try {
|
||||
layer.sendToPeer(peerID, forwardedPacket)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to bridge unicast packet to $id: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a locally originated packet to every active transport without applying relay TTL
|
||||
* handling. This is used for neighbor-only packets such as REQUEST_SYNC whose TTL is
|
||||
* intentionally zero on the first radio hop.
|
||||
*/
|
||||
fun broadcastFromLocal(packet: RoutedPacket) {
|
||||
val targets = transports.toMap()
|
||||
if (targets.isEmpty()) return
|
||||
|
||||
targets.forEach { (id, layer) ->
|
||||
try {
|
||||
layer.send(packet)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send local packet to $id: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a locally originated packet directly to a peer on every active transport.
|
||||
*/
|
||||
fun sendToPeerFromLocal(peerID: String, packet: BitchatPacket) {
|
||||
val targets = transports.toMap()
|
||||
if (targets.isEmpty()) return
|
||||
|
||||
targets.forEach { (id, layer) ->
|
||||
try {
|
||||
layer.sendToPeer(peerID, packet)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send local peer packet to $id: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun prepareForwardedPacket(kind: String, packet: BitchatPacket): BitchatPacket? {
|
||||
if (packet.ttl == 0u.toUByte()) {
|
||||
Log.d(TAG, "Dropping bridged packet type ${packet.type}: TTL expired")
|
||||
return null
|
||||
}
|
||||
|
||||
val key = "$kind:${logicalPacketId(packet)}"
|
||||
val now = System.currentTimeMillis()
|
||||
synchronized(seenPackets) {
|
||||
pruneSeen(now)
|
||||
val previous = seenPackets[key]
|
||||
if (previous != null && now - previous < SEEN_PACKET_TTL_MS) {
|
||||
Log.d(TAG, "Dropping duplicate bridged packet type ${packet.type}")
|
||||
return null
|
||||
}
|
||||
seenPackets[key] = now
|
||||
}
|
||||
|
||||
return packet.copy(ttl = (packet.ttl - 1u).toUByte())
|
||||
}
|
||||
|
||||
private fun pruneSeen(now: Long) {
|
||||
val iterator = seenPackets.entries.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val entry = iterator.next()
|
||||
if (now - entry.value > SEEN_PACKET_TTL_MS) {
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun logicalPacketId(packet: BitchatPacket): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
digest.update(packet.type.toByte())
|
||||
digest.update(packet.senderID)
|
||||
packet.recipientID?.let { digest.update(it) }
|
||||
digest.update(packet.timestamp.toString().toByteArray(Charsets.UTF_8))
|
||||
digest.update(packet.payload)
|
||||
packet.route?.forEach { digest.update(it) }
|
||||
packet.signature?.let { digest.update(it) }
|
||||
return digest.digest().toHexString()
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@ object AppStateStore {
|
||||
// Global de-dup set by message id to avoid duplicate keys in Compose lists
|
||||
private val seenMessageIds = mutableSetOf<String>()
|
||||
private val seenPublicMessageKeys = mutableSetOf<String>()
|
||||
private val peerIdsByTransport = mutableMapOf<String, Set<String>>()
|
||||
// Direct (single-hop) peer IDs per transport, used to gossip a unified neighbor set.
|
||||
private val directPeerIdsByTransport = mutableMapOf<String, Set<String>>()
|
||||
// Connected peer IDs (mesh ephemeral IDs)
|
||||
private val _peers = MutableStateFlow<List<String>>(emptyList())
|
||||
val peers: StateFlow<List<String>> = _peers.asStateFlow()
|
||||
@@ -31,7 +34,55 @@ object AppStateStore {
|
||||
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = _channelMessages.asStateFlow()
|
||||
|
||||
fun setPeers(ids: List<String>) {
|
||||
_peers.value = ids
|
||||
synchronized(this) {
|
||||
_peers.value = ids.distinct()
|
||||
}
|
||||
}
|
||||
|
||||
fun setTransportPeers(transportId: String, ids: List<String>) {
|
||||
synchronized(this) {
|
||||
peerIdsByTransport[transportId] = ids.toSet()
|
||||
publishTransportPeersLocked()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearTransportPeers(transportId: String) {
|
||||
synchronized(this) {
|
||||
peerIdsByTransport.remove(transportId)
|
||||
publishTransportPeersLocked()
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishTransportPeersLocked() {
|
||||
_peers.value = peerIdsByTransport.values
|
||||
.asSequence()
|
||||
.flatten()
|
||||
.distinct()
|
||||
.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the set of direct (single-hop) peers reachable over a given transport. Each transport
|
||||
* (BLE, Wi-Fi Aware, ...) only knows its own direct peers; [getDirectPeers] unions them so every
|
||||
* transport can gossip the same complete neighbor list under our shared node identity.
|
||||
*/
|
||||
fun setTransportDirectPeers(transportId: String, ids: Collection<String>) {
|
||||
synchronized(this) {
|
||||
directPeerIdsByTransport[transportId] = ids.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearTransportDirectPeers(transportId: String) {
|
||||
synchronized(this) {
|
||||
directPeerIdsByTransport.remove(transportId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Union of direct peers across all transports. */
|
||||
fun getDirectPeers(): Set<String> {
|
||||
synchronized(this) {
|
||||
return directPeerIdsByTransport.values.flatten().toSet()
|
||||
}
|
||||
}
|
||||
|
||||
fun addPublicMessage(msg: BitchatMessage) {
|
||||
@@ -104,6 +155,8 @@ object AppStateStore {
|
||||
synchronized(this) {
|
||||
seenMessageIds.clear()
|
||||
seenPublicMessageKeys.clear()
|
||||
peerIdsByTransport.clear()
|
||||
directPeerIdsByTransport.clear()
|
||||
_peers.value = emptyList()
|
||||
_publicMessages.value = emptyList()
|
||||
_privateMessages.value = emptyMap()
|
||||
|
||||
@@ -2,23 +2,23 @@ package com.bitchat.android.services
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.ReadReceipt
|
||||
import com.bitchat.android.nostr.NostrTransport
|
||||
|
||||
/**
|
||||
* Routes messages between BLE mesh and Nostr transports, matching iOS behavior.
|
||||
* Routes messages between local mesh transports and Nostr, matching iOS behavior.
|
||||
*/
|
||||
class MessageRouter private constructor(
|
||||
private val context: Context,
|
||||
private var mesh: BluetoothMeshService,
|
||||
private var mesh: MeshService,
|
||||
private val nostr: NostrTransport
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "MessageRouter"
|
||||
@Volatile private var INSTANCE: MessageRouter? = null
|
||||
fun tryGetInstance(): MessageRouter? = INSTANCE
|
||||
fun getInstance(context: Context, mesh: BluetoothMeshService): MessageRouter {
|
||||
fun getInstance(context: Context, mesh: MeshService): MessageRouter {
|
||||
val instance = INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: run {
|
||||
val nostr = NostrTransport.getInstance(context)
|
||||
@@ -70,9 +70,8 @@ class MessageRouter private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
val hasMesh = mesh.getPeerInfo(toPeerID)?.isConnected == true
|
||||
val hasEstablished = mesh.hasEstablishedSession(toPeerID)
|
||||
if (hasMesh && hasEstablished) {
|
||||
val hasMesh = isConnected(mesh, toPeerID)
|
||||
if (isReady(mesh, toPeerID)) {
|
||||
Log.d(TAG, "Routing PM via mesh to ${toPeerID} msg_id=${messageID.take(8)}…")
|
||||
mesh.sendPrivateMessage(content, toPeerID, recipientNickname, messageID)
|
||||
} else if (canSendViaNostr(toPeerID)) {
|
||||
@@ -83,12 +82,12 @@ class MessageRouter private constructor(
|
||||
val q = outbox.getOrPut(toPeerID) { mutableListOf() }
|
||||
q.add(Triple(content, recipientNickname, messageID))
|
||||
Log.d(TAG, "Initiating noise handshake after queueing PM for ${toPeerID.take(8)}…")
|
||||
mesh.initiateNoiseHandshake(toPeerID)
|
||||
if (hasMesh) mesh.initiateNoiseHandshake(toPeerID)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendReadReceipt(receipt: ReadReceipt, toPeerID: String) {
|
||||
if ((mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID)) {
|
||||
if (isReady(mesh, toPeerID)) {
|
||||
Log.d(TAG, "Routing READ via mesh to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}…")
|
||||
mesh.sendReadReceipt(receipt.originalMessageID, toPeerID, mesh.getPeerNicknames()[toPeerID] ?: mesh.myPeerID)
|
||||
} else {
|
||||
@@ -113,11 +112,11 @@ class MessageRouter private constructor(
|
||||
}
|
||||
|
||||
fun sendFavoriteNotification(toPeerID: String, isFavorite: Boolean) {
|
||||
if (mesh.getPeerInfo(toPeerID)?.isConnected == true) {
|
||||
if (mesh.getPeerInfo(toPeerID)?.isConnected == true && mesh.hasEstablishedSession(toPeerID)) {
|
||||
val myNpub = try { com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)?.npub } catch (_: Exception) { null }
|
||||
val content = if (isFavorite) "[FAVORITED]:${myNpub ?: ""}" else "[UNFAVORITED]:${myNpub ?: ""}"
|
||||
val nickname = mesh.getPeerNicknames()[toPeerID] ?: toPeerID
|
||||
mesh.sendPrivateMessage(content, toPeerID, nickname)
|
||||
mesh.sendPrivateMessage(content, toPeerID, nickname, null)
|
||||
} else {
|
||||
nostr.sendFavoriteNotification(toPeerID, isFavorite)
|
||||
}
|
||||
@@ -131,11 +130,11 @@ class MessageRouter private constructor(
|
||||
val iterator = queued.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val (content, nickname, messageID) = iterator.next()
|
||||
var hasMesh = mesh.getPeerInfo(peerID)?.isConnected == true && mesh.hasEstablishedSession(peerID)
|
||||
val hasMesh = isReady(mesh, peerID)
|
||||
// If this is a noiseHex key, see if there is a connected mesh peer for this identity
|
||||
if (!hasMesh && peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
||||
val meshPeer = resolveMeshPeerForNoiseHex(peerID)
|
||||
if (meshPeer != null && mesh.getPeerInfo(meshPeer)?.isConnected == true && mesh.hasEstablishedSession(meshPeer)) {
|
||||
val meshPeer = resolvePeerForNoiseHex(peerID, mesh)
|
||||
if (meshPeer != null && isReady(mesh, meshPeer)) {
|
||||
mesh.sendPrivateMessage(content, meshPeer, nickname, messageID)
|
||||
iterator.remove()
|
||||
continue
|
||||
@@ -182,10 +181,27 @@ class MessageRouter private constructor(
|
||||
return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
}
|
||||
|
||||
private fun resolveMeshPeerForNoiseHex(noiseHex: String): String? {
|
||||
private fun isConnected(service: MeshService, peerID: String): Boolean {
|
||||
return try {
|
||||
mesh.getPeerNicknames().keys.firstOrNull { pid ->
|
||||
val info = mesh.getPeerInfo(pid)
|
||||
service.getPeerInfo(peerID)?.isConnected == true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isReady(service: MeshService, peerID: String): Boolean {
|
||||
return try {
|
||||
service.getPeerInfo(peerID)?.isConnected == true &&
|
||||
service.hasEstablishedSession(peerID)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolvePeerForNoiseHex(noiseHex: String, service: MeshService): String? {
|
||||
return try {
|
||||
service.getPeerNicknames().keys.firstOrNull { pid ->
|
||||
val info = service.getPeerInfo(pid)
|
||||
val keyHex = info?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
|
||||
keyHex != null && keyHex.equals(noiseHex, ignoreCase = true)
|
||||
}
|
||||
|
||||
@@ -444,7 +444,7 @@ private fun MainHeader(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
PeerCounter(
|
||||
connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID },
|
||||
connectedPeers = connectedPeers.filter { it != viewModel.myPeerID },
|
||||
joinedChannels = joinedChannels,
|
||||
hasUnreadChannels = hasUnreadChannels,
|
||||
isConnected = isConnected,
|
||||
|
||||
@@ -133,7 +133,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
MessagesList(
|
||||
messages = displayMessages,
|
||||
currentUserNickname = nickname,
|
||||
meshService = viewModel.meshService,
|
||||
meshService = viewModel.meshServiceFacade,
|
||||
modifier = Modifier.weight(1f),
|
||||
forceScrollToBottom = forceScrollToBottom,
|
||||
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
|
||||
|
||||
@@ -11,7 +11,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Shield
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import java.text.SimpleDateFormat
|
||||
@@ -42,7 +42,7 @@ fun getRSSIColor(rssi: Int): Color {
|
||||
fun formatMessageAsAnnotatedString(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: BluetoothMeshService,
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
): AnnotatedString {
|
||||
@@ -162,7 +162,7 @@ fun formatMessageAsAnnotatedString(
|
||||
fun formatMessageHeaderAnnotatedString(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: BluetoothMeshService,
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
): AnnotatedString {
|
||||
|
||||
@@ -11,6 +11,7 @@ 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.mesh.MeshService
|
||||
import com.bitchat.android.service.MeshServiceHolder
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
@@ -38,12 +39,16 @@ import java.security.MessageDigest
|
||||
*/
|
||||
class ChatViewModel(
|
||||
application: Application,
|
||||
initialMeshService: BluetoothMeshService
|
||||
initialMeshService: BluetoothMeshService,
|
||||
initialUnifiedMeshService: MeshService
|
||||
) : AndroidViewModel(application), BluetoothMeshDelegate {
|
||||
|
||||
// Made var to support mesh service replacement after panic clear
|
||||
var meshService: BluetoothMeshService = initialMeshService
|
||||
private set
|
||||
private var unifiedMeshService: MeshService = initialUnifiedMeshService
|
||||
private val mesh: MeshService
|
||||
get() = unifiedMeshService
|
||||
private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } }
|
||||
|
||||
companion object {
|
||||
@@ -93,9 +98,9 @@ class ChatViewModel(
|
||||
|
||||
// Create Noise session delegate for clean dependency injection
|
||||
private val noiseSessionDelegate = object : NoiseSessionDelegate {
|
||||
override fun hasEstablishedSession(peerID: String): Boolean = meshService.hasEstablishedSession(peerID)
|
||||
override fun initiateHandshake(peerID: String) = meshService.initiateNoiseHandshake(peerID)
|
||||
override fun getMyPeerID(): String = meshService.myPeerID
|
||||
override fun hasEstablishedSession(peerID: String): Boolean = hasEstablishedSessionOnAnyLocalTransport(peerID)
|
||||
override fun initiateHandshake(peerID: String) = initiateNoiseHandshakeOnBestLocalTransport(peerID)
|
||||
override fun getMyPeerID(): String = mesh.myPeerID
|
||||
}
|
||||
|
||||
val privateChatManager = PrivateChatManager(state, messageManager, dataManager, noiseSessionDelegate)
|
||||
@@ -109,7 +114,7 @@ class ChatViewModel(
|
||||
private val verificationHandler = VerificationHandler(
|
||||
context = application.applicationContext,
|
||||
scope = viewModelScope,
|
||||
getMeshService = { meshService },
|
||||
getMeshService = { mesh },
|
||||
identityManager = identityManager,
|
||||
state = state,
|
||||
notificationManager = notificationManager,
|
||||
@@ -118,7 +123,7 @@ class ChatViewModel(
|
||||
val verifiedFingerprints = verificationHandler.verifiedFingerprints
|
||||
|
||||
// Media file sending manager
|
||||
private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager) { meshService }
|
||||
private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager) { mesh }
|
||||
|
||||
// Delegate handler for mesh callbacks
|
||||
private val meshDelegateHandler = MeshDelegateHandler(
|
||||
@@ -129,8 +134,8 @@ class ChatViewModel(
|
||||
notificationManager = notificationManager,
|
||||
coroutineScope = viewModelScope,
|
||||
onHapticFeedback = { ChatViewModelUtils.triggerHapticFeedback(application.applicationContext) },
|
||||
getMyPeerID = { meshService.myPeerID },
|
||||
getMeshService = { meshService }
|
||||
getMyPeerID = { mesh.myPeerID },
|
||||
getMeshService = { mesh }
|
||||
)
|
||||
|
||||
// New Geohash architecture ViewModel (replaces God object service usage in UI path)
|
||||
@@ -184,6 +189,18 @@ class ChatViewModel(
|
||||
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
|
||||
val teleportedGeo: StateFlow<Set<String>> = state.teleportedGeo
|
||||
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
|
||||
val meshServiceFacade: MeshService
|
||||
get() = mesh
|
||||
val myPeerID: String
|
||||
get() = mesh.myPeerID
|
||||
|
||||
fun getMeshPeerFingerprint(peerID: String): String? = mesh.getPeerFingerprint(peerID)
|
||||
|
||||
fun getMeshPeerInfo(peerID: String): com.bitchat.android.mesh.PeerInfo? = mesh.getPeerInfo(peerID)
|
||||
|
||||
fun initiateMeshHandshake(peerID: String) {
|
||||
mesh.initiateNoiseHandshake(peerID)
|
||||
}
|
||||
|
||||
init {
|
||||
// Note: Mesh service delegate is now set by MainActivity
|
||||
@@ -208,7 +225,7 @@ class ChatViewModel(
|
||||
// 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 myNick = state.getNicknameValue() ?: mesh.myPeerID
|
||||
val unread = mutableSetOf<String>()
|
||||
byPeer.forEach { (peer, list) ->
|
||||
if (list.any { msg -> msg.sender != myNick && !seen.hasRead(msg.id) }) unread.add(peer)
|
||||
@@ -299,7 +316,7 @@ class ChatViewModel(
|
||||
// Ensure NostrTransport knows our mesh peer ID for embedded packets
|
||||
try {
|
||||
val nostrTransport = com.bitchat.android.nostr.NostrTransport.getInstance(getApplication())
|
||||
nostrTransport.senderPeerID = meshService.myPeerID
|
||||
nostrTransport.senderPeerID = mesh.myPeerID
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Note: Mesh service is now started by MainActivity
|
||||
@@ -317,7 +334,7 @@ class ChatViewModel(
|
||||
fun setNickname(newNickname: String) {
|
||||
state.setNickname(newNickname)
|
||||
dataManager.saveNickname(newNickname)
|
||||
meshService.sendBroadcastAnnounce()
|
||||
mesh.sendBroadcastAnnounce()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -363,7 +380,7 @@ class ChatViewModel(
|
||||
// MARK: - Channel Management (delegated)
|
||||
|
||||
fun joinChannel(channel: String, password: String? = null): Boolean {
|
||||
return channelManager.joinChannel(channel, password, meshService.myPeerID)
|
||||
return channelManager.joinChannel(channel, password, mesh.myPeerID)
|
||||
}
|
||||
|
||||
fun switchToChannel(channel: String?) {
|
||||
@@ -372,7 +389,7 @@ class ChatViewModel(
|
||||
|
||||
fun leaveChannel(channel: String) {
|
||||
channelManager.leaveChannel(channel)
|
||||
meshService.sendMessage("left $channel")
|
||||
mesh.sendMessage("left $channel", emptyList(), null)
|
||||
}
|
||||
|
||||
// MARK: - Private Chat Management (delegated)
|
||||
@@ -383,7 +400,7 @@ class ChatViewModel(
|
||||
ensureGeohashDMSubscriptionIfNeeded(peerID)
|
||||
}
|
||||
|
||||
val success = privateChatManager.startPrivateChat(peerID, meshService)
|
||||
val success = privateChatManager.startPrivateChat(peerID, mesh)
|
||||
if (success) {
|
||||
// Notify notification manager about current private chat
|
||||
setCurrentPrivateChatPeer(peerID)
|
||||
@@ -420,7 +437,7 @@ class ChatViewModel(
|
||||
val unreadKeys = state.getUnreadPrivateMessagesValue()
|
||||
if (unreadKeys.isEmpty()) return
|
||||
|
||||
val me = state.getNicknameValue() ?: meshService.myPeerID
|
||||
val me = state.getNicknameValue() ?: mesh.myPeerID
|
||||
val chats = state.getPrivateChatsValue()
|
||||
|
||||
// Pick the latest incoming message among unread conversations
|
||||
@@ -451,8 +468,8 @@ class ChatViewModel(
|
||||
val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
|
||||
selectedPeerID = targetKey,
|
||||
connectedPeers = state.getConnectedPeersValue(),
|
||||
meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey },
|
||||
meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true },
|
||||
meshNoiseKeyForPeer = { pid -> mesh.getPeerInfo(pid)?.noisePublicKey },
|
||||
meshHasPeer = { pid -> mesh.getPeerInfo(pid)?.isConnected == true },
|
||||
nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) },
|
||||
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
|
||||
)
|
||||
@@ -476,24 +493,23 @@ class ChatViewModel(
|
||||
// Check for commands
|
||||
if (content.startsWith("/")) {
|
||||
val selectedLocationForCommand = state.selectedLocationChannel.value
|
||||
commandProcessor.processCommand(content, meshService, meshService.myPeerID, { messageContent, mentions, channel ->
|
||||
commandProcessor.processCommand(content, mesh, mesh.myPeerID, { messageContent, mentions, channel ->
|
||||
if (selectedLocationForCommand is com.bitchat.android.geohash.ChannelID.Location) {
|
||||
// Route command-generated public messages via Nostr in geohash channels
|
||||
geohashViewModel.sendGeohashMessage(
|
||||
messageContent,
|
||||
selectedLocationForCommand.channel,
|
||||
meshService.myPeerID,
|
||||
mesh.myPeerID,
|
||||
state.getNicknameValue()
|
||||
)
|
||||
} else {
|
||||
// Default: route via mesh
|
||||
meshService.sendMessage(messageContent, mentions, channel)
|
||||
mesh.sendMessage(messageContent, mentions, channel)
|
||||
}
|
||||
})
|
||||
}, this)
|
||||
return
|
||||
}
|
||||
|
||||
val mentions = messageManager.parseMentions(content, meshService.getPeerNicknames().values.toSet(), state.getNicknameValue())
|
||||
val mentions = messageManager.parseMentions(content, mesh.getPeerNicknames().values.toSet(), state.getNicknameValue())
|
||||
// REMOVED: Auto-join mentioned channels feature that was incorrectly parsing hashtags from @mentions
|
||||
// This was causing messages like "test @jack#1234 test" to auto-join channel "#1234"
|
||||
|
||||
@@ -505,13 +521,13 @@ class ChatViewModel(
|
||||
selectedPeer = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
|
||||
selectedPeerID = selectedPeer,
|
||||
connectedPeers = state.getConnectedPeersValue(),
|
||||
meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey },
|
||||
meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true },
|
||||
meshNoiseKeyForPeer = { pid -> mesh.getPeerInfo(pid)?.noisePublicKey },
|
||||
meshHasPeer = { pid -> mesh.getPeerInfo(pid)?.isConnected == true },
|
||||
nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) },
|
||||
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
|
||||
).also { canonical ->
|
||||
if (canonical != state.getSelectedPrivateChatPeerValue()) {
|
||||
privateChatManager.startPrivateChat(canonical, meshService)
|
||||
privateChatManager.startPrivateChat(canonical, mesh)
|
||||
// If we're in the private chat sheet, update its active peer too
|
||||
if (state.getPrivateChatSheetPeerValue() != null) {
|
||||
showPrivateChatSheet(canonical)
|
||||
@@ -519,16 +535,16 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
// Send private message
|
||||
val recipientNickname = meshService.getPeerNicknames()[selectedPeer]
|
||||
val recipientNickname = nicknameForPeer(selectedPeer)
|
||||
privateChatManager.sendPrivateMessage(
|
||||
content,
|
||||
selectedPeer,
|
||||
recipientNickname,
|
||||
state.getNicknameValue(),
|
||||
meshService.myPeerID
|
||||
mesh.myPeerID
|
||||
) { messageContent, peerID, recipientNicknameParam, messageId ->
|
||||
// Route via MessageRouter (mesh when connected+established, else Nostr)
|
||||
val router = com.bitchat.android.services.MessageRouter.getInstance(getApplication(), meshService)
|
||||
val router = com.bitchat.android.services.MessageRouter.getInstance(getApplication(), mesh)
|
||||
router.sendPrivate(messageContent, peerID, recipientNicknameParam, messageId)
|
||||
}
|
||||
} else {
|
||||
@@ -536,21 +552,21 @@ class ChatViewModel(
|
||||
val selectedLocationChannel = state.selectedLocationChannel.value
|
||||
if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) {
|
||||
// Send to geohash channel via Nostr ephemeral event
|
||||
geohashViewModel.sendGeohashMessage(content, selectedLocationChannel.channel, meshService.myPeerID, state.getNicknameValue())
|
||||
geohashViewModel.sendGeohashMessage(content, selectedLocationChannel.channel, mesh.myPeerID, state.getNicknameValue())
|
||||
} else {
|
||||
// Send public/channel message via mesh
|
||||
val message = BitchatMessage(
|
||||
sender = state.getNicknameValue() ?: meshService.myPeerID,
|
||||
sender = state.getNicknameValue() ?: mesh.myPeerID,
|
||||
content = content,
|
||||
timestamp = Date(),
|
||||
isRelay = false,
|
||||
senderPeerID = meshService.myPeerID,
|
||||
senderPeerID = mesh.myPeerID,
|
||||
mentions = if (mentions.isNotEmpty()) mentions else null,
|
||||
channel = currentChannelValue
|
||||
)
|
||||
|
||||
if (currentChannelValue != null) {
|
||||
channelManager.addChannelMessage(currentChannelValue, message, meshService.myPeerID)
|
||||
channelManager.addChannelMessage(currentChannelValue, message, mesh.myPeerID)
|
||||
|
||||
// Check if encrypted channel
|
||||
if (channelManager.hasChannelKey(currentChannelValue)) {
|
||||
@@ -559,21 +575,20 @@ class ChatViewModel(
|
||||
mentions,
|
||||
currentChannelValue,
|
||||
state.getNicknameValue(),
|
||||
meshService.myPeerID,
|
||||
mesh.myPeerID,
|
||||
onEncryptedPayload = { encryptedData ->
|
||||
// This would need proper mesh service integration
|
||||
meshService.sendMessage(content, mentions, currentChannelValue)
|
||||
mesh.sendMessage(content, mentions, currentChannelValue)
|
||||
},
|
||||
onFallback = {
|
||||
meshService.sendMessage(content, mentions, currentChannelValue)
|
||||
mesh.sendMessage(content, mentions, currentChannelValue)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
meshService.sendMessage(content, mentions, currentChannelValue)
|
||||
mesh.sendMessage(content, mentions, currentChannelValue)
|
||||
}
|
||||
} else {
|
||||
messageManager.addMessage(message)
|
||||
meshService.sendMessage(content, mentions, null)
|
||||
mesh.sendMessage(content, mentions, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -582,7 +597,7 @@ class ChatViewModel(
|
||||
// MARK: - Utility Functions
|
||||
|
||||
fun getPeerIDForNickname(nickname: String): String? {
|
||||
return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key
|
||||
return mesh.getPeerNicknames().entries.find { it.value == nickname }?.key
|
||||
}
|
||||
|
||||
fun toggleFavorite(peerID: String) {
|
||||
@@ -592,10 +607,10 @@ class ChatViewModel(
|
||||
// Persist relationship in FavoritesPersistenceService
|
||||
try {
|
||||
var noiseKey: ByteArray? = null
|
||||
var nickname: String = meshService.getPeerNicknames()[peerID] ?: peerID
|
||||
var nickname: String = mesh.getPeerNicknames()[peerID] ?: peerID
|
||||
|
||||
// Case 1: Live mesh peer with known info
|
||||
val peerInfo = meshService.getPeerInfo(peerID)
|
||||
val peerInfo = mesh.getPeerInfo(peerID)
|
||||
if (peerInfo?.noisePublicKey != null) {
|
||||
noiseKey = peerInfo.noisePublicKey
|
||||
nickname = peerInfo.nickname
|
||||
@@ -625,22 +640,9 @@ class ChatViewModel(
|
||||
|
||||
// Send favorite notification via mesh or Nostr with our npub if available
|
||||
try {
|
||||
val myNostr = com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(getApplication())
|
||||
val announcementContent = if (isNowFavorite) "[FAVORITED]:${myNostr?.npub ?: ""}" else "[UNFAVORITED]:${myNostr?.npub ?: ""}"
|
||||
// Prefer mesh if session established, else try Nostr
|
||||
if (meshService.hasEstablishedSession(peerID)) {
|
||||
// Reuse existing private message path for notifications
|
||||
meshService.sendPrivateMessage(
|
||||
announcementContent,
|
||||
peerID,
|
||||
nickname,
|
||||
java.util.UUID.randomUUID().toString()
|
||||
)
|
||||
} else {
|
||||
val nostrTransport = com.bitchat.android.nostr.NostrTransport.getInstance(getApplication())
|
||||
nostrTransport.senderPeerID = meshService.myPeerID
|
||||
nostrTransport.sendFavoriteNotification(peerID, isNowFavorite)
|
||||
}
|
||||
com.bitchat.android.services.MessageRouter
|
||||
.getInstance(getApplication(), mesh)
|
||||
.sendFavoriteNotification(peerID, isNowFavorite)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
@@ -656,6 +658,40 @@ class ChatViewModel(
|
||||
Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}")
|
||||
Log.i("ChatViewModel", "==============================")
|
||||
}
|
||||
|
||||
private fun isConnectedOnMesh(peerID: String): Boolean {
|
||||
return try {
|
||||
mesh.getPeerInfo(peerID)?.isConnected == true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasEstablishedSessionOnMesh(peerID: String): Boolean {
|
||||
return try {
|
||||
mesh.getPeerInfo(peerID)?.isConnected == true &&
|
||||
mesh.hasEstablishedSession(peerID)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasEstablishedSessionOnAnyLocalTransport(peerID: String): Boolean {
|
||||
return hasEstablishedSessionOnMesh(peerID)
|
||||
}
|
||||
|
||||
private fun initiateNoiseHandshakeOnBestLocalTransport(peerID: String) {
|
||||
mesh.initiateNoiseHandshake(peerID)
|
||||
}
|
||||
|
||||
private fun nicknameForPeer(peerID: String): String? {
|
||||
return state.peerNicknames.value[peerID]
|
||||
?: try { mesh.getPeerNicknames()[peerID] } catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
private fun sessionStateForPeer(peerID: String): NoiseSession.NoiseSessionState {
|
||||
return try { mesh.getSessionState(peerID) } catch (_: Exception) { NoiseSession.NoiseSessionState.Uninitialized }
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize session state monitoring for reactive UI updates
|
||||
@@ -680,7 +716,7 @@ class ChatViewModel(
|
||||
// Update session states
|
||||
val prevStates = state.getPeerSessionStatesValue()
|
||||
val sessionStates = currentPeers.associateWith { peerID ->
|
||||
meshService.getSessionState(peerID).toString()
|
||||
sessionStateForPeer(peerID).toString()
|
||||
}
|
||||
state.setPeerSessionStates(sessionStates)
|
||||
// Detect new established sessions and flush router outbox for them and their noiseHex aliases
|
||||
@@ -688,7 +724,7 @@ class ChatViewModel(
|
||||
val old = prevStates[peerID]
|
||||
if (old != "established" && newState == "established") {
|
||||
com.bitchat.android.services.MessageRouter
|
||||
.getInstance(getApplication(), meshService)
|
||||
.getInstance(getApplication(), mesh)
|
||||
.onSessionEstablished(peerID)
|
||||
}
|
||||
}
|
||||
@@ -697,7 +733,7 @@ class ChatViewModel(
|
||||
state.setPeerFingerprints(fingerprints)
|
||||
fingerprints.forEach { (peerID, fingerprint) ->
|
||||
identityManager.cachePeerFingerprint(peerID, fingerprint)
|
||||
val info = try { meshService.getPeerInfo(peerID) } catch (_: Exception) { null }
|
||||
val info = try { mesh.getPeerInfo(peerID) } catch (_: Exception) { null }
|
||||
val noiseKeyHex = info?.noisePublicKey?.hexEncodedString()
|
||||
if (noiseKeyHex != null) {
|
||||
identityManager.cachePeerNoiseKey(peerID, noiseKeyHex)
|
||||
@@ -708,23 +744,21 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
val nicknames = meshService.getPeerNicknames()
|
||||
state.setPeerNicknames(nicknames)
|
||||
state.setPeerNicknames(mesh.getPeerNicknames())
|
||||
|
||||
val rssiValues = meshService.getPeerRSSI()
|
||||
state.setPeerRSSI(rssiValues)
|
||||
state.setPeerRSSI(mesh.getPeerRSSI())
|
||||
|
||||
// Update directness per peer (driven by PeerManager state)
|
||||
try {
|
||||
val directMap = state.getConnectedPeersValue().associateWith { pid ->
|
||||
meshService.getPeerInfo(pid)?.isDirectConnection == true
|
||||
mesh.getPeerInfo(pid)?.isDirectConnection == true
|
||||
}
|
||||
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) {
|
||||
if (sessionStateForPeer(peerID) is NoiseSession.NoiseSessionState.Established) {
|
||||
verificationHandler.sendPendingVerificationIfNeeded(peerID)
|
||||
}
|
||||
}
|
||||
@@ -754,7 +788,7 @@ class ChatViewModel(
|
||||
// MARK: - Debug and Troubleshooting
|
||||
|
||||
fun getDebugStatus(): String {
|
||||
return meshService.getDebugStatus()
|
||||
return mesh.getDebugStatus()
|
||||
}
|
||||
|
||||
fun setCurrentPrivateChatPeer(peerID: String?) {
|
||||
@@ -851,7 +885,7 @@ class ChatViewModel(
|
||||
// MARK: - Mention Autocomplete
|
||||
|
||||
fun updateMentionSuggestions(input: String) {
|
||||
commandProcessor.updateMentionSuggestions(input, meshService, this)
|
||||
commandProcessor.updateMentionSuggestions(input, mesh, this)
|
||||
}
|
||||
|
||||
fun selectMentionSuggestion(nickname: String, currentText: String): String {
|
||||
@@ -867,7 +901,7 @@ class ChatViewModel(
|
||||
override fun didUpdatePeerList(peers: List<String>) {
|
||||
meshDelegateHandler.didUpdatePeerList(peers)
|
||||
}
|
||||
|
||||
|
||||
override fun didReceiveChannelLeave(channel: String, fromPeer: String) {
|
||||
meshDelegateHandler.didReceiveChannelLeave(channel, fromPeer)
|
||||
}
|
||||
@@ -956,7 +990,7 @@ class ChatViewModel(
|
||||
// Recreate mesh service with fresh identity
|
||||
recreateMeshServiceAfterPanic()
|
||||
|
||||
Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${meshService.myPeerID}")
|
||||
Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${mesh.myPeerID}")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -964,25 +998,27 @@ class ChatViewModel(
|
||||
* This ensures the new cryptographic keys are used for a new peer ID.
|
||||
*/
|
||||
private fun recreateMeshServiceAfterPanic() {
|
||||
val oldPeerID = meshService.myPeerID
|
||||
val oldPeerID = mesh.myPeerID
|
||||
|
||||
// Clear the holder so getOrCreate() returns a fresh instance
|
||||
MeshServiceHolder.clear()
|
||||
|
||||
// Create fresh mesh service with new identity (keys were regenerated in clearAllCryptographicData)
|
||||
val freshMeshService = MeshServiceHolder.getOrCreate(getApplication())
|
||||
val freshUnifiedMeshService = MeshServiceHolder.getUnifiedOrCreate(getApplication())
|
||||
|
||||
// Replace our reference and set up the new service
|
||||
meshService = freshMeshService
|
||||
meshService.delegate = this
|
||||
unifiedMeshService = freshUnifiedMeshService
|
||||
mesh.delegate = this
|
||||
|
||||
// Restart mesh operations with new identity
|
||||
meshService.startServices()
|
||||
meshService.sendBroadcastAnnounce()
|
||||
mesh.startServices()
|
||||
mesh.sendBroadcastAnnounce()
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"✅ Mesh service recreated. Old peerID: $oldPeerID, New peerID: ${meshService.myPeerID}"
|
||||
"✅ Mesh service recreated. Old peerID: $oldPeerID, New peerID: ${mesh.myPeerID}"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -992,7 +1028,7 @@ class ChatViewModel(
|
||||
private fun clearAllMeshServiceData() {
|
||||
try {
|
||||
// Request mesh service to clear all its internal data
|
||||
meshService.clearAllInternalData()
|
||||
mesh.clearAllInternalData()
|
||||
|
||||
Log.d(TAG, "✅ Cleared all mesh service data")
|
||||
} catch (e: Exception) {
|
||||
@@ -1006,7 +1042,7 @@ class ChatViewModel(
|
||||
private fun clearAllCryptographicData() {
|
||||
try {
|
||||
// Clear encryption service persistent identity (Ed25519 signing keys)
|
||||
meshService.clearAllEncryptionData()
|
||||
mesh.clearAllEncryptionData()
|
||||
|
||||
// Clear secure identity state (if used)
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import java.util.Date
|
||||
|
||||
@@ -29,21 +29,21 @@ class CommandProcessor(
|
||||
|
||||
// MARK: - Command Processing
|
||||
|
||||
fun processCommand(command: String, meshService: BluetoothMeshService, myPeerID: String, onSendMessage: (String, List<String>, String?) -> Unit, viewModel: ChatViewModel? = null): Boolean {
|
||||
fun processCommand(command: String, meshService: MeshService, myPeerID: String, onSendMessage: (String, List<String>, String?) -> Unit, viewModel: ChatViewModel? = null): Boolean {
|
||||
if (!command.startsWith("/")) return false
|
||||
|
||||
val parts = command.split(" ")
|
||||
val cmd = parts.first().lowercase()
|
||||
when (cmd) {
|
||||
"/j", "/join" -> handleJoinCommand(parts, myPeerID)
|
||||
"/m", "/msg" -> handleMessageCommand(parts, meshService)
|
||||
"/m", "/msg" -> handleMessageCommand(parts, meshService, viewModel)
|
||||
"/w" -> handleWhoCommand(meshService, viewModel)
|
||||
"/clear" -> handleClearCommand()
|
||||
"/pass" -> handlePassCommand(parts, myPeerID)
|
||||
"/block" -> handleBlockCommand(parts, meshService)
|
||||
"/unblock" -> handleUnblockCommand(parts, meshService)
|
||||
"/hug" -> handleActionCommand(parts, "gives", "a warm hug 🫂", meshService, myPeerID, onSendMessage)
|
||||
"/slap" -> handleActionCommand(parts, "slaps", "around a bit with a large trout 🐟", meshService, myPeerID, onSendMessage)
|
||||
"/hug" -> handleActionCommand(parts, "gives", "a warm hug 🫂", meshService, myPeerID, onSendMessage, viewModel)
|
||||
"/slap" -> handleActionCommand(parts, "slaps", "around a bit with a large trout 🐟", meshService, myPeerID, onSendMessage, viewModel)
|
||||
"/channels" -> handleChannelsCommand()
|
||||
else -> handleUnknownCommand(cmd)
|
||||
}
|
||||
@@ -77,7 +77,7 @@ class CommandProcessor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMessageCommand(parts: List<String>, meshService: BluetoothMeshService) {
|
||||
private fun handleMessageCommand(parts: List<String>, meshService: MeshService, viewModel: ChatViewModel?) {
|
||||
if (parts.size > 1) {
|
||||
val targetName = parts[1].removePrefix("@")
|
||||
val peerID = getPeerIDForNickname(targetName, meshService)
|
||||
@@ -96,8 +96,7 @@ class CommandProcessor(
|
||||
state.getNicknameValue(),
|
||||
getMyPeerID(meshService)
|
||||
) { content, peerIdParam, recipientNicknameParam, messageId ->
|
||||
// This would trigger the actual mesh service send
|
||||
sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId)
|
||||
sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId, viewModel)
|
||||
}
|
||||
} else {
|
||||
val systemMessage = BitchatMessage(
|
||||
@@ -129,7 +128,7 @@ class CommandProcessor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleWhoCommand(meshService: BluetoothMeshService, viewModel: ChatViewModel? = null) {
|
||||
private fun handleWhoCommand(meshService: MeshService, viewModel: ChatViewModel? = null) {
|
||||
// Channel-aware who command (matches iOS behavior)
|
||||
val (peerList, contextDescription) = if (viewModel != null) {
|
||||
when (val selectedChannel = viewModel.selectedLocationChannel.value) {
|
||||
@@ -248,7 +247,7 @@ class CommandProcessor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBlockCommand(parts: List<String>, meshService: BluetoothMeshService) {
|
||||
private fun handleBlockCommand(parts: List<String>, meshService: MeshService) {
|
||||
if (parts.size > 1) {
|
||||
val targetName = parts[1].removePrefix("@")
|
||||
privateChatManager.blockPeerByNickname(targetName, meshService)
|
||||
@@ -265,7 +264,7 @@ class CommandProcessor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUnblockCommand(parts: List<String>, meshService: BluetoothMeshService) {
|
||||
private fun handleUnblockCommand(parts: List<String>, meshService: MeshService) {
|
||||
if (parts.size > 1) {
|
||||
val targetName = parts[1].removePrefix("@")
|
||||
privateChatManager.unblockPeerByNickname(targetName, meshService)
|
||||
@@ -284,9 +283,10 @@ class CommandProcessor(
|
||||
parts: List<String>,
|
||||
verb: String,
|
||||
object_: String,
|
||||
meshService: BluetoothMeshService,
|
||||
meshService: MeshService,
|
||||
myPeerID: String,
|
||||
onSendMessage: (String, List<String>, String?) -> Unit
|
||||
onSendMessage: (String, List<String>, String?) -> Unit,
|
||||
viewModel: ChatViewModel?
|
||||
) {
|
||||
if (parts.size > 1) {
|
||||
val targetName = parts[1].removePrefix("@")
|
||||
@@ -306,7 +306,7 @@ class CommandProcessor(
|
||||
state.getNicknameValue(),
|
||||
myPeerID
|
||||
) { content, peerIdParam, recipientNicknameParam, messageId ->
|
||||
sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId)
|
||||
sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId, viewModel)
|
||||
}
|
||||
} else if (isInLocationChannel) {
|
||||
// Let the transport layer add the echo; just send it out
|
||||
@@ -423,7 +423,7 @@ class CommandProcessor(
|
||||
|
||||
// MARK: - Mention Autocomplete
|
||||
|
||||
fun updateMentionSuggestions(input: String, meshService: BluetoothMeshService, viewModel: ChatViewModel? = null) {
|
||||
fun updateMentionSuggestions(input: String, meshService: MeshService, viewModel: ChatViewModel? = null) {
|
||||
// Check if input contains @ and we're at the end of a word or at the end of input
|
||||
val atIndex = input.lastIndexOf('@')
|
||||
if (atIndex == -1) {
|
||||
@@ -503,19 +503,33 @@ class CommandProcessor(
|
||||
|
||||
// MARK: - Utility Functions
|
||||
|
||||
private fun getPeerIDForNickname(nickname: String, meshService: BluetoothMeshService): String? {
|
||||
private fun getPeerIDForNickname(nickname: String, meshService: MeshService): String? {
|
||||
return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key
|
||||
}
|
||||
|
||||
private fun getPeerNickname(peerID: String, meshService: BluetoothMeshService): String {
|
||||
return meshService.getPeerNicknames()[peerID] ?: peerID
|
||||
private fun getPeerNickname(peerID: String, meshService: MeshService): String {
|
||||
return meshService.getPeerNicknames()[peerID]
|
||||
?: peerID
|
||||
}
|
||||
|
||||
private fun getMyPeerID(meshService: BluetoothMeshService): String {
|
||||
private fun getMyPeerID(meshService: MeshService): String {
|
||||
return meshService.myPeerID
|
||||
}
|
||||
|
||||
private fun sendPrivateMessageVia(meshService: BluetoothMeshService, content: String, peerID: String, recipientNickname: String, messageId: String) {
|
||||
meshService.sendPrivateMessage(content, peerID, recipientNickname, messageId)
|
||||
private fun sendPrivateMessageVia(
|
||||
meshService: MeshService,
|
||||
content: String,
|
||||
peerID: String,
|
||||
recipientNickname: String,
|
||||
messageId: String,
|
||||
viewModel: ChatViewModel?
|
||||
) {
|
||||
if (viewModel != null) {
|
||||
com.bitchat.android.services.MessageRouter
|
||||
.getInstance(viewModel.getApplication(), meshService)
|
||||
.sendPrivate(content, peerID, recipientNickname, messageId)
|
||||
} else {
|
||||
meshService.sendPrivateMessage(content, peerID, recipientNickname, messageId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,7 +656,7 @@ private fun meshTitleWithCount(viewModel: ChatViewModel): String {
|
||||
}
|
||||
|
||||
private fun meshCount(viewModel: ChatViewModel): Int {
|
||||
val myID = viewModel.meshService.myPeerID
|
||||
val myID = viewModel.myPeerID
|
||||
return viewModel.connectedPeers.value?.count { peerID ->
|
||||
peerID != myID
|
||||
} ?: 0
|
||||
|
||||
@@ -77,7 +77,7 @@ fun MessageWithMatrixAnimation(
|
||||
message: com.bitchat.android.model.BitchatMessage,
|
||||
messages: List<com.bitchat.android.model.BitchatMessage> = emptyList(),
|
||||
currentUserNickname: String,
|
||||
meshService: com.bitchat.android.mesh.BluetoothMeshService,
|
||||
meshService: com.bitchat.android.mesh.MeshService,
|
||||
colorScheme: androidx.compose.material3.ColorScheme,
|
||||
timeFormatter: java.text.SimpleDateFormat,
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
@@ -124,7 +124,7 @@ fun MessageWithMatrixAnimation(
|
||||
private fun AnimatedMessageDisplay(
|
||||
message: com.bitchat.android.model.BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: com.bitchat.android.mesh.BluetoothMeshService,
|
||||
meshService: com.bitchat.android.mesh.MeshService,
|
||||
colorScheme: androidx.compose.material3.ColorScheme,
|
||||
timeFormatter: java.text.SimpleDateFormat,
|
||||
modifier: Modifier = Modifier
|
||||
@@ -241,7 +241,7 @@ private fun AnimatedMessageDisplay(
|
||||
private fun formatMessageAsAnnotatedStringWithoutTimestamp(
|
||||
message: com.bitchat.android.model.BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: com.bitchat.android.mesh.BluetoothMeshService,
|
||||
meshService: com.bitchat.android.mesh.MeshService,
|
||||
colorScheme: androidx.compose.material3.ColorScheme
|
||||
): AnnotatedString {
|
||||
// Get the full formatted text first
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.util.Log
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
@@ -16,10 +16,10 @@ class MediaSendingManager(
|
||||
private val state: ChatState,
|
||||
private val messageManager: MessageManager,
|
||||
private val channelManager: ChannelManager,
|
||||
private val getMeshService: () -> BluetoothMeshService
|
||||
private val getMeshService: () -> MeshService
|
||||
) {
|
||||
// Helper to get current mesh service (may change after panic clear)
|
||||
private val meshService: BluetoothMeshService
|
||||
private val meshService: MeshService
|
||||
get() = getMeshService()
|
||||
companion object {
|
||||
private const val TAG = "MediaSendingManager"
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.bitchat.android.ui
|
||||
|
||||
import com.bitchat.android.mesh.BluetoothMeshDelegate
|
||||
import com.bitchat.android.ui.NotificationTextUtils
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -21,7 +21,7 @@ class MeshDelegateHandler(
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val onHapticFeedback: () -> Unit,
|
||||
private val getMyPeerID: () -> String,
|
||||
private val getMeshService: () -> BluetoothMeshService
|
||||
private val getMeshService: () -> MeshService
|
||||
) : BluetoothMeshDelegate {
|
||||
|
||||
override fun didReceiveMessage(message: BitchatMessage) {
|
||||
@@ -96,99 +96,104 @@ class MeshDelegateHandler(
|
||||
|
||||
override fun didUpdatePeerList(peers: List<String>) {
|
||||
coroutineScope.launch {
|
||||
state.setConnectedPeers(peers)
|
||||
state.setIsConnected(peers.isNotEmpty())
|
||||
notificationManager.showActiveUserNotification(peers)
|
||||
// Flush router outbox for any peers that just connected (and their noiseHex aliases)
|
||||
runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onPeersUpdated(peers) }
|
||||
processPeerUpdate(peers.distinct())
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up channel members who disconnected
|
||||
channelManager.cleanupDisconnectedMembers(peers, getMyPeerID())
|
||||
private suspend fun processPeerUpdate(mergedPeers: List<String>) {
|
||||
state.setConnectedPeers(mergedPeers)
|
||||
state.setIsConnected(mergedPeers.isNotEmpty())
|
||||
notificationManager.showActiveUserNotification(mergedPeers)
|
||||
|
||||
// Flush router outbox for any peers that just connected (and their noiseHex aliases)
|
||||
runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onPeersUpdated(mergedPeers) }
|
||||
|
||||
// Handle chat view migration based on current selection and new peer list
|
||||
state.getSelectedPrivateChatPeerValue()?.let { currentPeer ->
|
||||
val isNostrAlias = currentPeer.startsWith("nostr_")
|
||||
val isNoiseHex = currentPeer.length == 64 && currentPeer.matches(Regex("^[0-9a-fA-F]+$"))
|
||||
val isMeshEphemeral = currentPeer.length == 16 && currentPeer.matches(Regex("^[0-9a-fA-F]+$"))
|
||||
// Clean up channel members who disconnected
|
||||
channelManager.cleanupDisconnectedMembers(mergedPeers, getMyPeerID())
|
||||
|
||||
if (isNostrAlias || isNoiseHex) {
|
||||
// Reverse case: Nostr/offline chat is open, and peer may have come online on mesh.
|
||||
// Resolve canonical target (prefer connected mesh peer if available)
|
||||
val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
|
||||
selectedPeerID = currentPeer,
|
||||
connectedPeers = peers,
|
||||
meshNoiseKeyForPeer = { pid -> getPeerInfo(pid)?.noisePublicKey },
|
||||
meshHasPeer = { pid -> peers.contains(pid) },
|
||||
nostrPubHexForAlias = { alias ->
|
||||
// Use GeohashAliasRegistry for geohash aliases, but for mesh favorites, derive from favorites mapping
|
||||
if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(alias)) {
|
||||
com.bitchat.android.nostr.GeohashAliasRegistry.get(alias)
|
||||
} else {
|
||||
// Best-effort: derive pub hex from favorites mapping for mesh nostr_ aliases
|
||||
val prefix = alias.removePrefix("nostr_")
|
||||
val favs = try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() } catch (_: Exception) { emptyList() }
|
||||
favs.firstNotNullOfOrNull { rel ->
|
||||
rel.peerNostrPublicKey?.let { s ->
|
||||
runCatching { com.bitchat.android.nostr.Bech32.decode(s) }.getOrNull()?.let { dec ->
|
||||
if (dec.first == "npub") dec.second.joinToString("") { b -> "%02x".format(b) } else null
|
||||
}
|
||||
// Handle chat view migration based on current selection and new peer list
|
||||
state.getSelectedPrivateChatPeerValue()?.let { currentPeer ->
|
||||
val isNostrAlias = currentPeer.startsWith("nostr_")
|
||||
val isNoiseHex = currentPeer.length == 64 && currentPeer.matches(Regex("^[0-9a-fA-F]+$"))
|
||||
val isMeshEphemeral = currentPeer.length == 16 && currentPeer.matches(Regex("^[0-9a-fA-F]+$"))
|
||||
|
||||
if (isNostrAlias || isNoiseHex) {
|
||||
// Reverse case: Nostr/offline chat is open, and peer may have come online on mesh.
|
||||
// Resolve canonical target (prefer connected mesh peer if available)
|
||||
val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
|
||||
selectedPeerID = currentPeer,
|
||||
connectedPeers = mergedPeers,
|
||||
meshNoiseKeyForPeer = { pid -> getPeerInfo(pid)?.noisePublicKey },
|
||||
meshHasPeer = { pid -> mergedPeers.contains(pid) },
|
||||
nostrPubHexForAlias = { alias ->
|
||||
// Use GeohashAliasRegistry for geohash aliases, but for mesh favorites, derive from favorites mapping
|
||||
if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(alias)) {
|
||||
com.bitchat.android.nostr.GeohashAliasRegistry.get(alias)
|
||||
} else {
|
||||
// Best-effort: derive pub hex from favorites mapping for mesh nostr_ aliases
|
||||
val prefix = alias.removePrefix("nostr_")
|
||||
val favs = try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() } catch (_: Exception) { emptyList() }
|
||||
favs.firstNotNullOfOrNull { rel ->
|
||||
rel.peerNostrPublicKey?.let { s ->
|
||||
runCatching { com.bitchat.android.nostr.Bech32.decode(s) }.getOrNull()?.let { dec ->
|
||||
if (dec.first == "npub") dec.second.joinToString("") { b -> "%02x".format(b) } else null
|
||||
}
|
||||
}?.takeIf { it.startsWith(prefix, ignoreCase = true) }
|
||||
}
|
||||
},
|
||||
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
|
||||
)
|
||||
if (canonical != currentPeer) {
|
||||
// Merge conversations and switch selection to the live mesh peer (or noiseHex)
|
||||
com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(state, canonical, listOf(currentPeer))
|
||||
state.setSelectedPrivateChatPeer(canonical)
|
||||
}
|
||||
} else if (isMeshEphemeral && !peers.contains(currentPeer)) {
|
||||
// Forward case: Mesh chat lost connection. If mutual favorite exists, migrate to Nostr (noiseHex)
|
||||
val favoriteRel = try {
|
||||
val info = getPeerInfo(currentPeer)
|
||||
val noiseKey = info?.noisePublicKey
|
||||
if (noiseKey != null) {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
|
||||
} else null
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
if (favoriteRel?.isMutual == true) {
|
||||
val noiseHex = favoriteRel.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
|
||||
if (noiseHex != currentPeer) {
|
||||
com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(
|
||||
state = state,
|
||||
targetPeerID = noiseHex,
|
||||
keysToMerge = listOf(currentPeer)
|
||||
)
|
||||
state.setSelectedPrivateChatPeer(noiseHex)
|
||||
}
|
||||
}?.takeIf { it.startsWith(prefix, ignoreCase = true) }
|
||||
}
|
||||
} else {
|
||||
privateChatManager.cleanupDisconnectedPeer(currentPeer)
|
||||
},
|
||||
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
|
||||
)
|
||||
if (canonical != currentPeer) {
|
||||
// Merge conversations and switch selection to the live mesh peer (or noiseHex)
|
||||
com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(state, canonical, listOf(currentPeer))
|
||||
state.setSelectedPrivateChatPeer(canonical)
|
||||
}
|
||||
} else if (isMeshEphemeral && !mergedPeers.contains(currentPeer)) {
|
||||
// Forward case: Mesh chat lost connection. If mutual favorite exists, migrate to Nostr (noiseHex)
|
||||
val favoriteRel = try {
|
||||
val info = getPeerInfo(currentPeer)
|
||||
val noiseKey = info?.noisePublicKey
|
||||
if (noiseKey != null) {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
|
||||
} else null
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
if (favoriteRel?.isMutual == true) {
|
||||
val noiseHex = favoriteRel.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
|
||||
if (noiseHex != currentPeer) {
|
||||
com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(
|
||||
state = state,
|
||||
targetPeerID = noiseHex,
|
||||
keysToMerge = listOf(currentPeer)
|
||||
)
|
||||
state.setSelectedPrivateChatPeer(noiseHex)
|
||||
}
|
||||
} else {
|
||||
privateChatManager.cleanupDisconnectedPeer(currentPeer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global unification: for each connected peer, merge any offline/stable conversations
|
||||
// (noiseHex or nostr_<pub16>) into the connected peer's chat so there is only one chat per identity.
|
||||
peers.forEach { pid ->
|
||||
try {
|
||||
val info = getPeerInfo(pid)
|
||||
val noiseKey = info?.noisePublicKey ?: return@forEach
|
||||
val noiseHex = noiseKey.joinToString("") { b -> "%02x".format(b) }
|
||||
// Global unification: for each connected peer, merge any offline/stable conversations
|
||||
// (noiseHex or nostr_<pub16>) into the connected peer's chat so there is only one chat per identity.
|
||||
mergedPeers.forEach { pid ->
|
||||
try {
|
||||
val info = getPeerInfo(pid)
|
||||
val noiseKey = info?.noisePublicKey ?: return@forEach
|
||||
val noiseHex = noiseKey.joinToString("") { b -> "%02x".format(b) }
|
||||
|
||||
// Derive temp nostr key from favorites npub
|
||||
val npub = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(noiseKey)
|
||||
val tempNostrKey: String? = try {
|
||||
if (npub != null) {
|
||||
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npub)
|
||||
if (hrp == "npub") "nostr_${data.joinToString("") { b -> "%02x".format(b) }.take(16)}" else null
|
||||
} else null
|
||||
} catch (_: Exception) { null }
|
||||
// Derive temp nostr key from favorites npub
|
||||
val npub = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(noiseKey)
|
||||
val tempNostrKey: String? = try {
|
||||
if (npub != null) {
|
||||
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npub)
|
||||
if (hrp == "npub") "nostr_${data.joinToString("") { b -> "%02x".format(b) }.take(16)}" else null
|
||||
} else null
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
unifyChatsIntoPeer(pid, listOfNotNull(noiseHex, tempNostrKey))
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
unifyChatsIntoPeer(pid, listOfNotNull(noiseHex, tempNostrKey))
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,15 +299,27 @@ class MeshDelegateHandler(
|
||||
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)
|
||||
val mesh = getMeshService()
|
||||
val sent = try {
|
||||
val hasMesh = mesh.getPeerInfo(senderPeerID!!)?.isConnected == true && mesh.hasEstablishedSession(senderPeerID)
|
||||
if (hasMesh) {
|
||||
mesh.sendReadReceipt(message.id, senderPeerID, nickname)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
if (sent) {
|
||||
// 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)")
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@ fun MeshPeerListSheet(
|
||||
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
|
||||
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle()
|
||||
val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() }
|
||||
|
||||
// Bottom sheet state
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
@@ -163,11 +165,12 @@ fun MeshPeerListSheet(
|
||||
nickname = nickname,
|
||||
colorScheme = colorScheme,
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
wifiAwarePeerIDs = wifiAwarePeerIDs,
|
||||
viewModel = viewModel,
|
||||
onPrivateChatStart = { peerID ->
|
||||
viewModel.showPrivateChatSheet(peerID)
|
||||
onDismiss()
|
||||
}
|
||||
onPrivateChatStart = { peerID ->
|
||||
viewModel.showPrivateChatSheet(peerID)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -279,6 +282,7 @@ fun PeopleSection(
|
||||
nickname: String,
|
||||
colorScheme: ColorScheme,
|
||||
selectedPrivatePeer: String?,
|
||||
wifiAwarePeerIDs: Set<String> = emptySet(),
|
||||
viewModel: ChatViewModel,
|
||||
onPrivateChatStart: (String) -> Unit
|
||||
) {
|
||||
@@ -333,7 +337,7 @@ 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.getMeshPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
|
||||
} catch (_: Exception) { null }
|
||||
}.filterValues { it != null }.mapValues { it.value!! }
|
||||
|
||||
@@ -411,11 +415,12 @@ fun PeopleSection(
|
||||
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
||||
|
||||
val directMap by viewModel.peerDirect.collectAsStateWithLifecycle()
|
||||
val isDirectLive = directMap[peerID] ?: try { viewModel.meshService.getPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false }
|
||||
val isDirectLive = directMap[peerID] ?: try { viewModel.getMeshPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false }
|
||||
PeerItem(
|
||||
peerID = peerID,
|
||||
displayName = displayName,
|
||||
isDirect = isDirectLive,
|
||||
isWifiAware = peerID in wifiAwarePeerIDs,
|
||||
isSelected = peerID == selectedPrivatePeer,
|
||||
isFavorite = isFavorite,
|
||||
isVerified = isVerified,
|
||||
@@ -544,6 +549,7 @@ private fun PeerItem(
|
||||
peerID: String,
|
||||
displayName: String,
|
||||
isDirect: Boolean,
|
||||
isWifiAware: Boolean = false,
|
||||
isSelected: Boolean,
|
||||
isFavorite: Boolean,
|
||||
isVerified: Boolean,
|
||||
@@ -619,8 +625,16 @@ private fun PeerItem(
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = if (isDirect) Icons.Outlined.Bluetooth else Icons.Filled.Route,
|
||||
contentDescription = if (isDirect) "Direct Bluetooth" else "Routed",
|
||||
imageVector = when {
|
||||
isWifiAware -> Icons.Filled.Wifi
|
||||
isDirect -> Icons.Outlined.Bluetooth
|
||||
else -> Icons.Filled.Route
|
||||
},
|
||||
contentDescription = when {
|
||||
isWifiAware -> "Direct Wi-Fi Aware"
|
||||
isDirect -> "Direct Bluetooth"
|
||||
else -> "Routed"
|
||||
},
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
@@ -652,6 +666,16 @@ private fun PeerItem(
|
||||
color = baseColor.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
|
||||
if (isWifiAware && hasUnreadDM) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Wifi,
|
||||
contentDescription = "Direct Wi-Fi Aware",
|
||||
modifier = Modifier.size(13.dp),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -763,6 +787,8 @@ fun PrivateChatSheet(
|
||||
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
|
||||
|
||||
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
|
||||
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle()
|
||||
val isWifiAware = peerID in wifiAwareConnected.keys
|
||||
|
||||
// Start private chat when screen opens
|
||||
LaunchedEffect(peerID) {
|
||||
@@ -831,7 +857,7 @@ fun PrivateChatSheet(
|
||||
MessagesList(
|
||||
messages = messages,
|
||||
currentUserNickname = nickname,
|
||||
meshService = viewModel.meshService,
|
||||
meshService = viewModel.meshServiceFacade,
|
||||
modifier = Modifier.weight(1f),
|
||||
forceScrollToBottom = forceScrollToBottom,
|
||||
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
|
||||
@@ -915,10 +941,26 @@ fun PrivateChatSheet(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
when {
|
||||
isNostrPeer -> {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Public,
|
||||
contentDescription = stringResource(R.string.cd_nostr_reachable),
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = Color(0xFF9C27B0)
|
||||
)
|
||||
}
|
||||
isWifiAware -> {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Wifi,
|
||||
contentDescription = "Direct Wi-Fi Aware",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
isDirect -> {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.SettingsInputAntenna,
|
||||
contentDescription = stringResource(R.string.cd_connected_peers),
|
||||
imageVector = Icons.Outlined.Bluetooth,
|
||||
contentDescription = "Direct Bluetooth",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
@@ -926,19 +968,11 @@ fun PrivateChatSheet(
|
||||
isConnected -> {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Route,
|
||||
contentDescription = stringResource(R.string.cd_ready_for_handshake),
|
||||
contentDescription = "Routed",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
isNostrPeer -> {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Public,
|
||||
contentDescription = stringResource(R.string.cd_nostr_reachable),
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = Color(0xFF9C27B0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
|
||||
@@ -31,7 +31,7 @@ import android.content.Intent
|
||||
import android.net.Uri
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import com.bitchat.android.ui.media.VoiceNotePlayer
|
||||
@@ -58,7 +58,7 @@ import androidx.compose.ui.res.stringResource
|
||||
fun MessagesList(
|
||||
messages: List<BitchatMessage>,
|
||||
currentUserNickname: String,
|
||||
meshService: BluetoothMeshService,
|
||||
meshService: MeshService,
|
||||
modifier: Modifier = Modifier,
|
||||
forceScrollToBottom: Boolean = false,
|
||||
onScrolledUpChanged: ((Boolean) -> Unit)? = null,
|
||||
@@ -137,7 +137,7 @@ fun MessagesList(
|
||||
fun MessageItem(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: BluetoothMeshService,
|
||||
meshService: MeshService,
|
||||
messages: List<BitchatMessage> = emptyList(),
|
||||
onNicknameClick: ((String) -> Unit)? = null,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)? = null,
|
||||
@@ -201,7 +201,7 @@ fun MessageItem(
|
||||
message: BitchatMessage,
|
||||
messages: List<BitchatMessage>,
|
||||
currentUserNickname: String,
|
||||
meshService: BluetoothMeshService,
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
|
||||
@@ -3,9 +3,9 @@ package com.bitchat.android.ui
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.mesh.PeerFingerprintManager
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import java.security.MessageDigest
|
||||
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import java.util.*
|
||||
import android.util.Log
|
||||
|
||||
@@ -42,7 +42,7 @@ class PrivateChatManager(
|
||||
|
||||
// MARK: - Private Chat Lifecycle
|
||||
|
||||
fun startPrivateChat(peerID: String, meshService: BluetoothMeshService): Boolean {
|
||||
fun startPrivateChat(peerID: String, meshService: MeshService): Boolean {
|
||||
if (isPeerBlocked(peerID)) {
|
||||
val peerNickname = getPeerNickname(peerID, meshService)
|
||||
val systemMessage = BitchatMessage(
|
||||
@@ -188,7 +188,7 @@ class PrivateChatManager(
|
||||
|
||||
// MARK: - Block/Unblock Operations
|
||||
|
||||
fun blockPeer(peerID: String, meshService: BluetoothMeshService): Boolean {
|
||||
fun blockPeer(peerID: String, meshService: MeshService): Boolean {
|
||||
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
|
||||
if (fingerprint != null) {
|
||||
dataManager.addBlockedUser(fingerprint)
|
||||
@@ -212,7 +212,7 @@ class PrivateChatManager(
|
||||
return false
|
||||
}
|
||||
|
||||
fun unblockPeer(peerID: String, meshService: BluetoothMeshService): Boolean {
|
||||
fun unblockPeer(peerID: String, meshService: MeshService): Boolean {
|
||||
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
|
||||
if (fingerprint != null && dataManager.isUserBlocked(fingerprint)) {
|
||||
dataManager.removeBlockedUser(fingerprint)
|
||||
@@ -230,7 +230,7 @@ class PrivateChatManager(
|
||||
return false
|
||||
}
|
||||
|
||||
fun blockPeerByNickname(targetName: String, meshService: BluetoothMeshService): Boolean {
|
||||
fun blockPeerByNickname(targetName: String, meshService: MeshService): Boolean {
|
||||
val peerID = getPeerIDForNickname(targetName, meshService)
|
||||
|
||||
if (peerID != null) {
|
||||
@@ -247,7 +247,7 @@ class PrivateChatManager(
|
||||
}
|
||||
}
|
||||
|
||||
fun unblockPeerByNickname(targetName: String, meshService: BluetoothMeshService): Boolean {
|
||||
fun unblockPeerByNickname(targetName: String, meshService: MeshService): Boolean {
|
||||
val peerID = getPeerIDForNickname(targetName, meshService)
|
||||
|
||||
if (peerID != null) {
|
||||
@@ -333,7 +333,7 @@ class PrivateChatManager(
|
||||
* Send read receipts for all unread messages from a specific peer
|
||||
* Called when the user focuses on a private chat
|
||||
*/
|
||||
fun sendReadReceiptsForPeer(peerID: String, meshService: BluetoothMeshService) {
|
||||
fun sendReadReceiptsForPeer(peerID: String, meshService: MeshService) {
|
||||
// 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()
|
||||
@@ -343,13 +343,16 @@ class PrivateChatManager(
|
||||
}
|
||||
|
||||
val myNickname = state.getNicknameValue() ?: "unknown"
|
||||
val hasMesh = try { meshService.getPeerInfo(peerID)?.isConnected == true && meshService.hasEstablishedSession(peerID) } catch (_: Exception) { false }
|
||||
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
|
||||
if (hasMesh) {
|
||||
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}")
|
||||
}
|
||||
@@ -380,7 +383,7 @@ class PrivateChatManager(
|
||||
* Establish Noise session if needed before starting private chat
|
||||
* Uses same lexicographical logic as MessageHandler.handleNoiseIdentityAnnouncement
|
||||
*/
|
||||
private fun establishNoiseSessionIfNeeded(peerID: String, meshService: BluetoothMeshService) {
|
||||
private fun establishNoiseSessionIfNeeded(peerID: String, meshService: MeshService) {
|
||||
if (noiseSessionDelegate.hasEstablishedSession(peerID)) {
|
||||
Log.d(TAG, "Noise session already established with $peerID")
|
||||
return
|
||||
@@ -413,11 +416,11 @@ class PrivateChatManager(
|
||||
|
||||
// MARK: - Utility Functions
|
||||
|
||||
private fun getPeerIDForNickname(nickname: String, meshService: BluetoothMeshService): String? {
|
||||
private fun getPeerIDForNickname(nickname: String, meshService: MeshService): String? {
|
||||
return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key
|
||||
}
|
||||
|
||||
private fun getPeerNickname(peerID: String, meshService: BluetoothMeshService): String {
|
||||
private fun getPeerNickname(peerID: String, meshService: MeshService): String {
|
||||
return meshService.getPeerNicknames()[peerID] ?: peerID
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ fun SecurityVerificationSheet(
|
||||
displayName = displayName,
|
||||
accent = accent,
|
||||
canStartHandshake = fingerprint == null && selectedPeerID.matches(peerHexRegex),
|
||||
onStartHandshake = { viewModel.meshService.initiateNoiseHandshake(selectedPeerID) },
|
||||
onStartHandshake = { viewModel.initiateMeshHandshake(selectedPeerID) },
|
||||
onVerify = { fp -> viewModel.verifyFingerprintValue(fp) },
|
||||
onUnverify = { fp -> viewModel.unverifyFingerprintValue(fp) }
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ 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.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.noise.NoiseSession
|
||||
import com.bitchat.android.nostr.GeohashAliasRegistry
|
||||
@@ -26,14 +26,14 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
class VerificationHandler(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val getMeshService: () -> BluetoothMeshService,
|
||||
private val getMeshService: () -> MeshService,
|
||||
private val identityManager: SecureIdentityStateManager,
|
||||
private val state: ChatState,
|
||||
private val notificationManager: NotificationManager,
|
||||
private val messageManager: MessageManager
|
||||
) {
|
||||
// Helper to get current mesh service (may change after panic clear)
|
||||
private val meshService: BluetoothMeshService
|
||||
private val meshService: MeshService
|
||||
get() = getMeshService()
|
||||
|
||||
private val _verifiedFingerprints = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
@@ -193,7 +193,7 @@ fun VerificationSheet(
|
||||
val fingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
|
||||
|
||||
if (peerID != null) {
|
||||
val fingerprint = viewModel.meshService.getPeerFingerprint(peerID!!)
|
||||
val fingerprint = viewModel.getMeshPeerFingerprint(peerID!!)
|
||||
if (fingerprint != null && fingerprints.contains(fingerprint)) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button(
|
||||
|
||||
@@ -20,7 +20,10 @@ 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
|
||||
// Transport master toggles
|
||||
private const val KEY_BLE_ENABLED = "ble_enabled"
|
||||
private const val KEY_WIFI_AWARE_ENABLED = "wifi_aware_enabled"
|
||||
private const val KEY_WIFI_AWARE_VERBOSE = "wifi_aware_verbose"
|
||||
|
||||
private lateinit var prefs: SharedPreferences
|
||||
|
||||
@@ -102,5 +105,25 @@ object DebugPreferenceManager {
|
||||
if (ready()) prefs.edit().putLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(value)).apply()
|
||||
}
|
||||
|
||||
// No longer storing persistent notification in debug prefs.
|
||||
// Transport toggles
|
||||
fun getBleEnabled(default: Boolean = true): Boolean =
|
||||
if (ready()) prefs.getBoolean(KEY_BLE_ENABLED, default) else default
|
||||
|
||||
fun setBleEnabled(value: Boolean) {
|
||||
if (ready()) prefs.edit().putBoolean(KEY_BLE_ENABLED, value).apply()
|
||||
}
|
||||
|
||||
fun getWifiAwareEnabled(default: Boolean = true): Boolean =
|
||||
if (ready()) prefs.getBoolean(KEY_WIFI_AWARE_ENABLED, default) else default
|
||||
|
||||
fun setWifiAwareEnabled(value: Boolean) {
|
||||
if (ready()) prefs.edit().putBoolean(KEY_WIFI_AWARE_ENABLED, value).apply()
|
||||
}
|
||||
|
||||
fun getWifiAwareVerbose(default: Boolean = false): Boolean =
|
||||
if (ready()) prefs.getBoolean(KEY_WIFI_AWARE_VERBOSE, default) else default
|
||||
|
||||
fun setWifiAwareVerbose(value: Boolean) {
|
||||
if (ready()) prefs.edit().putBoolean(KEY_WIFI_AWARE_VERBOSE, value).apply()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,17 @@ class DebugSettingsManager private constructor() {
|
||||
private val _packetRelayEnabled = MutableStateFlow(true)
|
||||
val packetRelayEnabled: StateFlow<Boolean> = _packetRelayEnabled.asStateFlow()
|
||||
|
||||
// Master transport toggles
|
||||
private val _bleEnabled = MutableStateFlow(true)
|
||||
val bleEnabled: StateFlow<Boolean> = _bleEnabled.asStateFlow()
|
||||
|
||||
private val _wifiAwareEnabled = MutableStateFlow(true)
|
||||
val wifiAwareEnabled: StateFlow<Boolean> = _wifiAwareEnabled.asStateFlow()
|
||||
|
||||
// Master transport toggles
|
||||
private val _wifiAwareVerbose = MutableStateFlow(false)
|
||||
val wifiAwareVerbose: StateFlow<Boolean> = _wifiAwareVerbose.asStateFlow()
|
||||
|
||||
// Visibility of the debug sheet; gates heavy work
|
||||
private val _debugSheetVisible = MutableStateFlow(false)
|
||||
val debugSheetVisible: StateFlow<Boolean> = _debugSheetVisible.asStateFlow()
|
||||
@@ -63,6 +74,10 @@ class DebugSettingsManager private constructor() {
|
||||
_maxConnectionsOverall.value = DebugPreferenceManager.getMaxConnectionsOverall(8)
|
||||
_maxServerConnections.value = DebugPreferenceManager.getMaxConnectionsServer(8)
|
||||
_maxClientConnections.value = DebugPreferenceManager.getMaxConnectionsClient(8)
|
||||
// Transport toggles
|
||||
_bleEnabled.value = DebugPreferenceManager.getBleEnabled(true)
|
||||
_wifiAwareEnabled.value = DebugPreferenceManager.getWifiAwareEnabled(true)
|
||||
_wifiAwareVerbose.value = DebugPreferenceManager.getWifiAwareVerbose(false)
|
||||
} catch (_: Exception) {
|
||||
// Preferences not ready yet; keep defaults. They will be applied on first change.
|
||||
}
|
||||
@@ -266,6 +281,30 @@ class DebugSettingsManager private constructor() {
|
||||
))
|
||||
}
|
||||
|
||||
fun setBleEnabled(enabled: Boolean) {
|
||||
DebugPreferenceManager.setBleEnabled(enabled)
|
||||
_bleEnabled.value = enabled
|
||||
addDebugMessage(DebugMessage.SystemMessage(if (enabled) "🟢 BLE enabled" else "🔴 BLE disabled"))
|
||||
try {
|
||||
com.bitchat.android.service.MeshServiceHolder.meshService?.setBleTransportEnabled(enabled)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
fun setWifiAwareEnabled(enabled: Boolean) {
|
||||
DebugPreferenceManager.setWifiAwareEnabled(enabled)
|
||||
_wifiAwareEnabled.value = enabled
|
||||
addDebugMessage(DebugMessage.SystemMessage(if (enabled) "🟢 Wi‑Fi Aware enabled" else "🔴 Wi‑Fi Aware disabled"))
|
||||
try {
|
||||
com.bitchat.android.wifiaware.WifiAwareController.setEnabled(enabled)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
fun setWifiAwareVerbose(enabled: Boolean) {
|
||||
DebugPreferenceManager.setWifiAwareVerbose(enabled)
|
||||
_wifiAwareVerbose.value = enabled
|
||||
addDebugMessage(DebugMessage.SystemMessage(if (enabled) "🔊 Wi‑Fi Aware verbose logging enabled" else "🔇 Wi‑Fi Aware verbose logging disabled"))
|
||||
}
|
||||
|
||||
fun setMaxConnectionsOverall(value: Int) {
|
||||
val clamped = value.coerceIn(1, 32)
|
||||
DebugPreferenceManager.setMaxConnectionsOverall(clamped)
|
||||
@@ -323,6 +362,16 @@ class DebugSettingsManager private constructor() {
|
||||
fun updateConnectedDevices(devices: List<ConnectedDevice>) {
|
||||
_connectedDevices.value = devices
|
||||
}
|
||||
|
||||
// Wi‑Fi Aware debug collections
|
||||
private val _wifiAwareDiscovered = MutableStateFlow<Map<String, String>>(emptyMap()) // peerID->nickname
|
||||
val wifiAwareDiscovered: StateFlow<Map<String, String>> = _wifiAwareDiscovered.asStateFlow()
|
||||
|
||||
private val _wifiAwareConnected = MutableStateFlow<Map<String, String>>(emptyMap()) // peerID->ip
|
||||
val wifiAwareConnected: StateFlow<Map<String, String>> = _wifiAwareConnected.asStateFlow()
|
||||
|
||||
fun updateWifiAwareDiscovered(map: Map<String, String>) { _wifiAwareDiscovered.value = map }
|
||||
fun updateWifiAwareConnected(map: Map<String, String>) { _wifiAwareConnected.value = map }
|
||||
|
||||
fun updateRelayStats(stats: PacketRelayStats) {
|
||||
_relayStats.value = stats
|
||||
|
||||
@@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bluetooth
|
||||
import androidx.compose.material.icons.filled.Wifi
|
||||
import androidx.compose.material.icons.filled.WifiTethering
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.filled.Devices
|
||||
import androidx.compose.material.icons.filled.PowerSettingsNew
|
||||
@@ -40,10 +42,15 @@ import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
|
||||
|
||||
@Composable
|
||||
fun MeshTopologySection() {
|
||||
fun MeshTopologySection(
|
||||
localPeerID: String? = null,
|
||||
blePeerIDs: Set<String> = emptySet(),
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val graphService = remember { MeshGraphService.getInstance() }
|
||||
val snapshot by graphService.graphState.collectAsState()
|
||||
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsState()
|
||||
val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() }
|
||||
|
||||
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@@ -60,6 +67,9 @@ fun MeshTopologySection() {
|
||||
ForceDirectedMeshGraph(
|
||||
nodes = nodes,
|
||||
edges = edges,
|
||||
wifiAwarePeerIDs = wifiAwarePeerIDs,
|
||||
blePeerIDs = blePeerIDs,
|
||||
localPeerID = localPeerID,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp)
|
||||
@@ -102,7 +112,7 @@ fun DebugSettingsSheet(
|
||||
val verboseLogging by manager.verboseLoggingEnabled.collectAsState()
|
||||
val gattServerEnabled by manager.gattServerEnabled.collectAsState()
|
||||
val gattClientEnabled by manager.gattClientEnabled.collectAsState()
|
||||
val packetRelayEnabled by manager.packetRelayEnabled.collectAsState()
|
||||
val packetRelayed by manager.packetRelayEnabled.collectAsState()
|
||||
val maxOverall by manager.maxConnectionsOverall.collectAsState()
|
||||
val maxServer by manager.maxServerConnections.collectAsState()
|
||||
val maxClient by manager.maxClientConnections.collectAsState()
|
||||
@@ -114,6 +124,15 @@ fun DebugSettingsSheet(
|
||||
val gcsMaxBytes by manager.gcsMaxBytes.collectAsState()
|
||||
val gcsFpr by manager.gcsFprPercent.collectAsState()
|
||||
val context = LocalContext.current
|
||||
|
||||
val bleEnabled by manager.bleEnabled.collectAsState()
|
||||
val wifiAwareEnabled by manager.wifiAwareEnabled.collectAsState()
|
||||
val wifiAwareVerbose by manager.wifiAwareVerbose.collectAsState()
|
||||
val wifiAwareDiscovered by manager.wifiAwareDiscovered.collectAsState()
|
||||
val wifiAwareConnected by manager.wifiAwareConnected.collectAsState()
|
||||
val wifiAwareSupported by com.bitchat.android.wifiaware.WifiAwareController.supported.collectAsState()
|
||||
val wifiAwareAvailable by com.bitchat.android.wifiaware.WifiAwareController.available.collectAsState()
|
||||
val wifiAwareSupportStatus by com.bitchat.android.wifiaware.WifiAwareController.supportStatus.collectAsState()
|
||||
// Persistent notification is now controlled solely by MeshServicePreferences.isBackgroundEnabled
|
||||
val listState = rememberLazyListState()
|
||||
val isScrolled by remember {
|
||||
@@ -148,6 +167,15 @@ fun DebugSettingsSheet(
|
||||
)
|
||||
}
|
||||
manager.updateConnectedDevices(devices)
|
||||
// Also surface Wi‑Fi Aware status
|
||||
try {
|
||||
val ctrl = com.bitchat.android.wifiaware.WifiAwareController
|
||||
val known = ctrl.knownPeers.value
|
||||
val discovered = ctrl.discoveredPeers.value
|
||||
val discoveredMap = discovered.associateWith { pid -> known[pid] ?: "" }
|
||||
manager.updateWifiAwareDiscovered(discoveredMap)
|
||||
manager.updateWifiAwareConnected(ctrl.connectedPeers.value)
|
||||
} catch (_: Exception) { }
|
||||
kotlinx.coroutines.delay(1000)
|
||||
}
|
||||
}
|
||||
@@ -204,7 +232,13 @@ fun DebugSettingsSheet(
|
||||
|
||||
// Mesh topology visualization (moved below verbose logging)
|
||||
item {
|
||||
MeshTopologySection()
|
||||
val blePeerIDs = remember(connectedDevices) {
|
||||
connectedDevices.mapNotNull { it.peerID }.toSet()
|
||||
}
|
||||
MeshTopologySection(
|
||||
localPeerID = meshService.myPeerID,
|
||||
blePeerIDs = blePeerIDs,
|
||||
)
|
||||
}
|
||||
|
||||
// GATT controls
|
||||
@@ -276,6 +310,53 @@ fun DebugSettingsSheet(
|
||||
}
|
||||
}
|
||||
|
||||
// Transport toggles (BLE + Wi‑Fi Aware)
|
||||
item {
|
||||
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF4CAF50))
|
||||
Text("Transports", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("BLE", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
|
||||
Switch(checked = bleEnabled, onCheckedChange = {
|
||||
manager.setBleEnabled(it)
|
||||
})
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.Wifi, contentDescription = null, tint = Color(0xFF9C27B0))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Wi‑Fi Aware", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
|
||||
val wifiSwitchEnabled = wifiAwareSupported
|
||||
Text(
|
||||
when {
|
||||
!wifiAwareSupported -> "unsupported"
|
||||
wifiAwareAvailable -> "available"
|
||||
else -> "unavailable"
|
||||
},
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Switch(
|
||||
checked = wifiAwareEnabled && wifiAwareSupported,
|
||||
enabled = wifiSwitchEnabled,
|
||||
onCheckedChange = { manager.setWifiAwareEnabled(it) }
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(Modifier.width(24.dp))
|
||||
Text("Wi‑Fi Aware verbose", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
|
||||
Switch(checked = wifiAwareVerbose, onCheckedChange = { manager.setWifiAwareVerbose(it) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Packet relay controls and stats
|
||||
item {
|
||||
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
||||
@@ -286,7 +367,7 @@ fun DebugSettingsSheet(
|
||||
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) })
|
||||
Switch(checked = packetRelayed, onCheckedChange = { manager.setPacketRelayEnabled(it) })
|
||||
}
|
||||
// Removed aggregate labels; we will show per-direction compact labels below titles
|
||||
// Toggle: overall vs per-connection vs per-peer
|
||||
@@ -535,6 +616,65 @@ fun DebugSettingsSheet(
|
||||
}
|
||||
}
|
||||
|
||||
// Wi‑Fi Aware controls and status
|
||||
item {
|
||||
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
val running by com.bitchat.android.wifiaware.WifiAwareController.running.collectAsState()
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Filled.WifiTethering, contentDescription = null, tint = Color(0xFF9C27B0))
|
||||
Text("Wi‑Fi Aware", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
Spacer(Modifier.weight(1f))
|
||||
val wifiStatusText = when {
|
||||
!wifiAwareSupported -> "unsupported"
|
||||
running -> "running"
|
||||
!wifiAwareAvailable -> "unavailable"
|
||||
else -> "stopped"
|
||||
}
|
||||
Text(wifiStatusText, fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||
}
|
||||
if (!wifiAwareSupported) {
|
||||
Text(
|
||||
wifiAwareSupportStatus?.reason ?: "Wi-Fi Aware is not supported on this device",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
AssistChip(
|
||||
onClick = { manager.setWifiAwareEnabled(true) },
|
||||
enabled = wifiAwareSupported,
|
||||
label = { Text("Start") }
|
||||
)
|
||||
AssistChip(onClick = { manager.setWifiAwareEnabled(false) }, label = { Text("Stop") })
|
||||
AssistChip(
|
||||
onClick = { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendBroadcastAnnounce() },
|
||||
enabled = running,
|
||||
label = { Text("Announce") }
|
||||
)
|
||||
}
|
||||
Text("Discovered: ${wifiAwareDiscovered.size}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
|
||||
if (wifiAwareDiscovered.isEmpty()) {
|
||||
Text("No discoveries yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
|
||||
} else {
|
||||
wifiAwareDiscovered.entries.take(50).forEach { (peer, nick) ->
|
||||
Text("• ${if (nick.isBlank()) peer.take(8) + "…" else nick} (${peer.take(8)}…) ", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Text("Connected: ${wifiAwareConnected.size}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
|
||||
if (wifiAwareConnected.isEmpty()) {
|
||||
Text("No active sockets", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
|
||||
} else {
|
||||
wifiAwareConnected.entries.take(50).forEach { (peer, ip) ->
|
||||
Text("• ${peer.take(8)}… @ $ip", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connected devices
|
||||
item {
|
||||
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
||||
|
||||
@@ -4,6 +4,12 @@ import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Wifi
|
||||
import androidx.compose.material.icons.outlined.Bluetooth
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
@@ -14,6 +20,7 @@ import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.services.meshgraph.MeshGraphService
|
||||
@@ -31,6 +38,44 @@ private const val DAMPING = 0.85f
|
||||
private const val MAX_VELOCITY = 30f
|
||||
private const val PULSE_DECAY = 0.05f
|
||||
private const val ROUTE_DECAY = 0.02f
|
||||
private val EDGE_ICON_OFFSET = 14.dp
|
||||
|
||||
private enum class EdgeTransport { WIFI, BLE }
|
||||
|
||||
private data class EdgeMarker(val x: Float, val y: Float, val transport: EdgeTransport)
|
||||
|
||||
private fun shouldMarkDirectEdge(
|
||||
edge: MeshGraphService.GraphEdge,
|
||||
transportPeerIDs: Set<String>,
|
||||
localID: String?
|
||||
): Boolean {
|
||||
if (transportPeerIDs.isEmpty()) return false
|
||||
return if (localID != null) {
|
||||
(edge.a == localID && edge.b in transportPeerIDs) ||
|
||||
(edge.b == localID && edge.a in transportPeerIDs)
|
||||
} else {
|
||||
edge.a in transportPeerIDs || edge.b in transportPeerIDs
|
||||
}
|
||||
}
|
||||
|
||||
private fun edgeMarkerPosition(
|
||||
x1: Float,
|
||||
y1: Float,
|
||||
x2: Float,
|
||||
y2: Float,
|
||||
offsetPx: Float,
|
||||
sideSign: Float
|
||||
): Pair<Float, Float> {
|
||||
val midX = (x1 + x2) / 2f
|
||||
val midY = (y1 + y2) / 2f
|
||||
val dx = x2 - x1
|
||||
val dy = y2 - y1
|
||||
val len = sqrt(dx * dx + dy * dy)
|
||||
if (len < 0.1f) return midX to midY
|
||||
val px = -dy / len * offsetPx * sideSign
|
||||
val py = dx / len * offsetPx * sideSign
|
||||
return (midX + px) to (midY + py)
|
||||
}
|
||||
|
||||
private class GraphNodeState(
|
||||
val id: String,
|
||||
@@ -212,6 +257,9 @@ private class Simulation {
|
||||
fun ForceDirectedMeshGraph(
|
||||
nodes: List<MeshGraphService.GraphNode>,
|
||||
edges: List<MeshGraphService.GraphEdge>,
|
||||
wifiAwarePeerIDs: Set<String> = emptySet(),
|
||||
blePeerIDs: Set<String> = emptySet(),
|
||||
localPeerID: String? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
@@ -405,5 +453,54 @@ fun ForceDirectedMeshGraph(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val iconSize = 16.dp
|
||||
val iconSizePx = with(density) { iconSize.toPx() }
|
||||
val halfIconSizePx = iconSizePx / 2f
|
||||
val iconOffsetPx = with(density) { EDGE_ICON_OFFSET.toPx() }
|
||||
val localID = localPeerID
|
||||
|
||||
val edgeMarkers = tick.let {
|
||||
simulation.edges.flatMap { edge ->
|
||||
val n1 = simulation.nodes[edge.a]
|
||||
val n2 = simulation.nodes[edge.b]
|
||||
if (n1 == null || n2 == null) return@flatMap emptyList()
|
||||
|
||||
val isWifi = shouldMarkDirectEdge(edge, wifiAwarePeerIDs, localID)
|
||||
val isBle = shouldMarkDirectEdge(edge, blePeerIDs, localID)
|
||||
if (!isWifi && !isBle) return@flatMap emptyList()
|
||||
|
||||
val markers = mutableListOf<EdgeMarker>()
|
||||
if (isWifi) {
|
||||
val (x, y) = edgeMarkerPosition(n1.x, n1.y, n2.x, n2.y, iconOffsetPx, sideSign = 1f)
|
||||
markers.add(EdgeMarker(x, y, EdgeTransport.WIFI))
|
||||
}
|
||||
if (isBle) {
|
||||
val side = if (isWifi) -1f else 1f
|
||||
val (x, y) = edgeMarkerPosition(n1.x, n1.y, n2.x, n2.y, iconOffsetPx, sideSign = side)
|
||||
markers.add(EdgeMarker(x, y, EdgeTransport.BLE))
|
||||
}
|
||||
markers
|
||||
}
|
||||
}
|
||||
|
||||
edgeMarkers.forEach { marker ->
|
||||
Icon(
|
||||
imageVector = when (marker.transport) {
|
||||
EdgeTransport.WIFI -> Icons.Filled.Wifi
|
||||
EdgeTransport.BLE -> Icons.Outlined.Bluetooth
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.82f),
|
||||
modifier = Modifier
|
||||
.offset {
|
||||
IntOffset(
|
||||
x = (marker.x - halfIconSizePx).roundToInt(),
|
||||
y = (marker.y - halfIconSizePx).roundToInt()
|
||||
)
|
||||
}
|
||||
.size(iconSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import java.text.SimpleDateFormat
|
||||
@@ -30,7 +30,7 @@ import java.text.SimpleDateFormat
|
||||
fun AudioMessageItem(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: BluetoothMeshService,
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
|
||||
@@ -27,7 +27,7 @@ import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import androidx.compose.material3.ColorScheme
|
||||
@@ -39,7 +39,7 @@ fun ImageMessageItem(
|
||||
message: BitchatMessage,
|
||||
messages: List<BitchatMessage>,
|
||||
currentUserNickname: String,
|
||||
meshService: BluetoothMeshService,
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.bitchat.android.wifiaware
|
||||
|
||||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.io.IOException
|
||||
import java.net.Socket
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* A synchronized wrapper around a raw Socket that implements a framed protocol:
|
||||
* [4 bytes length][N bytes payload]
|
||||
*/
|
||||
class SyncedSocket(
|
||||
val rawSocket: Socket,
|
||||
readTimeoutMs: Int = DEFAULT_READ_TIMEOUT_MS
|
||||
) {
|
||||
private val TAG = "SyncedSocket"
|
||||
private val writeLock = ReentrantLock()
|
||||
private val readLock = ReentrantLock()
|
||||
|
||||
private val inputStream: DataInputStream
|
||||
private val outputStream: DataOutputStream
|
||||
|
||||
companion object {
|
||||
// Both peers exchange keep-alive frames every ~2s while connected, so a read that
|
||||
// stalls well beyond that means the link is dead (half-open). Time out so the read
|
||||
// loop can detect it and trigger disconnection instead of blocking forever.
|
||||
const val DEFAULT_READ_TIMEOUT_MS = 15_000
|
||||
}
|
||||
|
||||
init {
|
||||
// A read timeout converts dead/half-open connections into a SocketTimeoutException
|
||||
// (an IOException) so read() returns null and the peer is cleaned up.
|
||||
try { rawSocket.soTimeout = readTimeoutMs } catch (_: Exception) {}
|
||||
// We wrap streams to create DataInput/Output helpers
|
||||
inputStream = DataInputStream(rawSocket.getInputStream())
|
||||
outputStream = DataOutputStream(rawSocket.getOutputStream())
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a framed message to the socket.
|
||||
* Thread-safe.
|
||||
*/
|
||||
fun write(data: ByteArray) {
|
||||
writeLock.withLock {
|
||||
Log.v(TAG, "Writing frame of size: ${data.size}")
|
||||
outputStream.writeInt(data.size)
|
||||
if (data.isNotEmpty()) {
|
||||
outputStream.write(data)
|
||||
}
|
||||
outputStream.flush()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a framed message from the socket.
|
||||
* Blocks until a full frame is available.
|
||||
* Returns null if socket is closed or EOF.
|
||||
* Returns empty byte array for keep-alive (0 length frame).
|
||||
*/
|
||||
fun read(): ByteArray? {
|
||||
readLock.withLock {
|
||||
try {
|
||||
// Read length prefix
|
||||
val length = try {
|
||||
inputStream.readInt()
|
||||
} catch (e: java.io.EOFException) {
|
||||
return null
|
||||
}
|
||||
Log.v(TAG, "Reading frame of size: $length")
|
||||
|
||||
if (length < 0) throw IOException("Negative frame length: $length")
|
||||
if (length > 64 * 1024) throw IOException("Frame length exceeds 64KB limit: $length")
|
||||
|
||||
if (length == 0) {
|
||||
return ByteArray(0)
|
||||
}
|
||||
|
||||
val buf = ByteArray(length)
|
||||
inputStream.readFully(buf)
|
||||
return buf
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Socket read failed: ${e.message}")
|
||||
// Socket closed or error
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
try { rawSocket.close() } catch (_: Exception) {}
|
||||
}
|
||||
|
||||
fun isClosed() = rawSocket.isClosed
|
||||
fun isConnected() = rawSocket.isConnected
|
||||
val inetAddress get() = rawSocket.inetAddress
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package com.bitchat.android.wifiaware
|
||||
|
||||
import android.net.ConnectivityManager
|
||||
import android.util.Log
|
||||
import com.bitchat.android.mesh.MeshConnectionTracker
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Tracks Wi-Fi Aware connections and manages retry logic using the shared state machine.
|
||||
*/
|
||||
class WifiAwareConnectionTracker(
|
||||
scope: CoroutineScope,
|
||||
private val cm: ConnectivityManager
|
||||
) : MeshConnectionTracker(scope, TAG) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WifiAwareConnectionTracker"
|
||||
}
|
||||
|
||||
// Active resources per peer
|
||||
val peerSockets = ConcurrentHashMap<String, SyncedSocket>()
|
||||
private val socketAliases = ConcurrentHashMap<String, String>()
|
||||
val serverSockets = ConcurrentHashMap<String, ServerSocket>()
|
||||
val networkCallbacks = ConcurrentHashMap<String, ConnectivityManager.NetworkCallback>()
|
||||
|
||||
override fun isConnected(id: String): Boolean {
|
||||
// We consider it connected if we have a client socket to them
|
||||
return getSocketForPeer(id) != null
|
||||
}
|
||||
|
||||
override fun disconnect(id: String) {
|
||||
Log.d(TAG, "Disconnecting peer $id")
|
||||
val canonicalId = resolveCanonicalPeerId(id)
|
||||
|
||||
// 1. Close client socket
|
||||
peerSockets.remove(canonicalId)?.let {
|
||||
try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing socket for $id: ${e.message}") }
|
||||
}
|
||||
socketAliases.entries.removeIf { it.key == id || it.key == canonicalId || it.value == canonicalId }
|
||||
|
||||
// 2. Close server socket
|
||||
serverSockets.remove(canonicalId)?.let {
|
||||
try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing server socket for $id: ${e.message}") }
|
||||
}
|
||||
|
||||
// Ensure any pending/active network request is explicitly released
|
||||
releaseNetworkRequest(canonicalId)
|
||||
removePendingConnection(id)
|
||||
removePendingConnection(canonicalId)
|
||||
}
|
||||
|
||||
fun releaseNetworkRequest(id: String) {
|
||||
val canonicalId = resolveCanonicalPeerId(id)
|
||||
if (!networkCallbacks.containsKey(canonicalId)) return
|
||||
|
||||
// 3. Unregister network callback properly from ConnectivityManager
|
||||
networkCallbacks.remove(canonicalId)?.let {
|
||||
try {
|
||||
Log.d(TAG, "Unregistering network callback for $canonicalId")
|
||||
cm.unregisterNetworkCallback(it)
|
||||
} catch (e: Exception) { Log.w(TAG, "Error unregistering callback for $canonicalId: ${e.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
override fun getConnectionCount(): Int = peerSockets.size
|
||||
|
||||
/**
|
||||
* Successfully established a client connection
|
||||
*/
|
||||
fun onClientConnected(peerId: String, socket: SyncedSocket) {
|
||||
// Close previous socket if one exists to prevent zombie readers
|
||||
peerSockets[peerId]?.let {
|
||||
try { it.close() } catch (_: Exception) {}
|
||||
}
|
||||
peerSockets[peerId] = socket
|
||||
removePendingConnection(peerId) // Clear retry state on success
|
||||
}
|
||||
|
||||
fun getSocketForPeer(peerId: String): SyncedSocket? {
|
||||
val canonicalId = resolveCanonicalPeerId(peerId)
|
||||
return peerSockets[canonicalId]
|
||||
}
|
||||
|
||||
fun canonicalPeerId(peerId: String): String = resolveCanonicalPeerId(peerId)
|
||||
|
||||
fun rebindPeerId(previousPeerId: String, resolvedPeerId: String, socket: SyncedSocket): String {
|
||||
if (previousPeerId == resolvedPeerId) {
|
||||
peerSockets[resolvedPeerId] = socket
|
||||
return resolvedPeerId
|
||||
}
|
||||
|
||||
val previousCanonical = resolveCanonicalPeerId(previousPeerId)
|
||||
val existing = peerSockets[previousCanonical]
|
||||
if (existing === socket) {
|
||||
peerSockets.remove(previousCanonical)
|
||||
}
|
||||
|
||||
peerSockets[resolvedPeerId]?.let { current ->
|
||||
if (current !== socket) {
|
||||
try { current.close() } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
peerSockets[resolvedPeerId] = socket
|
||||
serverSockets.remove(previousCanonical)?.let { serverSockets[resolvedPeerId] = it }
|
||||
networkCallbacks.remove(previousCanonical)?.let { networkCallbacks[resolvedPeerId] = it }
|
||||
socketAliases[previousPeerId] = resolvedPeerId
|
||||
if (previousCanonical != previousPeerId) {
|
||||
socketAliases[previousCanonical] = resolvedPeerId
|
||||
}
|
||||
removePendingConnection(previousPeerId)
|
||||
removePendingConnection(resolvedPeerId)
|
||||
|
||||
Log.i(TAG, "Rebound Wi-Fi Aware socket ${previousPeerId.take(8)} -> ${resolvedPeerId.take(8)}")
|
||||
return resolvedPeerId
|
||||
}
|
||||
|
||||
private fun resolveCanonicalPeerId(peerId: String): String {
|
||||
var current = peerId
|
||||
val visited = mutableSetOf<String>()
|
||||
while (visited.add(current)) {
|
||||
val next = socketAliases[current] ?: return current
|
||||
current = next
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
fun addServerSocket(peerId: String, socket: ServerSocket) {
|
||||
val canonicalId = resolveCanonicalPeerId(peerId)
|
||||
serverSockets.put(canonicalId, socket)?.let {
|
||||
try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing replaced server socket for $peerId: ${e.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
fun hasOpenServerSocket(peerId: String): Boolean {
|
||||
val canonicalId = resolveCanonicalPeerId(peerId)
|
||||
val socket = serverSockets[canonicalId] ?: return false
|
||||
if (!socket.isClosed) return true
|
||||
serverSockets.remove(canonicalId)
|
||||
return false
|
||||
}
|
||||
|
||||
fun closeServerSocket(peerId: String) {
|
||||
val canonicalId = resolveCanonicalPeerId(peerId)
|
||||
serverSockets.remove(canonicalId)?.let {
|
||||
try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing server socket for $peerId: ${e.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
fun hasPendingDataPathRequest(exceptPeerId: String? = null): Boolean {
|
||||
val exceptCanonical = exceptPeerId?.let { resolveCanonicalPeerId(it) }
|
||||
return pendingDataPathPeerIds(exceptCanonical).isNotEmpty()
|
||||
}
|
||||
|
||||
fun pendingDataPathPeerIds(exceptPeerId: String? = null): Set<String> {
|
||||
val exceptCanonical = exceptPeerId?.let { resolveCanonicalPeerId(it) }
|
||||
val pendingIds = linkedSetOf<String>()
|
||||
|
||||
pendingConnections.keys.forEach { peerId ->
|
||||
val canonicalId = resolveCanonicalPeerId(peerId)
|
||||
if (canonicalId != exceptCanonical && !isConnected(canonicalId)) {
|
||||
pendingIds.add(canonicalId)
|
||||
}
|
||||
}
|
||||
|
||||
networkCallbacks.keys.forEach { peerId ->
|
||||
val canonicalId = resolveCanonicalPeerId(peerId)
|
||||
if (canonicalId != exceptCanonical && !isConnected(canonicalId)) {
|
||||
pendingIds.add(canonicalId)
|
||||
}
|
||||
}
|
||||
|
||||
return pendingIds
|
||||
}
|
||||
|
||||
fun pendingServerDataPathPeerIds(exceptPeerId: String? = null): Set<String> {
|
||||
val exceptCanonical = exceptPeerId?.let { resolveCanonicalPeerId(it) }
|
||||
return serverSockets.keys.map { resolveCanonicalPeerId(it) }
|
||||
.filter { peerId ->
|
||||
peerId != exceptCanonical &&
|
||||
!isConnected(peerId) &&
|
||||
networkCallbacks.containsKey(peerId) &&
|
||||
serverSockets[peerId]?.isClosed == false
|
||||
}
|
||||
.toSet()
|
||||
}
|
||||
|
||||
fun cancelPendingServerDataPaths(exceptPeerId: String? = null): Set<String> {
|
||||
val cancelled = pendingServerDataPathPeerIds(exceptPeerId)
|
||||
cancelled.forEach { disconnect(it) }
|
||||
return cancelled
|
||||
}
|
||||
|
||||
fun addNetworkCallback(peerId: String, callback: ConnectivityManager.NetworkCallback) {
|
||||
val canonicalId = resolveCanonicalPeerId(peerId)
|
||||
networkCallbacks.put(canonicalId, callback)?.let {
|
||||
try {
|
||||
Log.d(TAG, "Replacing network callback for $canonicalId")
|
||||
cm.unregisterNetworkCallback(it)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error unregistering replaced callback for $canonicalId: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all resources
|
||||
*/
|
||||
override fun stop() {
|
||||
super.stop()
|
||||
val allIds = peerSockets.keys + serverSockets.keys + networkCallbacks.keys
|
||||
allIds.toSet().forEach { disconnect(it) }
|
||||
}
|
||||
|
||||
fun getDebugInfo(): String {
|
||||
return buildString {
|
||||
appendLine("Aware Connections: ${getConnectionCount()}")
|
||||
peerSockets.keys.forEach { pid ->
|
||||
appendLine(" - $pid (Socket)")
|
||||
}
|
||||
if (socketAliases.isNotEmpty()) {
|
||||
appendLine("Socket aliases:")
|
||||
socketAliases.forEach { (alias, canonical) ->
|
||||
appendLine(" - $alias -> $canonical")
|
||||
}
|
||||
}
|
||||
appendLine("Server Sockets: ${serverSockets.size}")
|
||||
serverSockets.keys.forEach { pid ->
|
||||
appendLine(" - $pid (Listening)")
|
||||
}
|
||||
appendLine("Pending Attempts: ${pendingConnections.size}")
|
||||
pendingConnections.forEach { (pid, attempt) ->
|
||||
appendLine(" - $pid: ${attempt.attempts} attempts")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package com.bitchat.android.wifiaware
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* WifiAwareController manages lifecycle and debug surfacing for the WifiAwareMeshService.
|
||||
* It starts/stops the service based on debug preferences and exposes simple flows for UI.
|
||||
*/
|
||||
object WifiAwareController {
|
||||
private const val TAG = "WifiAwareController"
|
||||
private const val MAX_RESTART_ATTEMPTS = 15
|
||||
private const val RESTART_RETRY_DELAY_MS = 2_000L
|
||||
|
||||
private var service: WifiAwareMeshService? = null
|
||||
private var appContext: Context? = null
|
||||
private val lifecycleLock = Any()
|
||||
private var starting = false
|
||||
private val restartInFlight = AtomicBoolean(false)
|
||||
private var awareReceiverRegistered = false
|
||||
private var lastBlockedReason: String? = null
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
private val _enabled = MutableStateFlow(false)
|
||||
val enabled: StateFlow<Boolean> = _enabled.asStateFlow()
|
||||
|
||||
private val _supported = MutableStateFlow(false)
|
||||
val supported: StateFlow<Boolean> = _supported.asStateFlow()
|
||||
|
||||
private val _available = MutableStateFlow(false)
|
||||
val available: StateFlow<Boolean> = _available.asStateFlow()
|
||||
|
||||
private val _supportStatus = MutableStateFlow<WifiAwareSupport.Status?>(null)
|
||||
val supportStatus: StateFlow<WifiAwareSupport.Status?> = _supportStatus.asStateFlow()
|
||||
|
||||
private val _running = MutableStateFlow(false)
|
||||
val running: StateFlow<Boolean> = _running.asStateFlow()
|
||||
|
||||
// Simple debug surfacing
|
||||
private val _connectedPeers = MutableStateFlow<Map<String, String>>(emptyMap()) // peerID -> ip
|
||||
val connectedPeers: StateFlow<Map<String, String>> = _connectedPeers.asStateFlow()
|
||||
|
||||
private val _knownPeers = MutableStateFlow<Map<String, String>>(emptyMap()) // peerID -> nickname
|
||||
val knownPeers: StateFlow<Map<String, String>> = _knownPeers.asStateFlow()
|
||||
|
||||
private val _discoveredPeers = MutableStateFlow<Set<String>>(emptySet())
|
||||
val discoveredPeers: StateFlow<Set<String>> = _discoveredPeers.asStateFlow()
|
||||
|
||||
fun initialize(context: Context, enabledByDefault: Boolean) {
|
||||
appContext = context.applicationContext
|
||||
val status = refreshSupportStatus(appContext!!)
|
||||
if (status.supported) {
|
||||
registerAwareStateReceiver(appContext!!)
|
||||
} else {
|
||||
Log.i(TAG, "Wi-Fi Aware unsupported: ${status.reason}")
|
||||
}
|
||||
setEnabled(enabledByDefault)
|
||||
// Start background poller for debug surfacing
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
try {
|
||||
val s = service
|
||||
if (s != null) {
|
||||
_connectedPeers.value = s.getDeviceAddressToPeerMapping() // peerID -> ip
|
||||
_knownPeers.value = s.getPeerNicknames()
|
||||
_discoveredPeers.value = s.getDiscoveredPeerIds()
|
||||
} else {
|
||||
_connectedPeers.value = emptyMap()
|
||||
_knownPeers.value = emptyMap()
|
||||
_discoveredPeers.value = emptySet()
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setEnabled(value: Boolean) {
|
||||
_enabled.value = value
|
||||
if (value) startIfPossible() else stop()
|
||||
}
|
||||
|
||||
fun startIfPossible() {
|
||||
val reusableService = synchronized(lifecycleLock) {
|
||||
if (!_enabled.value) return
|
||||
val existing = service
|
||||
if (existing?.isRunning() == true) {
|
||||
_running.value = true
|
||||
return
|
||||
}
|
||||
if (starting) return
|
||||
starting = true
|
||||
existing
|
||||
}
|
||||
|
||||
val ctx = appContext ?: run {
|
||||
synchronized(lifecycleLock) { starting = false }
|
||||
return
|
||||
}
|
||||
|
||||
val status = refreshSupportStatus(ctx)
|
||||
if (!status.supported) {
|
||||
val reason = status.reason ?: "not supported"
|
||||
Log.w(TAG, "Wi‑Fi Aware unsupported; not starting ($reason)")
|
||||
addBlockedDebugMessage("unsupported:$reason", "Wi-Fi Aware not supported on this device ($reason)")
|
||||
synchronized(lifecycleLock) { starting = false }
|
||||
return
|
||||
}
|
||||
|
||||
val awareManager = WifiAwareSupport.getManager(ctx)
|
||||
if (awareManager == null || !status.available) {
|
||||
Log.w(TAG, "Wi-Fi Aware is not currently available; not starting")
|
||||
addBlockedDebugMessage("unavailable", "Wi-Fi Aware is not available right now")
|
||||
synchronized(lifecycleLock) { starting = false }
|
||||
return
|
||||
}
|
||||
|
||||
// Check system location setting: WifiAwareManager.attach() throws SecurityException if disabled
|
||||
val lm = ctx.getSystemService(Context.LOCATION_SERVICE) as? android.location.LocationManager
|
||||
val locationEnabled = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
lm?.isLocationEnabled == true
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
lm?.isProviderEnabled(android.location.LocationManager.GPS_PROVIDER) == true ||
|
||||
lm?.isProviderEnabled(android.location.LocationManager.NETWORK_PROVIDER) == true
|
||||
}
|
||||
|
||||
if (!locationEnabled) {
|
||||
Log.w(TAG, "Location services are disabled; Wi-Fi Aware cannot start.")
|
||||
addBlockedDebugMessage("location-disabled", "Enable Location Services to start Wi-Fi Aware")
|
||||
synchronized(lifecycleLock) { starting = false }
|
||||
return
|
||||
}
|
||||
|
||||
// Android 13+: require NEARBY_WIFI_DEVICES runtime permission
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val granted = androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.NEARBY_WIFI_DEVICES) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
if (!granted) {
|
||||
Log.w(TAG, "Missing NEARBY_WIFI_DEVICES permission; not starting Wi‑Fi Aware")
|
||||
addBlockedDebugMessage("missing-nearby-wifi", "Grant Nearby Wi-Fi Devices to start Wi-Fi Aware")
|
||||
synchronized(lifecycleLock) { starting = false }
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!_enabled.value) {
|
||||
synchronized(lifecycleLock) { starting = false }
|
||||
return
|
||||
}
|
||||
try {
|
||||
val startedService = reusableService ?: run {
|
||||
Log.i(TAG, "Instantiating WifiAwareMeshService...")
|
||||
WifiAwareMeshService(ctx)
|
||||
}
|
||||
startedService.startServices()
|
||||
if (startedService.isRunning()) {
|
||||
synchronized(lifecycleLock) {
|
||||
service = startedService
|
||||
_running.value = true
|
||||
}
|
||||
try { com.bitchat.android.service.MeshServiceHolder.unifiedMeshService?.refreshDelegates() } catch (_: Exception) { }
|
||||
clearBlockedDebugMessage()
|
||||
try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware started")) } catch (_: Exception) {}
|
||||
} else {
|
||||
if (reusableService == null) {
|
||||
try { startedService.stopServices() } catch (_: Exception) { }
|
||||
}
|
||||
synchronized(lifecycleLock) {
|
||||
if (service === startedService) service = null
|
||||
_running.value = false
|
||||
}
|
||||
try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware did not start")) } catch (_: Exception) {}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Failed to start WifiAwareMeshService", e)
|
||||
_running.value = false
|
||||
try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware failed to start: ${e.message}")) } catch (_: Exception) {}
|
||||
} finally {
|
||||
synchronized(lifecycleLock) { starting = false }
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
val stopped = synchronized(lifecycleLock) {
|
||||
val current = service
|
||||
service = null
|
||||
starting = false
|
||||
_running.value = false
|
||||
current
|
||||
}
|
||||
try { stopped?.stopServices() } catch (_: Exception) { }
|
||||
try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { }
|
||||
_connectedPeers.value = emptyMap()
|
||||
_knownPeers.value = emptyMap()
|
||||
_discoveredPeers.value = emptySet()
|
||||
try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware stopped")) } catch (_: Exception) {}
|
||||
}
|
||||
|
||||
internal fun onServiceStopped(stoppedService: WifiAwareMeshService) {
|
||||
synchronized(lifecycleLock) {
|
||||
if (service !== stoppedService) return
|
||||
service = null
|
||||
_running.value = false
|
||||
try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { }
|
||||
_connectedPeers.value = emptyMap()
|
||||
_knownPeers.value = emptyMap()
|
||||
_discoveredPeers.value = emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a restart of the Wi-Fi Aware transport. Concurrent requests are coalesced into
|
||||
* a single in-flight loop that retries with backoff. This is important because a single fixed
|
||||
* delay can land while the service is still tearing down (recoveryInProgress), in which case
|
||||
* startServices() defers and we must try again rather than give up.
|
||||
*/
|
||||
internal fun restartIfStillEnabled(delayMs: Long = 0L) {
|
||||
if (!restartInFlight.compareAndSet(false, true)) {
|
||||
Log.d(TAG, "Restart already in flight; coalescing request")
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
try {
|
||||
if (delayMs > 0L) delay(delayMs)
|
||||
var attempt = 0
|
||||
while (_enabled.value && !_running.value && attempt < MAX_RESTART_ATTEMPTS) {
|
||||
val ctx = appContext
|
||||
if (ctx != null && !refreshSupportStatus(ctx).supported) break
|
||||
startIfPossible()
|
||||
if (_running.value) break
|
||||
attempt++
|
||||
delay(RESTART_RETRY_DELAY_MS)
|
||||
}
|
||||
} finally {
|
||||
restartInFlight.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listens for system Wi-Fi Aware availability changes. Aware can flip off/on at runtime
|
||||
* (Wi-Fi toggling, hotspot/SoftAP, location changes); without this we would only recover on
|
||||
* an unrelated trigger.
|
||||
*/
|
||||
private fun registerAwareStateReceiver(ctx: Context) {
|
||||
if (awareReceiverRegistered) return
|
||||
if (!refreshSupportStatus(ctx).supported) return
|
||||
try {
|
||||
val filter = android.content.IntentFilter(
|
||||
android.net.wifi.aware.WifiAwareManager.ACTION_WIFI_AWARE_STATE_CHANGED
|
||||
)
|
||||
ctx.registerReceiver(object : android.content.BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, intent: android.content.Intent?) {
|
||||
val status = refreshSupportStatus(ctx)
|
||||
Log.i(TAG, "Wi-Fi Aware availability changed: supported=${status.supported} available=${status.available} enabled=${_enabled.value} running=${_running.value}")
|
||||
if (status.available) {
|
||||
if (_enabled.value) restartIfStillEnabled(500)
|
||||
} else if (_running.value) {
|
||||
// Aware went away; tear down cleanly so we can re-attach when it returns.
|
||||
// Note: this does not change the enabled preference.
|
||||
stop()
|
||||
}
|
||||
}
|
||||
}, filter)
|
||||
awareReceiverRegistered = true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to register Wi-Fi Aware state receiver: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshSupportStatus(ctx: Context): WifiAwareSupport.Status {
|
||||
val status = WifiAwareSupport.evaluate(ctx)
|
||||
_supported.value = status.supported
|
||||
_available.value = status.available
|
||||
_supportStatus.value = status
|
||||
return status
|
||||
}
|
||||
|
||||
private fun addBlockedDebugMessage(key: String, message: String) {
|
||||
if (lastBlockedReason == key) return
|
||||
lastBlockedReason = key
|
||||
try {
|
||||
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance()
|
||||
.addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage(message))
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
private fun clearBlockedDebugMessage() {
|
||||
lastBlockedReason = null
|
||||
}
|
||||
|
||||
fun getService(): WifiAwareMeshService? = service
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.bitchat.android.wifiaware
|
||||
|
||||
typealias WifiAwareMeshDelegate = com.bitchat.android.mesh.MeshDelegate
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
package com.bitchat.android.wifiaware
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.wifi.aware.WifiAwareManager
|
||||
import android.os.Build
|
||||
|
||||
/**
|
||||
* Centralized Wi-Fi Aware capability checks.
|
||||
*
|
||||
* "Supported" is stable device/API capability. "Available" is runtime state and can change
|
||||
* when Wi-Fi, location, airplane mode, or system radio state changes.
|
||||
*/
|
||||
object WifiAwareSupport {
|
||||
data class Status(
|
||||
val supported: Boolean,
|
||||
val available: Boolean,
|
||||
val reason: String? = null
|
||||
)
|
||||
|
||||
fun evaluate(context: Context): Status {
|
||||
val appContext = context.applicationContext
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
return Status(
|
||||
supported = false,
|
||||
available = false,
|
||||
reason = "requires Android 10+"
|
||||
)
|
||||
}
|
||||
|
||||
val hasFeature = try {
|
||||
appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_WIFI_AWARE)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
if (!hasFeature) {
|
||||
return Status(
|
||||
supported = false,
|
||||
available = false,
|
||||
reason = "device does not advertise Wi-Fi Aware support"
|
||||
)
|
||||
}
|
||||
|
||||
val manager = getManager(appContext)
|
||||
?: return Status(
|
||||
supported = false,
|
||||
available = false,
|
||||
reason = "WifiAwareManager unavailable"
|
||||
)
|
||||
|
||||
val available = try {
|
||||
manager.isAvailable
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
return Status(
|
||||
supported = true,
|
||||
available = available,
|
||||
reason = if (available) null else "Wi-Fi Aware temporarily unavailable"
|
||||
)
|
||||
}
|
||||
|
||||
fun isSupported(context: Context): Boolean = evaluate(context).supported
|
||||
|
||||
fun getManager(context: Context): WifiAwareManager? {
|
||||
return try {
|
||||
context.applicationContext.getSystemService(WifiAwareManager::class.java)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -362,6 +362,7 @@
|
||||
<string name="location_notes_empty_desc">كن أول من يضيف ملاحظة لهذا المكان.</string>
|
||||
<string name="dismiss">إغلاق</string>
|
||||
<string name="location_notes_input_placeholder">أضف ملاحظة لهذا المكان</string>
|
||||
<string name="bluetooth_recommended">بلوتوث موصى به</string>
|
||||
<string name="mesh_service_notification_content">الشبكة تعمل — %1$d أقران</string>
|
||||
|
||||
<string name="verify_title">verify</string>
|
||||
|
||||
@@ -349,6 +349,7 @@
|
||||
<item quantity="other">%d জন</item>
|
||||
</plurals>
|
||||
|
||||
<string name="bluetooth_recommended">ব্লুটুথ প্রস্তাবিত</string>
|
||||
<string name="mesh_service_notification_content">মেশ চলছে — %1$d পিয়ার</string>
|
||||
|
||||
<string name="verify_title">verify</string>
|
||||
|
||||
@@ -363,6 +363,7 @@
|
||||
<string name="location_notes_empty_desc">sei der Erste, der hier eine Notiz hinterlässt.</string>
|
||||
<string name="dismiss">schließen</string>
|
||||
<string name="location_notes_input_placeholder">füge eine Notiz zu diesem Ort hinzu</string>
|
||||
<string name="bluetooth_recommended">Bluetooth empfohlen</string>
|
||||
<string name="mesh_service_notification_content">Mesh läuft — %1$d Peers</string>
|
||||
|
||||
<string name="verify_title">verifizieren</string>
|
||||
|
||||
@@ -362,6 +362,7 @@
|
||||
<string name="location_notes_empty_desc">sé el primero en añadir una para este sitio.</string>
|
||||
<string name="dismiss">cerrar</string>
|
||||
<string name="location_notes_input_placeholder">agrega una nota para este lugar</string>
|
||||
<string name="bluetooth_recommended">Bluetooth recomendado</string>
|
||||
<string name="mesh_service_notification_content">Mesh en ejecución — %1$d pares</string>
|
||||
|
||||
<string name="verify_title">verificar</string>
|
||||
|
||||
@@ -349,6 +349,7 @@
|
||||
<item quantity="other">%d نفر</item>
|
||||
</plurals>
|
||||
|
||||
<string name="bluetooth_recommended">بلوتوث توصیه می شود</string>
|
||||
<string name="mesh_service_notification_content">مش در حال اجرا — %1$d همتا</string>
|
||||
|
||||
<string name="verify_title">verify</string>
|
||||
|
||||
@@ -362,6 +362,7 @@
|
||||
<item quantity="other">%d tao</item>
|
||||
</plurals>
|
||||
|
||||
<string name="bluetooth_recommended">Bluetooth Recommended</string>
|
||||
<string name="mesh_service_notification_content">Tumatakbo ang Mesh — %1$d na peer</string>
|
||||
|
||||
<string name="verify_title">verify</string>
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
<string name="location_notes_empty_desc">soyez le premier à en ajouter pour cet endroit.</string>
|
||||
<string name="dismiss">fermer</string>
|
||||
<string name="location_notes_input_placeholder">ajoutez une note pour cet endroit</string>
|
||||
<string name="bluetooth_recommended">Bluetooth recommandé</string>
|
||||
<string name="mesh_service_notification_content">Mesh actif — %1$d pairs</string>
|
||||
|
||||
<string name="verify_title">vérifier</string>
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
<string name="location_notes_empty_desc">היה הראשון להוסיף הערה למקום זה.</string>
|
||||
<string name="dismiss">סגור</string>
|
||||
<string name="location_notes_input_placeholder">הוסף הערה למקום זה</string>
|
||||
<string name="skip">Skip</string>
|
||||
<string name="bluetooth_recommended">Bluetooth Recommended</string>
|
||||
<string name="mesh_service_notification_content">רשת Mesh פועלת — %1$d עמיתים</string>
|
||||
|
||||
<string name="verify_title">verify</string>
|
||||
|
||||
@@ -362,6 +362,7 @@
|
||||
<string name="location_notes_empty_desc">इस स्थान के लिए पहला नोट जोड़ें।</string>
|
||||
<string name="dismiss">बंद करें</string>
|
||||
<string name="location_notes_input_placeholder">इस स्थान के लिए एक नोट जोड़ें</string>
|
||||
<string name="bluetooth_recommended">ब्लूटूथ अनुशंसित</string>
|
||||
<string name="mesh_service_notification_content">मेश चल रहा है — %1$d पीयर्स</string>
|
||||
|
||||
<string name="verify_title">verify</string>
|
||||
|
||||
@@ -362,6 +362,7 @@
|
||||
<string name="location_notes_empty_desc">jadilah yang pertama menambahkan catatan untuk tempat ini.</string>
|
||||
<string name="dismiss">tutup</string>
|
||||
<string name="location_notes_input_placeholder">tambahkan catatan untuk tempat ini</string>
|
||||
<string name="bluetooth_recommended">Bluetooth Recommended</string>
|
||||
<string name="mesh_service_notification_content">Mesh berjalan — %1$d peer</string>
|
||||
|
||||
<string name="verify_title">verify</string>
|
||||
|
||||
@@ -396,6 +396,7 @@
|
||||
<string name="location_notes_empty_desc">sii il primo ad aggiungerne una per questo posto.</string>
|
||||
<string name="dismiss">chiudi</string>
|
||||
<string name="location_notes_input_placeholder">aggiungi una nota per questo luogo</string>
|
||||
<string name="bluetooth_recommended">Bluetooth consigliato</string>
|
||||
<string name="mesh_service_notification_content">Mesh in esecuzione — %1$d peer</string>
|
||||
|
||||
<string name="verify_title">verifica</string>
|
||||
|
||||
@@ -362,6 +362,7 @@
|
||||
<string name="location_notes_empty_desc">この場所の最初のメモを追加しましょう。</string>
|
||||
<string name="dismiss">閉じる</string>
|
||||
<string name="location_notes_input_placeholder">この場所へのメモを追加</string>
|
||||
<string name="bluetooth_recommended">Bluetooth推奨</string>
|
||||
<string name="mesh_service_notification_content">メッシュ実行中 — %1$d ピア</string>
|
||||
|
||||
<string name="verify_title">検証</string>
|
||||
|
||||
@@ -349,6 +349,7 @@
|
||||
<item quantity="other">%d ადამიანი</item>
|
||||
</plurals>
|
||||
|
||||
<string name="bluetooth_recommended">Bluetooth Recommended</string>
|
||||
<string name="mesh_service_notification_content">Mesh გაშვებულია — %1$d პირები</string>
|
||||
|
||||
<string name="verify_title">verify</string>
|
||||
|
||||
@@ -362,6 +362,7 @@
|
||||
<string name="location_notes_empty_desc">이 장소에 첫 번째 노트를 추가해 보세요.</string>
|
||||
<string name="dismiss">닫기</string>
|
||||
<string name="location_notes_input_placeholder">이 장소에 노트 추가</string>
|
||||
<string name="bluetooth_recommended">블루투스 권장</string>
|
||||
<string name="mesh_service_notification_content">메시 실행 중 — %1$d 피어</string>
|
||||
|
||||
<string name="verify_title">verify</string>
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
<item quantity="other">Olona %d</item>
|
||||
</plurals>
|
||||
|
||||
<string name="bluetooth_recommended">Bluetooth Recommended</string>
|
||||
<string name="mesh_service_notification_content">Mandeha ny Mesh — %1$d peers</string>
|
||||
|
||||
<string name="verify_title">verify</string>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- TODO: Malay translations -->
|
||||
<string name="skip">Skip</string>
|
||||
<string name="bluetooth_recommended">Bluetooth Recommended</string>
|
||||
<string name="mesh_service_notification_content">Mesh sedang berjalan — %1$d rakan</string>
|
||||
|
||||
<string name="verify_title">verify</string>
|
||||
|
||||
@@ -362,6 +362,7 @@
|
||||
<item quantity="other">%d जना</item>
|
||||
</plurals>
|
||||
|
||||
<string name="bluetooth_recommended">ब्लुटुथ सिफारिस गरिएको</string>
|
||||
<string name="mesh_service_notification_content">मेश चलिरहेको छ — %1$d पियर्स</string>
|
||||
|
||||
<string name="verify_title">verify</string>
|
||||
|
||||
@@ -394,6 +394,7 @@
|
||||
<string name="location_notes_empty_desc">wees de eerste die een notitie voor deze plek toevoegt.</string>
|
||||
<string name="dismiss">sluiten</string>
|
||||
<string name="location_notes_input_placeholder">voeg een notitie toe voor deze plek</string>
|
||||
<string name="bluetooth_recommended">Bluetooth aanbevolen</string>
|
||||
<string name="mesh_service_notification_content">Mesh actief — %1$d peers</string>
|
||||
|
||||
<string name="verify_title">verify</string>
|
||||
|
||||
@@ -349,6 +349,7 @@
|
||||
<item quantity="other">%d بندے</item>
|
||||
</plurals>
|
||||
|
||||
<string name="bluetooth_recommended">Bluetooth Recommended</string>
|
||||
<string name="mesh_service_notification_content">میش چل رہا ہے — %1$d ساتھی</string>
|
||||
|
||||
<string name="verify_title">verify</string>
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
<string name="location_notes_empty_desc">bądź pierwszy, który doda notatkę dla tego miejsca.</string>
|
||||
<string name="dismiss">zamknij</string>
|
||||
<string name="location_notes_input_placeholder">dodaj notatkę dla tego miejsca</string>
|
||||
<string name="skip">Pomiń</string>
|
||||
<string name="bluetooth_recommended">Bluetooth zalecany</string>
|
||||
<string name="mesh_service_notification_content">Mesh działa — %1$d peerów</string>
|
||||
|
||||
<string name="verify_title">verify</string>
|
||||
|
||||
@@ -362,6 +362,7 @@
|
||||
<string name="location_notes_empty_desc">seja o primeiro a adicionar uma para este local.</string>
|
||||
<string name="dismiss">fechar</string>
|
||||
<string name="location_notes_input_placeholder">adicione uma nota para este local</string>
|
||||
<string name="bluetooth_recommended">Bluetooth recomendado</string>
|
||||
<string name="mesh_service_notification_content">Mesh rodando — %1$d pares</string>
|
||||
|
||||
<string name="verify_title">verificar</string>
|
||||
|
||||
@@ -362,6 +362,7 @@
|
||||
<string name="location_notes_empty_desc">seja o primeiro a adicionar uma para este local.</string>
|
||||
<string name="dismiss">fechar</string>
|
||||
<string name="location_notes_input_placeholder">adicione uma nota para este local</string>
|
||||
<string name="bluetooth_recommended">Bluetooth recomendado</string>
|
||||
<string name="mesh_service_notification_content">Mesh em execução — %1$d pares</string>
|
||||
|
||||
<string name="verify_title">verificar</string>
|
||||
|
||||
@@ -352,6 +352,7 @@
|
||||
<string name="location_notes_empty_desc">станьте первым, кто добавит заметку для этого места.</string>
|
||||
<string name="dismiss">закрыть</string>
|
||||
<string name="location_notes_input_placeholder">добавьте заметку для этого места</string>
|
||||
<string name="bluetooth_recommended">Рекомендуется Bluetooth</string>
|
||||
<string name="mesh_service_notification_content">Mesh запущен — %1$d пиров</string>
|
||||
|
||||
<string name="verify_title">проверить</string>
|
||||
|
||||
@@ -350,6 +350,7 @@
|
||||
<string name="location_notes_empty_desc">var först med att lägga till en anteckning för den här platsen.</string>
|
||||
<string name="dismiss">stäng</string>
|
||||
<string name="location_notes_input_placeholder">lägg till en anteckning för den här platsen</string>
|
||||
<string name="bluetooth_recommended">Bluetooth rekommenderas</string>
|
||||
<string name="mesh_service_notification_content">Mesh körs — %1$d peers</string>
|
||||
|
||||
<string name="verify_title">verify</string>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- TODO: Tamil translations -->
|
||||
<string name="skip">Skip</string>
|
||||
<string name="bluetooth_recommended">Bluetooth Recommended</string>
|
||||
<string name="mesh_service_notification_content">மெஷ் இயங்குகிறது — %1$d பியர்ஸ்</string>
|
||||
|
||||
<string name="verify_title">verify</string>
|
||||
|
||||
@@ -349,6 +349,7 @@
|
||||
<item quantity="other">%d คน</item>
|
||||
</plurals>
|
||||
|
||||
<string name="bluetooth_recommended">Bluetooth Recommended</string>
|
||||
<string name="mesh_service_notification_content">Mesh กำลังทำงาน — %1$d เพื่อน</string>
|
||||
|
||||
<string name="verify_title">verify</string>
|
||||
|
||||
@@ -350,6 +350,7 @@
|
||||
<string name="location_notes_empty_desc">bu yer için ilk notu ekleyen siz olun.</string>
|
||||
<string name="dismiss">kapat</string>
|
||||
<string name="location_notes_input_placeholder">bu yer için bir not ekleyin</string>
|
||||
<string name="bluetooth_recommended">Bluetooth Önerilir</string>
|
||||
<string name="mesh_service_notification_content">Mesh çalışıyor — %1$d eş</string>
|
||||
|
||||
<string name="verify_title">verify</string>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- TODO: Ukrainian translations -->
|
||||
<string name="skip">Пропустити</string>
|
||||
<string name="bluetooth_recommended">Рекомендується Bluetooth</string>
|
||||
<string name="mesh_service_notification_content">Mesh працює — %1$d пірів</string>
|
||||
|
||||
<string name="verify_title">verify</string>
|
||||
|
||||
@@ -362,6 +362,7 @@
|
||||
<string name="location_notes_empty_desc">اس جگہ کے لیے پہلا نوٹ شامل کریں۔</string>
|
||||
<string name="dismiss">بند کریں</string>
|
||||
<string name="location_notes_input_placeholder">اس جگہ کے لیے ایک نوٹ شامل کریں</string>
|
||||
<string name="bluetooth_recommended">Bluetooth Recommended</string>
|
||||
<string name="mesh_service_notification_content">میش چل رہا ہے — %1$d ساتھی</string>
|
||||
|
||||
<string name="verify_title">verify</string>
|
||||
|
||||
@@ -349,6 +349,7 @@
|
||||
<item quantity="other">%d người</item>
|
||||
</plurals>
|
||||
|
||||
<string name="bluetooth_recommended">Khuyên dùng Bluetooth</string>
|
||||
<string name="mesh_service_notification_content">Mesh đang chạy — %1$d ngang hàng</string>
|
||||
|
||||
<string name="verify_title">verify</string>
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
<string name="location_notes_empty_desc">成为第一个为此地点添加笔记的人。</string>
|
||||
<string name="dismiss">关闭</string>
|
||||
<string name="location_notes_input_placeholder">为此地点添加一条笔记</string>
|
||||
<string name="skip">跳过</string>
|
||||
<string name="bluetooth_recommended">建议开启蓝牙</string>
|
||||
<string name="mesh_service_notification_content">Mesh 运行中 — %1$d 个节点</string>
|
||||
|
||||
<string name="verify_title">验证</string>
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
<string name="location_notes_empty_desc">成為第一個為此地點新增筆記的人。</string>
|
||||
<string name="dismiss">關閉</string>
|
||||
<string name="location_notes_input_placeholder">為此地點新增一則筆記</string>
|
||||
<string name="skip">跳過</string>
|
||||
<string name="bluetooth_recommended">建議開啟藍牙</string>
|
||||
<string name="mesh_service_notification_content">Mesh 運行中 — %1$d 個節點</string>
|
||||
|
||||
<string name="verify_title">验证</string>
|
||||
|
||||
@@ -375,6 +375,7 @@
|
||||
<string name="location_notes_empty_desc">成为第一个为此地点添加笔记的人。</string>
|
||||
<string name="dismiss">关闭</string>
|
||||
<string name="location_notes_input_placeholder">为此地点添加一条笔记</string>
|
||||
<string name="bluetooth_recommended">建议开启蓝牙</string>
|
||||
<string name="mesh_service_notification_content">Mesh 运行中 — %1$d 个节点</string>
|
||||
|
||||
<string name="verify_title">验证</string>
|
||||
|
||||
@@ -452,4 +452,5 @@
|
||||
<item quantity="other">%d people</item>
|
||||
</plurals>
|
||||
|
||||
<string name="bluetooth_recommended">Bluetooth Recommended</string>
|
||||
</resources>
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.bitchat.android.ui
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@@ -34,7 +34,7 @@ class CommandProcessorTest() {
|
||||
coroutineScope = testScope
|
||||
)
|
||||
|
||||
private val meshService: BluetoothMeshService = mock()
|
||||
private val meshService: MeshService = mock()
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.bitchat
|
||||
|
||||
import com.bitchat.android.mesh.MeshPacketUtils
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class MeshPacketUtilsTest {
|
||||
@Test
|
||||
fun hexStringToByteArray_parsesFullId() {
|
||||
val bytes = MeshPacketUtils.hexStringToByteArray("0011223344556677")
|
||||
assertEquals(8, bytes.size)
|
||||
assertEquals(0x00.toByte(), bytes[0])
|
||||
assertEquals(0x11.toByte(), bytes[1])
|
||||
assertEquals(0x22.toByte(), bytes[2])
|
||||
assertEquals(0x33.toByte(), bytes[3])
|
||||
assertEquals(0x44.toByte(), bytes[4])
|
||||
assertEquals(0x55.toByte(), bytes[5])
|
||||
assertEquals(0x66.toByte(), bytes[6])
|
||||
assertEquals(0x77.toByte(), bytes[7])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hexStringToByteArray_parsesShortId() {
|
||||
val bytes = MeshPacketUtils.hexStringToByteArray("ab")
|
||||
assertEquals(8, bytes.size)
|
||||
assertEquals(0xab.toByte(), bytes[0])
|
||||
assertEquals(0x00.toByte(), bytes[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hexStringToByteArray_handlesInvalidHex() {
|
||||
val bytes = MeshPacketUtils.hexStringToByteArray("zz")
|
||||
assertEquals(8, bytes.size)
|
||||
assertEquals(0x00.toByte(), bytes[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sha256Hex_matchesKnownValue() {
|
||||
val hash = MeshPacketUtils.sha256Hex("hello".toByteArray())
|
||||
assertEquals(
|
||||
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
|
||||
hash
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import android.os.Build
|
||||
import com.bitchat.android.model.IdentityAnnouncement
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.protocol.SpecialRecipients
|
||||
import com.bitchat.android.services.meshgraph.MeshGraphService
|
||||
import com.bitchat.android.util.AppConstants
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.eq
|
||||
import org.mockito.kotlin.isNull
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.never
|
||||
import org.mockito.kotlin.verify
|
||||
import org.mockito.kotlin.whenever
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
|
||||
class MessageHandlerTest {
|
||||
private lateinit var handler: MessageHandler
|
||||
private lateinit var delegate: MessageHandlerDelegate
|
||||
|
||||
private val myPeerID = "1111222233334444"
|
||||
private val peerID = "aaaabbbbccccdddd"
|
||||
private val nickname = "peer"
|
||||
private val noiseKey = ByteArray(32) { 0x0B }
|
||||
private val signingKey = ByteArray(32) { 0x0A }
|
||||
private val signature = ByteArray(64) { 1 }
|
||||
private val announceClockSkewToleranceMs = 10 * 60 * 1000L
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
MeshGraphService.resetForTesting()
|
||||
handler = MessageHandler(myPeerID, RuntimeEnvironment.getApplication())
|
||||
delegate = mock()
|
||||
handler.delegate = delegate
|
||||
|
||||
whenever(delegate.getPeerInfo(peerID)).thenReturn(null)
|
||||
whenever(delegate.verifyEd25519Signature(any(), any(), any())).thenReturn(true)
|
||||
whenever(delegate.updatePeerInfo(any(), any(), any(), any(), any())).thenReturn(true)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
MeshGraphService.resetForTesting()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleAnnounce accepts announce within clock skew tolerance for identity binding`() = runBlocking {
|
||||
val packet = announcePacket(ageMs = AppConstants.Mesh.STALE_PEER_TIMEOUT_MS + 1_000)
|
||||
|
||||
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
|
||||
|
||||
assertTrue("Announce within clock skew tolerance should still store peer identity", result)
|
||||
verify(delegate).updatePeerInfo(eq(peerID), eq(nickname), any(), any(), eq(true))
|
||||
verify(delegate).updatePeerIDBinding(eq(peerID), eq(nickname), any(), isNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleAnnounce accepts future announce within clock skew tolerance`() = runBlocking {
|
||||
val packet = announcePacket(ageMs = -(AppConstants.Mesh.STALE_PEER_TIMEOUT_MS + 1_000))
|
||||
|
||||
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
|
||||
|
||||
assertTrue("Future announce within clock skew tolerance should still store peer identity", result)
|
||||
verify(delegate).updatePeerInfo(eq(peerID), eq(nickname), any(), any(), eq(true))
|
||||
Unit
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleAnnounce rejects announce older than clock skew tolerance`() = runBlocking {
|
||||
val packet = announcePacket(ageMs = announceClockSkewToleranceMs + 1_000)
|
||||
|
||||
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "relay-link"))
|
||||
|
||||
assertFalse("Announce older than clock skew tolerance should not store peer identity", result)
|
||||
verify(delegate, never()).updatePeerInfo(any(), any(), any(), any(), any())
|
||||
verify(delegate, never()).updatePeerIDBinding(any(), any(), any(), any())
|
||||
}
|
||||
|
||||
private fun announcePacket(
|
||||
ageMs: Long,
|
||||
ttl: UByte = (AppConstants.MESSAGE_TTL_HOPS.toInt() - 1).toUByte()
|
||||
): BitchatPacket {
|
||||
val announcement = IdentityAnnouncement(
|
||||
nickname = nickname,
|
||||
noisePublicKey = noiseKey,
|
||||
signingPublicKey = signingKey
|
||||
)
|
||||
return BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.ANNOUNCE.value,
|
||||
senderID = peerID.hexToBytes(),
|
||||
recipientID = SpecialRecipients.BROADCAST,
|
||||
timestamp = (System.currentTimeMillis() - ageMs).toULong(),
|
||||
payload = announcement.encode()!!,
|
||||
signature = signature,
|
||||
ttl = ttl
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.hexToBytes(): ByteArray {
|
||||
return chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
}
|
||||
}
|
||||
@@ -55,4 +55,45 @@ class AppStateStoreTest {
|
||||
|
||||
assertEquals(listOf(first, second), AppStateStore.publicMessages.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `peer list merges transport updates instead of overwriting`() {
|
||||
AppStateStore.setTransportPeers("WIFI", listOf("wifi-peer"))
|
||||
AppStateStore.setTransportPeers("BLE", emptyList())
|
||||
|
||||
assertEquals(listOf("wifi-peer"), AppStateStore.peers.value)
|
||||
|
||||
AppStateStore.setTransportPeers("BLE", listOf("ble-peer"))
|
||||
|
||||
assertEquals(listOf("wifi-peer", "ble-peer"), AppStateStore.peers.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `direct peers union across transports`() {
|
||||
AppStateStore.setTransportDirectPeers("BLE", listOf("ble-1", "shared"))
|
||||
AppStateStore.setTransportDirectPeers("WIFI", listOf("wifi-1", "shared"))
|
||||
|
||||
assertEquals(
|
||||
setOf("ble-1", "wifi-1", "shared"),
|
||||
AppStateStore.getDirectPeers()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearing one transport keeps the other transport direct peers`() {
|
||||
AppStateStore.setTransportDirectPeers("BLE", listOf("ble-1"))
|
||||
AppStateStore.setTransportDirectPeers("WIFI", listOf("wifi-1"))
|
||||
|
||||
AppStateStore.clearTransportDirectPeers("WIFI")
|
||||
|
||||
assertEquals(setOf("ble-1"), AppStateStore.getDirectPeers())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `latest direct peer set replaces previous set for same transport`() {
|
||||
AppStateStore.setTransportDirectPeers("WIFI", listOf("wifi-1", "wifi-2"))
|
||||
AppStateStore.setTransportDirectPeers("WIFI", listOf("wifi-3"))
|
||||
|
||||
assertEquals(setOf("wifi-3"), AppStateStore.getDirectPeers())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user