mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 20:25:21 +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 -->
|
<!-- Internet permissions for Nostr relay connections -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||||
|
|
||||||
<!-- Bluetooth permissions -->
|
<!-- Bluetooth permissions -->
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||||
@@ -20,6 +21,12 @@
|
|||||||
|
|
||||||
<!-- Notification permissions -->
|
<!-- Notification permissions -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<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 -->
|
<!-- Signature permission for internal UI shutdown broadcasts -->
|
||||||
<uses-permission android:name="com.bitchat.android.permission.FORCE_FINISH" />
|
<uses-permission android:name="com.bitchat.android.permission.FORCE_FINISH" />
|
||||||
<!-- Foreground service and boot permissions for long-running background mesh -->
|
<!-- Foreground service and boot permissions for long-running background mesh -->
|
||||||
@@ -52,6 +59,8 @@
|
|||||||
<!-- Hardware features -->
|
<!-- Hardware features -->
|
||||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
||||||
<uses-feature android:name="android.hardware.bluetooth" android:required="true" />
|
<uses-feature android:name="android.hardware.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" />
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
|
|
||||||
<permission
|
<permission
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ class BitchatApplication : Application() {
|
|||||||
// Initialize debug preference manager (persists debug toggles)
|
// Initialize debug preference manager (persists debug toggles)
|
||||||
try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { }
|
try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { }
|
||||||
|
|
||||||
|
// Initialize 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
|
// Initialize Geohash Registries for persistence
|
||||||
try {
|
try {
|
||||||
com.bitchat.android.nostr.GeohashAliasRegistry.initialize(this)
|
com.bitchat.android.nostr.GeohashAliasRegistry.initialize(this)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import androidx.lifecycle.ViewModelProvider
|
|||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import com.bitchat.android.mesh.BluetoothMeshService
|
import com.bitchat.android.mesh.BluetoothMeshService
|
||||||
|
import com.bitchat.android.mesh.MeshService
|
||||||
import com.bitchat.android.onboarding.BluetoothCheckScreen
|
import com.bitchat.android.onboarding.BluetoothCheckScreen
|
||||||
import com.bitchat.android.onboarding.BluetoothStatus
|
import com.bitchat.android.onboarding.BluetoothStatus
|
||||||
import com.bitchat.android.onboarding.BluetoothStatusManager
|
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.ChatViewModel
|
||||||
import com.bitchat.android.ui.OrientationAwareActivity
|
import com.bitchat.android.ui.OrientationAwareActivity
|
||||||
import com.bitchat.android.ui.theme.BitchatTheme
|
import com.bitchat.android.ui.theme.BitchatTheme
|
||||||
|
import com.bitchat.android.wifiaware.WifiAwareController
|
||||||
import com.bitchat.android.nostr.PoWPreferenceManager
|
import com.bitchat.android.nostr.PoWPreferenceManager
|
||||||
import com.bitchat.android.services.VerificationService
|
import com.bitchat.android.services.VerificationService
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -55,12 +57,13 @@ class MainActivity : OrientationAwareActivity() {
|
|||||||
|
|
||||||
// Core mesh service - provided by the foreground service holder
|
// Core mesh service - provided by the foreground service holder
|
||||||
private lateinit var meshService: BluetoothMeshService
|
private lateinit var meshService: BluetoothMeshService
|
||||||
|
private lateinit var unifiedMeshService: MeshService
|
||||||
private val mainViewModel: MainViewModel by viewModels()
|
private val mainViewModel: MainViewModel by viewModels()
|
||||||
private val chatViewModel: ChatViewModel by viewModels {
|
private val chatViewModel: ChatViewModel by viewModels {
|
||||||
object : ViewModelProvider.Factory {
|
object : ViewModelProvider.Factory {
|
||||||
override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@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
|
// Ensure foreground service is running and get mesh instance from holder
|
||||||
try { com.bitchat.android.service.MeshForegroundService.start(applicationContext) } catch (_: Exception) { }
|
try { com.bitchat.android.service.MeshForegroundService.start(applicationContext) } catch (_: Exception) { }
|
||||||
meshService = com.bitchat.android.service.MeshServiceHolder.getOrCreate(applicationContext)
|
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(
|
bluetoothStatusManager = BluetoothStatusManager(
|
||||||
activity = this,
|
activity = this,
|
||||||
context = 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
|
// Only start onboarding process if we're in the initial CHECKING state
|
||||||
// This prevents restarting onboarding on configuration changes
|
// This prevents restarting onboarding on configuration changes
|
||||||
@@ -222,6 +240,10 @@ class MainActivity : OrientationAwareActivity() {
|
|||||||
onRetry = {
|
onRetry = {
|
||||||
checkBluetoothAndProceed()
|
checkBluetoothAndProceed()
|
||||||
},
|
},
|
||||||
|
onSkip = {
|
||||||
|
mainViewModel.skipBluetoothCheck()
|
||||||
|
checkLocationAndProceed()
|
||||||
|
},
|
||||||
isLoading = isBluetoothLoading
|
isLoading = isBluetoothLoading
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -355,6 +377,13 @@ class MainActivity : OrientationAwareActivity() {
|
|||||||
private fun checkBluetoothAndProceed() {
|
private fun checkBluetoothAndProceed() {
|
||||||
// Log.d("MainActivity", "Checking Bluetooth status")
|
// 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
|
// For first-time users, skip Bluetooth check and go straight to permissions
|
||||||
// We'll check Bluetooth after permissions are granted
|
// We'll check Bluetooth after permissions are granted
|
||||||
if (permissionManager.isFirstTimeLaunch()) {
|
if (permissionManager.isFirstTimeLaunch()) {
|
||||||
@@ -367,6 +396,12 @@ class MainActivity : OrientationAwareActivity() {
|
|||||||
bluetoothStatusManager.logBluetoothStatus()
|
bluetoothStatusManager.logBluetoothStatus()
|
||||||
mainViewModel.updateBluetoothStatus(bluetoothStatusManager.checkBluetoothStatus())
|
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) {
|
when (mainViewModel.bluetoothStatus.value) {
|
||||||
BluetoothStatus.ENABLED -> {
|
BluetoothStatus.ENABLED -> {
|
||||||
// Bluetooth is enabled, check location services next
|
// Bluetooth is enabled, check location services next
|
||||||
@@ -472,6 +507,8 @@ class MainActivity : OrientationAwareActivity() {
|
|||||||
Log.d("MainActivity", "Location services enabled by user")
|
Log.d("MainActivity", "Location services enabled by user")
|
||||||
mainViewModel.updateLocationLoading(false)
|
mainViewModel.updateLocationLoading(false)
|
||||||
mainViewModel.updateLocationStatus(LocationStatus.ENABLED)
|
mainViewModel.updateLocationStatus(LocationStatus.ENABLED)
|
||||||
|
// Ensure Wi-Fi Aware starts now that location is enabled
|
||||||
|
com.bitchat.android.wifiaware.WifiAwareController.startIfPossible()
|
||||||
checkBatteryOptimizationAndProceed()
|
checkBatteryOptimizationAndProceed()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,8 +577,9 @@ class MainActivity : OrientationAwareActivity() {
|
|||||||
else -> BatteryOptimizationStatus.ENABLED
|
else -> BatteryOptimizationStatus.ENABLED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val bleRequired2 = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
|
||||||
when {
|
when {
|
||||||
currentBluetoothStatus != BluetoothStatus.ENABLED -> {
|
bleRequired2 && currentBluetoothStatus != BluetoothStatus.ENABLED -> {
|
||||||
// Bluetooth still disabled, but now we have permissions to enable it
|
// Bluetooth still disabled, but now we have permissions to enable it
|
||||||
Log.d("MainActivity", "Permissions granted, but Bluetooth still disabled. Showing Bluetooth enable screen.")
|
Log.d("MainActivity", "Permissions granted, but Bluetooth still disabled. Showing Bluetooth enable screen.")
|
||||||
mainViewModel.updateBluetoothStatus(currentBluetoothStatus)
|
mainViewModel.updateBluetoothStatus(currentBluetoothStatus)
|
||||||
@@ -674,9 +712,9 @@ class MainActivity : OrientationAwareActivity() {
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up mesh service delegate and start services
|
// Set up unified mesh delegate and start enabled transports
|
||||||
meshService.delegate = chatViewModel
|
unifiedMeshService.delegate = chatViewModel
|
||||||
meshService.startServices()
|
unifiedMeshService.startServices()
|
||||||
|
|
||||||
Log.d("MainActivity", "Mesh service started successfully")
|
Log.d("MainActivity", "Mesh service started successfully")
|
||||||
|
|
||||||
@@ -720,11 +758,12 @@ class MainActivity : OrientationAwareActivity() {
|
|||||||
// Check Bluetooth and Location status on resume and handle accordingly
|
// Check Bluetooth and Location status on resume and handle accordingly
|
||||||
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
||||||
// Reattach mesh delegate to new ChatViewModel instance after Activity recreation
|
// 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
|
// Check if Bluetooth was disabled while app was backgrounded
|
||||||
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
|
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")
|
Log.w("MainActivity", "Bluetooth disabled while app was backgrounded")
|
||||||
mainViewModel.updateBluetoothStatus(currentBluetoothStatus)
|
mainViewModel.updateBluetoothStatus(currentBluetoothStatus)
|
||||||
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
|
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
|
||||||
@@ -739,6 +778,9 @@ class MainActivity : OrientationAwareActivity() {
|
|||||||
mainViewModel.updateLocationStatus(currentLocationStatus)
|
mainViewModel.updateLocationStatus(currentLocationStatus)
|
||||||
mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK)
|
mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK)
|
||||||
mainViewModel.updateLocationLoading(false)
|
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
|
// Only set background state if app is fully initialized
|
||||||
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
||||||
// Detach UI delegate so the foreground service can own DM notifications while UI is closed
|
// 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)
|
private val _isBatteryOptimizationLoading = MutableStateFlow(false)
|
||||||
val isBatteryOptimizationLoading: StateFlow<Boolean> = _isBatteryOptimizationLoading.asStateFlow()
|
val isBatteryOptimizationLoading: StateFlow<Boolean> = _isBatteryOptimizationLoading.asStateFlow()
|
||||||
|
|
||||||
|
private val _isBluetoothCheckSkipped = MutableStateFlow(false)
|
||||||
|
val isBluetoothCheckSkipped: StateFlow<Boolean> = _isBluetoothCheckSkipped.asStateFlow()
|
||||||
|
|
||||||
// Public update functions for MainActivity
|
// Public update functions for MainActivity
|
||||||
fun updateOnboardingState(state: OnboardingState) {
|
fun updateOnboardingState(state: OnboardingState) {
|
||||||
_onboardingState.value = state
|
_onboardingState.value = state
|
||||||
@@ -67,4 +70,8 @@ class MainViewModel : ViewModel() {
|
|||||||
fun updateBatteryOptimizationLoading(loading: Boolean) {
|
fun updateBatteryOptimizationLoading(loading: Boolean) {
|
||||||
_isBatteryOptimizationLoading.value = loading
|
_isBatteryOptimizationLoading.value = loading
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun skipBluetoothCheck() {
|
||||||
|
_isBluetoothCheckSkipped.value = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -88,22 +88,49 @@ class BluetoothConnectionManager(
|
|||||||
// Public property for address-peer mapping
|
// Public property for address-peer mapping
|
||||||
val addressPeerMap get() = connectionTracker.addressPeerMap
|
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 {
|
init {
|
||||||
powerManager.delegate = this
|
powerManager.delegate = this
|
||||||
// Observe debug settings to enforce role state while active
|
// Observe debug settings to enforce role state while active
|
||||||
try {
|
try {
|
||||||
val dbg = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance()
|
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
|
// Role enable/disable
|
||||||
connectionScope.launch {
|
connectionScope.launch {
|
||||||
dbg.gattServerEnabled.collect { enabled ->
|
dbg.gattServerEnabled.collect { enabled ->
|
||||||
if (!isActive) return@collect
|
if (!isActive) return@collect
|
||||||
if (enabled) startServer() else stopServer()
|
if (enabled && isBleTransportEnabled()) startServer() else stopServer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
connectionScope.launch {
|
connectionScope.launch {
|
||||||
dbg.gattClientEnabled.collect { enabled ->
|
dbg.gattClientEnabled.collect { enabled ->
|
||||||
if (!isActive) return@collect
|
if (!isActive) return@collect
|
||||||
if (enabled) startClient() else stopClient()
|
if (enabled && isBleTransportEnabled()) startClient() else stopClient()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +190,12 @@ class BluetoothConnectionManager(
|
|||||||
*/
|
*/
|
||||||
fun startServices(): Boolean {
|
fun startServices(): Boolean {
|
||||||
Log.i(TAG, "Starting power-optimized Bluetooth services...")
|
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()) {
|
if (!permissionManager.hasBluetoothPermissions()) {
|
||||||
Log.e(TAG, "Missing Bluetooth permissions")
|
Log.e(TAG, "Missing Bluetooth permissions")
|
||||||
@@ -197,9 +230,8 @@ class BluetoothConnectionManager(
|
|||||||
powerManager.start()
|
powerManager.start()
|
||||||
|
|
||||||
// Start server/client based on debug settings
|
// Start server/client based on debug settings
|
||||||
val dbg = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (_: Exception) { null }
|
val startServer = isGattServerEnabled()
|
||||||
val startServer = dbg?.gattServerEnabled?.value != false
|
val startClient = isGattClientEnabled()
|
||||||
val startClient = dbg?.gattClientEnabled?.value != false
|
|
||||||
|
|
||||||
if (startServer) {
|
if (startServer) {
|
||||||
if (!serverManager.start()) {
|
if (!serverManager.start()) {
|
||||||
@@ -234,6 +266,19 @@ class BluetoothConnectionManager(
|
|||||||
return false
|
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
|
* Stop all Bluetooth services with proper cleanup
|
||||||
@@ -279,7 +324,7 @@ class BluetoothConnectionManager(
|
|||||||
* Automatically fragments large packets to fit within BLE MTU limits
|
* Automatically fragments large packets to fit within BLE MTU limits
|
||||||
*/
|
*/
|
||||||
fun broadcastPacket(routed: RoutedPacket) {
|
fun broadcastPacket(routed: RoutedPacket) {
|
||||||
if (!isActive) return
|
if (!isActive || !isBleTransportEnabled()) return
|
||||||
|
|
||||||
packetBroadcaster.broadcastPacket(
|
packetBroadcaster.broadcastPacket(
|
||||||
routed,
|
routed,
|
||||||
@@ -289,7 +334,7 @@ class BluetoothConnectionManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
|
fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
|
||||||
if (!isActive) return false
|
if (!isActive || !isBleTransportEnabled()) return false
|
||||||
return packetBroadcaster.sendToPeer(
|
return packetBroadcaster.sendToPeer(
|
||||||
peerID,
|
peerID,
|
||||||
routed,
|
routed,
|
||||||
@@ -306,7 +351,7 @@ class BluetoothConnectionManager(
|
|||||||
* Send a packet directly to a specific peer, without broadcasting to others.
|
* Send a packet directly to a specific peer, without broadcasting to others.
|
||||||
*/
|
*/
|
||||||
fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean {
|
fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean {
|
||||||
if (!isActive) return false
|
if (!isActive || !isBleTransportEnabled()) return false
|
||||||
return packetBroadcaster.sendPacketToPeer(
|
return packetBroadcaster.sendPacketToPeer(
|
||||||
RoutedPacket(packet),
|
RoutedPacket(packet),
|
||||||
peerID,
|
peerID,
|
||||||
@@ -317,9 +362,15 @@ class BluetoothConnectionManager(
|
|||||||
|
|
||||||
|
|
||||||
// Expose role controls for debug UI
|
// 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 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() } }
|
fun stopClient() { connectionScope.launch { clientManager.stop() } }
|
||||||
|
|
||||||
// Inject nickname resolver for broadcaster logs
|
// Inject nickname resolver for broadcaster logs
|
||||||
@@ -347,7 +398,10 @@ class BluetoothConnectionManager(
|
|||||||
/**
|
/**
|
||||||
* Public: connect/disconnect helpers for debug UI
|
* 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) }
|
fun disconnectAddress(address: String) { connectionTracker.disconnectDevice(address) }
|
||||||
|
|
||||||
|
|
||||||
@@ -358,10 +412,10 @@ class BluetoothConnectionManager(
|
|||||||
clientManager.stop()
|
clientManager.stop()
|
||||||
serverManager.stop()
|
serverManager.stop()
|
||||||
delay(200)
|
delay(200)
|
||||||
if (isActive) {
|
if (isActive && isBleTransportEnabled()) {
|
||||||
// Restart managers if service is active
|
// Restart managers if service is active
|
||||||
serverManager.start()
|
if (isGattServerEnabled()) serverManager.start()
|
||||||
clientManager.start()
|
if (isGattClientEnabled()) clientManager.start()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -396,11 +450,17 @@ class BluetoothConnectionManager(
|
|||||||
Log.i(TAG, "Power mode changed to: $newMode")
|
Log.i(TAG, "Power mode changed to: $newMode")
|
||||||
|
|
||||||
connectionScope.launch {
|
connectionScope.launch {
|
||||||
|
if (!isActive || !isBleTransportEnabled()) {
|
||||||
|
serverManager.stop()
|
||||||
|
clientManager.stop()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
// Avoid rapid scan restarts by checking if we need to change scan behavior
|
// Avoid rapid scan restarts by checking if we need to change scan behavior
|
||||||
val wasUsingDutyCycle = powerManager.shouldUseDutyCycle()
|
val wasUsingDutyCycle = powerManager.shouldUseDutyCycle()
|
||||||
|
|
||||||
// Update advertising with new power settings if server enabled
|
// 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) {
|
if (serverEnabled) {
|
||||||
serverManager.restartAdvertising()
|
serverManager.restartAdvertising()
|
||||||
} else {
|
} else {
|
||||||
@@ -411,7 +471,7 @@ class BluetoothConnectionManager(
|
|||||||
val nowUsingDutyCycle = powerManager.shouldUseDutyCycle()
|
val nowUsingDutyCycle = powerManager.shouldUseDutyCycle()
|
||||||
if (wasUsingDutyCycle != nowUsingDutyCycle) {
|
if (wasUsingDutyCycle != nowUsingDutyCycle) {
|
||||||
Log.d(TAG, "Duty cycle behavior changed (${wasUsingDutyCycle} -> ${nowUsingDutyCycle}), restarting scan")
|
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) {
|
if (clientEnabled) {
|
||||||
clientManager.restartScanning()
|
clientManager.restartScanning()
|
||||||
} else {
|
} else {
|
||||||
@@ -427,6 +487,10 @@ class BluetoothConnectionManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onScanStateChanged(shouldScan: Boolean) {
|
override fun onScanStateChanged(shouldScan: Boolean) {
|
||||||
|
if (!isActive || !isBleTransportEnabled()) {
|
||||||
|
clientManager.onScanStateChanged(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
clientManager.onScanStateChanged(shouldScan)
|
clientManager.onScanStateChanged(shouldScan)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,29 +16,22 @@ import java.util.concurrent.CopyOnWriteArrayList
|
|||||||
class BluetoothConnectionTracker(
|
class BluetoothConnectionTracker(
|
||||||
private val connectionScope: CoroutineScope,
|
private val connectionScope: CoroutineScope,
|
||||||
private val powerManager: PowerManager
|
private val powerManager: PowerManager
|
||||||
) {
|
) : MeshConnectionTracker(connectionScope, TAG) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "BluetoothConnectionTracker"
|
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_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
|
// Connection tracking - reduced memory footprint
|
||||||
private val connectedDevices = ConcurrentHashMap<String, DeviceConnection>()
|
private val connectedDevices = ConcurrentHashMap<String, DeviceConnection>()
|
||||||
private val subscribedDevices = CopyOnWriteArrayList<BluetoothDevice>()
|
private val subscribedDevices = CopyOnWriteArrayList<BluetoothDevice>()
|
||||||
val addressPeerMap = ConcurrentHashMap<String, String>()
|
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)
|
// RSSI tracking from scan results (for devices we discover but may connect as servers)
|
||||||
private val scanRSSI = ConcurrentHashMap<String, Int>()
|
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
|
* Consolidated device connection information
|
||||||
*/
|
*/
|
||||||
@@ -52,37 +45,28 @@ class BluetoothConnectionTracker(
|
|||||||
val peerID: String? = null
|
val peerID: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
override fun start() {
|
||||||
* Connection attempt tracking with automatic expiry
|
super.start()
|
||||||
*/
|
|
||||||
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 stop() {
|
||||||
* Start the connection tracker
|
super.stop()
|
||||||
*/
|
|
||||||
fun start() {
|
|
||||||
isActive = true
|
|
||||||
startPeriodicCleanup()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the connection tracker
|
|
||||||
*/
|
|
||||||
fun stop() {
|
|
||||||
isActive = false
|
|
||||||
cleanupAllConnections()
|
cleanupAllConnections()
|
||||||
clearAllConnections()
|
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
|
* Add a device connection
|
||||||
@@ -90,7 +74,9 @@ class BluetoothConnectionTracker(
|
|||||||
fun addDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) {
|
fun addDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) {
|
||||||
Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}")
|
Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}")
|
||||||
connectedDevices[deviceAddress] = deviceConn
|
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
|
* Check if device is already connected
|
||||||
*/
|
*/
|
||||||
fun isDeviceConnected(deviceAddress: String): Boolean {
|
fun isDeviceConnected(deviceAddress: String): Boolean = isConnected(deviceAddress)
|
||||||
return connectedDevices.containsKey(deviceAddress)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a peer is already connected (by PeerID)
|
* Check if a peer is already connected (by PeerID)
|
||||||
@@ -175,63 +159,15 @@ class BluetoothConnectionTracker(
|
|||||||
return connectedDevices.values.any { it.peerID == peerID }
|
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)
|
* Disconnect a specific device (by MAC address)
|
||||||
*/
|
*/
|
||||||
fun disconnectDevice(deviceAddress: String) {
|
fun disconnectDevice(deviceAddress: String) = disconnect(deviceAddress)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get connected device count
|
* Get connected device count
|
||||||
*/
|
*/
|
||||||
fun getConnectedDeviceCount(): Int = connectedDevices.size
|
fun getConnectedDeviceCount(): Int = getConnectionCount()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if connection limit is reached
|
* Check if connection limit is reached
|
||||||
@@ -301,6 +237,7 @@ class BluetoothConnectionTracker(
|
|||||||
subscribedDevices.removeAll { it.address == deviceAddress }
|
subscribedDevices.removeAll { it.address == deviceAddress }
|
||||||
addressPeerMap.remove(deviceAddress)
|
addressPeerMap.remove(deviceAddress)
|
||||||
}
|
}
|
||||||
|
firstAnnounceSeen.remove(deviceAddress)
|
||||||
Log.d(TAG, "Cleaned up device connection for $deviceAddress")
|
Log.d(TAG, "Cleaned up device connection for $deviceAddress")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,36 +271,21 @@ class BluetoothConnectionTracker(
|
|||||||
addressPeerMap.clear()
|
addressPeerMap.clear()
|
||||||
pendingConnections.clear()
|
pendingConnections.clear()
|
||||||
scanRSSI.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() {
|
fun noteAnnounceReceived(deviceAddress: String) {
|
||||||
connectionScope.launch {
|
firstAnnounceSeen[deviceAddress] = true
|
||||||
while (isActive) {
|
}
|
||||||
delay(CLEANUP_INTERVAL)
|
|
||||||
|
/**
|
||||||
if (!isActive) break
|
* Check whether the first ANNOUNCE has been seen for a device connection.
|
||||||
|
*/
|
||||||
try {
|
fun hasSeenFirstAnnounce(deviceAddress: String): Boolean {
|
||||||
// Clean up expired pending connections
|
return firstAnnounceSeen[deviceAddress] == true
|
||||||
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}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ class BluetoothGattClientManager(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "BluetoothGattClientManager"
|
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
|
// Core Bluetooth components
|
||||||
@@ -39,11 +44,28 @@ class BluetoothGattClientManager(
|
|||||||
context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
|
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
|
||||||
private val bleScanner: BluetoothLeScanner? = bluetoothAdapter?.bluetoothLeScanner
|
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)
|
* Public: Connect to a device by MAC address (for debug UI)
|
||||||
*/
|
*/
|
||||||
fun connectToAddress(deviceAddress: String): Boolean {
|
fun connectToAddress(deviceAddress: String): Boolean {
|
||||||
|
if (!isClientRoleEnabled()) {
|
||||||
|
Log.i(TAG, "connectToAddress skipped: BLE client disabled")
|
||||||
|
return false
|
||||||
|
}
|
||||||
val device = bluetoothAdapter?.getRemoteDevice(deviceAddress)
|
val device = bluetoothAdapter?.getRemoteDevice(deviceAddress)
|
||||||
return if (device != null) {
|
return if (device != null) {
|
||||||
val rssi = connectionTracker.getBestRSSI(deviceAddress) ?: -50
|
val rssi = connectionTracker.getBestRSSI(deviceAddress) ?: -50
|
||||||
@@ -61,8 +83,16 @@ class BluetoothGattClientManager(
|
|||||||
// Scan rate limiting to prevent "scanning too frequently" errors
|
// Scan rate limiting to prevent "scanning too frequently" errors
|
||||||
private var lastScanStartTime = 0L
|
private var lastScanStartTime = 0L
|
||||||
private var lastScanStopTime = 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
|
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
|
// RSSI monitoring state
|
||||||
private var rssiMonitoringJob: Job? = null
|
private var rssiMonitoringJob: Job? = null
|
||||||
@@ -75,12 +105,10 @@ class BluetoothGattClientManager(
|
|||||||
*/
|
*/
|
||||||
fun start(): Boolean {
|
fun start(): Boolean {
|
||||||
// Respect debug setting
|
// Respect debug setting
|
||||||
try {
|
if (!isClientRoleEnabled()) {
|
||||||
if (!com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value) {
|
Log.i(TAG, "Client start skipped: BLE/GATT Client disabled in debug settings")
|
||||||
Log.i(TAG, "Client start skipped: GATT Client disabled in debug settings")
|
return false
|
||||||
return false
|
}
|
||||||
}
|
|
||||||
} catch (_: Exception) { }
|
|
||||||
|
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
Log.d(TAG, "GATT client already active; start is a no-op")
|
Log.d(TAG, "GATT client already active; start is a no-op")
|
||||||
@@ -106,12 +134,16 @@ class BluetoothGattClientManager(
|
|||||||
connectionScope.launch {
|
connectionScope.launch {
|
||||||
if (powerManager.shouldUseDutyCycle()) {
|
if (powerManager.shouldUseDutyCycle()) {
|
||||||
Log.i(TAG, "Using power-aware duty cycling")
|
Log.i(TAG, "Using power-aware duty cycling")
|
||||||
|
// Duty cycle drives onScanStateChanged(true/false); scanningDesired follows that.
|
||||||
} else {
|
} else {
|
||||||
|
scanningDesired = true
|
||||||
startScanning()
|
startScanning()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start RSSI monitoring
|
// Start RSSI monitoring
|
||||||
startRSSIMonitoring()
|
startRSSIMonitoring()
|
||||||
|
// Start the scan watchdog so a silently-dead or wedged scanner self-heals.
|
||||||
|
startScanWatchdog()
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
@@ -121,6 +153,8 @@ class BluetoothGattClientManager(
|
|||||||
* Stop client manager
|
* Stop client manager
|
||||||
*/
|
*/
|
||||||
fun stop() {
|
fun stop() {
|
||||||
|
scanningDesired = false
|
||||||
|
stopScanWatchdog()
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
// Idempotent stop
|
// Idempotent stop
|
||||||
stopScanning()
|
stopScanning()
|
||||||
@@ -150,7 +184,8 @@ class BluetoothGattClientManager(
|
|||||||
* Handle scan state changes from power manager
|
* Handle scan state changes from power manager
|
||||||
*/
|
*/
|
||||||
fun onScanStateChanged(shouldScan: Boolean) {
|
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) {
|
if (shouldScan && enabled) {
|
||||||
startScanning()
|
startScanning()
|
||||||
} else {
|
} else {
|
||||||
@@ -199,7 +234,7 @@ class BluetoothGattClientManager(
|
|||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
private fun startScanning() {
|
private fun startScanning() {
|
||||||
// Respect debug setting
|
// 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
|
if (!permissionManager.hasBluetoothPermissions() || bleScanner == null || !isActive || !enabled) return
|
||||||
|
|
||||||
// Rate limit scan starts to prevent "scanning too frequently" errors
|
// Rate limit scan starts to prevent "scanning too frequently" errors
|
||||||
@@ -217,7 +252,7 @@ class BluetoothGattClientManager(
|
|||||||
// Schedule delayed scan start
|
// Schedule delayed scan start
|
||||||
connectionScope.launch {
|
connectionScope.launch {
|
||||||
delay(remainingWait)
|
delay(remainingWait)
|
||||||
if (isActive && !isCurrentlyScanning) {
|
if (isActive && !isCurrentlyScanning && isClientRoleEnabled()) {
|
||||||
startScanning()
|
startScanning()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,22 +286,39 @@ class BluetoothGattClientManager(
|
|||||||
lastScanStopTime = System.currentTimeMillis()
|
lastScanStopTime = System.currentTimeMillis()
|
||||||
|
|
||||||
when (errorCode) {
|
when (errorCode) {
|
||||||
1 -> Log.e(TAG, "SCAN_FAILED_ALREADY_STARTED")
|
1 -> {
|
||||||
2 -> Log.e(TAG, "SCAN_FAILED_APPLICATION_REGISTRATION_FAILED")
|
// Already started: the stack thinks a scan is running. Re-arm from a clean
|
||||||
3 -> Log.e(TAG, "SCAN_FAILED_INTERNAL_ERROR")
|
// state so we don't stay wedged (stop then restart with backoff).
|
||||||
4 -> Log.e(TAG, "SCAN_FAILED_FEATURE_UNSUPPORTED")
|
Log.e(TAG, "SCAN_FAILED_ALREADY_STARTED")
|
||||||
5 -> Log.e(TAG, "SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES")
|
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 -> {
|
6 -> {
|
||||||
Log.e(TAG, "SCAN_FAILED_SCANNING_TOO_FREQUENTLY")
|
Log.e(TAG, "SCAN_FAILED_SCANNING_TOO_FREQUENTLY")
|
||||||
Log.w(TAG, "Scan failed due to rate limiting - will retry after delay")
|
Log.w(TAG, "Scan failed due to rate limiting - will retry after delay")
|
||||||
connectionScope.launch {
|
scheduleScanRestart("too-frequently", 10_000L)
|
||||||
delay(10000) // Wait 10 seconds before retrying
|
}
|
||||||
if (isActive) {
|
else -> {
|
||||||
startScanning()
|
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()
|
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
|
* Handle scan result and initiate connection if appropriate
|
||||||
@@ -320,6 +442,10 @@ class BluetoothGattClientManager(
|
|||||||
return
|
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
|
// Try to extract peerID from Service Data (if available) for stable identity
|
||||||
val serviceData = scanRecord?.getServiceData(ParcelUuid(AppConstants.Mesh.Gatt.SERVICE_UUID))
|
val serviceData = scanRecord?.getServiceData(ParcelUuid(AppConstants.Mesh.Gatt.SERVICE_UUID))
|
||||||
val peerID = if (serviceData != null && serviceData.size >= 8) {
|
val peerID = if (serviceData != null && serviceData.size >= 8) {
|
||||||
@@ -402,6 +528,7 @@ class BluetoothGattClientManager(
|
|||||||
*/
|
*/
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
private fun connectToDevice(device: BluetoothDevice, rssi: Int, peerID: String? = null) {
|
private fun connectToDevice(device: BluetoothDevice, rssi: Int, peerID: String? = null) {
|
||||||
|
if (!isClientRoleEnabled()) return
|
||||||
if (!permissionManager.hasBluetoothPermissions()) return
|
if (!permissionManager.hasBluetoothPermissions()) return
|
||||||
|
|
||||||
val deviceAddress = device.address
|
val deviceAddress = device.address
|
||||||
@@ -562,7 +689,7 @@ class BluetoothGattClientManager(
|
|||||||
*/
|
*/
|
||||||
fun restartScanning() {
|
fun restartScanning() {
|
||||||
// Respect debug setting
|
// 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
|
if (!isActive || !enabled) return
|
||||||
|
|
||||||
connectionScope.launch {
|
connectionScope.launch {
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ class BluetoothGattServerManager(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "BluetoothGattServerManager"
|
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
|
// Core Bluetooth components
|
||||||
@@ -42,10 +45,24 @@ class BluetoothGattServerManager(
|
|||||||
private var gattServer: BluetoothGattServer? = null
|
private var gattServer: BluetoothGattServer? = null
|
||||||
private var characteristic: BluetoothGattCharacteristic? = null
|
private var characteristic: BluetoothGattCharacteristic? = null
|
||||||
private var advertiseCallback: AdvertiseCallback? = null
|
private var advertiseCallback: AdvertiseCallback? = null
|
||||||
|
private var advertiseRetryCount = 0
|
||||||
|
|
||||||
// State management
|
// State management
|
||||||
private var isActive = false
|
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)
|
* Disconnect a specific device (used by ConnectionManager to enforce overall limits)
|
||||||
*/
|
*/
|
||||||
@@ -62,12 +79,10 @@ class BluetoothGattServerManager(
|
|||||||
*/
|
*/
|
||||||
fun start(): Boolean {
|
fun start(): Boolean {
|
||||||
// Respect debug setting
|
// Respect debug setting
|
||||||
try {
|
if (!isServerRoleEnabled()) {
|
||||||
if (!com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value) {
|
Log.i(TAG, "Server start skipped: BLE/GATT Server disabled in debug settings")
|
||||||
Log.i(TAG, "Server start skipped: GATT Server disabled in debug settings")
|
return false
|
||||||
return false
|
}
|
||||||
}
|
|
||||||
} catch (_: Exception) { }
|
|
||||||
|
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
Log.d(TAG, "GATT server already active; start is a no-op")
|
Log.d(TAG, "GATT server already active; start is a no-op")
|
||||||
@@ -322,7 +337,7 @@ class BluetoothGattServerManager(
|
|||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
private fun startAdvertising() {
|
private fun startAdvertising() {
|
||||||
// Respect debug setting
|
// 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
|
// Guard conditions – never throw here to avoid crashing the app from a background coroutine
|
||||||
if (!permissionManager.hasBluetoothPermissions()) {
|
if (!permissionManager.hasBluetoothPermissions()) {
|
||||||
@@ -374,6 +389,7 @@ class BluetoothGattServerManager(
|
|||||||
|
|
||||||
advertiseCallback = object : AdvertiseCallback() {
|
advertiseCallback = object : AdvertiseCallback() {
|
||||||
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
|
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
|
||||||
|
advertiseRetryCount = 0
|
||||||
val mode = try {
|
val mode = try {
|
||||||
powerManager.getPowerInfo().split("Current Mode: ")[1].split("\n")[0]
|
powerManager.getPowerInfo().split("Current Mode: ")[1].split("\n")[0]
|
||||||
} catch (_: Exception) { "unknown" }
|
} catch (_: Exception) { "unknown" }
|
||||||
@@ -382,6 +398,28 @@ class BluetoothGattServerManager(
|
|||||||
|
|
||||||
override fun onStartFailure(errorCode: Int) {
|
override fun onStartFailure(errorCode: Int) {
|
||||||
Log.e(TAG, "Advertising failed: $errorCode")
|
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)
|
* Restart advertising (for power mode changes)
|
||||||
*/
|
*/
|
||||||
fun restartAdvertising() {
|
fun restartAdvertising() {
|
||||||
// Respect debug setting
|
// Respect debug setting
|
||||||
val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true }
|
val enabled = isServerRoleEnabled()
|
||||||
if (!isActive || !enabled) {
|
if (!isActive || !enabled) {
|
||||||
stopAdvertising()
|
stopAdvertising()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import com.bitchat.android.model.RequestSyncPacket
|
|||||||
import com.bitchat.android.sync.GossipSyncManager
|
import com.bitchat.android.sync.GossipSyncManager
|
||||||
import com.bitchat.android.util.toHexString
|
import com.bitchat.android.util.toHexString
|
||||||
import com.bitchat.android.services.VerificationService
|
import com.bitchat.android.services.VerificationService
|
||||||
|
import com.bitchat.android.service.TransportBridgeService
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.math.sign
|
import kotlin.math.sign
|
||||||
@@ -34,7 +35,7 @@ import kotlin.random.Random
|
|||||||
* - BluetoothConnectionManager: BLE connections and GATT operations
|
* - BluetoothConnectionManager: BLE connections and GATT operations
|
||||||
* - PacketProcessor: Incoming packet routing
|
* - 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 } }
|
private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } }
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -70,6 +71,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
|
|
||||||
// Coroutines
|
// Coroutines
|
||||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
private var announceJob: Job? = null
|
||||||
// Tracks whether this instance has been terminated via stopServices()
|
// Tracks whether this instance has been terminated via stopServices()
|
||||||
private var terminated = false
|
private var terminated = false
|
||||||
|
|
||||||
@@ -99,17 +101,11 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Wire sync manager delegate
|
com.bitchat.android.service.MeshServiceHolder.setGossipManager(gossipSyncManager) { packet ->
|
||||||
gossipSyncManager.delegate = object : GossipSyncManager.Delegate {
|
signPacketBeforeBroadcast(packet)
|
||||||
override fun sendPacket(packet: BitchatPacket) {
|
}
|
||||||
connectionManager.broadcastPacket(RoutedPacket(packet))
|
if (isBleTransportEnabled()) {
|
||||||
}
|
TransportBridgeService.register("BLE", this)
|
||||||
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
|
|
||||||
connectionManager.sendPacketToPeer(peerID, packet)
|
|
||||||
}
|
|
||||||
override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket {
|
|
||||||
return signPacketBeforeBroadcast(packet)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject dynamic direct connection check into PeerManager
|
// 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")
|
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
|
* Start periodic debug logging every 10 seconds
|
||||||
@@ -146,7 +166,8 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
* Send broadcast announcement every 30 seconds
|
* Send broadcast announcement every 30 seconds
|
||||||
*/
|
*/
|
||||||
private fun sendPeriodicBroadcastAnnounce() {
|
private fun sendPeriodicBroadcastAnnounce() {
|
||||||
serviceScope.launch {
|
announceJob?.cancel()
|
||||||
|
announceJob = serviceScope.launch {
|
||||||
Log.d(TAG, "Starting periodic announce loop")
|
Log.d(TAG, "Starting periodic announce loop")
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
try {
|
try {
|
||||||
@@ -175,7 +196,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
peerManager.delegate = object : PeerManagerDelegate {
|
peerManager.delegate = object : PeerManagerDelegate {
|
||||||
override fun onPeerListUpdated(peerIDs: List<String>) {
|
override fun onPeerListUpdated(peerIDs: List<String>) {
|
||||||
// Update process-wide state first
|
// 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
|
// Then notify UI delegate if attached
|
||||||
delegate?.didUpdatePeerList(peerIDs)
|
delegate?.didUpdatePeerList(peerIDs)
|
||||||
}
|
}
|
||||||
@@ -221,7 +242,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
)
|
)
|
||||||
// Sign the handshake response
|
// Sign the handshake response
|
||||||
val signedPacket = signPacketBeforeBroadcast(responsePacket)
|
val signedPacket = signPacketBeforeBroadcast(responsePacket)
|
||||||
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||||
Log.d(TAG, "Sent Noise handshake response to $peerID (${response.size} bytes)")
|
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) {
|
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) {
|
override fun sendPacket(packet: BitchatPacket) {
|
||||||
// Sign the packet before broadcasting
|
// Sign the packet before broadcasting
|
||||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||||
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun relayPacket(routed: RoutedPacket) {
|
override fun relayPacket(routed: RoutedPacket) {
|
||||||
connectionManager.broadcastPacket(routed)
|
broadcastRoutedPacket(routed)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getBroadcastRecipient(): ByteArray {
|
override fun getBroadcastRecipient(): ByteArray {
|
||||||
@@ -335,7 +356,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
|
|
||||||
// Sign the handshake packet before broadcasting
|
// Sign the handshake packet before broadcasting
|
||||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||||
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||||
Log.d(TAG, "Initiated Noise handshake with $peerID (${handshakeData.size} bytes)")
|
Log.d(TAG, "Initiated Noise handshake with $peerID (${handshakeData.size} bytes)")
|
||||||
} else {
|
} else {
|
||||||
Log.w(TAG, "Failed to generate Noise handshake data for $peerID")
|
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) {
|
override fun relayPacket(routed: RoutedPacket) {
|
||||||
connectionManager.broadcastPacket(routed)
|
broadcastRoutedPacket(routed)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
|
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) {
|
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")
|
Log.w(TAG, "Mesh service already active, ignoring duplicate start request")
|
||||||
return
|
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) {
|
if (terminated) {
|
||||||
// This instance's scope was cancelled previously; refuse to start to avoid using dead scopes.
|
// 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")
|
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()) {
|
if (connectionManager.startServices()) {
|
||||||
isActive = true
|
isActive = true
|
||||||
|
TransportBridgeService.register("BLE", this)
|
||||||
|
|
||||||
// Start periodic announcements for peer discovery and connectivity
|
// Start periodic announcements for peer discovery and connectivity
|
||||||
sendPeriodicBroadcastAnnounce()
|
sendPeriodicBroadcastAnnounce()
|
||||||
Log.d(TAG, "Started periodic broadcast announcements (every 30 seconds)")
|
Log.d(TAG, "Started periodic broadcast announcements (every 30 seconds)")
|
||||||
// Start periodic syncs
|
// Start periodic syncs
|
||||||
gossipSyncManager.start()
|
com.bitchat.android.service.MeshServiceHolder.startSharedGossip("BLE")
|
||||||
Log.d(TAG, "GossipSyncManager started")
|
Log.d(TAG, "GossipSyncManager started")
|
||||||
} else {
|
} else {
|
||||||
Log.e(TAG, "Failed to start Bluetooth services")
|
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
|
* Stop all mesh services
|
||||||
@@ -658,6 +715,11 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
|
|
||||||
Log.i(TAG, "Stopping Bluetooth mesh service")
|
Log.i(TAG, "Stopping Bluetooth mesh service")
|
||||||
isActive = false
|
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
|
// Send leave announcement
|
||||||
sendLeaveAnnouncement()
|
sendLeaveAnnouncement()
|
||||||
@@ -667,7 +729,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
delay(200) // Give leave message time to send
|
delay(200) // Give leave message time to send
|
||||||
|
|
||||||
// Stop all components
|
// Stop all components
|
||||||
gossipSyncManager.stop()
|
com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("BLE")
|
||||||
Log.d(TAG, "GossipSyncManager stopped")
|
Log.d(TAG, "GossipSyncManager stopped")
|
||||||
connectionManager.stopServices()
|
connectionManager.stopServices()
|
||||||
Log.d(TAG, "BluetoothConnectionManager stop requested")
|
Log.d(TAG, "BluetoothConnectionManager stop requested")
|
||||||
@@ -717,7 +779,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
|
|
||||||
// Sign the packet before broadcasting
|
// Sign the packet before broadcasting
|
||||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||||
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||||
// Track our own broadcast message for sync
|
// Track our own broadcast message for sync
|
||||||
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
|
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
|
||||||
}
|
}
|
||||||
@@ -749,7 +811,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
val signed = signPacketBeforeBroadcast(packet)
|
val signed = signPacketBeforeBroadcast(packet)
|
||||||
// Use a stable transferId based on the file TLV payload for progress tracking
|
// Use a stable transferId based on the file TLV payload for progress tracking
|
||||||
val transferId = sha256Hex(payload)
|
val transferId = sha256Hex(payload)
|
||||||
connectionManager.broadcastPacket(RoutedPacket(signed, transferId = transferId))
|
broadcastRoutedPacket(RoutedPacket(signed, transferId = transferId))
|
||||||
try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { }
|
try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { }
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -793,7 +855,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
|
|
||||||
// Create NOISE_ENCRYPTED packet (not FILE_TRANSFER!)
|
// Create NOISE_ENCRYPTED packet (not FILE_TRANSFER!)
|
||||||
val packet = BitchatPacket(
|
val packet = BitchatPacket(
|
||||||
version = 1u,
|
version = if (encrypted.size > 0xFFFF) 2u else 1u,
|
||||||
type = MessageType.NOISE_ENCRYPTED.value,
|
type = MessageType.NOISE_ENCRYPTED.value,
|
||||||
senderID = hexStringToByteArray(myPeerID),
|
senderID = hexStringToByteArray(myPeerID),
|
||||||
recipientID = hexStringToByteArray(recipientPeerID),
|
recipientID = hexStringToByteArray(recipientPeerID),
|
||||||
@@ -807,7 +869,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
val signed = signPacketBeforeBroadcast(packet)
|
val signed = signPacketBeforeBroadcast(packet)
|
||||||
// Use a stable transferId based on the unencrypted file TLV payload for progress tracking
|
// Use a stable transferId based on the unencrypted file TLV payload for progress tracking
|
||||||
val transferId = sha256Hex(filePayload)
|
val transferId = sha256Hex(filePayload)
|
||||||
connectionManager.broadcastPacket(RoutedPacket(signed, transferId = transferId))
|
broadcastRoutedPacket(RoutedPacket(signed, transferId = transferId))
|
||||||
Log.d(TAG, "✅ Sent encrypted file to $recipientPeerID")
|
Log.d(TAG, "✅ Sent encrypted file to $recipientPeerID")
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -887,7 +949,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
|
|
||||||
// Sign the packet before broadcasting
|
// Sign the packet before broadcasting
|
||||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||||
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||||
Log.d(TAG, "📤 Sent encrypted private message to $recipientPeerID (${encrypted.size} bytes)")
|
Log.d(TAG, "📤 Sent encrypted private message to $recipientPeerID (${encrypted.size} bytes)")
|
||||||
|
|
||||||
// FIXED: Don't send didReceiveMessage for our own sent messages
|
// 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
|
// Sign the packet before broadcasting
|
||||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||||
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||||
Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID")
|
Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID")
|
||||||
|
|
||||||
// Persist as read after successful send
|
// Persist as read after successful send
|
||||||
@@ -1006,7 +1068,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||||
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||||
Log.d(TAG, "📤 Sent $label to $recipientPeerID (${payload.data.size} bytes)")
|
Log.d(TAG, "📤 Sent $label to $recipientPeerID (${payload.data.size} bytes)")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to send $label to $recipientPeerID: ${e.message}")
|
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.copy(signature = signature)
|
||||||
} ?: announcePacket
|
} ?: announcePacket
|
||||||
|
|
||||||
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||||
Log.d(TAG, "Sent iOS-compatible signed TLV announce (${tlvPayload.size} bytes)")
|
Log.d(TAG, "Sent iOS-compatible signed TLV announce (${tlvPayload.size} bytes)")
|
||||||
// Track announce for sync
|
// Track announce for sync
|
||||||
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
|
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
|
||||||
@@ -1133,7 +1195,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
packet.copy(signature = signature)
|
packet.copy(signature = signature)
|
||||||
} ?: packet
|
} ?: packet
|
||||||
|
|
||||||
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||||
peerManager.markPeerAsAnnouncedTo(peerID)
|
peerManager.markPeerAsAnnouncedTo(peerID)
|
||||||
Log.d(TAG, "Sent iOS-compatible signed TLV peer announce to $peerID (${tlvPayload.size} bytes)")
|
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 {
|
return try {
|
||||||
// Prefer verified peers that are currently marked as direct
|
// Prefer verified peers that are currently marked as direct
|
||||||
val verified = peerManager.getVerifiedPeers()
|
val verified = peerManager.getVerifiedPeers()
|
||||||
val direct = verified.filter { it.value.isDirectConnection }.keys.toList()
|
val direct = verified.filter { it.value.isDirectConnection }.keys.toSet()
|
||||||
direct.take(10)
|
// 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) {
|
} catch (_: Exception) {
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
@@ -1168,7 +1236,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
|
|
||||||
// Sign the packet before broadcasting
|
// Sign the packet before broadcasting
|
||||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
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 {
|
interface BluetoothMeshDelegate : MeshDelegate {
|
||||||
fun didReceiveMessage(message: BitchatMessage)
|
override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long)
|
||||||
fun didUpdatePeerList(peers: List<String>)
|
override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
import kotlinx.coroutines.channels.actor
|
import kotlinx.coroutines.channels.actor
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -117,7 +115,7 @@ class BluetoothPacketBroadcaster(
|
|||||||
|
|
||||||
// Actor scope for the broadcaster
|
// Actor scope for the broadcaster
|
||||||
private val broadcasterScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
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
|
// SERIALIZATION: Actor to serialize all broadcast operations
|
||||||
@OptIn(kotlinx.coroutines.ObsoleteCoroutinesApi::class)
|
@OptIn(kotlinx.coroutines.ObsoleteCoroutinesApi::class)
|
||||||
@@ -139,71 +137,14 @@ class BluetoothPacketBroadcaster(
|
|||||||
gattServer: BluetoothGattServer?,
|
gattServer: BluetoothGattServer?,
|
||||||
characteristic: BluetoothGattCharacteristic?
|
characteristic: BluetoothGattCharacteristic?
|
||||||
) {
|
) {
|
||||||
val packet = routed.packet
|
fragmentingSender.send(routed, "BLE broadcast") { packet ->
|
||||||
val isFile = packet.type == MessageType.FILE_TRANSFER.value
|
broadcastSinglePacket(packet, gattServer, characteristic)
|
||||||
if (isFile) {
|
true
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelTransfer(transferId: String): Boolean {
|
fun cancelTransfer(transferId: String): Boolean {
|
||||||
val job = transferJobs.remove(transferId) ?: return false
|
return fragmentingSender.cancelTransfer(transferId)
|
||||||
job.cancel()
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -215,6 +156,18 @@ class BluetoothPacketBroadcaster(
|
|||||||
targetPeerID: String,
|
targetPeerID: String,
|
||||||
gattServer: BluetoothGattServer?,
|
gattServer: BluetoothGattServer?,
|
||||||
characteristic: BluetoothGattCharacteristic?
|
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 {
|
): Boolean {
|
||||||
val packet = routed.packet
|
val packet = routed.packet
|
||||||
val data = packet.toBinaryData() ?: return false
|
val data = packet.toBinaryData() ?: return false
|
||||||
@@ -222,11 +175,6 @@ class BluetoothPacketBroadcaster(
|
|||||||
if (isFile) {
|
if (isFile) {
|
||||||
Log.d(TAG, "📤 Broadcasting FILE_TRANSFER: ${packet.payload.size} bytes")
|
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 typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString()
|
||||||
val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
|
val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
|
||||||
val incomingAddr = routed.relayAddress
|
val incomingAddr = routed.relayAddress
|
||||||
@@ -241,10 +189,6 @@ class BluetoothPacketBroadcaster(
|
|||||||
if (serverTarget != null) {
|
if (serverTarget != null) {
|
||||||
if (notifyDevice(serverTarget, data, gattServer, characteristic)) {
|
if (notifyDevice(serverTarget, data, gattServer, characteristic)) {
|
||||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, serverTarget.address, packet.ttl, packet.version, routeInfo)
|
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
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,10 +199,6 @@ class BluetoothPacketBroadcaster(
|
|||||||
if (clientTarget != null) {
|
if (clientTarget != null) {
|
||||||
if (writeToDeviceConn(clientTarget, data)) {
|
if (writeToDeviceConn(clientTarget, data)) {
|
||||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, clientTarget.device.address, packet.ttl, packet.version, routeInfo)
|
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
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,12 +206,6 @@ class BluetoothPacketBroadcaster(
|
|||||||
return false
|
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
|
* Public entry point for broadcasting - submits request to actor for serialization
|
||||||
@@ -303,34 +237,19 @@ class BluetoothPacketBroadcaster(
|
|||||||
gattServer: BluetoothGattServer?,
|
gattServer: BluetoothGattServer?,
|
||||||
characteristic: BluetoothGattCharacteristic?
|
characteristic: BluetoothGattCharacteristic?
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val packet = routed.packet
|
if (!hasPeerConnection(targetPeerID)) return false
|
||||||
val data = packet.toBinaryData() ?: return false
|
return fragmentingSender.send(routed, "BLE peer ${targetPeerID.take(8)}") { packet ->
|
||||||
val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString()
|
sendSinglePacketToPeer(packet, targetPeerID, gattServer, characteristic)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Try client-side connections next
|
private fun hasPeerConnection(targetPeerID: String): Boolean {
|
||||||
val targetConn = connectionTracker.getConnectedDevices().values
|
val hasServerTarget = connectionTracker.getSubscribedDevices()
|
||||||
.firstOrNull { connectionTracker.addressPeerMap[it.device.address] == targetPeerID }
|
.any { connectionTracker.addressPeerMap[it.address] == targetPeerID }
|
||||||
if (targetConn != null) {
|
if (hasServerTarget) return true
|
||||||
if (writeToDeviceConn(targetConn, data)) {
|
|
||||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, targetConn.device.address, packet.ttl)
|
return connectionTracker.getConnectedDevices().values
|
||||||
return true
|
.any { connectionTracker.addressPeerMap[it.device.address] == targetPeerID }
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 {
|
companion object {
|
||||||
private const val TAG = "MessageHandler"
|
private const val TAG = "MessageHandler"
|
||||||
|
private const val ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS = 10 * 60 * 1000L
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delegate for callbacks
|
// Delegate for callbacks
|
||||||
@@ -220,12 +221,15 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
|||||||
|
|
||||||
if (peerID == myPeerID) return false
|
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 now = System.currentTimeMillis()
|
||||||
val age = now - packet.timestamp.toLong()
|
val clockSkewMs = kotlin.math.abs(now - packet.timestamp.toLong())
|
||||||
if (age > com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS) {
|
if (clockSkewMs > ANNOUNCE_CLOCK_SKEW_TOLERANCE_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)")
|
Log.w(TAG, "Ignoring ANNOUNCE from ${peerID.take(8)} with excessive clock skew (${clockSkewMs}ms > ${ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS}ms)")
|
||||||
return false
|
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
|
// Try to decode as iOS-compatible IdentityAnnouncement with TLV format
|
||||||
|
|||||||
@@ -110,10 +110,21 @@ class PeerManager {
|
|||||||
isVerified: Boolean
|
isVerified: Boolean
|
||||||
): Boolean {
|
): Boolean {
|
||||||
if (peerID == "unknown") return false
|
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 now = System.currentTimeMillis()
|
||||||
val existingPeer = peers[peerID]
|
val existingPeer = peers[peerID]
|
||||||
val isNewPeer = existingPeer == null
|
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
|
// Update or create peer info
|
||||||
val peerInfo = PeerInfo(
|
val peerInfo = PeerInfo(
|
||||||
@@ -133,18 +144,27 @@ class PeerManager {
|
|||||||
// No legacy maps; peers map is the single source of truth
|
// No legacy maps; peers map is the single source of truth
|
||||||
// Maintain announcedPeers for first-time announce semantics
|
// 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) {
|
if (isNewPeer && isVerified) {
|
||||||
announcedPeers.add(peerID)
|
announcedPeers.add(peerID)
|
||||||
notifyPeerListUpdate()
|
|
||||||
Log.d(TAG, "🆕 New verified peer: $nickname ($peerID)")
|
Log.d(TAG, "🆕 New verified peer: $nickname ($peerID)")
|
||||||
return true
|
|
||||||
} else if (isVerified) {
|
} else if (isVerified) {
|
||||||
Log.d(TAG, "🔄 Updated verified peer: $nickname ($peerID)")
|
Log.d(TAG, "🔄 Updated verified peer: $nickname ($peerID)")
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "⚠️ Unverified peer announcement from: $nickname ($peerID)")
|
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)
|
// MARK: - Legacy Methods (maintained for compatibility)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -414,6 +426,10 @@ class PeerManager {
|
|||||||
val peerList = getActivePeerIDs()
|
val peerList = getActivePeerIDs()
|
||||||
delegate?.onPeerListUpdated(peerList)
|
delegate?.onPeerListUpdated(peerList)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun refreshPeerList() {
|
||||||
|
notifyPeerListUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start periodic cleanup of stale peers
|
* 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() {
|
private fun initializeSessionManager() {
|
||||||
// Create new session manager with current keys
|
// 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
|
// Set up session callbacks
|
||||||
sessionManager.onSessionEstablished = { peerID, remoteStaticKey ->
|
sessionManager.onSessionEstablished = { peerID, remoteStaticKey ->
|
||||||
|
|||||||
@@ -153,6 +153,9 @@ class NoiseSession(
|
|||||||
// Session state
|
// Session state
|
||||||
private var state: NoiseSessionState = NoiseSessionState.Uninitialized
|
private var state: NoiseSessionState = NoiseSessionState.Uninitialized
|
||||||
private val creationTime = System.currentTimeMillis()
|
private val creationTime = System.currentTimeMillis()
|
||||||
|
private var handshakeStartMs: Long? = null
|
||||||
|
private var lastHandshakeActivityMs: Long? = null
|
||||||
|
private var handshakeMessage1: ByteArray? = null
|
||||||
|
|
||||||
// Session counters
|
// Session counters
|
||||||
private var currentPattern = 0;
|
private var currentPattern = 0;
|
||||||
@@ -195,6 +198,15 @@ class NoiseSession(
|
|||||||
fun isEstablished(): Boolean = state is NoiseSessionState.Established
|
fun isEstablished(): Boolean = state is NoiseSessionState.Established
|
||||||
fun isHandshaking(): Boolean = state is NoiseSessionState.Handshaking
|
fun isHandshaking(): Boolean = state is NoiseSessionState.Handshaking
|
||||||
fun getCreationTime(): Long = creationTime
|
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 {
|
init {
|
||||||
try {
|
try {
|
||||||
@@ -317,19 +329,25 @@ class NoiseSession(
|
|||||||
// Initialize handshake as initiator
|
// Initialize handshake as initiator
|
||||||
initializeNoiseHandshake(HandshakeState.INITIATOR)
|
initializeNoiseHandshake(HandshakeState.INITIATOR)
|
||||||
state = NoiseSessionState.Handshaking
|
state = NoiseSessionState.Handshaking
|
||||||
|
if (handshakeStartMs == null) {
|
||||||
|
handshakeStartMs = System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
lastHandshakeActivityMs = System.currentTimeMillis()
|
||||||
|
|
||||||
val messageBuffer = ByteArray(XX_MESSAGE_1_SIZE)
|
val messageBuffer = ByteArray(XX_MESSAGE_1_SIZE)
|
||||||
val handshakeStateLocal = handshakeState ?: throw IllegalStateException("Handshake state is null")
|
val handshakeStateLocal = handshakeState ?: throw IllegalStateException("Handshake state is null")
|
||||||
val messageLength = handshakeStateLocal.writeMessage(messageBuffer, 0, null, 0, 0)
|
val messageLength = handshakeStateLocal.writeMessage(messageBuffer, 0, null, 0, 0)
|
||||||
currentPattern++
|
currentPattern++
|
||||||
val firstMessage = messageBuffer.copyOf(messageLength)
|
val firstMessage = messageBuffer.copyOf(messageLength)
|
||||||
|
handshakeMessage1 = firstMessage
|
||||||
|
|
||||||
// Validate message size matches XX pattern expectations
|
// Validate message size matches XX pattern expectations
|
||||||
if (firstMessage.size != XX_MESSAGE_1_SIZE) {
|
if (firstMessage.size != XX_MESSAGE_1_SIZE) {
|
||||||
Log.w(TAG, "Warning: XX message 1 size ${firstMessage.size} != expected $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
|
return firstMessage
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
state = NoiseSessionState.Failed(e)
|
state = NoiseSessionState.Failed(e)
|
||||||
@@ -344,19 +362,24 @@ class NoiseSession(
|
|||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun processHandshakeMessage(message: ByteArray): ByteArray? {
|
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 {
|
try {
|
||||||
// Initialize as responder if receiving first message
|
// Initialize as responder if receiving first message
|
||||||
if (state == NoiseSessionState.Uninitialized && !isInitiator) {
|
if (state == NoiseSessionState.Uninitialized && !isInitiator) {
|
||||||
initializeNoiseHandshake(HandshakeState.RESPONDER)
|
initializeNoiseHandshake(HandshakeState.RESPONDER)
|
||||||
state = NoiseSessionState.Handshaking
|
state = NoiseSessionState.Handshaking
|
||||||
|
if (handshakeStartMs == null) {
|
||||||
|
handshakeStartMs = System.currentTimeMillis()
|
||||||
|
}
|
||||||
Log.d(TAG, "Initialized as RESPONDER for XX handshake with $peerID")
|
Log.d(TAG, "Initialized as RESPONDER for XX handshake with $peerID")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state != NoiseSessionState.Handshaking) {
|
if (state != NoiseSessionState.Handshaking) {
|
||||||
throw IllegalStateException("Invalid state for handshake: $state")
|
throw IllegalStateException("Invalid state for handshake: $state")
|
||||||
}
|
}
|
||||||
|
lastHandshakeActivityMs = System.currentTimeMillis()
|
||||||
|
|
||||||
val handshakeStateLocal = handshakeState ?: throw IllegalStateException("Handshake state is null")
|
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
|
// Read the incoming message - the Noise library will handle validation
|
||||||
val payloadLength = handshakeStateLocal.readMessage(message, 0, message.size, payloadBuffer, 0)
|
val payloadLength = handshakeStateLocal.readMessage(message, 0, message.size, payloadBuffer, 0)
|
||||||
currentPattern++
|
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
|
// Check what action the handshake state wants us to take next
|
||||||
val action = handshakeStateLocal.getAction()
|
val action = handshakeStateLocal.getAction()
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ import java.util.concurrent.ConcurrentHashMap
|
|||||||
*/
|
*/
|
||||||
class NoiseSessionManager(
|
class NoiseSessionManager(
|
||||||
private val localStaticPrivateKey: ByteArray,
|
private val localStaticPrivateKey: ByteArray,
|
||||||
private val localStaticPublicKey: ByteArray
|
private val localStaticPublicKey: ByteArray,
|
||||||
|
private val localPeerID: String
|
||||||
) {
|
) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "NoiseSessionManager"
|
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>()
|
private val sessions = ConcurrentHashMap<String, NoiseSession>()
|
||||||
@@ -51,11 +54,30 @@ class NoiseSessionManager(
|
|||||||
/**
|
/**
|
||||||
* SIMPLIFIED: Initiate handshake - no tie breaker, just start
|
* SIMPLIFIED: Initiate handshake - no tie breaker, just start
|
||||||
*/
|
*/
|
||||||
fun initiateHandshake(peerID: String): ByteArray {
|
fun initiateHandshake(peerID: String): ByteArray? {
|
||||||
Log.d(TAG, "initiateHandshake($peerID)")
|
Log.d(TAG, "initiateHandshake($peerID)")
|
||||||
|
|
||||||
// Remove any existing session first
|
val now = System.currentTimeMillis()
|
||||||
removeSession(peerID)
|
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
|
// Create new session as initiator
|
||||||
val session = NoiseSession(
|
val session = NoiseSession(
|
||||||
@@ -85,6 +107,23 @@ class NoiseSessionManager(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
var session = getSession(peerID)
|
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 no session exists, create one as responder
|
||||||
if (session == null) {
|
if (session == null) {
|
||||||
@@ -119,6 +158,12 @@ class NoiseSessionManager(
|
|||||||
throw e
|
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
|
* SIMPLIFIED: Encrypt data
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ fun BluetoothCheckScreen(
|
|||||||
status: BluetoothStatus,
|
status: BluetoothStatus,
|
||||||
onEnableBluetooth: () -> Unit,
|
onEnableBluetooth: () -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
|
onSkip: () -> Unit,
|
||||||
isLoading: Boolean = false
|
isLoading: Boolean = false
|
||||||
) {
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
@@ -39,13 +40,15 @@ fun BluetoothCheckScreen(
|
|||||||
BluetoothDisabledContent(
|
BluetoothDisabledContent(
|
||||||
onEnableBluetooth = onEnableBluetooth,
|
onEnableBluetooth = onEnableBluetooth,
|
||||||
onRetry = onRetry,
|
onRetry = onRetry,
|
||||||
|
onSkip = onSkip,
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
isLoading = isLoading
|
isLoading = isLoading
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
BluetoothStatus.NOT_SUPPORTED -> {
|
BluetoothStatus.NOT_SUPPORTED -> {
|
||||||
BluetoothNotSupportedContent(
|
BluetoothNotSupportedContent(
|
||||||
colorScheme = colorScheme
|
colorScheme = colorScheme,
|
||||||
|
onSkip = onSkip
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
BluetoothStatus.ENABLED -> {
|
BluetoothStatus.ENABLED -> {
|
||||||
@@ -61,6 +64,7 @@ fun BluetoothCheckScreen(
|
|||||||
private fun BluetoothDisabledContent(
|
private fun BluetoothDisabledContent(
|
||||||
onEnableBluetooth: () -> Unit,
|
onEnableBluetooth: () -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
|
onSkip: () -> Unit,
|
||||||
colorScheme: ColorScheme,
|
colorScheme: ColorScheme,
|
||||||
isLoading: Boolean
|
isLoading: Boolean
|
||||||
) {
|
) {
|
||||||
@@ -77,7 +81,7 @@ private fun BluetoothDisabledContent(
|
|||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.bluetooth_required),
|
text = stringResource(R.string.bluetooth_recommended),
|
||||||
style = MaterialTheme.typography.headlineSmall.copy(
|
style = MaterialTheme.typography.headlineSmall.copy(
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
@@ -141,20 +145,17 @@ private fun BluetoothDisabledContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
//Since we are automatically checking bluetooth state -- commented
|
TextButton(
|
||||||
|
onClick = onSkip,
|
||||||
// OutlinedButton(
|
modifier = Modifier.fillMaxWidth()
|
||||||
// onClick = onRetry,
|
) {
|
||||||
// modifier = Modifier.fillMaxWidth()
|
Text(
|
||||||
// ) {
|
text = stringResource(R.string.skip),
|
||||||
// Text(
|
style = MaterialTheme.typography.labelLarge.copy(
|
||||||
// text = "Check Again",
|
color = colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
// style = MaterialTheme.typography.bodyMedium.copy(
|
)
|
||||||
// fontFamily = FontFamily.Monospace
|
)
|
||||||
// ),
|
}
|
||||||
// modifier = Modifier.padding(vertical = 4.dp)
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,7 +163,8 @@ private fun BluetoothDisabledContent(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun BluetoothNotSupportedContent(
|
private fun BluetoothNotSupportedContent(
|
||||||
colorScheme: ColorScheme
|
colorScheme: ColorScheme,
|
||||||
|
onSkip: () -> Unit
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
@@ -209,6 +211,16 @@ private fun BluetoothNotSupportedContent(
|
|||||||
textAlign = TextAlign.Center
|
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("BLUETOOTH") -> "Bluetooth/Nearby Devices"
|
||||||
permission.contains("BACKGROUND") -> "Background Location"
|
permission.contains("BACKGROUND") -> "Background Location"
|
||||||
permission.contains("LOCATION") -> "Location (for Bluetooth scanning)"
|
permission.contains("LOCATION") -> "Location (for Bluetooth scanning)"
|
||||||
|
permission.contains("NEARBY_WIFI") -> "Nearby Wi‑Fi Devices (for Wi‑Fi Aware)"
|
||||||
permission.contains("NOTIFICATION") -> "Notifications"
|
permission.contains("NOTIFICATION") -> "Notifications"
|
||||||
else -> permission.substringAfterLast(".")
|
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.Power
|
||||||
import androidx.compose.material.icons.filled.Mic
|
import androidx.compose.material.icons.filled.Mic
|
||||||
import androidx.compose.material.icons.filled.Security
|
import androidx.compose.material.icons.filled.Security
|
||||||
|
import androidx.compose.material.icons.filled.Wifi
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
@@ -218,6 +219,7 @@ private fun getPermissionIcon(permissionType: PermissionType): ImageVector {
|
|||||||
PermissionType.BACKGROUND_LOCATION -> Icons.Filled.LocationOn
|
PermissionType.BACKGROUND_LOCATION -> Icons.Filled.LocationOn
|
||||||
PermissionType.MICROPHONE -> Icons.Filled.Mic
|
PermissionType.MICROPHONE -> Icons.Filled.Mic
|
||||||
PermissionType.NOTIFICATIONS -> Icons.Filled.Notifications
|
PermissionType.NOTIFICATIONS -> Icons.Filled.Notifications
|
||||||
|
PermissionType.WIFI_AWARE -> Icons.Filled.Wifi
|
||||||
PermissionType.BATTERY_OPTIMIZATION -> Icons.Filled.Power
|
PermissionType.BATTERY_OPTIMIZATION -> Icons.Filled.Power
|
||||||
PermissionType.OTHER -> Icons.Filled.Settings
|
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 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
|
* 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
|
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
|
// Notification permission intentionally excluded to keep it optional
|
||||||
|
|
||||||
return permissions
|
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()) {
|
if (needsBackgroundLocationPermission()) {
|
||||||
val backgroundPermission = listOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
|
val backgroundPermission = listOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
|
||||||
categories.add(
|
categories.add(
|
||||||
@@ -308,6 +343,7 @@ enum class PermissionType(val nameValue: String) {
|
|||||||
BACKGROUND_LOCATION("Background Location"),
|
BACKGROUND_LOCATION("Background Location"),
|
||||||
MICROPHONE("Microphone"),
|
MICROPHONE("Microphone"),
|
||||||
NOTIFICATIONS("Notifications"),
|
NOTIFICATIONS("Notifications"),
|
||||||
|
WIFI_AWARE("Wi‑Fi Aware"),
|
||||||
BATTERY_OPTIMIZATION("Battery Optimization"),
|
BATTERY_OPTIMIZATION("Battery Optimization"),
|
||||||
OTHER("Other")
|
OTHER("Other")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,6 +255,10 @@ object BinaryProtocol {
|
|||||||
if (packet.version >= 2u.toUByte()) {
|
if (packet.version >= 2u.toUByte()) {
|
||||||
buffer.putInt(payloadDataSize) // 4 bytes for v2+
|
buffer.putInt(payloadDataSize) // 4 bytes for v2+
|
||||||
} else {
|
} 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
|
buffer.putShort(payloadDataSize.toShort()) // 2 bytes for v1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package com.bitchat.android.service
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.os.Process
|
import android.os.Process
|
||||||
import androidx.core.app.NotificationManagerCompat
|
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.ArtiTorManager
|
||||||
import com.bitchat.android.net.TorMode
|
import com.bitchat.android.net.TorMode
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -39,7 +39,7 @@ object AppShutdownCoordinator {
|
|||||||
|
|
||||||
fun requestFullShutdownAndKill(
|
fun requestFullShutdownAndKill(
|
||||||
app: Application,
|
app: Application,
|
||||||
mesh: BluetoothMeshService?,
|
mesh: MeshService?,
|
||||||
notificationManager: NotificationManagerCompat,
|
notificationManager: NotificationManagerCompat,
|
||||||
stopForeground: () -> Unit,
|
stopForeground: () -> Unit,
|
||||||
stopService: () -> Unit
|
stopService: () -> Unit
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ class MeshForegroundService : Service() {
|
|||||||
private var updateJob: Job? = null
|
private var updateJob: Job? = null
|
||||||
private val meshService: BluetoothMeshService?
|
private val meshService: BluetoothMeshService?
|
||||||
get() = MeshServiceHolder.meshService
|
get() = MeshServiceHolder.meshService
|
||||||
|
private val unifiedMeshService: com.bitchat.android.mesh.MeshService?
|
||||||
|
get() = MeshServiceHolder.unifiedMeshService
|
||||||
private val serviceJob = Job()
|
private val serviceJob = Job()
|
||||||
private val scope = CoroutineScope(Dispatchers.Default + serviceJob)
|
private val scope = CoroutineScope(Dispatchers.Default + serviceJob)
|
||||||
private var isInForeground: Boolean = false
|
private var isInForeground: Boolean = false
|
||||||
@@ -134,6 +136,7 @@ class MeshForegroundService : Service() {
|
|||||||
Log.i("MeshForegroundService", "Created new BluetoothMeshService via holder")
|
Log.i("MeshForegroundService", "Created new BluetoothMeshService via holder")
|
||||||
MeshServiceHolder.attach(created)
|
MeshServiceHolder.attach(created)
|
||||||
}
|
}
|
||||||
|
MeshServiceHolder.getUnifiedOrCreate(applicationContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
@@ -147,7 +150,7 @@ class MeshForegroundService : Service() {
|
|||||||
when (intent?.action) {
|
when (intent?.action) {
|
||||||
ACTION_STOP -> {
|
ACTION_STOP -> {
|
||||||
// Stop FGS and mesh cleanly
|
// Stop FGS and mesh cleanly
|
||||||
try { meshService?.stopServices() } catch (_: Exception) { }
|
try { unifiedMeshService?.stopServices() ?: meshService?.stopServices() } catch (_: Exception) { }
|
||||||
try { MeshServiceHolder.clear() } catch (_: Exception) { }
|
try { MeshServiceHolder.clear() } catch (_: Exception) { }
|
||||||
try { stopForeground(true) } catch (_: Exception) { }
|
try { stopForeground(true) } catch (_: Exception) { }
|
||||||
notificationManager.cancel(NOTIFICATION_ID)
|
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
|
// Fully stop all background activity, stop Tor (without changing setting), then kill the app
|
||||||
AppShutdownCoordinator.requestFullShutdownAndKill(
|
AppShutdownCoordinator.requestFullShutdownAndKill(
|
||||||
app = application,
|
app = application,
|
||||||
mesh = meshService,
|
mesh = unifiedMeshService,
|
||||||
notificationManager = notificationManager,
|
notificationManager = notificationManager,
|
||||||
stopForeground = {
|
stopForeground = {
|
||||||
try { stopForeground(true) } catch (_: Exception) { }
|
try { stopForeground(true) } catch (_: Exception) { }
|
||||||
@@ -178,7 +181,7 @@ class MeshForegroundService : Service() {
|
|||||||
ACTION_UPDATE_NOTIFICATION -> {
|
ACTION_UPDATE_NOTIFICATION -> {
|
||||||
// If we became eligible and are not in foreground yet, promote once
|
// If we became eligible and are not in foreground yet, promote once
|
||||||
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
|
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
|
||||||
val n = buildNotification(meshService?.getActivePeerCount() ?: 0)
|
val n = buildNotification(getUnifiedActivePeerCount())
|
||||||
startForegroundCompat(n)
|
startForegroundCompat(n)
|
||||||
isInForeground = true
|
isInForeground = true
|
||||||
} else {
|
} else {
|
||||||
@@ -193,7 +196,7 @@ class MeshForegroundService : Service() {
|
|||||||
|
|
||||||
// Promote exactly once when eligible, otherwise stay background (or stop)
|
// Promote exactly once when eligible, otherwise stay background (or stop)
|
||||||
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
|
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
|
||||||
val notification = buildNotification(meshService?.getActivePeerCount() ?: 0)
|
val notification = buildNotification(getUnifiedActivePeerCount())
|
||||||
startForegroundCompat(notification)
|
startForegroundCompat(notification)
|
||||||
isInForeground = true
|
isInForeground = true
|
||||||
}
|
}
|
||||||
@@ -226,6 +229,21 @@ class MeshForegroundService : Service() {
|
|||||||
|
|
||||||
private fun ensureMeshStarted() {
|
private fun ensureMeshStarted() {
|
||||||
if (isShuttingDown) return
|
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
|
if (!hasBluetoothPermissions()) return
|
||||||
try {
|
try {
|
||||||
android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started")
|
android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started")
|
||||||
@@ -241,7 +259,7 @@ class MeshForegroundService : Service() {
|
|||||||
notificationManager.cancel(NOTIFICATION_ID)
|
notificationManager.cancel(NOTIFICATION_ID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val count = meshService?.getActivePeerCount() ?: 0
|
val count = getUnifiedActivePeerCount()
|
||||||
val notification = buildNotification(count)
|
val notification = buildNotification(count)
|
||||||
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) {
|
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) {
|
||||||
notificationManager.notify(NOTIFICATION_ID, notification)
|
notificationManager.notify(NOTIFICATION_ID, notification)
|
||||||
@@ -261,6 +279,14 @@ class MeshForegroundService : Service() {
|
|||||||
return hasBluetoothPermissions() && hasNotificationPermission()
|
return hasBluetoothPermissions() && hasNotificationPermission()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getUnifiedActivePeerCount(): Int {
|
||||||
|
return try {
|
||||||
|
unifiedMeshService?.getActivePeerCount() ?: meshService?.getActivePeerCount() ?: 0
|
||||||
|
} catch (_: Exception) {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun hasBluetoothPermissions(): Boolean {
|
private fun hasBluetoothPermissions(): Boolean {
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_ADVERTISE) == android.content.pm.PackageManager.PERMISSION_GRANTED &&
|
androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_ADVERTISE) == android.content.pm.PackageManager.PERMISSION_GRANTED &&
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ package com.bitchat.android.service
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.bitchat.android.mesh.BluetoothMeshService
|
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
|
* Process-wide holder to share a single BluetoothMeshService instance
|
||||||
@@ -9,10 +13,69 @@ import com.bitchat.android.mesh.BluetoothMeshService
|
|||||||
*/
|
*/
|
||||||
object MeshServiceHolder {
|
object MeshServiceHolder {
|
||||||
private const val TAG = "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
|
@Volatile
|
||||||
var meshService: BluetoothMeshService? = null
|
var meshService: BluetoothMeshService? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var unifiedMeshService: UnifiedMeshService? = null
|
||||||
|
private set
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun getOrCreate(context: Context): BluetoothMeshService {
|
fun getOrCreate(context: Context): BluetoothMeshService {
|
||||||
val existing = meshService
|
val existing = meshService
|
||||||
@@ -31,18 +94,35 @@ object MeshServiceHolder {
|
|||||||
val created = BluetoothMeshService(context.applicationContext)
|
val created = BluetoothMeshService(context.applicationContext)
|
||||||
android.util.Log.i(TAG, "Created new BluetoothMeshService (replacement)")
|
android.util.Log.i(TAG, "Created new BluetoothMeshService (replacement)")
|
||||||
meshService = created
|
meshService = created
|
||||||
|
unifiedMeshService = null
|
||||||
created
|
created
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.e(TAG, "Error checking service reusability; creating new instance: ${e.message}")
|
android.util.Log.e(TAG, "Error checking service reusability; creating new instance: ${e.message}")
|
||||||
val created = BluetoothMeshService(context.applicationContext)
|
val created = BluetoothMeshService(context.applicationContext)
|
||||||
meshService = created
|
meshService = created
|
||||||
|
unifiedMeshService = null
|
||||||
created
|
created
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val created = BluetoothMeshService(context.applicationContext)
|
val created = BluetoothMeshService(context.applicationContext)
|
||||||
android.util.Log.i(TAG, "Created new BluetoothMeshService (no existing instance)")
|
android.util.Log.i(TAG, "Created new BluetoothMeshService (no existing instance)")
|
||||||
meshService = created
|
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
|
return created
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,11 +130,16 @@ object MeshServiceHolder {
|
|||||||
fun attach(service: BluetoothMeshService) {
|
fun attach(service: BluetoothMeshService) {
|
||||||
android.util.Log.d(TAG, "Attaching BluetoothMeshService to holder")
|
android.util.Log.d(TAG, "Attaching BluetoothMeshService to holder")
|
||||||
meshService = service
|
meshService = service
|
||||||
|
unifiedMeshService = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun clear() {
|
fun clear() {
|
||||||
android.util.Log.d(TAG, "Clearing BluetoothMeshService from holder")
|
android.util.Log.d(TAG, "Clearing BluetoothMeshService from holder")
|
||||||
|
try { sharedGossipSyncManager?.stop() } catch (_: Exception) { }
|
||||||
|
sharedGossipSyncManager = null
|
||||||
|
activeGossipOwners.clear()
|
||||||
meshService = null
|
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
|
// Global de-dup set by message id to avoid duplicate keys in Compose lists
|
||||||
private val seenMessageIds = mutableSetOf<String>()
|
private val seenMessageIds = mutableSetOf<String>()
|
||||||
private val seenPublicMessageKeys = 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)
|
// Connected peer IDs (mesh ephemeral IDs)
|
||||||
private val _peers = MutableStateFlow<List<String>>(emptyList())
|
private val _peers = MutableStateFlow<List<String>>(emptyList())
|
||||||
val peers: StateFlow<List<String>> = _peers.asStateFlow()
|
val peers: StateFlow<List<String>> = _peers.asStateFlow()
|
||||||
@@ -31,7 +34,55 @@ object AppStateStore {
|
|||||||
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = _channelMessages.asStateFlow()
|
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = _channelMessages.asStateFlow()
|
||||||
|
|
||||||
fun setPeers(ids: List<String>) {
|
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) {
|
fun addPublicMessage(msg: BitchatMessage) {
|
||||||
@@ -104,6 +155,8 @@ object AppStateStore {
|
|||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
seenMessageIds.clear()
|
seenMessageIds.clear()
|
||||||
seenPublicMessageKeys.clear()
|
seenPublicMessageKeys.clear()
|
||||||
|
peerIdsByTransport.clear()
|
||||||
|
directPeerIdsByTransport.clear()
|
||||||
_peers.value = emptyList()
|
_peers.value = emptyList()
|
||||||
_publicMessages.value = emptyList()
|
_publicMessages.value = emptyList()
|
||||||
_privateMessages.value = emptyMap()
|
_privateMessages.value = emptyMap()
|
||||||
|
|||||||
@@ -2,23 +2,23 @@ package com.bitchat.android.services
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
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.model.ReadReceipt
|
||||||
import com.bitchat.android.nostr.NostrTransport
|
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(
|
class MessageRouter private constructor(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private var mesh: BluetoothMeshService,
|
private var mesh: MeshService,
|
||||||
private val nostr: NostrTransport
|
private val nostr: NostrTransport
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "MessageRouter"
|
private const val TAG = "MessageRouter"
|
||||||
@Volatile private var INSTANCE: MessageRouter? = null
|
@Volatile private var INSTANCE: MessageRouter? = null
|
||||||
fun tryGetInstance(): MessageRouter? = INSTANCE
|
fun tryGetInstance(): MessageRouter? = INSTANCE
|
||||||
fun getInstance(context: Context, mesh: BluetoothMeshService): MessageRouter {
|
fun getInstance(context: Context, mesh: MeshService): MessageRouter {
|
||||||
val instance = INSTANCE ?: synchronized(this) {
|
val instance = INSTANCE ?: synchronized(this) {
|
||||||
INSTANCE ?: run {
|
INSTANCE ?: run {
|
||||||
val nostr = NostrTransport.getInstance(context)
|
val nostr = NostrTransport.getInstance(context)
|
||||||
@@ -70,9 +70,8 @@ class MessageRouter private constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val hasMesh = mesh.getPeerInfo(toPeerID)?.isConnected == true
|
val hasMesh = isConnected(mesh, toPeerID)
|
||||||
val hasEstablished = mesh.hasEstablishedSession(toPeerID)
|
if (isReady(mesh, toPeerID)) {
|
||||||
if (hasMesh && hasEstablished) {
|
|
||||||
Log.d(TAG, "Routing PM via mesh to ${toPeerID} msg_id=${messageID.take(8)}…")
|
Log.d(TAG, "Routing PM via mesh to ${toPeerID} msg_id=${messageID.take(8)}…")
|
||||||
mesh.sendPrivateMessage(content, toPeerID, recipientNickname, messageID)
|
mesh.sendPrivateMessage(content, toPeerID, recipientNickname, messageID)
|
||||||
} else if (canSendViaNostr(toPeerID)) {
|
} else if (canSendViaNostr(toPeerID)) {
|
||||||
@@ -83,12 +82,12 @@ class MessageRouter private constructor(
|
|||||||
val q = outbox.getOrPut(toPeerID) { mutableListOf() }
|
val q = outbox.getOrPut(toPeerID) { mutableListOf() }
|
||||||
q.add(Triple(content, recipientNickname, messageID))
|
q.add(Triple(content, recipientNickname, messageID))
|
||||||
Log.d(TAG, "Initiating noise handshake after queueing PM for ${toPeerID.take(8)}…")
|
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) {
|
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)}…")
|
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)
|
mesh.sendReadReceipt(receipt.originalMessageID, toPeerID, mesh.getPeerNicknames()[toPeerID] ?: mesh.myPeerID)
|
||||||
} else {
|
} else {
|
||||||
@@ -113,11 +112,11 @@ class MessageRouter private constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun sendFavoriteNotification(toPeerID: String, isFavorite: Boolean) {
|
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 myNpub = try { com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)?.npub } catch (_: Exception) { null }
|
||||||
val content = if (isFavorite) "[FAVORITED]:${myNpub ?: ""}" else "[UNFAVORITED]:${myNpub ?: ""}"
|
val content = if (isFavorite) "[FAVORITED]:${myNpub ?: ""}" else "[UNFAVORITED]:${myNpub ?: ""}"
|
||||||
val nickname = mesh.getPeerNicknames()[toPeerID] ?: toPeerID
|
val nickname = mesh.getPeerNicknames()[toPeerID] ?: toPeerID
|
||||||
mesh.sendPrivateMessage(content, toPeerID, nickname)
|
mesh.sendPrivateMessage(content, toPeerID, nickname, null)
|
||||||
} else {
|
} else {
|
||||||
nostr.sendFavoriteNotification(toPeerID, isFavorite)
|
nostr.sendFavoriteNotification(toPeerID, isFavorite)
|
||||||
}
|
}
|
||||||
@@ -131,11 +130,11 @@ class MessageRouter private constructor(
|
|||||||
val iterator = queued.iterator()
|
val iterator = queued.iterator()
|
||||||
while (iterator.hasNext()) {
|
while (iterator.hasNext()) {
|
||||||
val (content, nickname, messageID) = iterator.next()
|
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 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]+$"))) {
|
if (!hasMesh && peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
||||||
val meshPeer = resolveMeshPeerForNoiseHex(peerID)
|
val meshPeer = resolvePeerForNoiseHex(peerID, mesh)
|
||||||
if (meshPeer != null && mesh.getPeerInfo(meshPeer)?.isConnected == true && mesh.hasEstablishedSession(meshPeer)) {
|
if (meshPeer != null && isReady(mesh, meshPeer)) {
|
||||||
mesh.sendPrivateMessage(content, meshPeer, nickname, messageID)
|
mesh.sendPrivateMessage(content, meshPeer, nickname, messageID)
|
||||||
iterator.remove()
|
iterator.remove()
|
||||||
continue
|
continue
|
||||||
@@ -182,10 +181,27 @@ class MessageRouter private constructor(
|
|||||||
return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
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 {
|
return try {
|
||||||
mesh.getPeerNicknames().keys.firstOrNull { pid ->
|
service.getPeerInfo(peerID)?.isConnected == true
|
||||||
val info = mesh.getPeerInfo(pid)
|
} 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) }
|
val keyHex = info?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
|
||||||
keyHex != null && keyHex.equals(noiseHex, ignoreCase = true)
|
keyHex != null && keyHex.equals(noiseHex, ignoreCase = true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -444,7 +444,7 @@ private fun MainHeader(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(2.dp))
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
PeerCounter(
|
PeerCounter(
|
||||||
connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID },
|
connectedPeers = connectedPeers.filter { it != viewModel.myPeerID },
|
||||||
joinedChannels = joinedChannels,
|
joinedChannels = joinedChannels,
|
||||||
hasUnreadChannels = hasUnreadChannels,
|
hasUnreadChannels = hasUnreadChannels,
|
||||||
isConnected = isConnected,
|
isConnected = isConnected,
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
|||||||
MessagesList(
|
MessagesList(
|
||||||
messages = displayMessages,
|
messages = displayMessages,
|
||||||
currentUserNickname = nickname,
|
currentUserNickname = nickname,
|
||||||
meshService = viewModel.meshService,
|
meshService = viewModel.meshServiceFacade,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
forceScrollToBottom = forceScrollToBottom,
|
forceScrollToBottom = forceScrollToBottom,
|
||||||
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
|
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.outlined.Shield
|
import androidx.compose.material.icons.outlined.Shield
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import com.bitchat.android.model.BitchatMessage
|
import com.bitchat.android.model.BitchatMessage
|
||||||
import com.bitchat.android.mesh.BluetoothMeshService
|
import com.bitchat.android.mesh.MeshService
|
||||||
import androidx.compose.material3.ColorScheme
|
import androidx.compose.material3.ColorScheme
|
||||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@@ -42,7 +42,7 @@ fun getRSSIColor(rssi: Int): Color {
|
|||||||
fun formatMessageAsAnnotatedString(
|
fun formatMessageAsAnnotatedString(
|
||||||
message: BitchatMessage,
|
message: BitchatMessage,
|
||||||
currentUserNickname: String,
|
currentUserNickname: String,
|
||||||
meshService: BluetoothMeshService,
|
meshService: MeshService,
|
||||||
colorScheme: ColorScheme,
|
colorScheme: ColorScheme,
|
||||||
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||||
): AnnotatedString {
|
): AnnotatedString {
|
||||||
@@ -162,7 +162,7 @@ fun formatMessageAsAnnotatedString(
|
|||||||
fun formatMessageHeaderAnnotatedString(
|
fun formatMessageHeaderAnnotatedString(
|
||||||
message: BitchatMessage,
|
message: BitchatMessage,
|
||||||
currentUserNickname: String,
|
currentUserNickname: String,
|
||||||
meshService: BluetoothMeshService,
|
meshService: MeshService,
|
||||||
colorScheme: ColorScheme,
|
colorScheme: ColorScheme,
|
||||||
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||||
): AnnotatedString {
|
): AnnotatedString {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import com.bitchat.android.mesh.BluetoothMeshDelegate
|
import com.bitchat.android.mesh.BluetoothMeshDelegate
|
||||||
import com.bitchat.android.mesh.BluetoothMeshService
|
import com.bitchat.android.mesh.BluetoothMeshService
|
||||||
|
import com.bitchat.android.mesh.MeshService
|
||||||
import com.bitchat.android.service.MeshServiceHolder
|
import com.bitchat.android.service.MeshServiceHolder
|
||||||
import com.bitchat.android.model.BitchatMessage
|
import com.bitchat.android.model.BitchatMessage
|
||||||
import com.bitchat.android.model.BitchatMessageType
|
import com.bitchat.android.model.BitchatMessageType
|
||||||
@@ -38,12 +39,16 @@ import java.security.MessageDigest
|
|||||||
*/
|
*/
|
||||||
class ChatViewModel(
|
class ChatViewModel(
|
||||||
application: Application,
|
application: Application,
|
||||||
initialMeshService: BluetoothMeshService
|
initialMeshService: BluetoothMeshService,
|
||||||
|
initialUnifiedMeshService: MeshService
|
||||||
) : AndroidViewModel(application), BluetoothMeshDelegate {
|
) : AndroidViewModel(application), BluetoothMeshDelegate {
|
||||||
|
|
||||||
// Made var to support mesh service replacement after panic clear
|
// Made var to support mesh service replacement after panic clear
|
||||||
var meshService: BluetoothMeshService = initialMeshService
|
var meshService: BluetoothMeshService = initialMeshService
|
||||||
private set
|
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 } }
|
private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } }
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -93,9 +98,9 @@ class ChatViewModel(
|
|||||||
|
|
||||||
// Create Noise session delegate for clean dependency injection
|
// Create Noise session delegate for clean dependency injection
|
||||||
private val noiseSessionDelegate = object : NoiseSessionDelegate {
|
private val noiseSessionDelegate = object : NoiseSessionDelegate {
|
||||||
override fun hasEstablishedSession(peerID: String): Boolean = meshService.hasEstablishedSession(peerID)
|
override fun hasEstablishedSession(peerID: String): Boolean = hasEstablishedSessionOnAnyLocalTransport(peerID)
|
||||||
override fun initiateHandshake(peerID: String) = meshService.initiateNoiseHandshake(peerID)
|
override fun initiateHandshake(peerID: String) = initiateNoiseHandshakeOnBestLocalTransport(peerID)
|
||||||
override fun getMyPeerID(): String = meshService.myPeerID
|
override fun getMyPeerID(): String = mesh.myPeerID
|
||||||
}
|
}
|
||||||
|
|
||||||
val privateChatManager = PrivateChatManager(state, messageManager, dataManager, noiseSessionDelegate)
|
val privateChatManager = PrivateChatManager(state, messageManager, dataManager, noiseSessionDelegate)
|
||||||
@@ -109,7 +114,7 @@ class ChatViewModel(
|
|||||||
private val verificationHandler = VerificationHandler(
|
private val verificationHandler = VerificationHandler(
|
||||||
context = application.applicationContext,
|
context = application.applicationContext,
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
getMeshService = { meshService },
|
getMeshService = { mesh },
|
||||||
identityManager = identityManager,
|
identityManager = identityManager,
|
||||||
state = state,
|
state = state,
|
||||||
notificationManager = notificationManager,
|
notificationManager = notificationManager,
|
||||||
@@ -118,7 +123,7 @@ class ChatViewModel(
|
|||||||
val verifiedFingerprints = verificationHandler.verifiedFingerprints
|
val verifiedFingerprints = verificationHandler.verifiedFingerprints
|
||||||
|
|
||||||
// Media file sending manager
|
// 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
|
// Delegate handler for mesh callbacks
|
||||||
private val meshDelegateHandler = MeshDelegateHandler(
|
private val meshDelegateHandler = MeshDelegateHandler(
|
||||||
@@ -129,8 +134,8 @@ class ChatViewModel(
|
|||||||
notificationManager = notificationManager,
|
notificationManager = notificationManager,
|
||||||
coroutineScope = viewModelScope,
|
coroutineScope = viewModelScope,
|
||||||
onHapticFeedback = { ChatViewModelUtils.triggerHapticFeedback(application.applicationContext) },
|
onHapticFeedback = { ChatViewModelUtils.triggerHapticFeedback(application.applicationContext) },
|
||||||
getMyPeerID = { meshService.myPeerID },
|
getMyPeerID = { mesh.myPeerID },
|
||||||
getMeshService = { meshService }
|
getMeshService = { mesh }
|
||||||
)
|
)
|
||||||
|
|
||||||
// New Geohash architecture ViewModel (replaces God object service usage in UI path)
|
// 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 geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
|
||||||
val teleportedGeo: StateFlow<Set<String>> = state.teleportedGeo
|
val teleportedGeo: StateFlow<Set<String>> = state.teleportedGeo
|
||||||
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
|
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 {
|
init {
|
||||||
// Note: Mesh service delegate is now set by MainActivity
|
// 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
|
// Recompute unread set using SeenMessageStore for robustness across Activity recreation
|
||||||
try {
|
try {
|
||||||
val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication())
|
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>()
|
val unread = mutableSetOf<String>()
|
||||||
byPeer.forEach { (peer, list) ->
|
byPeer.forEach { (peer, list) ->
|
||||||
if (list.any { msg -> msg.sender != myNick && !seen.hasRead(msg.id) }) unread.add(peer)
|
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
|
// Ensure NostrTransport knows our mesh peer ID for embedded packets
|
||||||
try {
|
try {
|
||||||
val nostrTransport = com.bitchat.android.nostr.NostrTransport.getInstance(getApplication())
|
val nostrTransport = com.bitchat.android.nostr.NostrTransport.getInstance(getApplication())
|
||||||
nostrTransport.senderPeerID = meshService.myPeerID
|
nostrTransport.senderPeerID = mesh.myPeerID
|
||||||
} catch (_: Exception) { }
|
} catch (_: Exception) { }
|
||||||
|
|
||||||
// Note: Mesh service is now started by MainActivity
|
// Note: Mesh service is now started by MainActivity
|
||||||
@@ -317,7 +334,7 @@ class ChatViewModel(
|
|||||||
fun setNickname(newNickname: String) {
|
fun setNickname(newNickname: String) {
|
||||||
state.setNickname(newNickname)
|
state.setNickname(newNickname)
|
||||||
dataManager.saveNickname(newNickname)
|
dataManager.saveNickname(newNickname)
|
||||||
meshService.sendBroadcastAnnounce()
|
mesh.sendBroadcastAnnounce()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -363,7 +380,7 @@ class ChatViewModel(
|
|||||||
// MARK: - Channel Management (delegated)
|
// MARK: - Channel Management (delegated)
|
||||||
|
|
||||||
fun joinChannel(channel: String, password: String? = null): Boolean {
|
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?) {
|
fun switchToChannel(channel: String?) {
|
||||||
@@ -372,7 +389,7 @@ class ChatViewModel(
|
|||||||
|
|
||||||
fun leaveChannel(channel: String) {
|
fun leaveChannel(channel: String) {
|
||||||
channelManager.leaveChannel(channel)
|
channelManager.leaveChannel(channel)
|
||||||
meshService.sendMessage("left $channel")
|
mesh.sendMessage("left $channel", emptyList(), null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private Chat Management (delegated)
|
// MARK: - Private Chat Management (delegated)
|
||||||
@@ -383,7 +400,7 @@ class ChatViewModel(
|
|||||||
ensureGeohashDMSubscriptionIfNeeded(peerID)
|
ensureGeohashDMSubscriptionIfNeeded(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
val success = privateChatManager.startPrivateChat(peerID, meshService)
|
val success = privateChatManager.startPrivateChat(peerID, mesh)
|
||||||
if (success) {
|
if (success) {
|
||||||
// Notify notification manager about current private chat
|
// Notify notification manager about current private chat
|
||||||
setCurrentPrivateChatPeer(peerID)
|
setCurrentPrivateChatPeer(peerID)
|
||||||
@@ -420,7 +437,7 @@ class ChatViewModel(
|
|||||||
val unreadKeys = state.getUnreadPrivateMessagesValue()
|
val unreadKeys = state.getUnreadPrivateMessagesValue()
|
||||||
if (unreadKeys.isEmpty()) return
|
if (unreadKeys.isEmpty()) return
|
||||||
|
|
||||||
val me = state.getNicknameValue() ?: meshService.myPeerID
|
val me = state.getNicknameValue() ?: mesh.myPeerID
|
||||||
val chats = state.getPrivateChatsValue()
|
val chats = state.getPrivateChatsValue()
|
||||||
|
|
||||||
// Pick the latest incoming message among unread conversations
|
// Pick the latest incoming message among unread conversations
|
||||||
@@ -451,8 +468,8 @@ class ChatViewModel(
|
|||||||
val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
|
val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
|
||||||
selectedPeerID = targetKey,
|
selectedPeerID = targetKey,
|
||||||
connectedPeers = state.getConnectedPeersValue(),
|
connectedPeers = state.getConnectedPeersValue(),
|
||||||
meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey },
|
meshNoiseKeyForPeer = { pid -> mesh.getPeerInfo(pid)?.noisePublicKey },
|
||||||
meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true },
|
meshHasPeer = { pid -> mesh.getPeerInfo(pid)?.isConnected == true },
|
||||||
nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) },
|
nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) },
|
||||||
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
|
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
|
||||||
)
|
)
|
||||||
@@ -476,24 +493,23 @@ class ChatViewModel(
|
|||||||
// Check for commands
|
// Check for commands
|
||||||
if (content.startsWith("/")) {
|
if (content.startsWith("/")) {
|
||||||
val selectedLocationForCommand = state.selectedLocationChannel.value
|
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) {
|
if (selectedLocationForCommand is com.bitchat.android.geohash.ChannelID.Location) {
|
||||||
// Route command-generated public messages via Nostr in geohash channels
|
// Route command-generated public messages via Nostr in geohash channels
|
||||||
geohashViewModel.sendGeohashMessage(
|
geohashViewModel.sendGeohashMessage(
|
||||||
messageContent,
|
messageContent,
|
||||||
selectedLocationForCommand.channel,
|
selectedLocationForCommand.channel,
|
||||||
meshService.myPeerID,
|
mesh.myPeerID,
|
||||||
state.getNicknameValue()
|
state.getNicknameValue()
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Default: route via mesh
|
mesh.sendMessage(messageContent, mentions, channel)
|
||||||
meshService.sendMessage(messageContent, mentions, channel)
|
|
||||||
}
|
}
|
||||||
})
|
}, this)
|
||||||
return
|
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
|
// 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"
|
// 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(
|
selectedPeer = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
|
||||||
selectedPeerID = selectedPeer,
|
selectedPeerID = selectedPeer,
|
||||||
connectedPeers = state.getConnectedPeersValue(),
|
connectedPeers = state.getConnectedPeersValue(),
|
||||||
meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey },
|
meshNoiseKeyForPeer = { pid -> mesh.getPeerInfo(pid)?.noisePublicKey },
|
||||||
meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true },
|
meshHasPeer = { pid -> mesh.getPeerInfo(pid)?.isConnected == true },
|
||||||
nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) },
|
nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) },
|
||||||
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
|
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
|
||||||
).also { canonical ->
|
).also { canonical ->
|
||||||
if (canonical != state.getSelectedPrivateChatPeerValue()) {
|
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 we're in the private chat sheet, update its active peer too
|
||||||
if (state.getPrivateChatSheetPeerValue() != null) {
|
if (state.getPrivateChatSheetPeerValue() != null) {
|
||||||
showPrivateChatSheet(canonical)
|
showPrivateChatSheet(canonical)
|
||||||
@@ -519,16 +535,16 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Send private message
|
// Send private message
|
||||||
val recipientNickname = meshService.getPeerNicknames()[selectedPeer]
|
val recipientNickname = nicknameForPeer(selectedPeer)
|
||||||
privateChatManager.sendPrivateMessage(
|
privateChatManager.sendPrivateMessage(
|
||||||
content,
|
content,
|
||||||
selectedPeer,
|
selectedPeer,
|
||||||
recipientNickname,
|
recipientNickname,
|
||||||
state.getNicknameValue(),
|
state.getNicknameValue(),
|
||||||
meshService.myPeerID
|
mesh.myPeerID
|
||||||
) { messageContent, peerID, recipientNicknameParam, messageId ->
|
) { messageContent, peerID, recipientNicknameParam, messageId ->
|
||||||
// Route via MessageRouter (mesh when connected+established, else Nostr)
|
// 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)
|
router.sendPrivate(messageContent, peerID, recipientNicknameParam, messageId)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -536,21 +552,21 @@ class ChatViewModel(
|
|||||||
val selectedLocationChannel = state.selectedLocationChannel.value
|
val selectedLocationChannel = state.selectedLocationChannel.value
|
||||||
if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) {
|
if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) {
|
||||||
// Send to geohash channel via Nostr ephemeral event
|
// 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 {
|
} else {
|
||||||
// Send public/channel message via mesh
|
// Send public/channel message via mesh
|
||||||
val message = BitchatMessage(
|
val message = BitchatMessage(
|
||||||
sender = state.getNicknameValue() ?: meshService.myPeerID,
|
sender = state.getNicknameValue() ?: mesh.myPeerID,
|
||||||
content = content,
|
content = content,
|
||||||
timestamp = Date(),
|
timestamp = Date(),
|
||||||
isRelay = false,
|
isRelay = false,
|
||||||
senderPeerID = meshService.myPeerID,
|
senderPeerID = mesh.myPeerID,
|
||||||
mentions = if (mentions.isNotEmpty()) mentions else null,
|
mentions = if (mentions.isNotEmpty()) mentions else null,
|
||||||
channel = currentChannelValue
|
channel = currentChannelValue
|
||||||
)
|
)
|
||||||
|
|
||||||
if (currentChannelValue != null) {
|
if (currentChannelValue != null) {
|
||||||
channelManager.addChannelMessage(currentChannelValue, message, meshService.myPeerID)
|
channelManager.addChannelMessage(currentChannelValue, message, mesh.myPeerID)
|
||||||
|
|
||||||
// Check if encrypted channel
|
// Check if encrypted channel
|
||||||
if (channelManager.hasChannelKey(currentChannelValue)) {
|
if (channelManager.hasChannelKey(currentChannelValue)) {
|
||||||
@@ -559,21 +575,20 @@ class ChatViewModel(
|
|||||||
mentions,
|
mentions,
|
||||||
currentChannelValue,
|
currentChannelValue,
|
||||||
state.getNicknameValue(),
|
state.getNicknameValue(),
|
||||||
meshService.myPeerID,
|
mesh.myPeerID,
|
||||||
onEncryptedPayload = { encryptedData ->
|
onEncryptedPayload = { encryptedData ->
|
||||||
// This would need proper mesh service integration
|
mesh.sendMessage(content, mentions, currentChannelValue)
|
||||||
meshService.sendMessage(content, mentions, currentChannelValue)
|
|
||||||
},
|
},
|
||||||
onFallback = {
|
onFallback = {
|
||||||
meshService.sendMessage(content, mentions, currentChannelValue)
|
mesh.sendMessage(content, mentions, currentChannelValue)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
meshService.sendMessage(content, mentions, currentChannelValue)
|
mesh.sendMessage(content, mentions, currentChannelValue)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
messageManager.addMessage(message)
|
messageManager.addMessage(message)
|
||||||
meshService.sendMessage(content, mentions, null)
|
mesh.sendMessage(content, mentions, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -582,7 +597,7 @@ class ChatViewModel(
|
|||||||
// MARK: - Utility Functions
|
// MARK: - Utility Functions
|
||||||
|
|
||||||
fun getPeerIDForNickname(nickname: String): String? {
|
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) {
|
fun toggleFavorite(peerID: String) {
|
||||||
@@ -592,10 +607,10 @@ class ChatViewModel(
|
|||||||
// Persist relationship in FavoritesPersistenceService
|
// Persist relationship in FavoritesPersistenceService
|
||||||
try {
|
try {
|
||||||
var noiseKey: ByteArray? = null
|
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
|
// Case 1: Live mesh peer with known info
|
||||||
val peerInfo = meshService.getPeerInfo(peerID)
|
val peerInfo = mesh.getPeerInfo(peerID)
|
||||||
if (peerInfo?.noisePublicKey != null) {
|
if (peerInfo?.noisePublicKey != null) {
|
||||||
noiseKey = peerInfo.noisePublicKey
|
noiseKey = peerInfo.noisePublicKey
|
||||||
nickname = peerInfo.nickname
|
nickname = peerInfo.nickname
|
||||||
@@ -625,22 +640,9 @@ class ChatViewModel(
|
|||||||
|
|
||||||
// Send favorite notification via mesh or Nostr with our npub if available
|
// Send favorite notification via mesh or Nostr with our npub if available
|
||||||
try {
|
try {
|
||||||
val myNostr = com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(getApplication())
|
com.bitchat.android.services.MessageRouter
|
||||||
val announcementContent = if (isNowFavorite) "[FAVORITED]:${myNostr?.npub ?: ""}" else "[UNFAVORITED]:${myNostr?.npub ?: ""}"
|
.getInstance(getApplication(), mesh)
|
||||||
// Prefer mesh if session established, else try Nostr
|
.sendFavoriteNotification(peerID, isNowFavorite)
|
||||||
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)
|
|
||||||
}
|
|
||||||
} catch (_: Exception) { }
|
} catch (_: Exception) { }
|
||||||
}
|
}
|
||||||
} catch (_: Exception) { }
|
} catch (_: Exception) { }
|
||||||
@@ -656,6 +658,40 @@ class ChatViewModel(
|
|||||||
Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}")
|
Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}")
|
||||||
Log.i("ChatViewModel", "==============================")
|
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
|
* Initialize session state monitoring for reactive UI updates
|
||||||
@@ -680,7 +716,7 @@ class ChatViewModel(
|
|||||||
// Update session states
|
// Update session states
|
||||||
val prevStates = state.getPeerSessionStatesValue()
|
val prevStates = state.getPeerSessionStatesValue()
|
||||||
val sessionStates = currentPeers.associateWith { peerID ->
|
val sessionStates = currentPeers.associateWith { peerID ->
|
||||||
meshService.getSessionState(peerID).toString()
|
sessionStateForPeer(peerID).toString()
|
||||||
}
|
}
|
||||||
state.setPeerSessionStates(sessionStates)
|
state.setPeerSessionStates(sessionStates)
|
||||||
// Detect new established sessions and flush router outbox for them and their noiseHex aliases
|
// Detect new established sessions and flush router outbox for them and their noiseHex aliases
|
||||||
@@ -688,7 +724,7 @@ class ChatViewModel(
|
|||||||
val old = prevStates[peerID]
|
val old = prevStates[peerID]
|
||||||
if (old != "established" && newState == "established") {
|
if (old != "established" && newState == "established") {
|
||||||
com.bitchat.android.services.MessageRouter
|
com.bitchat.android.services.MessageRouter
|
||||||
.getInstance(getApplication(), meshService)
|
.getInstance(getApplication(), mesh)
|
||||||
.onSessionEstablished(peerID)
|
.onSessionEstablished(peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -697,7 +733,7 @@ class ChatViewModel(
|
|||||||
state.setPeerFingerprints(fingerprints)
|
state.setPeerFingerprints(fingerprints)
|
||||||
fingerprints.forEach { (peerID, fingerprint) ->
|
fingerprints.forEach { (peerID, fingerprint) ->
|
||||||
identityManager.cachePeerFingerprint(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()
|
val noiseKeyHex = info?.noisePublicKey?.hexEncodedString()
|
||||||
if (noiseKeyHex != null) {
|
if (noiseKeyHex != null) {
|
||||||
identityManager.cachePeerNoiseKey(peerID, noiseKeyHex)
|
identityManager.cachePeerNoiseKey(peerID, noiseKeyHex)
|
||||||
@@ -708,23 +744,21 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val nicknames = meshService.getPeerNicknames()
|
state.setPeerNicknames(mesh.getPeerNicknames())
|
||||||
state.setPeerNicknames(nicknames)
|
|
||||||
|
|
||||||
val rssiValues = meshService.getPeerRSSI()
|
state.setPeerRSSI(mesh.getPeerRSSI())
|
||||||
state.setPeerRSSI(rssiValues)
|
|
||||||
|
|
||||||
// Update directness per peer (driven by PeerManager state)
|
// Update directness per peer (driven by PeerManager state)
|
||||||
try {
|
try {
|
||||||
val directMap = state.getConnectedPeersValue().associateWith { pid ->
|
val directMap = state.getConnectedPeersValue().associateWith { pid ->
|
||||||
meshService.getPeerInfo(pid)?.isDirectConnection == true
|
mesh.getPeerInfo(pid)?.isDirectConnection == true
|
||||||
}
|
}
|
||||||
state.setPeerDirect(directMap)
|
state.setPeerDirect(directMap)
|
||||||
} catch (_: Exception) { }
|
} catch (_: Exception) { }
|
||||||
|
|
||||||
// Flush any pending QR verification once a Noise session is established
|
// Flush any pending QR verification once a Noise session is established
|
||||||
currentPeers.forEach { peerID ->
|
currentPeers.forEach { peerID ->
|
||||||
if (meshService.getSessionState(peerID) is NoiseSession.NoiseSessionState.Established) {
|
if (sessionStateForPeer(peerID) is NoiseSession.NoiseSessionState.Established) {
|
||||||
verificationHandler.sendPendingVerificationIfNeeded(peerID)
|
verificationHandler.sendPendingVerificationIfNeeded(peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -754,7 +788,7 @@ class ChatViewModel(
|
|||||||
// MARK: - Debug and Troubleshooting
|
// MARK: - Debug and Troubleshooting
|
||||||
|
|
||||||
fun getDebugStatus(): String {
|
fun getDebugStatus(): String {
|
||||||
return meshService.getDebugStatus()
|
return mesh.getDebugStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setCurrentPrivateChatPeer(peerID: String?) {
|
fun setCurrentPrivateChatPeer(peerID: String?) {
|
||||||
@@ -851,7 +885,7 @@ class ChatViewModel(
|
|||||||
// MARK: - Mention Autocomplete
|
// MARK: - Mention Autocomplete
|
||||||
|
|
||||||
fun updateMentionSuggestions(input: String) {
|
fun updateMentionSuggestions(input: String) {
|
||||||
commandProcessor.updateMentionSuggestions(input, meshService, this)
|
commandProcessor.updateMentionSuggestions(input, mesh, this)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectMentionSuggestion(nickname: String, currentText: String): String {
|
fun selectMentionSuggestion(nickname: String, currentText: String): String {
|
||||||
@@ -867,7 +901,7 @@ class ChatViewModel(
|
|||||||
override fun didUpdatePeerList(peers: List<String>) {
|
override fun didUpdatePeerList(peers: List<String>) {
|
||||||
meshDelegateHandler.didUpdatePeerList(peers)
|
meshDelegateHandler.didUpdatePeerList(peers)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun didReceiveChannelLeave(channel: String, fromPeer: String) {
|
override fun didReceiveChannelLeave(channel: String, fromPeer: String) {
|
||||||
meshDelegateHandler.didReceiveChannelLeave(channel, fromPeer)
|
meshDelegateHandler.didReceiveChannelLeave(channel, fromPeer)
|
||||||
}
|
}
|
||||||
@@ -956,7 +990,7 @@ class ChatViewModel(
|
|||||||
// Recreate mesh service with fresh identity
|
// Recreate mesh service with fresh identity
|
||||||
recreateMeshServiceAfterPanic()
|
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.
|
* This ensures the new cryptographic keys are used for a new peer ID.
|
||||||
*/
|
*/
|
||||||
private fun recreateMeshServiceAfterPanic() {
|
private fun recreateMeshServiceAfterPanic() {
|
||||||
val oldPeerID = meshService.myPeerID
|
val oldPeerID = mesh.myPeerID
|
||||||
|
|
||||||
// Clear the holder so getOrCreate() returns a fresh instance
|
// Clear the holder so getOrCreate() returns a fresh instance
|
||||||
MeshServiceHolder.clear()
|
MeshServiceHolder.clear()
|
||||||
|
|
||||||
// Create fresh mesh service with new identity (keys were regenerated in clearAllCryptographicData)
|
// Create fresh mesh service with new identity (keys were regenerated in clearAllCryptographicData)
|
||||||
val freshMeshService = MeshServiceHolder.getOrCreate(getApplication())
|
val freshMeshService = MeshServiceHolder.getOrCreate(getApplication())
|
||||||
|
val freshUnifiedMeshService = MeshServiceHolder.getUnifiedOrCreate(getApplication())
|
||||||
|
|
||||||
// Replace our reference and set up the new service
|
// Replace our reference and set up the new service
|
||||||
meshService = freshMeshService
|
meshService = freshMeshService
|
||||||
meshService.delegate = this
|
unifiedMeshService = freshUnifiedMeshService
|
||||||
|
mesh.delegate = this
|
||||||
|
|
||||||
// Restart mesh operations with new identity
|
// Restart mesh operations with new identity
|
||||||
meshService.startServices()
|
mesh.startServices()
|
||||||
meshService.sendBroadcastAnnounce()
|
mesh.sendBroadcastAnnounce()
|
||||||
|
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
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() {
|
private fun clearAllMeshServiceData() {
|
||||||
try {
|
try {
|
||||||
// Request mesh service to clear all its internal data
|
// Request mesh service to clear all its internal data
|
||||||
meshService.clearAllInternalData()
|
mesh.clearAllInternalData()
|
||||||
|
|
||||||
Log.d(TAG, "✅ Cleared all mesh service data")
|
Log.d(TAG, "✅ Cleared all mesh service data")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -1006,7 +1042,7 @@ class ChatViewModel(
|
|||||||
private fun clearAllCryptographicData() {
|
private fun clearAllCryptographicData() {
|
||||||
try {
|
try {
|
||||||
// Clear encryption service persistent identity (Ed25519 signing keys)
|
// Clear encryption service persistent identity (Ed25519 signing keys)
|
||||||
meshService.clearAllEncryptionData()
|
mesh.clearAllEncryptionData()
|
||||||
|
|
||||||
// Clear secure identity state (if used)
|
// Clear secure identity state (if used)
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.bitchat.android.ui
|
package com.bitchat.android.ui
|
||||||
|
|
||||||
import com.bitchat.android.mesh.BluetoothMeshService
|
import com.bitchat.android.mesh.MeshService
|
||||||
import com.bitchat.android.model.BitchatMessage
|
import com.bitchat.android.model.BitchatMessage
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
@@ -29,21 +29,21 @@ class CommandProcessor(
|
|||||||
|
|
||||||
// MARK: - Command Processing
|
// 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
|
if (!command.startsWith("/")) return false
|
||||||
|
|
||||||
val parts = command.split(" ")
|
val parts = command.split(" ")
|
||||||
val cmd = parts.first().lowercase()
|
val cmd = parts.first().lowercase()
|
||||||
when (cmd) {
|
when (cmd) {
|
||||||
"/j", "/join" -> handleJoinCommand(parts, myPeerID)
|
"/j", "/join" -> handleJoinCommand(parts, myPeerID)
|
||||||
"/m", "/msg" -> handleMessageCommand(parts, meshService)
|
"/m", "/msg" -> handleMessageCommand(parts, meshService, viewModel)
|
||||||
"/w" -> handleWhoCommand(meshService, viewModel)
|
"/w" -> handleWhoCommand(meshService, viewModel)
|
||||||
"/clear" -> handleClearCommand()
|
"/clear" -> handleClearCommand()
|
||||||
"/pass" -> handlePassCommand(parts, myPeerID)
|
"/pass" -> handlePassCommand(parts, myPeerID)
|
||||||
"/block" -> handleBlockCommand(parts, meshService)
|
"/block" -> handleBlockCommand(parts, meshService)
|
||||||
"/unblock" -> handleUnblockCommand(parts, meshService)
|
"/unblock" -> handleUnblockCommand(parts, meshService)
|
||||||
"/hug" -> handleActionCommand(parts, "gives", "a warm hug 🫂", 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)
|
"/slap" -> handleActionCommand(parts, "slaps", "around a bit with a large trout 🐟", meshService, myPeerID, onSendMessage, viewModel)
|
||||||
"/channels" -> handleChannelsCommand()
|
"/channels" -> handleChannelsCommand()
|
||||||
else -> handleUnknownCommand(cmd)
|
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) {
|
if (parts.size > 1) {
|
||||||
val targetName = parts[1].removePrefix("@")
|
val targetName = parts[1].removePrefix("@")
|
||||||
val peerID = getPeerIDForNickname(targetName, meshService)
|
val peerID = getPeerIDForNickname(targetName, meshService)
|
||||||
@@ -96,8 +96,7 @@ class CommandProcessor(
|
|||||||
state.getNicknameValue(),
|
state.getNicknameValue(),
|
||||||
getMyPeerID(meshService)
|
getMyPeerID(meshService)
|
||||||
) { content, peerIdParam, recipientNicknameParam, messageId ->
|
) { content, peerIdParam, recipientNicknameParam, messageId ->
|
||||||
// This would trigger the actual mesh service send
|
sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId, viewModel)
|
||||||
sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val systemMessage = BitchatMessage(
|
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)
|
// Channel-aware who command (matches iOS behavior)
|
||||||
val (peerList, contextDescription) = if (viewModel != null) {
|
val (peerList, contextDescription) = if (viewModel != null) {
|
||||||
when (val selectedChannel = viewModel.selectedLocationChannel.value) {
|
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) {
|
if (parts.size > 1) {
|
||||||
val targetName = parts[1].removePrefix("@")
|
val targetName = parts[1].removePrefix("@")
|
||||||
privateChatManager.blockPeerByNickname(targetName, meshService)
|
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) {
|
if (parts.size > 1) {
|
||||||
val targetName = parts[1].removePrefix("@")
|
val targetName = parts[1].removePrefix("@")
|
||||||
privateChatManager.unblockPeerByNickname(targetName, meshService)
|
privateChatManager.unblockPeerByNickname(targetName, meshService)
|
||||||
@@ -284,9 +283,10 @@ class CommandProcessor(
|
|||||||
parts: List<String>,
|
parts: List<String>,
|
||||||
verb: String,
|
verb: String,
|
||||||
object_: String,
|
object_: String,
|
||||||
meshService: BluetoothMeshService,
|
meshService: MeshService,
|
||||||
myPeerID: String,
|
myPeerID: String,
|
||||||
onSendMessage: (String, List<String>, String?) -> Unit
|
onSendMessage: (String, List<String>, String?) -> Unit,
|
||||||
|
viewModel: ChatViewModel?
|
||||||
) {
|
) {
|
||||||
if (parts.size > 1) {
|
if (parts.size > 1) {
|
||||||
val targetName = parts[1].removePrefix("@")
|
val targetName = parts[1].removePrefix("@")
|
||||||
@@ -306,7 +306,7 @@ class CommandProcessor(
|
|||||||
state.getNicknameValue(),
|
state.getNicknameValue(),
|
||||||
myPeerID
|
myPeerID
|
||||||
) { content, peerIdParam, recipientNicknameParam, messageId ->
|
) { content, peerIdParam, recipientNicknameParam, messageId ->
|
||||||
sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId)
|
sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId, viewModel)
|
||||||
}
|
}
|
||||||
} else if (isInLocationChannel) {
|
} else if (isInLocationChannel) {
|
||||||
// Let the transport layer add the echo; just send it out
|
// Let the transport layer add the echo; just send it out
|
||||||
@@ -423,7 +423,7 @@ class CommandProcessor(
|
|||||||
|
|
||||||
// MARK: - Mention Autocomplete
|
// 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
|
// Check if input contains @ and we're at the end of a word or at the end of input
|
||||||
val atIndex = input.lastIndexOf('@')
|
val atIndex = input.lastIndexOf('@')
|
||||||
if (atIndex == -1) {
|
if (atIndex == -1) {
|
||||||
@@ -503,19 +503,33 @@ class CommandProcessor(
|
|||||||
|
|
||||||
// MARK: - Utility Functions
|
// 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
|
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
|
return meshService.getPeerNicknames()[peerID]
|
||||||
|
?: peerID
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getMyPeerID(meshService: BluetoothMeshService): String {
|
private fun getMyPeerID(meshService: MeshService): String {
|
||||||
return meshService.myPeerID
|
return meshService.myPeerID
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sendPrivateMessageVia(meshService: BluetoothMeshService, content: String, peerID: String, recipientNickname: String, messageId: String) {
|
private fun sendPrivateMessageVia(
|
||||||
meshService.sendPrivateMessage(content, peerID, recipientNickname, messageId)
|
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 {
|
private fun meshCount(viewModel: ChatViewModel): Int {
|
||||||
val myID = viewModel.meshService.myPeerID
|
val myID = viewModel.myPeerID
|
||||||
return viewModel.connectedPeers.value?.count { peerID ->
|
return viewModel.connectedPeers.value?.count { peerID ->
|
||||||
peerID != myID
|
peerID != myID
|
||||||
} ?: 0
|
} ?: 0
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ fun MessageWithMatrixAnimation(
|
|||||||
message: com.bitchat.android.model.BitchatMessage,
|
message: com.bitchat.android.model.BitchatMessage,
|
||||||
messages: List<com.bitchat.android.model.BitchatMessage> = emptyList(),
|
messages: List<com.bitchat.android.model.BitchatMessage> = emptyList(),
|
||||||
currentUserNickname: String,
|
currentUserNickname: String,
|
||||||
meshService: com.bitchat.android.mesh.BluetoothMeshService,
|
meshService: com.bitchat.android.mesh.MeshService,
|
||||||
colorScheme: androidx.compose.material3.ColorScheme,
|
colorScheme: androidx.compose.material3.ColorScheme,
|
||||||
timeFormatter: java.text.SimpleDateFormat,
|
timeFormatter: java.text.SimpleDateFormat,
|
||||||
onNicknameClick: ((String) -> Unit)?,
|
onNicknameClick: ((String) -> Unit)?,
|
||||||
@@ -124,7 +124,7 @@ fun MessageWithMatrixAnimation(
|
|||||||
private fun AnimatedMessageDisplay(
|
private fun AnimatedMessageDisplay(
|
||||||
message: com.bitchat.android.model.BitchatMessage,
|
message: com.bitchat.android.model.BitchatMessage,
|
||||||
currentUserNickname: String,
|
currentUserNickname: String,
|
||||||
meshService: com.bitchat.android.mesh.BluetoothMeshService,
|
meshService: com.bitchat.android.mesh.MeshService,
|
||||||
colorScheme: androidx.compose.material3.ColorScheme,
|
colorScheme: androidx.compose.material3.ColorScheme,
|
||||||
timeFormatter: java.text.SimpleDateFormat,
|
timeFormatter: java.text.SimpleDateFormat,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
@@ -241,7 +241,7 @@ private fun AnimatedMessageDisplay(
|
|||||||
private fun formatMessageAsAnnotatedStringWithoutTimestamp(
|
private fun formatMessageAsAnnotatedStringWithoutTimestamp(
|
||||||
message: com.bitchat.android.model.BitchatMessage,
|
message: com.bitchat.android.model.BitchatMessage,
|
||||||
currentUserNickname: String,
|
currentUserNickname: String,
|
||||||
meshService: com.bitchat.android.mesh.BluetoothMeshService,
|
meshService: com.bitchat.android.mesh.MeshService,
|
||||||
colorScheme: androidx.compose.material3.ColorScheme
|
colorScheme: androidx.compose.material3.ColorScheme
|
||||||
): AnnotatedString {
|
): AnnotatedString {
|
||||||
// Get the full formatted text first
|
// Get the full formatted text first
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.bitchat.android.ui
|
package com.bitchat.android.ui
|
||||||
|
|
||||||
import android.util.Log
|
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.BitchatFilePacket
|
||||||
import com.bitchat.android.model.BitchatMessage
|
import com.bitchat.android.model.BitchatMessage
|
||||||
import com.bitchat.android.model.BitchatMessageType
|
import com.bitchat.android.model.BitchatMessageType
|
||||||
@@ -16,10 +16,10 @@ class MediaSendingManager(
|
|||||||
private val state: ChatState,
|
private val state: ChatState,
|
||||||
private val messageManager: MessageManager,
|
private val messageManager: MessageManager,
|
||||||
private val channelManager: ChannelManager,
|
private val channelManager: ChannelManager,
|
||||||
private val getMeshService: () -> BluetoothMeshService
|
private val getMeshService: () -> MeshService
|
||||||
) {
|
) {
|
||||||
// Helper to get current mesh service (may change after panic clear)
|
// Helper to get current mesh service (may change after panic clear)
|
||||||
private val meshService: BluetoothMeshService
|
private val meshService: MeshService
|
||||||
get() = getMeshService()
|
get() = getMeshService()
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "MediaSendingManager"
|
private const val TAG = "MediaSendingManager"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package com.bitchat.android.ui
|
|||||||
|
|
||||||
import com.bitchat.android.mesh.BluetoothMeshDelegate
|
import com.bitchat.android.mesh.BluetoothMeshDelegate
|
||||||
import com.bitchat.android.ui.NotificationTextUtils
|
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.BitchatMessage
|
||||||
import com.bitchat.android.model.DeliveryStatus
|
import com.bitchat.android.model.DeliveryStatus
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -21,7 +21,7 @@ class MeshDelegateHandler(
|
|||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
private val onHapticFeedback: () -> Unit,
|
private val onHapticFeedback: () -> Unit,
|
||||||
private val getMyPeerID: () -> String,
|
private val getMyPeerID: () -> String,
|
||||||
private val getMeshService: () -> BluetoothMeshService
|
private val getMeshService: () -> MeshService
|
||||||
) : BluetoothMeshDelegate {
|
) : BluetoothMeshDelegate {
|
||||||
|
|
||||||
override fun didReceiveMessage(message: BitchatMessage) {
|
override fun didReceiveMessage(message: BitchatMessage) {
|
||||||
@@ -96,99 +96,104 @@ class MeshDelegateHandler(
|
|||||||
|
|
||||||
override fun didUpdatePeerList(peers: List<String>) {
|
override fun didUpdatePeerList(peers: List<String>) {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
state.setConnectedPeers(peers)
|
processPeerUpdate(peers.distinct())
|
||||||
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) }
|
|
||||||
|
|
||||||
// Clean up channel members who disconnected
|
private suspend fun processPeerUpdate(mergedPeers: List<String>) {
|
||||||
channelManager.cleanupDisconnectedMembers(peers, getMyPeerID())
|
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
|
// Clean up channel members who disconnected
|
||||||
state.getSelectedPrivateChatPeerValue()?.let { currentPeer ->
|
channelManager.cleanupDisconnectedMembers(mergedPeers, getMyPeerID())
|
||||||
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) {
|
// Handle chat view migration based on current selection and new peer list
|
||||||
// Reverse case: Nostr/offline chat is open, and peer may have come online on mesh.
|
state.getSelectedPrivateChatPeerValue()?.let { currentPeer ->
|
||||||
// Resolve canonical target (prefer connected mesh peer if available)
|
val isNostrAlias = currentPeer.startsWith("nostr_")
|
||||||
val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
|
val isNoiseHex = currentPeer.length == 64 && currentPeer.matches(Regex("^[0-9a-fA-F]+$"))
|
||||||
selectedPeerID = currentPeer,
|
val isMeshEphemeral = currentPeer.length == 16 && currentPeer.matches(Regex("^[0-9a-fA-F]+$"))
|
||||||
connectedPeers = peers,
|
|
||||||
meshNoiseKeyForPeer = { pid -> getPeerInfo(pid)?.noisePublicKey },
|
if (isNostrAlias || isNoiseHex) {
|
||||||
meshHasPeer = { pid -> peers.contains(pid) },
|
// Reverse case: Nostr/offline chat is open, and peer may have come online on mesh.
|
||||||
nostrPubHexForAlias = { alias ->
|
// Resolve canonical target (prefer connected mesh peer if available)
|
||||||
// Use GeohashAliasRegistry for geohash aliases, but for mesh favorites, derive from favorites mapping
|
val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
|
||||||
if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(alias)) {
|
selectedPeerID = currentPeer,
|
||||||
com.bitchat.android.nostr.GeohashAliasRegistry.get(alias)
|
connectedPeers = mergedPeers,
|
||||||
} else {
|
meshNoiseKeyForPeer = { pid -> getPeerInfo(pid)?.noisePublicKey },
|
||||||
// Best-effort: derive pub hex from favorites mapping for mesh nostr_ aliases
|
meshHasPeer = { pid -> mergedPeers.contains(pid) },
|
||||||
val prefix = alias.removePrefix("nostr_")
|
nostrPubHexForAlias = { alias ->
|
||||||
val favs = try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() } catch (_: Exception) { emptyList() }
|
// Use GeohashAliasRegistry for geohash aliases, but for mesh favorites, derive from favorites mapping
|
||||||
favs.firstNotNullOfOrNull { rel ->
|
if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(alias)) {
|
||||||
rel.peerNostrPublicKey?.let { s ->
|
com.bitchat.android.nostr.GeohashAliasRegistry.get(alias)
|
||||||
runCatching { com.bitchat.android.nostr.Bech32.decode(s) }.getOrNull()?.let { dec ->
|
} else {
|
||||||
if (dec.first == "npub") dec.second.joinToString("") { b -> "%02x".format(b) } else null
|
// 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) }
|
}
|
||||||
}
|
}?.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)
|
|
||||||
}
|
}
|
||||||
} 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
|
// 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.
|
// (noiseHex or nostr_<pub16>) into the connected peer's chat so there is only one chat per identity.
|
||||||
peers.forEach { pid ->
|
mergedPeers.forEach { pid ->
|
||||||
try {
|
try {
|
||||||
val info = getPeerInfo(pid)
|
val info = getPeerInfo(pid)
|
||||||
val noiseKey = info?.noisePublicKey ?: return@forEach
|
val noiseKey = info?.noisePublicKey ?: return@forEach
|
||||||
val noiseHex = noiseKey.joinToString("") { b -> "%02x".format(b) }
|
val noiseHex = noiseKey.joinToString("") { b -> "%02x".format(b) }
|
||||||
|
|
||||||
// Derive temp nostr key from favorites npub
|
// Derive temp nostr key from favorites npub
|
||||||
val npub = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(noiseKey)
|
val npub = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(noiseKey)
|
||||||
val tempNostrKey: String? = try {
|
val tempNostrKey: String? = try {
|
||||||
if (npub != null) {
|
if (npub != null) {
|
||||||
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npub)
|
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npub)
|
||||||
if (hrp == "npub") "nostr_${data.joinToString("") { b -> "%02x".format(b) }.take(16)}" else null
|
if (hrp == "npub") "nostr_${data.joinToString("") { b -> "%02x".format(b) }.take(16)}" else null
|
||||||
} else null
|
} else null
|
||||||
} catch (_: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
|
|
||||||
unifyChatsIntoPeer(pid, listOfNotNull(noiseHex, tempNostrKey))
|
unifyChatsIntoPeer(pid, listOfNotNull(noiseHex, tempNostrKey))
|
||||||
} catch (_: Exception) { }
|
} catch (_: Exception) { }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,15 +299,27 @@ class MeshDelegateHandler(
|
|||||||
if (shouldSendReadReceipt) {
|
if (shouldSendReadReceipt) {
|
||||||
android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID (message=${message.id})")
|
android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID (message=${message.id})")
|
||||||
val nickname = state.getNicknameValue() ?: "unknown"
|
val nickname = state.getNicknameValue() ?: "unknown"
|
||||||
// Send directly for this message to avoid relying on unread queues
|
val mesh = getMeshService()
|
||||||
getMeshService().sendReadReceipt(message.id, senderPeerID!!, nickname)
|
val sent = try {
|
||||||
// Ensure unread badge is cleared for this peer immediately
|
val hasMesh = mesh.getPeerInfo(senderPeerID!!)?.isConnected == true && mesh.hasEstablishedSession(senderPeerID)
|
||||||
try {
|
if (hasMesh) {
|
||||||
val current = state.getUnreadPrivateMessagesValue().toMutableSet()
|
mesh.sendReadReceipt(message.id, senderPeerID, nickname)
|
||||||
if (current.remove(senderPeerID)) {
|
true
|
||||||
state.setUnreadPrivateMessages(current)
|
} 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 {
|
} else {
|
||||||
android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)")
|
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 peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
|
||||||
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
|
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
|
||||||
val selectedLocationChannel by viewModel.selectedLocationChannel.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
|
// Bottom sheet state
|
||||||
val sheetState = rememberModalBottomSheetState(
|
val sheetState = rememberModalBottomSheetState(
|
||||||
@@ -163,11 +165,12 @@ fun MeshPeerListSheet(
|
|||||||
nickname = nickname,
|
nickname = nickname,
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
selectedPrivatePeer = selectedPrivatePeer,
|
selectedPrivatePeer = selectedPrivatePeer,
|
||||||
|
wifiAwarePeerIDs = wifiAwarePeerIDs,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onPrivateChatStart = { peerID ->
|
onPrivateChatStart = { peerID ->
|
||||||
viewModel.showPrivateChatSheet(peerID)
|
viewModel.showPrivateChatSheet(peerID)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -279,6 +282,7 @@ fun PeopleSection(
|
|||||||
nickname: String,
|
nickname: String,
|
||||||
colorScheme: ColorScheme,
|
colorScheme: ColorScheme,
|
||||||
selectedPrivatePeer: String?,
|
selectedPrivatePeer: String?,
|
||||||
|
wifiAwarePeerIDs: Set<String> = emptySet(),
|
||||||
viewModel: ChatViewModel,
|
viewModel: ChatViewModel,
|
||||||
onPrivateChatStart: (String) -> Unit
|
onPrivateChatStart: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -333,7 +337,7 @@ fun PeopleSection(
|
|||||||
// Build mapping of connected peerID -> noise key hex to unify with offline favorites
|
// Build mapping of connected peerID -> noise key hex to unify with offline favorites
|
||||||
val noiseHexByPeerID: Map<String, String> = connectedPeers.associateWith { pid ->
|
val noiseHexByPeerID: Map<String, String> = connectedPeers.associateWith { pid ->
|
||||||
try {
|
try {
|
||||||
viewModel.meshService.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
|
viewModel.getMeshPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
|
||||||
} catch (_: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
}.filterValues { it != null }.mapValues { it.value!! }
|
}.filterValues { it != null }.mapValues { it.value!! }
|
||||||
|
|
||||||
@@ -411,11 +415,12 @@ fun PeopleSection(
|
|||||||
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
||||||
|
|
||||||
val directMap by viewModel.peerDirect.collectAsStateWithLifecycle()
|
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(
|
PeerItem(
|
||||||
peerID = peerID,
|
peerID = peerID,
|
||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
isDirect = isDirectLive,
|
isDirect = isDirectLive,
|
||||||
|
isWifiAware = peerID in wifiAwarePeerIDs,
|
||||||
isSelected = peerID == selectedPrivatePeer,
|
isSelected = peerID == selectedPrivatePeer,
|
||||||
isFavorite = isFavorite,
|
isFavorite = isFavorite,
|
||||||
isVerified = isVerified,
|
isVerified = isVerified,
|
||||||
@@ -544,6 +549,7 @@ private fun PeerItem(
|
|||||||
peerID: String,
|
peerID: String,
|
||||||
displayName: String,
|
displayName: String,
|
||||||
isDirect: Boolean,
|
isDirect: Boolean,
|
||||||
|
isWifiAware: Boolean = false,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
isFavorite: Boolean,
|
isFavorite: Boolean,
|
||||||
isVerified: Boolean,
|
isVerified: Boolean,
|
||||||
@@ -619,8 +625,16 @@ private fun PeerItem(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (isDirect) Icons.Outlined.Bluetooth else Icons.Filled.Route,
|
imageVector = when {
|
||||||
contentDescription = if (isDirect) "Direct Bluetooth" else "Routed",
|
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),
|
modifier = Modifier.size(16.dp),
|
||||||
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||||
)
|
)
|
||||||
@@ -652,6 +666,16 @@ private fun PeerItem(
|
|||||||
color = baseColor.copy(alpha = 0.6f)
|
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 peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val verifiedFingerprints by viewModel.verifiedFingerprints.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
|
// Start private chat when screen opens
|
||||||
LaunchedEffect(peerID) {
|
LaunchedEffect(peerID) {
|
||||||
@@ -831,7 +857,7 @@ fun PrivateChatSheet(
|
|||||||
MessagesList(
|
MessagesList(
|
||||||
messages = messages,
|
messages = messages,
|
||||||
currentUserNickname = nickname,
|
currentUserNickname = nickname,
|
||||||
meshService = viewModel.meshService,
|
meshService = viewModel.meshServiceFacade,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
forceScrollToBottom = forceScrollToBottom,
|
forceScrollToBottom = forceScrollToBottom,
|
||||||
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
|
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
|
||||||
@@ -915,10 +941,26 @@ fun PrivateChatSheet(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
) {
|
) {
|
||||||
when {
|
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 -> {
|
isDirect -> {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Outlined.SettingsInputAntenna,
|
imageVector = Icons.Outlined.Bluetooth,
|
||||||
contentDescription = stringResource(R.string.cd_connected_peers),
|
contentDescription = "Direct Bluetooth",
|
||||||
modifier = Modifier.size(14.dp),
|
modifier = Modifier.size(14.dp),
|
||||||
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||||
)
|
)
|
||||||
@@ -926,19 +968,11 @@ fun PrivateChatSheet(
|
|||||||
isConnected -> {
|
isConnected -> {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Route,
|
imageVector = Icons.Filled.Route,
|
||||||
contentDescription = stringResource(R.string.cd_ready_for_handshake),
|
contentDescription = "Routed",
|
||||||
modifier = Modifier.size(14.dp),
|
modifier = Modifier.size(14.dp),
|
||||||
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
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(
|
Text(
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import android.content.Intent
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import com.bitchat.android.model.BitchatMessage
|
import com.bitchat.android.model.BitchatMessage
|
||||||
import com.bitchat.android.model.DeliveryStatus
|
import com.bitchat.android.model.DeliveryStatus
|
||||||
import com.bitchat.android.mesh.BluetoothMeshService
|
import com.bitchat.android.mesh.MeshService
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import com.bitchat.android.ui.media.VoiceNotePlayer
|
import com.bitchat.android.ui.media.VoiceNotePlayer
|
||||||
@@ -58,7 +58,7 @@ import androidx.compose.ui.res.stringResource
|
|||||||
fun MessagesList(
|
fun MessagesList(
|
||||||
messages: List<BitchatMessage>,
|
messages: List<BitchatMessage>,
|
||||||
currentUserNickname: String,
|
currentUserNickname: String,
|
||||||
meshService: BluetoothMeshService,
|
meshService: MeshService,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
forceScrollToBottom: Boolean = false,
|
forceScrollToBottom: Boolean = false,
|
||||||
onScrolledUpChanged: ((Boolean) -> Unit)? = null,
|
onScrolledUpChanged: ((Boolean) -> Unit)? = null,
|
||||||
@@ -137,7 +137,7 @@ fun MessagesList(
|
|||||||
fun MessageItem(
|
fun MessageItem(
|
||||||
message: BitchatMessage,
|
message: BitchatMessage,
|
||||||
currentUserNickname: String,
|
currentUserNickname: String,
|
||||||
meshService: BluetoothMeshService,
|
meshService: MeshService,
|
||||||
messages: List<BitchatMessage> = emptyList(),
|
messages: List<BitchatMessage> = emptyList(),
|
||||||
onNicknameClick: ((String) -> Unit)? = null,
|
onNicknameClick: ((String) -> Unit)? = null,
|
||||||
onMessageLongPress: ((BitchatMessage) -> Unit)? = null,
|
onMessageLongPress: ((BitchatMessage) -> Unit)? = null,
|
||||||
@@ -201,7 +201,7 @@ fun MessageItem(
|
|||||||
message: BitchatMessage,
|
message: BitchatMessage,
|
||||||
messages: List<BitchatMessage>,
|
messages: List<BitchatMessage>,
|
||||||
currentUserNickname: String,
|
currentUserNickname: String,
|
||||||
meshService: BluetoothMeshService,
|
meshService: MeshService,
|
||||||
colorScheme: ColorScheme,
|
colorScheme: ColorScheme,
|
||||||
timeFormatter: SimpleDateFormat,
|
timeFormatter: SimpleDateFormat,
|
||||||
onNicknameClick: ((String) -> Unit)?,
|
onNicknameClick: ((String) -> Unit)?,
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package com.bitchat.android.ui
|
|||||||
import com.bitchat.android.model.BitchatMessage
|
import com.bitchat.android.model.BitchatMessage
|
||||||
import com.bitchat.android.model.DeliveryStatus
|
import com.bitchat.android.model.DeliveryStatus
|
||||||
import com.bitchat.android.mesh.PeerFingerprintManager
|
import com.bitchat.android.mesh.PeerFingerprintManager
|
||||||
|
import com.bitchat.android.mesh.MeshService
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
|
||||||
import com.bitchat.android.mesh.BluetoothMeshService
|
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ class PrivateChatManager(
|
|||||||
|
|
||||||
// MARK: - Private Chat Lifecycle
|
// MARK: - Private Chat Lifecycle
|
||||||
|
|
||||||
fun startPrivateChat(peerID: String, meshService: BluetoothMeshService): Boolean {
|
fun startPrivateChat(peerID: String, meshService: MeshService): Boolean {
|
||||||
if (isPeerBlocked(peerID)) {
|
if (isPeerBlocked(peerID)) {
|
||||||
val peerNickname = getPeerNickname(peerID, meshService)
|
val peerNickname = getPeerNickname(peerID, meshService)
|
||||||
val systemMessage = BitchatMessage(
|
val systemMessage = BitchatMessage(
|
||||||
@@ -188,7 +188,7 @@ class PrivateChatManager(
|
|||||||
|
|
||||||
// MARK: - Block/Unblock Operations
|
// MARK: - Block/Unblock Operations
|
||||||
|
|
||||||
fun blockPeer(peerID: String, meshService: BluetoothMeshService): Boolean {
|
fun blockPeer(peerID: String, meshService: MeshService): Boolean {
|
||||||
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
|
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
|
||||||
if (fingerprint != null) {
|
if (fingerprint != null) {
|
||||||
dataManager.addBlockedUser(fingerprint)
|
dataManager.addBlockedUser(fingerprint)
|
||||||
@@ -212,7 +212,7 @@ class PrivateChatManager(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unblockPeer(peerID: String, meshService: BluetoothMeshService): Boolean {
|
fun unblockPeer(peerID: String, meshService: MeshService): Boolean {
|
||||||
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
|
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
|
||||||
if (fingerprint != null && dataManager.isUserBlocked(fingerprint)) {
|
if (fingerprint != null && dataManager.isUserBlocked(fingerprint)) {
|
||||||
dataManager.removeBlockedUser(fingerprint)
|
dataManager.removeBlockedUser(fingerprint)
|
||||||
@@ -230,7 +230,7 @@ class PrivateChatManager(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun blockPeerByNickname(targetName: String, meshService: BluetoothMeshService): Boolean {
|
fun blockPeerByNickname(targetName: String, meshService: MeshService): Boolean {
|
||||||
val peerID = getPeerIDForNickname(targetName, meshService)
|
val peerID = getPeerIDForNickname(targetName, meshService)
|
||||||
|
|
||||||
if (peerID != null) {
|
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)
|
val peerID = getPeerIDForNickname(targetName, meshService)
|
||||||
|
|
||||||
if (peerID != null) {
|
if (peerID != null) {
|
||||||
@@ -333,7 +333,7 @@ class PrivateChatManager(
|
|||||||
* Send read receipts for all unread messages from a specific peer
|
* Send read receipts for all unread messages from a specific peer
|
||||||
* Called when the user focuses on a private chat
|
* Called when the user focuses on a private chat
|
||||||
*/
|
*/
|
||||||
fun sendReadReceiptsForPeer(peerID: String, meshService: BluetoothMeshService) {
|
fun sendReadReceiptsForPeer(peerID: String, meshService: MeshService) {
|
||||||
// Collect candidate messages: all incoming messages from this peer in the conversation
|
// Collect candidate messages: all incoming messages from this peer in the conversation
|
||||||
val chats = try { state.getPrivateChatsValue() } catch (_: Exception) { emptyMap<String, List<BitchatMessage>>() }
|
val chats = try { state.getPrivateChatsValue() } catch (_: Exception) { emptyMap<String, List<BitchatMessage>>() }
|
||||||
val messages = chats[peerID].orEmpty()
|
val messages = chats[peerID].orEmpty()
|
||||||
@@ -343,13 +343,16 @@ class PrivateChatManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val myNickname = state.getNicknameValue() ?: "unknown"
|
val myNickname = state.getNicknameValue() ?: "unknown"
|
||||||
|
val hasMesh = try { meshService.getPeerInfo(peerID)?.isConnected == true && meshService.hasEstablishedSession(peerID) } catch (_: Exception) { false }
|
||||||
var sentCount = 0
|
var sentCount = 0
|
||||||
messages.forEach { msg ->
|
messages.forEach { msg ->
|
||||||
// Only for incoming messages from this peer
|
// Only for incoming messages from this peer
|
||||||
if (msg.senderPeerID == peerID) {
|
if (msg.senderPeerID == peerID) {
|
||||||
try {
|
try {
|
||||||
meshService.sendReadReceipt(msg.id, peerID, myNickname)
|
if (hasMesh) {
|
||||||
sentCount += 1
|
meshService.sendReadReceipt(msg.id, peerID, myNickname)
|
||||||
|
sentCount += 1
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Failed to send read receipt for message ${msg.id}: ${e.message}")
|
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
|
* Establish Noise session if needed before starting private chat
|
||||||
* Uses same lexicographical logic as MessageHandler.handleNoiseIdentityAnnouncement
|
* 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)) {
|
if (noiseSessionDelegate.hasEstablishedSession(peerID)) {
|
||||||
Log.d(TAG, "Noise session already established with $peerID")
|
Log.d(TAG, "Noise session already established with $peerID")
|
||||||
return
|
return
|
||||||
@@ -413,11 +416,11 @@ class PrivateChatManager(
|
|||||||
|
|
||||||
// MARK: - Utility Functions
|
// 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
|
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
|
return meshService.getPeerNicknames()[peerID] ?: peerID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ fun SecurityVerificationSheet(
|
|||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
accent = accent,
|
accent = accent,
|
||||||
canStartHandshake = fingerprint == null && selectedPeerID.matches(peerHexRegex),
|
canStartHandshake = fingerprint == null && selectedPeerID.matches(peerHexRegex),
|
||||||
onStartHandshake = { viewModel.meshService.initiateNoiseHandshake(selectedPeerID) },
|
onStartHandshake = { viewModel.initiateMeshHandshake(selectedPeerID) },
|
||||||
onVerify = { fp -> viewModel.verifyFingerprintValue(fp) },
|
onVerify = { fp -> viewModel.verifyFingerprintValue(fp) },
|
||||||
onUnverify = { fp -> viewModel.unverifyFingerprintValue(fp) }
|
onUnverify = { fp -> viewModel.unverifyFingerprintValue(fp) }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import android.content.Context
|
|||||||
import com.bitchat.android.R
|
import com.bitchat.android.R
|
||||||
import com.bitchat.android.favorites.FavoritesPersistenceService
|
import com.bitchat.android.favorites.FavoritesPersistenceService
|
||||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
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.model.BitchatMessage
|
||||||
import com.bitchat.android.noise.NoiseSession
|
import com.bitchat.android.noise.NoiseSession
|
||||||
import com.bitchat.android.nostr.GeohashAliasRegistry
|
import com.bitchat.android.nostr.GeohashAliasRegistry
|
||||||
@@ -26,14 +26,14 @@ import java.util.concurrent.ConcurrentHashMap
|
|||||||
class VerificationHandler(
|
class VerificationHandler(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val getMeshService: () -> BluetoothMeshService,
|
private val getMeshService: () -> MeshService,
|
||||||
private val identityManager: SecureIdentityStateManager,
|
private val identityManager: SecureIdentityStateManager,
|
||||||
private val state: ChatState,
|
private val state: ChatState,
|
||||||
private val notificationManager: NotificationManager,
|
private val notificationManager: NotificationManager,
|
||||||
private val messageManager: MessageManager
|
private val messageManager: MessageManager
|
||||||
) {
|
) {
|
||||||
// Helper to get current mesh service (may change after panic clear)
|
// Helper to get current mesh service (may change after panic clear)
|
||||||
private val meshService: BluetoothMeshService
|
private val meshService: MeshService
|
||||||
get() = getMeshService()
|
get() = getMeshService()
|
||||||
|
|
||||||
private val _verifiedFingerprints = MutableStateFlow<Set<String>>(emptySet())
|
private val _verifiedFingerprints = MutableStateFlow<Set<String>>(emptySet())
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ fun VerificationSheet(
|
|||||||
val fingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
|
val fingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
if (peerID != null) {
|
if (peerID != null) {
|
||||||
val fingerprint = viewModel.meshService.getPeerFingerprint(peerID!!)
|
val fingerprint = viewModel.getMeshPeerFingerprint(peerID!!)
|
||||||
if (fingerprint != null && fingerprints.contains(fingerprint)) {
|
if (fingerprint != null && fingerprints.contains(fingerprint)) {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
Button(
|
Button(
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ object DebugPreferenceManager {
|
|||||||
// GCS keys (no migration/back-compat)
|
// GCS keys (no migration/back-compat)
|
||||||
private const val KEY_GCS_MAX_BYTES = "gcs_max_filter_bytes"
|
private const val KEY_GCS_MAX_BYTES = "gcs_max_filter_bytes"
|
||||||
private const val KEY_GCS_FPR = "gcs_filter_fpr_percent"
|
private const val KEY_GCS_FPR = "gcs_filter_fpr_percent"
|
||||||
// Removed: persistent notification toggle is now governed by MeshServicePreferences.isBackgroundEnabled
|
// 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
|
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()
|
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)
|
private val _packetRelayEnabled = MutableStateFlow(true)
|
||||||
val packetRelayEnabled: StateFlow<Boolean> = _packetRelayEnabled.asStateFlow()
|
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
|
// Visibility of the debug sheet; gates heavy work
|
||||||
private val _debugSheetVisible = MutableStateFlow(false)
|
private val _debugSheetVisible = MutableStateFlow(false)
|
||||||
val debugSheetVisible: StateFlow<Boolean> = _debugSheetVisible.asStateFlow()
|
val debugSheetVisible: StateFlow<Boolean> = _debugSheetVisible.asStateFlow()
|
||||||
@@ -63,6 +74,10 @@ class DebugSettingsManager private constructor() {
|
|||||||
_maxConnectionsOverall.value = DebugPreferenceManager.getMaxConnectionsOverall(8)
|
_maxConnectionsOverall.value = DebugPreferenceManager.getMaxConnectionsOverall(8)
|
||||||
_maxServerConnections.value = DebugPreferenceManager.getMaxConnectionsServer(8)
|
_maxServerConnections.value = DebugPreferenceManager.getMaxConnectionsServer(8)
|
||||||
_maxClientConnections.value = DebugPreferenceManager.getMaxConnectionsClient(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) {
|
} catch (_: Exception) {
|
||||||
// Preferences not ready yet; keep defaults. They will be applied on first change.
|
// 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) {
|
fun setMaxConnectionsOverall(value: Int) {
|
||||||
val clamped = value.coerceIn(1, 32)
|
val clamped = value.coerceIn(1, 32)
|
||||||
DebugPreferenceManager.setMaxConnectionsOverall(clamped)
|
DebugPreferenceManager.setMaxConnectionsOverall(clamped)
|
||||||
@@ -323,6 +362,16 @@ class DebugSettingsManager private constructor() {
|
|||||||
fun updateConnectedDevices(devices: List<ConnectedDevice>) {
|
fun updateConnectedDevices(devices: List<ConnectedDevice>) {
|
||||||
_connectedDevices.value = devices
|
_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) {
|
fun updateRelayStats(stats: PacketRelayStats) {
|
||||||
_relayStats.value = stats
|
_relayStats.value = stats
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Bluetooth
|
import androidx.compose.material.icons.filled.Bluetooth
|
||||||
|
import androidx.compose.material.icons.filled.Wifi
|
||||||
|
import androidx.compose.material.icons.filled.WifiTethering
|
||||||
import androidx.compose.material.icons.filled.BugReport
|
import androidx.compose.material.icons.filled.BugReport
|
||||||
import androidx.compose.material.icons.filled.Devices
|
import androidx.compose.material.icons.filled.Devices
|
||||||
import androidx.compose.material.icons.filled.PowerSettingsNew
|
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
|
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MeshTopologySection() {
|
fun MeshTopologySection(
|
||||||
|
localPeerID: String? = null,
|
||||||
|
blePeerIDs: Set<String> = emptySet(),
|
||||||
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
val graphService = remember { MeshGraphService.getInstance() }
|
val graphService = remember { MeshGraphService.getInstance() }
|
||||||
val snapshot by graphService.graphState.collectAsState()
|
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)) {
|
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
||||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
@@ -60,6 +67,9 @@ fun MeshTopologySection() {
|
|||||||
ForceDirectedMeshGraph(
|
ForceDirectedMeshGraph(
|
||||||
nodes = nodes,
|
nodes = nodes,
|
||||||
edges = edges,
|
edges = edges,
|
||||||
|
wifiAwarePeerIDs = wifiAwarePeerIDs,
|
||||||
|
blePeerIDs = blePeerIDs,
|
||||||
|
localPeerID = localPeerID,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(300.dp)
|
.height(300.dp)
|
||||||
@@ -102,7 +112,7 @@ fun DebugSettingsSheet(
|
|||||||
val verboseLogging by manager.verboseLoggingEnabled.collectAsState()
|
val verboseLogging by manager.verboseLoggingEnabled.collectAsState()
|
||||||
val gattServerEnabled by manager.gattServerEnabled.collectAsState()
|
val gattServerEnabled by manager.gattServerEnabled.collectAsState()
|
||||||
val gattClientEnabled by manager.gattClientEnabled.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 maxOverall by manager.maxConnectionsOverall.collectAsState()
|
||||||
val maxServer by manager.maxServerConnections.collectAsState()
|
val maxServer by manager.maxServerConnections.collectAsState()
|
||||||
val maxClient by manager.maxClientConnections.collectAsState()
|
val maxClient by manager.maxClientConnections.collectAsState()
|
||||||
@@ -114,6 +124,15 @@ fun DebugSettingsSheet(
|
|||||||
val gcsMaxBytes by manager.gcsMaxBytes.collectAsState()
|
val gcsMaxBytes by manager.gcsMaxBytes.collectAsState()
|
||||||
val gcsFpr by manager.gcsFprPercent.collectAsState()
|
val gcsFpr by manager.gcsFprPercent.collectAsState()
|
||||||
val context = LocalContext.current
|
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
|
// Persistent notification is now controlled solely by MeshServicePreferences.isBackgroundEnabled
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val isScrolled by remember {
|
val isScrolled by remember {
|
||||||
@@ -148,6 +167,15 @@ fun DebugSettingsSheet(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
manager.updateConnectedDevices(devices)
|
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)
|
kotlinx.coroutines.delay(1000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,7 +232,13 @@ fun DebugSettingsSheet(
|
|||||||
|
|
||||||
// Mesh topology visualization (moved below verbose logging)
|
// Mesh topology visualization (moved below verbose logging)
|
||||||
item {
|
item {
|
||||||
MeshTopologySection()
|
val blePeerIDs = remember(connectedDevices) {
|
||||||
|
connectedDevices.mapNotNull { it.peerID }.toSet()
|
||||||
|
}
|
||||||
|
MeshTopologySection(
|
||||||
|
localPeerID = meshService.myPeerID,
|
||||||
|
blePeerIDs = blePeerIDs,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GATT controls
|
// 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
|
// Packet relay controls and stats
|
||||||
item {
|
item {
|
||||||
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
||||||
@@ -286,7 +367,7 @@ fun DebugSettingsSheet(
|
|||||||
Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, tint = Color(0xFFFF9500))
|
Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, tint = Color(0xFFFF9500))
|
||||||
Text(stringResource(R.string.debug_packet_relay), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
Text(stringResource(R.string.debug_packet_relay), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
Switch(checked = packetRelayEnabled, onCheckedChange = { manager.setPacketRelayEnabled(it) })
|
Switch(checked = packetRelayed, onCheckedChange = { manager.setPacketRelayEnabled(it) })
|
||||||
}
|
}
|
||||||
// Removed aggregate labels; we will show per-direction compact labels below titles
|
// Removed aggregate labels; we will show per-direction compact labels below titles
|
||||||
// Toggle: overall vs per-connection vs per-peer
|
// 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
|
// Connected devices
|
||||||
item {
|
item {
|
||||||
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import androidx.compose.foundation.Canvas
|
|||||||
import androidx.compose.foundation.gestures.detectDragGestures
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
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.graphics.toArgb
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.bitchat.android.services.meshgraph.MeshGraphService
|
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 MAX_VELOCITY = 30f
|
||||||
private const val PULSE_DECAY = 0.05f
|
private const val PULSE_DECAY = 0.05f
|
||||||
private const val ROUTE_DECAY = 0.02f
|
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(
|
private class GraphNodeState(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -212,6 +257,9 @@ private class Simulation {
|
|||||||
fun ForceDirectedMeshGraph(
|
fun ForceDirectedMeshGraph(
|
||||||
nodes: List<MeshGraphService.GraphNode>,
|
nodes: List<MeshGraphService.GraphNode>,
|
||||||
edges: List<MeshGraphService.GraphEdge>,
|
edges: List<MeshGraphService.GraphEdge>,
|
||||||
|
wifiAwarePeerIDs: Set<String> = emptySet(),
|
||||||
|
blePeerIDs: Set<String> = emptySet(),
|
||||||
|
localPeerID: String? = null,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
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.unit.dp
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import com.bitchat.android.R
|
import com.bitchat.android.R
|
||||||
import com.bitchat.android.mesh.BluetoothMeshService
|
import com.bitchat.android.mesh.MeshService
|
||||||
import com.bitchat.android.model.BitchatMessage
|
import com.bitchat.android.model.BitchatMessage
|
||||||
import androidx.compose.material3.ColorScheme
|
import androidx.compose.material3.ColorScheme
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@@ -30,7 +30,7 @@ import java.text.SimpleDateFormat
|
|||||||
fun AudioMessageItem(
|
fun AudioMessageItem(
|
||||||
message: BitchatMessage,
|
message: BitchatMessage,
|
||||||
currentUserNickname: String,
|
currentUserNickname: String,
|
||||||
meshService: BluetoothMeshService,
|
meshService: MeshService,
|
||||||
colorScheme: ColorScheme,
|
colorScheme: ColorScheme,
|
||||||
timeFormatter: SimpleDateFormat,
|
timeFormatter: SimpleDateFormat,
|
||||||
onNicknameClick: ((String) -> Unit)?,
|
onNicknameClick: ((String) -> Unit)?,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import androidx.compose.ui.text.TextLayoutResult
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
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.BitchatMessage
|
||||||
import com.bitchat.android.model.BitchatMessageType
|
import com.bitchat.android.model.BitchatMessageType
|
||||||
import androidx.compose.material3.ColorScheme
|
import androidx.compose.material3.ColorScheme
|
||||||
@@ -39,7 +39,7 @@ fun ImageMessageItem(
|
|||||||
message: BitchatMessage,
|
message: BitchatMessage,
|
||||||
messages: List<BitchatMessage>,
|
messages: List<BitchatMessage>,
|
||||||
currentUserNickname: String,
|
currentUserNickname: String,
|
||||||
meshService: BluetoothMeshService,
|
meshService: MeshService,
|
||||||
colorScheme: ColorScheme,
|
colorScheme: ColorScheme,
|
||||||
timeFormatter: SimpleDateFormat,
|
timeFormatter: SimpleDateFormat,
|
||||||
onNicknameClick: ((String) -> Unit)?,
|
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="location_notes_empty_desc">كن أول من يضيف ملاحظة لهذا المكان.</string>
|
||||||
<string name="dismiss">إغلاق</string>
|
<string name="dismiss">إغلاق</string>
|
||||||
<string name="location_notes_input_placeholder">أضف ملاحظة لهذا المكان</string>
|
<string name="location_notes_input_placeholder">أضف ملاحظة لهذا المكان</string>
|
||||||
|
<string name="bluetooth_recommended">بلوتوث موصى به</string>
|
||||||
<string name="mesh_service_notification_content">الشبكة تعمل — %1$d أقران</string>
|
<string name="mesh_service_notification_content">الشبكة تعمل — %1$d أقران</string>
|
||||||
|
|
||||||
<string name="verify_title">verify</string>
|
<string name="verify_title">verify</string>
|
||||||
|
|||||||
@@ -349,6 +349,7 @@
|
|||||||
<item quantity="other">%d জন</item>
|
<item quantity="other">%d জন</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<string name="bluetooth_recommended">ব্লুটুথ প্রস্তাবিত</string>
|
||||||
<string name="mesh_service_notification_content">মেশ চলছে — %1$d পিয়ার</string>
|
<string name="mesh_service_notification_content">মেশ চলছে — %1$d পিয়ার</string>
|
||||||
|
|
||||||
<string name="verify_title">verify</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="location_notes_empty_desc">sei der Erste, der hier eine Notiz hinterlässt.</string>
|
||||||
<string name="dismiss">schließen</string>
|
<string name="dismiss">schließen</string>
|
||||||
<string name="location_notes_input_placeholder">füge eine Notiz zu diesem Ort hinzu</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="mesh_service_notification_content">Mesh läuft — %1$d Peers</string>
|
||||||
|
|
||||||
<string name="verify_title">verifizieren</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="location_notes_empty_desc">sé el primero en añadir una para este sitio.</string>
|
||||||
<string name="dismiss">cerrar</string>
|
<string name="dismiss">cerrar</string>
|
||||||
<string name="location_notes_input_placeholder">agrega una nota para este lugar</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="mesh_service_notification_content">Mesh en ejecución — %1$d pares</string>
|
||||||
|
|
||||||
<string name="verify_title">verificar</string>
|
<string name="verify_title">verificar</string>
|
||||||
|
|||||||
@@ -349,6 +349,7 @@
|
|||||||
<item quantity="other">%d نفر</item>
|
<item quantity="other">%d نفر</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<string name="bluetooth_recommended">بلوتوث توصیه می شود</string>
|
||||||
<string name="mesh_service_notification_content">مش در حال اجرا — %1$d همتا</string>
|
<string name="mesh_service_notification_content">مش در حال اجرا — %1$d همتا</string>
|
||||||
|
|
||||||
<string name="verify_title">verify</string>
|
<string name="verify_title">verify</string>
|
||||||
|
|||||||
@@ -362,6 +362,7 @@
|
|||||||
<item quantity="other">%d tao</item>
|
<item quantity="other">%d tao</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<string name="bluetooth_recommended">Bluetooth Recommended</string>
|
||||||
<string name="mesh_service_notification_content">Tumatakbo ang Mesh — %1$d na peer</string>
|
<string name="mesh_service_notification_content">Tumatakbo ang Mesh — %1$d na peer</string>
|
||||||
|
|
||||||
<string name="verify_title">verify</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="location_notes_empty_desc">soyez le premier à en ajouter pour cet endroit.</string>
|
||||||
<string name="dismiss">fermer</string>
|
<string name="dismiss">fermer</string>
|
||||||
<string name="location_notes_input_placeholder">ajoutez une note pour cet endroit</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="mesh_service_notification_content">Mesh actif — %1$d pairs</string>
|
||||||
|
|
||||||
<string name="verify_title">vérifier</string>
|
<string name="verify_title">vérifier</string>
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
<string name="location_notes_empty_desc">היה הראשון להוסיף הערה למקום זה.</string>
|
<string name="location_notes_empty_desc">היה הראשון להוסיף הערה למקום זה.</string>
|
||||||
<string name="dismiss">סגור</string>
|
<string name="dismiss">סגור</string>
|
||||||
<string name="location_notes_input_placeholder">הוסף הערה למקום זה</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="mesh_service_notification_content">רשת Mesh פועלת — %1$d עמיתים</string>
|
||||||
|
|
||||||
<string name="verify_title">verify</string>
|
<string name="verify_title">verify</string>
|
||||||
|
|||||||
@@ -362,6 +362,7 @@
|
|||||||
<string name="location_notes_empty_desc">इस स्थान के लिए पहला नोट जोड़ें।</string>
|
<string name="location_notes_empty_desc">इस स्थान के लिए पहला नोट जोड़ें।</string>
|
||||||
<string name="dismiss">बंद करें</string>
|
<string name="dismiss">बंद करें</string>
|
||||||
<string name="location_notes_input_placeholder">इस स्थान के लिए एक नोट जोड़ें</string>
|
<string name="location_notes_input_placeholder">इस स्थान के लिए एक नोट जोड़ें</string>
|
||||||
|
<string name="bluetooth_recommended">ब्लूटूथ अनुशंसित</string>
|
||||||
<string name="mesh_service_notification_content">मेश चल रहा है — %1$d पीयर्स</string>
|
<string name="mesh_service_notification_content">मेश चल रहा है — %1$d पीयर्स</string>
|
||||||
|
|
||||||
<string name="verify_title">verify</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="location_notes_empty_desc">jadilah yang pertama menambahkan catatan untuk tempat ini.</string>
|
||||||
<string name="dismiss">tutup</string>
|
<string name="dismiss">tutup</string>
|
||||||
<string name="location_notes_input_placeholder">tambahkan catatan untuk tempat ini</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="mesh_service_notification_content">Mesh berjalan — %1$d peer</string>
|
||||||
|
|
||||||
<string name="verify_title">verify</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="location_notes_empty_desc">sii il primo ad aggiungerne una per questo posto.</string>
|
||||||
<string name="dismiss">chiudi</string>
|
<string name="dismiss">chiudi</string>
|
||||||
<string name="location_notes_input_placeholder">aggiungi una nota per questo luogo</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="mesh_service_notification_content">Mesh in esecuzione — %1$d peer</string>
|
||||||
|
|
||||||
<string name="verify_title">verifica</string>
|
<string name="verify_title">verifica</string>
|
||||||
|
|||||||
@@ -362,6 +362,7 @@
|
|||||||
<string name="location_notes_empty_desc">この場所の最初のメモを追加しましょう。</string>
|
<string name="location_notes_empty_desc">この場所の最初のメモを追加しましょう。</string>
|
||||||
<string name="dismiss">閉じる</string>
|
<string name="dismiss">閉じる</string>
|
||||||
<string name="location_notes_input_placeholder">この場所へのメモを追加</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="mesh_service_notification_content">メッシュ実行中 — %1$d ピア</string>
|
||||||
|
|
||||||
<string name="verify_title">検証</string>
|
<string name="verify_title">検証</string>
|
||||||
|
|||||||
@@ -349,6 +349,7 @@
|
|||||||
<item quantity="other">%d ადამიანი</item>
|
<item quantity="other">%d ადამიანი</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<string name="bluetooth_recommended">Bluetooth Recommended</string>
|
||||||
<string name="mesh_service_notification_content">Mesh გაშვებულია — %1$d პირები</string>
|
<string name="mesh_service_notification_content">Mesh გაშვებულია — %1$d პირები</string>
|
||||||
|
|
||||||
<string name="verify_title">verify</string>
|
<string name="verify_title">verify</string>
|
||||||
|
|||||||
@@ -362,6 +362,7 @@
|
|||||||
<string name="location_notes_empty_desc">이 장소에 첫 번째 노트를 추가해 보세요.</string>
|
<string name="location_notes_empty_desc">이 장소에 첫 번째 노트를 추가해 보세요.</string>
|
||||||
<string name="dismiss">닫기</string>
|
<string name="dismiss">닫기</string>
|
||||||
<string name="location_notes_input_placeholder">이 장소에 노트 추가</string>
|
<string name="location_notes_input_placeholder">이 장소에 노트 추가</string>
|
||||||
|
<string name="bluetooth_recommended">블루투스 권장</string>
|
||||||
<string name="mesh_service_notification_content">메시 실행 중 — %1$d 피어</string>
|
<string name="mesh_service_notification_content">메시 실행 중 — %1$d 피어</string>
|
||||||
|
|
||||||
<string name="verify_title">verify</string>
|
<string name="verify_title">verify</string>
|
||||||
|
|||||||
@@ -376,6 +376,7 @@
|
|||||||
<item quantity="other">Olona %d</item>
|
<item quantity="other">Olona %d</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<string name="bluetooth_recommended">Bluetooth Recommended</string>
|
||||||
<string name="mesh_service_notification_content">Mandeha ny Mesh — %1$d peers</string>
|
<string name="mesh_service_notification_content">Mandeha ny Mesh — %1$d peers</string>
|
||||||
|
|
||||||
<string name="verify_title">verify</string>
|
<string name="verify_title">verify</string>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- TODO: Malay translations -->
|
<!-- 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="mesh_service_notification_content">Mesh sedang berjalan — %1$d rakan</string>
|
||||||
|
|
||||||
<string name="verify_title">verify</string>
|
<string name="verify_title">verify</string>
|
||||||
|
|||||||
@@ -362,6 +362,7 @@
|
|||||||
<item quantity="other">%d जना</item>
|
<item quantity="other">%d जना</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<string name="bluetooth_recommended">ब्लुटुथ सिफारिस गरिएको</string>
|
||||||
<string name="mesh_service_notification_content">मेश चलिरहेको छ — %1$d पियर्स</string>
|
<string name="mesh_service_notification_content">मेश चलिरहेको छ — %1$d पियर्स</string>
|
||||||
|
|
||||||
<string name="verify_title">verify</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="location_notes_empty_desc">wees de eerste die een notitie voor deze plek toevoegt.</string>
|
||||||
<string name="dismiss">sluiten</string>
|
<string name="dismiss">sluiten</string>
|
||||||
<string name="location_notes_input_placeholder">voeg een notitie toe voor deze plek</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="mesh_service_notification_content">Mesh actief — %1$d peers</string>
|
||||||
|
|
||||||
<string name="verify_title">verify</string>
|
<string name="verify_title">verify</string>
|
||||||
|
|||||||
@@ -349,6 +349,7 @@
|
|||||||
<item quantity="other">%d بندے</item>
|
<item quantity="other">%d بندے</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<string name="bluetooth_recommended">Bluetooth Recommended</string>
|
||||||
<string name="mesh_service_notification_content">میش چل رہا ہے — %1$d ساتھی</string>
|
<string name="mesh_service_notification_content">میش چل رہا ہے — %1$d ساتھی</string>
|
||||||
|
|
||||||
<string name="verify_title">verify</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="location_notes_empty_desc">bądź pierwszy, który doda notatkę dla tego miejsca.</string>
|
||||||
<string name="dismiss">zamknij</string>
|
<string name="dismiss">zamknij</string>
|
||||||
<string name="location_notes_input_placeholder">dodaj notatkę dla tego miejsca</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="mesh_service_notification_content">Mesh działa — %1$d peerów</string>
|
||||||
|
|
||||||
<string name="verify_title">verify</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="location_notes_empty_desc">seja o primeiro a adicionar uma para este local.</string>
|
||||||
<string name="dismiss">fechar</string>
|
<string name="dismiss">fechar</string>
|
||||||
<string name="location_notes_input_placeholder">adicione uma nota para este local</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="mesh_service_notification_content">Mesh rodando — %1$d pares</string>
|
||||||
|
|
||||||
<string name="verify_title">verificar</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="location_notes_empty_desc">seja o primeiro a adicionar uma para este local.</string>
|
||||||
<string name="dismiss">fechar</string>
|
<string name="dismiss">fechar</string>
|
||||||
<string name="location_notes_input_placeholder">adicione uma nota para este local</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="mesh_service_notification_content">Mesh em execução — %1$d pares</string>
|
||||||
|
|
||||||
<string name="verify_title">verificar</string>
|
<string name="verify_title">verificar</string>
|
||||||
|
|||||||
@@ -352,6 +352,7 @@
|
|||||||
<string name="location_notes_empty_desc">станьте первым, кто добавит заметку для этого места.</string>
|
<string name="location_notes_empty_desc">станьте первым, кто добавит заметку для этого места.</string>
|
||||||
<string name="dismiss">закрыть</string>
|
<string name="dismiss">закрыть</string>
|
||||||
<string name="location_notes_input_placeholder">добавьте заметку для этого места</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="mesh_service_notification_content">Mesh запущен — %1$d пиров</string>
|
||||||
|
|
||||||
<string name="verify_title">проверить</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="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="dismiss">stäng</string>
|
||||||
<string name="location_notes_input_placeholder">lägg till en anteckning för den här platsen</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="mesh_service_notification_content">Mesh körs — %1$d peers</string>
|
||||||
|
|
||||||
<string name="verify_title">verify</string>
|
<string name="verify_title">verify</string>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- TODO: Tamil translations -->
|
<!-- 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="mesh_service_notification_content">மெஷ் இயங்குகிறது — %1$d பியர்ஸ்</string>
|
||||||
|
|
||||||
<string name="verify_title">verify</string>
|
<string name="verify_title">verify</string>
|
||||||
|
|||||||
@@ -349,6 +349,7 @@
|
|||||||
<item quantity="other">%d คน</item>
|
<item quantity="other">%d คน</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<string name="bluetooth_recommended">Bluetooth Recommended</string>
|
||||||
<string name="mesh_service_notification_content">Mesh กำลังทำงาน — %1$d เพื่อน</string>
|
<string name="mesh_service_notification_content">Mesh กำลังทำงาน — %1$d เพื่อน</string>
|
||||||
|
|
||||||
<string name="verify_title">verify</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="location_notes_empty_desc">bu yer için ilk notu ekleyen siz olun.</string>
|
||||||
<string name="dismiss">kapat</string>
|
<string name="dismiss">kapat</string>
|
||||||
<string name="location_notes_input_placeholder">bu yer için bir not ekleyin</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="mesh_service_notification_content">Mesh çalışıyor — %1$d eş</string>
|
||||||
|
|
||||||
<string name="verify_title">verify</string>
|
<string name="verify_title">verify</string>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- TODO: Ukrainian translations -->
|
<!-- 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="mesh_service_notification_content">Mesh працює — %1$d пірів</string>
|
||||||
|
|
||||||
<string name="verify_title">verify</string>
|
<string name="verify_title">verify</string>
|
||||||
|
|||||||
@@ -362,6 +362,7 @@
|
|||||||
<string name="location_notes_empty_desc">اس جگہ کے لیے پہلا نوٹ شامل کریں۔</string>
|
<string name="location_notes_empty_desc">اس جگہ کے لیے پہلا نوٹ شامل کریں۔</string>
|
||||||
<string name="dismiss">بند کریں</string>
|
<string name="dismiss">بند کریں</string>
|
||||||
<string name="location_notes_input_placeholder">اس جگہ کے لیے ایک نوٹ شامل کریں</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="mesh_service_notification_content">میش چل رہا ہے — %1$d ساتھی</string>
|
||||||
|
|
||||||
<string name="verify_title">verify</string>
|
<string name="verify_title">verify</string>
|
||||||
|
|||||||
@@ -349,6 +349,7 @@
|
|||||||
<item quantity="other">%d người</item>
|
<item quantity="other">%d người</item>
|
||||||
</plurals>
|
</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="mesh_service_notification_content">Mesh đang chạy — %1$d ngang hàng</string>
|
||||||
|
|
||||||
<string name="verify_title">verify</string>
|
<string name="verify_title">verify</string>
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
<string name="location_notes_empty_desc">成为第一个为此地点添加笔记的人。</string>
|
<string name="location_notes_empty_desc">成为第一个为此地点添加笔记的人。</string>
|
||||||
<string name="dismiss">关闭</string>
|
<string name="dismiss">关闭</string>
|
||||||
<string name="location_notes_input_placeholder">为此地点添加一条笔记</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="mesh_service_notification_content">Mesh 运行中 — %1$d 个节点</string>
|
||||||
|
|
||||||
<string name="verify_title">验证</string>
|
<string name="verify_title">验证</string>
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
<string name="location_notes_empty_desc">成為第一個為此地點新增筆記的人。</string>
|
<string name="location_notes_empty_desc">成為第一個為此地點新增筆記的人。</string>
|
||||||
<string name="dismiss">關閉</string>
|
<string name="dismiss">關閉</string>
|
||||||
<string name="location_notes_input_placeholder">為此地點新增一則筆記</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="mesh_service_notification_content">Mesh 運行中 — %1$d 個節點</string>
|
||||||
|
|
||||||
<string name="verify_title">验证</string>
|
<string name="verify_title">验证</string>
|
||||||
|
|||||||
@@ -375,6 +375,7 @@
|
|||||||
<string name="location_notes_empty_desc">成为第一个为此地点添加笔记的人。</string>
|
<string name="location_notes_empty_desc">成为第一个为此地点添加笔记的人。</string>
|
||||||
<string name="dismiss">关闭</string>
|
<string name="dismiss">关闭</string>
|
||||||
<string name="location_notes_input_placeholder">为此地点添加一条笔记</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="mesh_service_notification_content">Mesh 运行中 — %1$d 个节点</string>
|
||||||
|
|
||||||
<string name="verify_title">验证</string>
|
<string name="verify_title">验证</string>
|
||||||
|
|||||||
@@ -452,4 +452,5 @@
|
|||||||
<item quantity="other">%d people</item>
|
<item quantity="other">%d people</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
|
<string name="bluetooth_recommended">Bluetooth Recommended</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package com.bitchat.android.ui
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.test.core.app.ApplicationProvider
|
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 com.bitchat.android.model.BitchatMessage
|
||||||
import junit.framework.TestCase.assertEquals
|
import junit.framework.TestCase.assertEquals
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
@@ -34,7 +34,7 @@ class CommandProcessorTest() {
|
|||||||
coroutineScope = testScope
|
coroutineScope = testScope
|
||||||
)
|
)
|
||||||
|
|
||||||
private val meshService: BluetoothMeshService = mock()
|
private val meshService: MeshService = mock()
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setup() {
|
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)
|
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