This commit is contained in:
CC
2026-06-09 14:25:23 +02:00
parent b22629faa4
commit ff740229dc
31 changed files with 837 additions and 394 deletions
@@ -19,6 +19,7 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.Lifecycle
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.onboarding.BluetoothCheckScreen
import com.bitchat.android.onboarding.BluetoothStatus
import com.bitchat.android.onboarding.BluetoothStatusManager
@@ -41,7 +42,6 @@ import com.bitchat.android.ui.ChatViewModel
import com.bitchat.android.ui.OrientationAwareActivity
import com.bitchat.android.ui.theme.BitchatTheme
import com.bitchat.android.wifiaware.WifiAwareController
import com.bitchat.android.wifiaware.WifiAwareMeshDelegate
import com.bitchat.android.nostr.PoWPreferenceManager
import com.bitchat.android.services.VerificationService
import kotlinx.coroutines.delay
@@ -57,43 +57,13 @@ class MainActivity : OrientationAwareActivity() {
// Core mesh service - provided by the foreground service holder
private lateinit var meshService: BluetoothMeshService
private lateinit var unifiedMeshService: MeshService
private val mainViewModel: MainViewModel by viewModels()
private val chatViewModel: ChatViewModel by viewModels {
object : ViewModelProvider.Factory {
override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return ChatViewModel(application, meshService) as T
}
}
}
private val wifiAwareDelegate by lazy {
object : WifiAwareMeshDelegate {
override fun didReceiveMessage(message: com.bitchat.android.model.BitchatMessage) {
chatViewModel.didReceiveMessage(message)
}
override fun didUpdatePeerList(peers: List<String>) {
chatViewModel.onWifiPeersUpdated(peers)
}
override fun didReceiveChannelLeave(channel: String, fromPeer: String) {
chatViewModel.didReceiveChannelLeave(channel, fromPeer)
}
override fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) {
chatViewModel.didReceiveDeliveryAck(messageID, recipientPeerID)
}
override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) {
chatViewModel.didReceiveReadReceipt(messageID, recipientPeerID)
}
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
return chatViewModel.decryptChannelMessage(encryptedContent, channel)
}
override fun getNickname(): String? {
return chatViewModel.getNickname()
}
override fun isFavorite(peerID: String): Boolean {
return try {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.isMutual == true
} catch (_: Exception) { false }
return ChatViewModel(application, meshService, unifiedMeshService) as T
}
}
}
@@ -147,6 +117,7 @@ class MainActivity : OrientationAwareActivity() {
// Ensure foreground service is running and get mesh instance from holder
try { com.bitchat.android.service.MeshForegroundService.start(applicationContext) } catch (_: Exception) { }
meshService = com.bitchat.android.service.MeshServiceHolder.getOrCreate(applicationContext)
unifiedMeshService = com.bitchat.android.service.MeshServiceHolder.getUnifiedOrCreate(applicationContext)
// Expose BLE mesh to WiFi Aware controller for cross-transport relays - DEPRECATED
// Bridging is now handled by TransportBridgeService automatically
@@ -201,13 +172,12 @@ class MainActivity : OrientationAwareActivity() {
}
}
// Bridge WiFi Aware callbacks into ChatViewModel (reusing BLE delegate methods)
// Keep the unified mesh delegate attached when Wi-Fi Aware starts after the UI.
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
WifiAwareController.running.collect { running ->
val svc = WifiAwareController.getService()
if (running && svc != null && lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
svc.delegate = wifiAwareDelegate
if (running && lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
unifiedMeshService.delegate = chatViewModel
}
}
}
@@ -742,9 +712,9 @@ class MainActivity : OrientationAwareActivity() {
return@launch
}
// Set up mesh service delegate and start services
meshService.delegate = chatViewModel
meshService.startServices()
// Set up unified mesh delegate and start enabled transports
unifiedMeshService.delegate = chatViewModel
unifiedMeshService.startServices()
Log.d("MainActivity", "Mesh service started successfully")
@@ -788,8 +758,7 @@ class MainActivity : OrientationAwareActivity() {
// Check Bluetooth and Location status on resume and handle accordingly
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
// Reattach mesh delegate to new ChatViewModel instance after Activity recreation
try { meshService.delegate = chatViewModel } catch (_: Exception) { }
try { WifiAwareController.getService()?.delegate = wifiAwareDelegate } catch (_: Exception) { }
try { unifiedMeshService.delegate = chatViewModel } catch (_: Exception) { }
// Check if Bluetooth was disabled while app was backgrounded
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
@@ -821,8 +790,7 @@ class MainActivity : OrientationAwareActivity() {
// Only set background state if app is fully initialized
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
// Detach UI delegate so the foreground service can own DM notifications while UI is closed
try { meshService.delegate = null } catch (_: Exception) { }
try { WifiAwareController.getService()?.delegate = null } catch (_: Exception) { }
try { unifiedMeshService.delegate = null } catch (_: Exception) { }
}
}
@@ -32,6 +32,11 @@ class BluetoothGattClientManager(
companion object {
private const val TAG = "BluetoothGattClientManager"
// Self-healing scan recovery tuning
private const val SCAN_RETRY_BASE_MS = 3_000L // base backoff for transient scan failures
private const val SCAN_MAX_RETRY_DELAY_MS = 30_000L // cap on backoff delay
private const val SCAN_WATCHDOG_INTERVAL_MS = 30_000L // how often to verify the scanner is alive
private const val SCAN_STALE_RESULT_MS = 120_000L // force a scan restart if no results for this long
}
// Core Bluetooth components
@@ -78,8 +83,16 @@ class BluetoothGattClientManager(
// Scan rate limiting to prevent "scanning too frequently" errors
private var lastScanStartTime = 0L
private var lastScanStopTime = 0L
private var isCurrentlyScanning = false
@Volatile private var isCurrentlyScanning = false
private val scanRateLimit = 5000L // Minimum 5 seconds between scan start attempts
// Self-healing scan state.
// scanningDesired distinguishes "we want to be scanning but it isn't running" (a fault to recover
// from) from "scanning is intentionally off" (e.g. duty-cycle OFF window or client disabled).
@Volatile private var scanningDesired = false
@Volatile private var lastScanResultTime = 0L
private var scanRetryCount = 0
private var scanWatchdogJob: Job? = null
// RSSI monitoring state
private var rssiMonitoringJob: Job? = null
@@ -121,12 +134,16 @@ class BluetoothGattClientManager(
connectionScope.launch {
if (powerManager.shouldUseDutyCycle()) {
Log.i(TAG, "Using power-aware duty cycling")
// Duty cycle drives onScanStateChanged(true/false); scanningDesired follows that.
} else {
scanningDesired = true
startScanning()
}
// Start RSSI monitoring
startRSSIMonitoring()
// Start the scan watchdog so a silently-dead or wedged scanner self-heals.
startScanWatchdog()
}
return true
@@ -136,6 +153,8 @@ class BluetoothGattClientManager(
* Stop client manager
*/
fun stop() {
scanningDesired = false
stopScanWatchdog()
if (!isActive) {
// Idempotent stop
stopScanning()
@@ -166,6 +185,7 @@ class BluetoothGattClientManager(
*/
fun onScanStateChanged(shouldScan: Boolean) {
val enabled = isClientRoleEnabled()
scanningDesired = shouldScan && enabled
if (shouldScan && enabled) {
startScanning()
} else {
@@ -266,22 +286,39 @@ class BluetoothGattClientManager(
lastScanStopTime = System.currentTimeMillis()
when (errorCode) {
1 -> Log.e(TAG, "SCAN_FAILED_ALREADY_STARTED")
2 -> Log.e(TAG, "SCAN_FAILED_APPLICATION_REGISTRATION_FAILED")
3 -> Log.e(TAG, "SCAN_FAILED_INTERNAL_ERROR")
4 -> Log.e(TAG, "SCAN_FAILED_FEATURE_UNSUPPORTED")
5 -> Log.e(TAG, "SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES")
1 -> {
// Already started: the stack thinks a scan is running. Re-arm from a clean
// state so we don't stay wedged (stop then restart with backoff).
Log.e(TAG, "SCAN_FAILED_ALREADY_STARTED")
stopScanning()
scheduleScanRestart("already-started", SCAN_RETRY_BASE_MS)
}
2 -> {
// App registration failed: common transient stack fault. Previously had NO
// retry, which left discovery dead until a manual BLE toggle.
Log.e(TAG, "SCAN_FAILED_APPLICATION_REGISTRATION_FAILED")
scheduleScanRestart("registration-failed", SCAN_RETRY_BASE_MS)
}
3 -> {
Log.e(TAG, "SCAN_FAILED_INTERNAL_ERROR")
scheduleScanRestart("internal-error", SCAN_RETRY_BASE_MS)
}
4 -> Log.e(TAG, "SCAN_FAILED_FEATURE_UNSUPPORTED") // permanent: don't retry
5 -> {
// Out of hardware resources: back off longer so other scanners/connections
// can free up before we try again.
Log.e(TAG, "SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES")
scheduleScanRestart("out-of-resources", SCAN_RETRY_BASE_MS * 3)
}
6 -> {
Log.e(TAG, "SCAN_FAILED_SCANNING_TOO_FREQUENTLY")
Log.w(TAG, "Scan failed due to rate limiting - will retry after delay")
connectionScope.launch {
delay(10000) // Wait 10 seconds before retrying
if (isActive) {
startScanning()
}
}
scheduleScanRestart("too-frequently", 10_000L)
}
else -> {
Log.e(TAG, "Unknown scan failure code: $errorCode")
scheduleScanRestart("unknown-$errorCode", SCAN_RETRY_BASE_MS)
}
else -> Log.e(TAG, "Unknown scan failure code: $errorCode")
}
}
}
@@ -319,6 +356,76 @@ class BluetoothGattClientManager(
lastScanStopTime = System.currentTimeMillis()
}
}
/**
* Schedule a scan restart with incremental backoff. Used to recover from transient scan
* failures that previously had no retry path (codes 2/3/5), leaving discovery dead until a
* manual BLE toggle.
*/
private fun scheduleScanRestart(reason: String, baseDelayMs: Long) {
scanRetryCount++
val delayMs = (baseDelayMs * scanRetryCount).coerceAtMost(SCAN_MAX_RETRY_DELAY_MS)
Log.w(TAG, "Scheduling scan restart in ${delayMs}ms (attempt $scanRetryCount, reason=$reason)")
connectionScope.launch {
delay(delayMs)
if (isActive && scanningDesired && isClientRoleEnabled() && !isCurrentlyScanning) {
startScanning()
}
}
}
/**
* Periodic watchdog that self-heals the scanner. Android can stop a scan without ever invoking
* onScanFailed (internal stack reset, Doze, background throttling), which leaves the app
* believing it is scanning while it is not. This re-arms the scanner in those cases.
*/
private fun startScanWatchdog() {
scanWatchdogJob?.cancel()
scanWatchdogJob = connectionScope.launch {
while (isActive) {
delay(SCAN_WATCHDOG_INTERVAL_MS)
try {
// Only act when we are supposed to be scanning. Honors duty-cycle OFF windows
// and the client-disabled state via scanningDesired.
if (!isActive || !scanningDesired || !isClientRoleEnabled()) continue
if (!permissionManager.hasBluetoothPermissions() || bluetoothAdapter?.isEnabled != true) continue
val now = System.currentTimeMillis()
if (!isCurrentlyScanning) {
Log.w(TAG, "Watchdog: scan desired but not running -> restarting scan")
startScanning()
} else if (lastScanResultTime > 0L &&
now - lastScanResultTime > SCAN_STALE_RESULT_MS &&
now - lastScanStartTime > SCAN_STALE_RESULT_MS) {
// We think we're scanning but haven't seen anything for a long time. The scan
// may have silently died (flag wedged true). Force a clean re-arm.
Log.w(TAG, "Watchdog: no scan results for ${(now - lastScanResultTime) / 1000}s -> forcing scan restart")
forceRestartScan()
}
} catch (e: Exception) {
Log.w(TAG, "Scan watchdog error: ${e.message}")
}
}
}
}
private fun stopScanWatchdog() {
scanWatchdogJob?.cancel()
scanWatchdogJob = null
}
/**
* Force a clean scan restart, clearing a possibly-wedged isCurrentlyScanning flag.
*/
private fun forceRestartScan() {
stopScanning()
connectionScope.launch {
delay(500)
if (isActive && scanningDesired && isClientRoleEnabled() && !isCurrentlyScanning) {
startScanning()
}
}
}
/**
* Handle scan result and initiate connection if appropriate
@@ -335,6 +442,10 @@ class BluetoothGattClientManager(
return
}
// Proof the scanner is alive and finding our network: refresh liveness and clear backoff.
lastScanResultTime = System.currentTimeMillis()
scanRetryCount = 0
// Try to extract peerID from Service Data (if available) for stable identity
val serviceData = scanRecord?.getServiceData(ParcelUuid(AppConstants.Mesh.Gatt.SERVICE_UUID))
val peerID = if (serviceData != null && serviceData.size >= 8) {
@@ -30,6 +30,9 @@ class BluetoothGattServerManager(
companion object {
private const val TAG = "BluetoothGattServerManager"
// Self-healing advertising recovery tuning
private const val ADVERTISE_RETRY_BASE_MS = 3_000L // base backoff for transient advertise failures
private const val ADVERTISE_MAX_RETRY_DELAY_MS = 30_000L // cap on backoff delay
}
// Core Bluetooth components
@@ -42,6 +45,7 @@ class BluetoothGattServerManager(
private var gattServer: BluetoothGattServer? = null
private var characteristic: BluetoothGattCharacteristic? = null
private var advertiseCallback: AdvertiseCallback? = null
private var advertiseRetryCount = 0
// State management
private var isActive = false
@@ -385,6 +389,7 @@ class BluetoothGattServerManager(
advertiseCallback = object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
advertiseRetryCount = 0
val mode = try {
powerManager.getPowerInfo().split("Current Mode: ")[1].split("\n")[0]
} catch (_: Exception) { "unknown" }
@@ -393,6 +398,28 @@ class BluetoothGattServerManager(
override fun onStartFailure(errorCode: Int) {
Log.e(TAG, "Advertising failed: $errorCode")
// Previously this only logged, so if advertising failed this device became
// undiscoverable until a manual BLE toggle. Retry transient failures with backoff.
when (errorCode) {
ADVERTISE_FAILED_ALREADY_STARTED ->
Log.w(TAG, "ADVERTISE_FAILED_ALREADY_STARTED - already advertising, no retry")
ADVERTISE_FAILED_DATA_TOO_LARGE ->
Log.e(TAG, "ADVERTISE_FAILED_DATA_TOO_LARGE - config issue, not retrying")
ADVERTISE_FAILED_FEATURE_UNSUPPORTED ->
Log.e(TAG, "ADVERTISE_FAILED_FEATURE_UNSUPPORTED - unsupported, not retrying")
ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> {
Log.w(TAG, "ADVERTISE_FAILED_TOO_MANY_ADVERTISERS - will retry after backoff")
scheduleAdvertiseRestart("too-many-advertisers")
}
ADVERTISE_FAILED_INTERNAL_ERROR -> {
Log.w(TAG, "ADVERTISE_FAILED_INTERNAL_ERROR - will retry after backoff")
scheduleAdvertiseRestart("internal-error")
}
else -> {
Log.w(TAG, "Unknown advertise failure $errorCode - will retry after backoff")
scheduleAdvertiseRestart("unknown-$errorCode")
}
}
}
}
@@ -418,6 +445,23 @@ 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)
*/
@@ -1500,18 +1500,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
/**
* Delegate interface for mesh service callbacks (maintains exact same interface)
* Delegate interface for BLE mesh callbacks. Extends the shared mesh delegate so
* transport-agnostic facades can receive the same callback stream.
*/
interface BluetoothMeshDelegate {
fun didReceiveMessage(message: BitchatMessage)
fun didUpdatePeerList(peers: List<String>)
fun didReceiveChannelLeave(channel: String, fromPeer: String)
fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String)
fun didReceiveReadReceipt(messageID: String, recipientPeerID: String)
fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long)
fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long)
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
fun getNickname(): String?
fun isFavorite(peerID: String): Boolean
// registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager
interface BluetoothMeshDelegate : MeshDelegate {
override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long)
override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long)
}
@@ -315,11 +315,11 @@ class MeshCore(
}
override fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
// MeshDelegate intentionally does not expose QR verification yet.
delegate?.didReceiveVerifyChallenge(peerID, payload, timestampMs)
}
override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
// MeshDelegate intentionally does not expose QR verification yet.
delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
}
}
@@ -549,6 +549,44 @@ class MeshCore(
}
}
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()
@@ -718,6 +756,8 @@ class MeshCore(
fun getIdentityFingerprint(): String = encryptionService.getIdentityFingerprint()
fun getStaticNoisePublicKey(): ByteArray? = encryptionService.getStaticPublicKey()
fun shouldShowEncryptionIcon(peerID: String): Boolean = encryptionService.hasEstablishedSession(peerID)
fun getEncryptedPeers(): List<String> = emptyList()
@@ -11,6 +11,8 @@ interface MeshDelegate {
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
@@ -15,6 +15,10 @@ interface MeshService {
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
@@ -38,6 +42,7 @@ interface MeshService {
isVerified: Boolean
): Boolean
fun getIdentityFingerprint(): String
fun getStaticNoisePublicKey(): ByteArray?
fun shouldShowEncryptionIcon(peerID: String): Boolean
fun getEncryptedPeers(): List<String>
@@ -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 }
}
}
@@ -3,7 +3,7 @@ package com.bitchat.android.service
import android.app.Application
import android.os.Process
import androidx.core.app.NotificationManagerCompat
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.net.ArtiTorManager
import com.bitchat.android.net.TorMode
import kotlinx.coroutines.CoroutineScope
@@ -39,7 +39,7 @@ object AppShutdownCoordinator {
fun requestFullShutdownAndKill(
app: Application,
mesh: BluetoothMeshService?,
mesh: MeshService?,
notificationManager: NotificationManagerCompat,
stopForeground: () -> Unit,
stopService: () -> Unit
@@ -115,6 +115,8 @@ class MeshForegroundService : Service() {
private var updateJob: Job? = null
private val meshService: BluetoothMeshService?
get() = MeshServiceHolder.meshService
private val unifiedMeshService: com.bitchat.android.mesh.MeshService?
get() = MeshServiceHolder.unifiedMeshService
private val serviceJob = Job()
private val scope = CoroutineScope(Dispatchers.Default + serviceJob)
private var isInForeground: Boolean = false
@@ -134,6 +136,7 @@ class MeshForegroundService : Service() {
Log.i("MeshForegroundService", "Created new BluetoothMeshService via holder")
MeshServiceHolder.attach(created)
}
MeshServiceHolder.getUnifiedOrCreate(applicationContext)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -147,7 +150,7 @@ class MeshForegroundService : Service() {
when (intent?.action) {
ACTION_STOP -> {
// Stop FGS and mesh cleanly
try { meshService?.stopServices() } catch (_: Exception) { }
try { unifiedMeshService?.stopServices() ?: meshService?.stopServices() } catch (_: Exception) { }
try { MeshServiceHolder.clear() } catch (_: Exception) { }
try { stopForeground(true) } catch (_: Exception) { }
notificationManager.cancel(NOTIFICATION_ID)
@@ -165,7 +168,7 @@ class MeshForegroundService : Service() {
// Fully stop all background activity, stop Tor (without changing setting), then kill the app
AppShutdownCoordinator.requestFullShutdownAndKill(
app = application,
mesh = meshService,
mesh = unifiedMeshService,
notificationManager = notificationManager,
stopForeground = {
try { stopForeground(true) } catch (_: Exception) { }
@@ -178,7 +181,7 @@ class MeshForegroundService : Service() {
ACTION_UPDATE_NOTIFICATION -> {
// If we became eligible and are not in foreground yet, promote once
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
val n = buildNotification(meshService?.getActivePeerCount() ?: 0)
val n = buildNotification(getUnifiedActivePeerCount())
startForegroundCompat(n)
isInForeground = true
} else {
@@ -193,7 +196,7 @@ class MeshForegroundService : Service() {
// Promote exactly once when eligible, otherwise stay background (or stop)
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
val notification = buildNotification(meshService?.getActivePeerCount() ?: 0)
val notification = buildNotification(getUnifiedActivePeerCount())
startForegroundCompat(notification)
isInForeground = true
}
@@ -256,7 +259,7 @@ class MeshForegroundService : Service() {
notificationManager.cancel(NOTIFICATION_ID)
return
}
val count = meshService?.getActivePeerCount() ?: 0
val count = getUnifiedActivePeerCount()
val notification = buildNotification(count)
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) {
notificationManager.notify(NOTIFICATION_ID, notification)
@@ -276,6 +279,14 @@ class MeshForegroundService : Service() {
return hasBluetoothPermissions() && hasNotificationPermission()
}
private fun getUnifiedActivePeerCount(): Int {
return try {
unifiedMeshService?.getActivePeerCount() ?: meshService?.getActivePeerCount() ?: 0
} catch (_: Exception) {
0
}
}
private fun hasBluetoothPermissions(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_ADVERTISE) == android.content.pm.PackageManager.PERMISSION_GRANTED &&
@@ -2,6 +2,7 @@ package com.bitchat.android.service
import android.content.Context
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.UnifiedMeshService
/**
* Process-wide holder to share a single BluetoothMeshService instance
@@ -19,6 +20,10 @@ object MeshServiceHolder {
var meshService: BluetoothMeshService? = null
private set
@Volatile
var unifiedMeshService: UnifiedMeshService? = null
private set
@Synchronized
fun getOrCreate(context: Context): BluetoothMeshService {
val existing = meshService
@@ -37,18 +42,35 @@ object MeshServiceHolder {
val created = BluetoothMeshService(context.applicationContext)
android.util.Log.i(TAG, "Created new BluetoothMeshService (replacement)")
meshService = created
unifiedMeshService = null
created
}
} catch (e: Exception) {
android.util.Log.e(TAG, "Error checking service reusability; creating new instance: ${e.message}")
val created = BluetoothMeshService(context.applicationContext)
meshService = created
unifiedMeshService = null
created
}
}
val created = BluetoothMeshService(context.applicationContext)
android.util.Log.i(TAG, "Created new BluetoothMeshService (no existing instance)")
meshService = created
unifiedMeshService = null
return created
}
@Synchronized
fun getUnifiedOrCreate(context: Context): UnifiedMeshService {
val bluetooth = getOrCreate(context)
val existing = unifiedMeshService
if (existing != null) {
existing.refreshDelegates()
return existing
}
val created = UnifiedMeshService(context.applicationContext, bluetooth)
unifiedMeshService = created
android.util.Log.i(TAG, "Created new UnifiedMeshService")
return created
}
@@ -56,11 +78,13 @@ object MeshServiceHolder {
fun attach(service: BluetoothMeshService) {
android.util.Log.d(TAG, "Attaching BluetoothMeshService to holder")
meshService = service
unifiedMeshService = null
}
@Synchronized
fun clear() {
android.util.Log.d(TAG, "Clearing BluetoothMeshService from holder")
meshService = null
unifiedMeshService = null
}
}
@@ -2,7 +2,6 @@ package com.bitchat.android.services
import android.content.Context
import android.util.Log
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.ReadReceipt
import com.bitchat.android.nostr.NostrTransport
@@ -12,14 +11,14 @@ import com.bitchat.android.nostr.NostrTransport
*/
class MessageRouter private constructor(
private val context: Context,
private var mesh: BluetoothMeshService,
private var mesh: MeshService,
private val nostr: NostrTransport
) {
companion object {
private const val TAG = "MessageRouter"
@Volatile private var INSTANCE: MessageRouter? = null
fun tryGetInstance(): MessageRouter? = INSTANCE
fun getInstance(context: Context, mesh: BluetoothMeshService): MessageRouter {
fun getInstance(context: Context, mesh: MeshService): MessageRouter {
val instance = INSTANCE ?: synchronized(this) {
INSTANCE ?: run {
val nostr = NostrTransport.getInstance(context)
@@ -71,15 +70,10 @@ class MessageRouter private constructor(
}
}
val aware = getAwareService()
val hasMesh = isConnected(mesh, toPeerID)
val hasAwareConnection = isConnected(aware, toPeerID)
if (isReady(mesh, toPeerID)) {
Log.d(TAG, "Routing PM via mesh to ${toPeerID} msg_id=${messageID.take(8)}")
mesh.sendPrivateMessage(content, toPeerID, recipientNickname, messageID)
} else if (isReady(aware, toPeerID)) {
Log.d(TAG, "Routing PM via WiFi Aware to ${toPeerID} msg_id=${messageID.take(8)}")
aware?.sendPrivateMessage(content, toPeerID, recipientNickname, messageID)
} else if (canSendViaNostr(toPeerID)) {
Log.d(TAG, "Routing PM via Nostr to ${toPeerID.take(32)}… msg_id=${messageID.take(8)}")
nostr.sendPrivateMessage(content, toPeerID, recipientNickname, messageID)
@@ -88,23 +82,14 @@ class MessageRouter private constructor(
val q = outbox.getOrPut(toPeerID) { mutableListOf() }
q.add(Triple(content, recipientNickname, messageID))
Log.d(TAG, "Initiating noise handshake after queueing PM for ${toPeerID.take(8)}")
when {
hasMesh -> mesh.initiateNoiseHandshake(toPeerID)
hasAwareConnection -> aware?.initiateNoiseHandshake(toPeerID)
else -> aware?.initiateNoiseHandshake(toPeerID)
}
if (hasMesh) mesh.initiateNoiseHandshake(toPeerID)
}
}
fun sendReadReceipt(receipt: ReadReceipt, toPeerID: String) {
val aware = getAwareService()
if (isReady(mesh, toPeerID)) {
Log.d(TAG, "Routing READ via mesh to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}")
mesh.sendReadReceipt(receipt.originalMessageID, toPeerID, mesh.getPeerNicknames()[toPeerID] ?: mesh.myPeerID)
} else if (isReady(aware, toPeerID)) {
Log.d(TAG, "Routing READ via WiFi Aware to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}")
val me = try { aware?.myPeerID } catch (_: Exception) { null }
aware?.sendReadReceipt(receipt.originalMessageID, toPeerID, me ?: "")
} else {
Log.d(TAG, "Routing READ via Nostr to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}")
nostr.sendReadReceipt(receipt, toPeerID)
@@ -127,7 +112,7 @@ class MessageRouter private constructor(
}
fun sendFavoriteNotification(toPeerID: String, isFavorite: Boolean) {
if (mesh.getPeerInfo(toPeerID)?.isConnected == true) {
if (mesh.getPeerInfo(toPeerID)?.isConnected == true && mesh.hasEstablishedSession(toPeerID)) {
val myNpub = try { com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)?.npub } catch (_: Exception) { null }
val content = if (isFavorite) "[FAVORITED]:${myNpub ?: ""}" else "[UNFAVORITED]:${myNpub ?: ""}"
val nickname = mesh.getPeerNicknames()[toPeerID] ?: toPeerID
@@ -142,34 +127,23 @@ class MessageRouter private constructor(
val queued = outbox[peerID] ?: return
if (queued.isEmpty()) return
Log.d(TAG, "Flushing outbox for ${peerID.take(8)}… count=${queued.size}")
val aware = getAwareService()
val iterator = queued.iterator()
while (iterator.hasNext()) {
val (content, nickname, messageID) = iterator.next()
val hasMesh = isReady(mesh, peerID)
val hasAware = isReady(aware, peerID)
// If this is a noiseHex key, see if there is a connected mesh peer for this identity
if (!hasMesh && !hasAware && peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
if (!hasMesh && peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
val meshPeer = resolvePeerForNoiseHex(peerID, mesh)
if (meshPeer != null && isReady(mesh, meshPeer)) {
mesh.sendPrivateMessage(content, meshPeer, nickname, messageID)
iterator.remove()
continue
}
val awarePeer = resolvePeerForNoiseHex(peerID, aware)
if (awarePeer != null && isReady(aware, awarePeer)) {
aware?.sendPrivateMessage(content, awarePeer, nickname, messageID)
iterator.remove()
continue
}
}
val canNostr = canSendViaNostr(peerID)
if (hasMesh) {
mesh.sendPrivateMessage(content, peerID, nickname, messageID)
iterator.remove()
} else if (hasAware) {
aware?.sendPrivateMessage(content, peerID, nickname, messageID)
iterator.remove()
} else if (canNostr) {
nostr.sendPrivateMessage(content, peerID, nickname, messageID)
iterator.remove()
@@ -207,11 +181,7 @@ class MessageRouter private constructor(
return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
private fun getAwareService(): MeshService? {
return try { com.bitchat.android.wifiaware.WifiAwareController.getService() } catch (_: Exception) { null }
}
private fun isConnected(service: BluetoothMeshService, peerID: String): Boolean {
private fun isConnected(service: MeshService, peerID: String): Boolean {
return try {
service.getPeerInfo(peerID)?.isConnected == true
} catch (_: Exception) {
@@ -219,15 +189,7 @@ class MessageRouter private constructor(
}
}
private fun isConnected(service: MeshService?, peerID: String): Boolean {
return try {
service != null && service.getPeerInfo(peerID)?.isConnected == true
} catch (_: Exception) {
false
}
}
private fun isReady(service: BluetoothMeshService, peerID: String): Boolean {
private fun isReady(service: MeshService, peerID: String): Boolean {
return try {
service.getPeerInfo(peerID)?.isConnected == true &&
service.hasEstablishedSession(peerID)
@@ -236,29 +198,8 @@ class MessageRouter private constructor(
}
}
private fun isReady(service: MeshService?, peerID: String): Boolean {
private fun resolvePeerForNoiseHex(noiseHex: String, service: MeshService): String? {
return try {
service != null &&
service.getPeerInfo(peerID)?.isConnected == true &&
service.hasEstablishedSession(peerID)
} catch (_: Exception) {
false
}
}
private fun resolvePeerForNoiseHex(noiseHex: String, service: BluetoothMeshService): String? {
return try {
service.getPeerNicknames().keys.firstOrNull { pid ->
val info = service.getPeerInfo(pid)
val keyHex = info?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
keyHex != null && keyHex.equals(noiseHex, ignoreCase = true)
}
} catch (_: Exception) { null }
}
private fun resolvePeerForNoiseHex(noiseHex: String, service: MeshService?): String? {
return try {
if (service == null) return null
service.getPeerNicknames().keys.firstOrNull { pid ->
val info = service.getPeerInfo(pid)
val keyHex = info?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
@@ -275,10 +216,6 @@ class MessageRouter private constructor(
mesh.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
} catch (_: Exception) { null }
noiseHex?.let { flushOutboxFor(it) }
val awareNoiseHex = try {
getAwareService()?.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
} catch (_: Exception) { null }
awareNoiseHex?.let { flushOutboxFor(it) }
}
}
@@ -289,9 +226,5 @@ class MessageRouter private constructor(
mesh.getPeerInfo(peerID)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
} catch (_: Exception) { null }
noiseHex?.let { flushOutboxFor(it) }
val awareNoiseHex = try {
getAwareService()?.getPeerInfo(peerID)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
} catch (_: Exception) { null }
awareNoiseHex?.let { flushOutboxFor(it) }
}
}
@@ -444,7 +444,7 @@ private fun MainHeader(
)
Spacer(modifier = Modifier.width(2.dp))
PeerCounter(
connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID },
connectedPeers = connectedPeers.filter { it != viewModel.myPeerID },
joinedChannels = joinedChannels,
hasUnreadChannels = hasUnreadChannels,
isConnected = isConnected,
@@ -133,7 +133,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
MessagesList(
messages = displayMessages,
currentUserNickname = nickname,
meshService = viewModel.meshService,
meshService = viewModel.meshServiceFacade,
modifier = Modifier.weight(1f),
forceScrollToBottom = forceScrollToBottom,
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
@@ -11,7 +11,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Shield
import androidx.compose.ui.graphics.vector.ImageVector
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import androidx.compose.material3.ColorScheme
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import java.text.SimpleDateFormat
@@ -42,7 +42,7 @@ fun getRSSIColor(rssi: Int): Color {
fun formatMessageAsAnnotatedString(
message: BitchatMessage,
currentUserNickname: String,
meshService: BluetoothMeshService,
meshService: MeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
): AnnotatedString {
@@ -162,7 +162,7 @@ fun formatMessageAsAnnotatedString(
fun formatMessageHeaderAnnotatedString(
message: BitchatMessage,
currentUserNickname: String,
meshService: BluetoothMeshService,
meshService: MeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
): AnnotatedString {
@@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import com.bitchat.android.mesh.BluetoothMeshDelegate
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.service.MeshServiceHolder
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
@@ -38,12 +39,16 @@ import java.security.MessageDigest
*/
class ChatViewModel(
application: Application,
initialMeshService: BluetoothMeshService
initialMeshService: BluetoothMeshService,
initialUnifiedMeshService: MeshService
) : AndroidViewModel(application), BluetoothMeshDelegate {
// Made var to support mesh service replacement after panic clear
var meshService: BluetoothMeshService = initialMeshService
private set
private var unifiedMeshService: MeshService = initialUnifiedMeshService
private val mesh: MeshService
get() = unifiedMeshService
private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } }
companion object {
@@ -95,7 +100,7 @@ class ChatViewModel(
private val noiseSessionDelegate = object : NoiseSessionDelegate {
override fun hasEstablishedSession(peerID: String): Boolean = hasEstablishedSessionOnAnyLocalTransport(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)
@@ -109,7 +114,7 @@ class ChatViewModel(
private val verificationHandler = VerificationHandler(
context = application.applicationContext,
scope = viewModelScope,
getMeshService = { meshService },
getMeshService = { mesh },
identityManager = identityManager,
state = state,
notificationManager = notificationManager,
@@ -118,7 +123,7 @@ class ChatViewModel(
val verifiedFingerprints = verificationHandler.verifiedFingerprints
// Media file sending manager
private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager) { meshService }
private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager) { mesh }
// Delegate handler for mesh callbacks
private val meshDelegateHandler = MeshDelegateHandler(
@@ -129,8 +134,8 @@ class ChatViewModel(
notificationManager = notificationManager,
coroutineScope = viewModelScope,
onHapticFeedback = { ChatViewModelUtils.triggerHapticFeedback(application.applicationContext) },
getMyPeerID = { meshService.myPeerID },
getMeshService = { meshService }
getMyPeerID = { mesh.myPeerID },
getMeshService = { mesh }
)
// New Geohash architecture ViewModel (replaces God object service usage in UI path)
@@ -184,6 +189,18 @@ class ChatViewModel(
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
val teleportedGeo: StateFlow<Set<String>> = state.teleportedGeo
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
val meshServiceFacade: MeshService
get() = mesh
val myPeerID: String
get() = mesh.myPeerID
fun getMeshPeerFingerprint(peerID: String): String? = mesh.getPeerFingerprint(peerID)
fun getMeshPeerInfo(peerID: String): com.bitchat.android.mesh.PeerInfo? = mesh.getPeerInfo(peerID)
fun initiateMeshHandshake(peerID: String) {
mesh.initiateNoiseHandshake(peerID)
}
init {
// Note: Mesh service delegate is now set by MainActivity
@@ -208,7 +225,7 @@ class ChatViewModel(
// Recompute unread set using SeenMessageStore for robustness across Activity recreation
try {
val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication())
val myNick = state.getNicknameValue() ?: meshService.myPeerID
val myNick = state.getNicknameValue() ?: mesh.myPeerID
val unread = mutableSetOf<String>()
byPeer.forEach { (peer, list) ->
if (list.any { msg -> msg.sender != myNick && !seen.hasRead(msg.id) }) unread.add(peer)
@@ -299,7 +316,7 @@ class ChatViewModel(
// Ensure NostrTransport knows our mesh peer ID for embedded packets
try {
val nostrTransport = com.bitchat.android.nostr.NostrTransport.getInstance(getApplication())
nostrTransport.senderPeerID = meshService.myPeerID
nostrTransport.senderPeerID = mesh.myPeerID
} catch (_: Exception) { }
// Note: Mesh service is now started by MainActivity
@@ -317,7 +334,7 @@ class ChatViewModel(
fun setNickname(newNickname: String) {
state.setNickname(newNickname)
dataManager.saveNickname(newNickname)
meshService.sendBroadcastAnnounce()
mesh.sendBroadcastAnnounce()
}
/**
@@ -363,7 +380,7 @@ class ChatViewModel(
// MARK: - Channel Management (delegated)
fun joinChannel(channel: String, password: String? = null): Boolean {
return channelManager.joinChannel(channel, password, meshService.myPeerID)
return channelManager.joinChannel(channel, password, mesh.myPeerID)
}
fun switchToChannel(channel: String?) {
@@ -372,7 +389,7 @@ class ChatViewModel(
fun leaveChannel(channel: String) {
channelManager.leaveChannel(channel)
meshService.sendMessage("left $channel", emptyList(), null)
mesh.sendMessage("left $channel", emptyList(), null)
}
// MARK: - Private Chat Management (delegated)
@@ -383,7 +400,7 @@ class ChatViewModel(
ensureGeohashDMSubscriptionIfNeeded(peerID)
}
val success = privateChatManager.startPrivateChat(peerID, meshService)
val success = privateChatManager.startPrivateChat(peerID, mesh)
if (success) {
// Notify notification manager about current private chat
setCurrentPrivateChatPeer(peerID)
@@ -420,7 +437,7 @@ class ChatViewModel(
val unreadKeys = state.getUnreadPrivateMessagesValue()
if (unreadKeys.isEmpty()) return
val me = state.getNicknameValue() ?: meshService.myPeerID
val me = state.getNicknameValue() ?: mesh.myPeerID
val chats = state.getPrivateChatsValue()
// Pick the latest incoming message among unread conversations
@@ -451,8 +468,8 @@ class ChatViewModel(
val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
selectedPeerID = targetKey,
connectedPeers = state.getConnectedPeersValue(),
meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey },
meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true },
meshNoiseKeyForPeer = { pid -> mesh.getPeerInfo(pid)?.noisePublicKey },
meshHasPeer = { pid -> mesh.getPeerInfo(pid)?.isConnected == true },
nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) },
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
)
@@ -476,25 +493,23 @@ class ChatViewModel(
// Check for commands
if (content.startsWith("/")) {
val selectedLocationForCommand = state.selectedLocationChannel.value
commandProcessor.processCommand(content, meshService, meshService.myPeerID, { messageContent, mentions, channel ->
commandProcessor.processCommand(content, mesh, mesh.myPeerID, { messageContent, mentions, channel ->
if (selectedLocationForCommand is com.bitchat.android.geohash.ChannelID.Location) {
// Route command-generated public messages via Nostr in geohash channels
geohashViewModel.sendGeohashMessage(
messageContent,
selectedLocationForCommand.channel,
meshService.myPeerID,
mesh.myPeerID,
state.getNicknameValue()
)
} else {
// Default: route via mesh + WiFi Aware
meshService.sendMessage(messageContent, mentions, channel)
try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(messageContent, mentions, channel) } catch (_: Exception) {}
mesh.sendMessage(messageContent, mentions, channel)
}
}, this)
return
}
val mentions = messageManager.parseMentions(content, meshService.getPeerNicknames().values.toSet(), state.getNicknameValue())
val mentions = messageManager.parseMentions(content, mesh.getPeerNicknames().values.toSet(), state.getNicknameValue())
// REMOVED: Auto-join mentioned channels feature that was incorrectly parsing hashtags from @mentions
// This was causing messages like "test @jack#1234 test" to auto-join channel "#1234"
@@ -506,13 +521,13 @@ class ChatViewModel(
selectedPeer = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
selectedPeerID = selectedPeer,
connectedPeers = state.getConnectedPeersValue(),
meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey },
meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true },
meshNoiseKeyForPeer = { pid -> mesh.getPeerInfo(pid)?.noisePublicKey },
meshHasPeer = { pid -> mesh.getPeerInfo(pid)?.isConnected == true },
nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) },
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
).also { canonical ->
if (canonical != state.getSelectedPrivateChatPeerValue()) {
privateChatManager.startPrivateChat(canonical, meshService)
privateChatManager.startPrivateChat(canonical, mesh)
// If we're in the private chat sheet, update its active peer too
if (state.getPrivateChatSheetPeerValue() != null) {
showPrivateChatSheet(canonical)
@@ -526,10 +541,10 @@ class ChatViewModel(
selectedPeer,
recipientNickname,
state.getNicknameValue(),
meshService.myPeerID
mesh.myPeerID
) { messageContent, peerID, recipientNicknameParam, messageId ->
// Route via MessageRouter (mesh when connected+established, else Nostr)
val router = com.bitchat.android.services.MessageRouter.getInstance(getApplication(), meshService)
val router = com.bitchat.android.services.MessageRouter.getInstance(getApplication(), mesh)
router.sendPrivate(messageContent, peerID, recipientNicknameParam, messageId)
}
} else {
@@ -537,21 +552,21 @@ class ChatViewModel(
val selectedLocationChannel = state.selectedLocationChannel.value
if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) {
// Send to geohash channel via Nostr ephemeral event
geohashViewModel.sendGeohashMessage(content, selectedLocationChannel.channel, meshService.myPeerID, state.getNicknameValue())
geohashViewModel.sendGeohashMessage(content, selectedLocationChannel.channel, mesh.myPeerID, state.getNicknameValue())
} else {
// Send public/channel message via mesh
val message = BitchatMessage(
sender = state.getNicknameValue() ?: meshService.myPeerID,
sender = state.getNicknameValue() ?: mesh.myPeerID,
content = content,
timestamp = Date(),
isRelay = false,
senderPeerID = meshService.myPeerID,
senderPeerID = mesh.myPeerID,
mentions = if (mentions.isNotEmpty()) mentions else null,
channel = currentChannelValue
)
if (currentChannelValue != null) {
channelManager.addChannelMessage(currentChannelValue, message, meshService.myPeerID)
channelManager.addChannelMessage(currentChannelValue, message, mesh.myPeerID)
// Check if encrypted channel
if (channelManager.hasChannelKey(currentChannelValue)) {
@@ -560,25 +575,20 @@ class ChatViewModel(
mentions,
currentChannelValue,
state.getNicknameValue(),
meshService.myPeerID,
mesh.myPeerID,
onEncryptedPayload = { encryptedData ->
// Send encrypted payload announcement over both transports for reachability
meshService.sendMessage(content, mentions, currentChannelValue)
try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, currentChannelValue) } catch (_: Exception) {}
mesh.sendMessage(content, mentions, currentChannelValue)
},
onFallback = {
meshService.sendMessage(content, mentions, currentChannelValue)
try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, currentChannelValue) } catch (_: Exception) {}
mesh.sendMessage(content, mentions, currentChannelValue)
}
)
} else {
meshService.sendMessage(content, mentions, currentChannelValue)
try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, currentChannelValue) } catch (_: Exception) {}
mesh.sendMessage(content, mentions, currentChannelValue)
}
} else {
messageManager.addMessage(message)
meshService.sendMessage(content, mentions, null)
try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, null) } catch (_: Exception) {}
mesh.sendMessage(content, mentions, null)
}
}
}
@@ -587,7 +597,7 @@ class ChatViewModel(
// MARK: - Utility Functions
fun getPeerIDForNickname(nickname: String): String? {
return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key
return mesh.getPeerNicknames().entries.find { it.value == nickname }?.key
}
fun toggleFavorite(peerID: String) {
@@ -597,10 +607,10 @@ class ChatViewModel(
// Persist relationship in FavoritesPersistenceService
try {
var noiseKey: ByteArray? = null
var nickname: String = meshService.getPeerNicknames()[peerID] ?: peerID
var nickname: String = mesh.getPeerNicknames()[peerID] ?: peerID
// Case 1: Live mesh peer with known info
val peerInfo = meshService.getPeerInfo(peerID)
val peerInfo = mesh.getPeerInfo(peerID)
if (peerInfo?.noisePublicKey != null) {
noiseKey = peerInfo.noisePublicKey
nickname = peerInfo.nickname
@@ -630,22 +640,9 @@ class ChatViewModel(
// Send favorite notification via mesh or Nostr with our npub if available
try {
val myNostr = com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(getApplication())
val announcementContent = if (isNowFavorite) "[FAVORITED]:${myNostr?.npub ?: ""}" else "[UNFAVORITED]:${myNostr?.npub ?: ""}"
// Prefer mesh if session established, else try Nostr
if (meshService.hasEstablishedSession(peerID)) {
// Reuse existing private message path for notifications
meshService.sendPrivateMessage(
announcementContent,
peerID,
nickname,
java.util.UUID.randomUUID().toString()
)
} else {
val nostrTransport = com.bitchat.android.nostr.NostrTransport.getInstance(getApplication())
nostrTransport.senderPeerID = meshService.myPeerID
nostrTransport.sendFavoriteNotification(peerID, isNowFavorite)
}
com.bitchat.android.services.MessageRouter
.getInstance(getApplication(), mesh)
.sendFavoriteNotification(peerID, isNowFavorite)
} catch (_: Exception) { }
}
} catch (_: Exception) { }
@@ -662,21 +659,9 @@ class ChatViewModel(
Log.i("ChatViewModel", "==============================")
}
private fun getWifiAwareService(): com.bitchat.android.mesh.MeshService? {
return try { com.bitchat.android.wifiaware.WifiAwareController.getService() } catch (_: Exception) { null }
}
private fun isConnectedOnMesh(peerID: String): Boolean {
return try {
meshService.getPeerInfo(peerID)?.isConnected == true
} catch (_: Exception) {
false
}
}
private fun isConnectedOnService(service: com.bitchat.android.mesh.MeshService?, peerID: String): Boolean {
return try {
service != null && service.getPeerInfo(peerID)?.isConnected == true
mesh.getPeerInfo(peerID)?.isConnected == true
} catch (_: Exception) {
false
}
@@ -684,55 +669,28 @@ class ChatViewModel(
private fun hasEstablishedSessionOnMesh(peerID: String): Boolean {
return try {
meshService.getPeerInfo(peerID)?.isConnected == true &&
meshService.hasEstablishedSession(peerID)
} catch (_: Exception) {
false
}
}
private fun hasEstablishedSessionOnService(service: com.bitchat.android.mesh.MeshService?, peerID: String): Boolean {
return try {
service != null &&
service.getPeerInfo(peerID)?.isConnected == true &&
service.hasEstablishedSession(peerID)
mesh.getPeerInfo(peerID)?.isConnected == true &&
mesh.hasEstablishedSession(peerID)
} catch (_: Exception) {
false
}
}
private fun hasEstablishedSessionOnAnyLocalTransport(peerID: String): Boolean {
return hasEstablishedSessionOnMesh(peerID) ||
hasEstablishedSessionOnService(getWifiAwareService(), peerID)
return hasEstablishedSessionOnMesh(peerID)
}
private fun initiateNoiseHandshakeOnBestLocalTransport(peerID: String) {
val aware = getWifiAwareService()
when {
isConnectedOnMesh(peerID) -> meshService.initiateNoiseHandshake(peerID)
isConnectedOnService(aware, peerID) -> aware?.initiateNoiseHandshake(peerID)
else -> meshService.initiateNoiseHandshake(peerID)
}
mesh.initiateNoiseHandshake(peerID)
}
private fun nicknameForPeer(peerID: String): String? {
return state.peerNicknames.value[peerID]
?: try { meshService.getPeerNicknames()[peerID] } catch (_: Exception) { null }
?: try { getWifiAwareService()?.getPeerNicknames()?.get(peerID) } catch (_: Exception) { null }
?: try { mesh.getPeerNicknames()[peerID] } catch (_: Exception) { null }
}
private fun sessionStateForPeer(peerID: String): NoiseSession.NoiseSessionState {
val meshState = try { meshService.getSessionState(peerID) } catch (_: Exception) { NoiseSession.NoiseSessionState.Uninitialized }
val awareState = try { getWifiAwareService()?.getSessionState(peerID) } catch (_: Exception) { null }
return when {
meshState is NoiseSession.NoiseSessionState.Established -> meshState
awareState is NoiseSession.NoiseSessionState.Established -> awareState
meshState is NoiseSession.NoiseSessionState.Handshaking -> meshState
awareState is NoiseSession.NoiseSessionState.Handshaking -> awareState
meshState !is NoiseSession.NoiseSessionState.Uninitialized -> meshState
awareState != null -> awareState
else -> meshState
}
return try { mesh.getSessionState(peerID) } catch (_: Exception) { NoiseSession.NoiseSessionState.Uninitialized }
}
/**
@@ -766,7 +724,7 @@ class ChatViewModel(
val old = prevStates[peerID]
if (old != "established" && newState == "established") {
com.bitchat.android.services.MessageRouter
.getInstance(getApplication(), meshService)
.getInstance(getApplication(), mesh)
.onSessionEstablished(peerID)
}
}
@@ -775,7 +733,7 @@ class ChatViewModel(
state.setPeerFingerprints(fingerprints)
fingerprints.forEach { (peerID, fingerprint) ->
identityManager.cachePeerFingerprint(peerID, fingerprint)
val info = try { meshService.getPeerInfo(peerID) } catch (_: Exception) { null }
val info = try { mesh.getPeerInfo(peerID) } catch (_: Exception) { null }
val noiseKeyHex = info?.noisePublicKey?.hexEncodedString()
if (noiseKeyHex != null) {
identityManager.cachePeerNoiseKey(peerID, noiseKeyHex)
@@ -786,23 +744,14 @@ class ChatViewModel(
}
}
// Merge nicknames from BLE and WiFi Aware to display names for all peers
val bleNick = meshService.getPeerNicknames()
val awareNickRaw = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerNicknamesMap() } catch (_: Exception) { null }
val mergedNick = if (awareNickRaw != null) bleNick + awareNickRaw.filter { it.value != null }.mapValues { it.value!! }.filterKeys { it !in bleNick || bleNick[it].isNullOrBlank() } else bleNick
state.setPeerNicknames(mergedNick)
state.setPeerNicknames(mesh.getPeerNicknames())
val rssiValues = meshService.getPeerRSSI()
val awareRssi = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerRSSI() } catch (_: Exception) { null }
val mergedRssi = if (awareRssi != null) rssiValues + awareRssi.filterKeys { it !in rssiValues } else rssiValues
state.setPeerRSSI(mergedRssi)
state.setPeerRSSI(mesh.getPeerRSSI())
// Update directness per peer (driven by PeerManager state)
try {
val directMap = state.getConnectedPeersValue().associateWith { pid ->
val ble = meshService.getPeerInfo(pid)?.isDirectConnection == true
val aware = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerInfo(pid)?.isDirectConnection == true } catch (_: Exception) { false }
ble || aware
mesh.getPeerInfo(pid)?.isDirectConnection == true
}
state.setPeerDirect(directMap)
} catch (_: Exception) { }
@@ -839,7 +788,7 @@ class ChatViewModel(
// MARK: - Debug and Troubleshooting
fun getDebugStatus(): String {
return meshService.getDebugStatus()
return mesh.getDebugStatus()
}
fun setCurrentPrivateChatPeer(peerID: String?) {
@@ -936,7 +885,7 @@ class ChatViewModel(
// MARK: - Mention Autocomplete
fun updateMentionSuggestions(input: String) {
commandProcessor.updateMentionSuggestions(input, meshService, this)
commandProcessor.updateMentionSuggestions(input, mesh, this)
}
fun selectMentionSuggestion(nickname: String, currentText: String): String {
@@ -953,10 +902,6 @@ class ChatViewModel(
meshDelegateHandler.didUpdatePeerList(peers)
}
fun onWifiPeersUpdated(peers: List<String>) {
meshDelegateHandler.onWifiPeersUpdated(peers)
}
override fun didReceiveChannelLeave(channel: String, fromPeer: String) {
meshDelegateHandler.didReceiveChannelLeave(channel, fromPeer)
}
@@ -1040,7 +985,7 @@ class ChatViewModel(
// Recreate mesh service with fresh identity
recreateMeshServiceAfterPanic()
Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${meshService.myPeerID}")
Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${mesh.myPeerID}")
}
/**
@@ -1048,25 +993,27 @@ class ChatViewModel(
* This ensures the new cryptographic keys are used for a new peer ID.
*/
private fun recreateMeshServiceAfterPanic() {
val oldPeerID = meshService.myPeerID
val oldPeerID = mesh.myPeerID
// Clear the holder so getOrCreate() returns a fresh instance
MeshServiceHolder.clear()
// Create fresh mesh service with new identity (keys were regenerated in clearAllCryptographicData)
val freshMeshService = MeshServiceHolder.getOrCreate(getApplication())
val freshUnifiedMeshService = MeshServiceHolder.getUnifiedOrCreate(getApplication())
// Replace our reference and set up the new service
meshService = freshMeshService
meshService.delegate = this
unifiedMeshService = freshUnifiedMeshService
mesh.delegate = this
// Restart mesh operations with new identity
meshService.startServices()
meshService.sendBroadcastAnnounce()
mesh.startServices()
mesh.sendBroadcastAnnounce()
Log.d(
TAG,
"✅ Mesh service recreated. Old peerID: $oldPeerID, New peerID: ${meshService.myPeerID}"
"✅ Mesh service recreated. Old peerID: $oldPeerID, New peerID: ${mesh.myPeerID}"
)
}
@@ -1076,7 +1023,7 @@ class ChatViewModel(
private fun clearAllMeshServiceData() {
try {
// Request mesh service to clear all its internal data
meshService.clearAllInternalData()
mesh.clearAllInternalData()
Log.d(TAG, "✅ Cleared all mesh service data")
} catch (e: Exception) {
@@ -1090,7 +1037,7 @@ class ChatViewModel(
private fun clearAllCryptographicData() {
try {
// Clear encryption service persistent identity (Ed25519 signing keys)
meshService.clearAllEncryptionData()
mesh.clearAllEncryptionData()
// Clear secure identity state (if used)
try {
@@ -1,6 +1,6 @@
package com.bitchat.android.ui
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import java.util.Date
@@ -29,7 +29,7 @@ class CommandProcessor(
// MARK: - Command Processing
fun processCommand(command: String, meshService: BluetoothMeshService, myPeerID: String, onSendMessage: (String, List<String>, String?) -> Unit, viewModel: ChatViewModel? = null): Boolean {
fun processCommand(command: String, meshService: MeshService, myPeerID: String, onSendMessage: (String, List<String>, String?) -> Unit, viewModel: ChatViewModel? = null): Boolean {
if (!command.startsWith("/")) return false
val parts = command.split(" ")
@@ -77,7 +77,7 @@ class CommandProcessor(
}
}
private fun handleMessageCommand(parts: List<String>, meshService: BluetoothMeshService, viewModel: ChatViewModel?) {
private fun handleMessageCommand(parts: List<String>, meshService: MeshService, viewModel: ChatViewModel?) {
if (parts.size > 1) {
val targetName = parts[1].removePrefix("@")
val peerID = getPeerIDForNickname(targetName, meshService)
@@ -128,7 +128,7 @@ class CommandProcessor(
}
}
private fun handleWhoCommand(meshService: BluetoothMeshService, viewModel: ChatViewModel? = null) {
private fun handleWhoCommand(meshService: MeshService, viewModel: ChatViewModel? = null) {
// Channel-aware who command (matches iOS behavior)
val (peerList, contextDescription) = if (viewModel != null) {
when (val selectedChannel = viewModel.selectedLocationChannel.value) {
@@ -247,7 +247,7 @@ class CommandProcessor(
}
}
private fun handleBlockCommand(parts: List<String>, meshService: BluetoothMeshService) {
private fun handleBlockCommand(parts: List<String>, meshService: MeshService) {
if (parts.size > 1) {
val targetName = parts[1].removePrefix("@")
privateChatManager.blockPeerByNickname(targetName, meshService)
@@ -264,7 +264,7 @@ class CommandProcessor(
}
}
private fun handleUnblockCommand(parts: List<String>, meshService: BluetoothMeshService) {
private fun handleUnblockCommand(parts: List<String>, meshService: MeshService) {
if (parts.size > 1) {
val targetName = parts[1].removePrefix("@")
privateChatManager.unblockPeerByNickname(targetName, meshService)
@@ -283,7 +283,7 @@ class CommandProcessor(
parts: List<String>,
verb: String,
object_: String,
meshService: BluetoothMeshService,
meshService: MeshService,
myPeerID: String,
onSendMessage: (String, List<String>, String?) -> Unit,
viewModel: ChatViewModel?
@@ -423,7 +423,7 @@ class CommandProcessor(
// MARK: - Mention Autocomplete
fun updateMentionSuggestions(input: String, meshService: BluetoothMeshService, viewModel: ChatViewModel? = null) {
fun updateMentionSuggestions(input: String, meshService: MeshService, viewModel: ChatViewModel? = null) {
// Check if input contains @ and we're at the end of a word or at the end of input
val atIndex = input.lastIndexOf('@')
if (atIndex == -1) {
@@ -503,31 +503,21 @@ class CommandProcessor(
// MARK: - Utility Functions
private fun getPeerIDForNickname(nickname: String, meshService: BluetoothMeshService): String? {
private fun getPeerIDForNickname(nickname: String, meshService: MeshService): String? {
return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key
?: try {
com.bitchat.android.wifiaware.WifiAwareController.getService()
?.getPeerNicknames()
?.entries
?.find { it.value == nickname }
?.key
} catch (_: Exception) {
null
}
}
private fun getPeerNickname(peerID: String, meshService: BluetoothMeshService): String {
private fun getPeerNickname(peerID: String, meshService: MeshService): String {
return meshService.getPeerNicknames()[peerID]
?: try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerNicknames()?.get(peerID) } catch (_: Exception) { null }
?: peerID
}
private fun getMyPeerID(meshService: BluetoothMeshService): String {
private fun getMyPeerID(meshService: MeshService): String {
return meshService.myPeerID
}
private fun sendPrivateMessageVia(
meshService: BluetoothMeshService,
meshService: MeshService,
content: String,
peerID: String,
recipientNickname: String,
@@ -656,7 +656,7 @@ private fun meshTitleWithCount(viewModel: ChatViewModel): String {
}
private fun meshCount(viewModel: ChatViewModel): Int {
val myID = viewModel.meshService.myPeerID
val myID = viewModel.myPeerID
return viewModel.connectedPeers.value?.count { peerID ->
peerID != myID
} ?: 0
@@ -77,7 +77,7 @@ fun MessageWithMatrixAnimation(
message: com.bitchat.android.model.BitchatMessage,
messages: List<com.bitchat.android.model.BitchatMessage> = emptyList(),
currentUserNickname: String,
meshService: com.bitchat.android.mesh.BluetoothMeshService,
meshService: com.bitchat.android.mesh.MeshService,
colorScheme: androidx.compose.material3.ColorScheme,
timeFormatter: java.text.SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
@@ -124,7 +124,7 @@ fun MessageWithMatrixAnimation(
private fun AnimatedMessageDisplay(
message: com.bitchat.android.model.BitchatMessage,
currentUserNickname: String,
meshService: com.bitchat.android.mesh.BluetoothMeshService,
meshService: com.bitchat.android.mesh.MeshService,
colorScheme: androidx.compose.material3.ColorScheme,
timeFormatter: java.text.SimpleDateFormat,
modifier: Modifier = Modifier
@@ -241,7 +241,7 @@ private fun AnimatedMessageDisplay(
private fun formatMessageAsAnnotatedStringWithoutTimestamp(
message: com.bitchat.android.model.BitchatMessage,
currentUserNickname: String,
meshService: com.bitchat.android.mesh.BluetoothMeshService,
meshService: com.bitchat.android.mesh.MeshService,
colorScheme: androidx.compose.material3.ColorScheme
): AnnotatedString {
// Get the full formatted text first
@@ -1,7 +1,7 @@
package com.bitchat.android.ui
import android.util.Log
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
@@ -16,10 +16,10 @@ class MediaSendingManager(
private val state: ChatState,
private val messageManager: MessageManager,
private val channelManager: ChannelManager,
private val getMeshService: () -> BluetoothMeshService
private val getMeshService: () -> MeshService
) {
// Helper to get current mesh service (may change after panic clear)
private val meshService: BluetoothMeshService
private val meshService: MeshService
get() = getMeshService()
companion object {
private const val TAG = "MediaSendingManager"
@@ -213,7 +213,6 @@ class MediaSendingManager(
Log.d(TAG, "📤 Calling meshService.sendFilePrivate to $toPeerID")
meshService.sendFilePrivate(toPeerID, filePacket)
try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendFilePrivate(toPeerID, filePacket) } catch (_: Exception) {}
Log.d(TAG, "✅ File send completed successfully")
}
@@ -268,7 +267,6 @@ class MediaSendingManager(
Log.d(TAG, "📤 Calling meshService.sendFileBroadcast")
meshService.sendFileBroadcast(filePacket)
try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendFileBroadcast(filePacket) } catch (_: Exception) {}
Log.d(TAG, "✅ File broadcast completed successfully")
}
@@ -2,7 +2,7 @@ package com.bitchat.android.ui
import com.bitchat.android.mesh.BluetoothMeshDelegate
import com.bitchat.android.ui.NotificationTextUtils
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import kotlinx.coroutines.CoroutineScope
@@ -21,7 +21,7 @@ class MeshDelegateHandler(
private val coroutineScope: CoroutineScope,
private val onHapticFeedback: () -> Unit,
private val getMyPeerID: () -> String,
private val getMeshService: () -> BluetoothMeshService
private val getMeshService: () -> MeshService
) : BluetoothMeshDelegate {
override fun didReceiveMessage(message: BitchatMessage) {
@@ -94,30 +94,13 @@ class MeshDelegateHandler(
}
}
private var blePeers: Set<String> = emptySet()
private var wifiPeers: Set<String> = emptySet()
override fun didUpdatePeerList(peers: List<String>) {
coroutineScope.launch {
blePeers = peers.toSet()
try { com.bitchat.android.services.AppStateStore.setTransportPeers("BLE", peers) } catch (_: Exception) { }
processPeerUpdate()
processPeerUpdate(peers.distinct())
}
}
fun onWifiPeersUpdated(peers: List<String>) {
coroutineScope.launch {
wifiPeers = peers.toSet()
try { com.bitchat.android.services.AppStateStore.setTransportPeers("WIFI", peers) } catch (_: Exception) { }
processPeerUpdate()
}
}
private suspend fun processPeerUpdate() {
// Merge peers from multiple transports
val mergedPeers = com.bitchat.android.services.AppStateStore.peers.value
.ifEmpty { (blePeers + wifiPeers).toList() }
private suspend fun processPeerUpdate(mergedPeers: List<String>) {
state.setConnectedPeers(mergedPeers)
state.setIsConnected(mergedPeers.isNotEmpty())
notificationManager.showActiveUserNotification(mergedPeers)
@@ -323,14 +306,7 @@ class MeshDelegateHandler(
mesh.sendReadReceipt(message.id, senderPeerID, nickname)
true
} else {
val aware = try { com.bitchat.android.wifiaware.WifiAwareController.getService() } catch (_: Exception) { null }
val hasAware = try { aware?.getPeerInfo(senderPeerID)?.isConnected == true && aware.hasEstablishedSession(senderPeerID) } catch (_: Exception) { false }
if (hasAware) {
aware?.sendReadReceipt(message.id, senderPeerID, nickname)
true
} else {
false
}
false
}
} catch (_: Exception) {
false
@@ -337,7 +337,7 @@ fun PeopleSection(
// Build mapping of connected peerID -> noise key hex to unify with offline favorites
val noiseHexByPeerID: Map<String, String> = connectedPeers.associateWith { pid ->
try {
viewModel.meshService.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
viewModel.getMeshPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
} catch (_: Exception) { null }
}.filterValues { it != null }.mapValues { it.value!! }
@@ -415,7 +415,7 @@ fun PeopleSection(
val showHash = (baseNameCounts[bName] ?: 0) > 1
val directMap by viewModel.peerDirect.collectAsStateWithLifecycle()
val isDirectLive = directMap[peerID] ?: try { viewModel.meshService.getPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false }
val isDirectLive = directMap[peerID] ?: try { viewModel.getMeshPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false }
PeerItem(
peerID = peerID,
displayName = displayName,
@@ -787,6 +787,8 @@ fun PrivateChatSheet(
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle()
val isWifiAware = peerID in wifiAwareConnected.keys
// Start private chat when screen opens
LaunchedEffect(peerID) {
@@ -855,7 +857,7 @@ fun PrivateChatSheet(
MessagesList(
messages = messages,
currentUserNickname = nickname,
meshService = viewModel.meshService,
meshService = viewModel.meshServiceFacade,
modifier = Modifier.weight(1f),
forceScrollToBottom = forceScrollToBottom,
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
@@ -939,10 +941,26 @@ fun PrivateChatSheet(
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
when {
isNostrPeer -> {
Icon(
imageVector = Icons.Filled.Public,
contentDescription = stringResource(R.string.cd_nostr_reachable),
modifier = Modifier.size(14.dp),
tint = Color(0xFF9C27B0)
)
}
isWifiAware -> {
Icon(
imageVector = Icons.Filled.Wifi,
contentDescription = "Direct Wi-Fi Aware",
modifier = Modifier.size(14.dp),
tint = colorScheme.onSurface.copy(alpha = 0.6f)
)
}
isDirect -> {
Icon(
imageVector = Icons.Outlined.SettingsInputAntenna,
contentDescription = stringResource(R.string.cd_connected_peers),
imageVector = Icons.Outlined.Bluetooth,
contentDescription = "Direct Bluetooth",
modifier = Modifier.size(14.dp),
tint = colorScheme.onSurface.copy(alpha = 0.6f)
)
@@ -950,19 +968,11 @@ fun PrivateChatSheet(
isConnected -> {
Icon(
imageVector = Icons.Filled.Route,
contentDescription = stringResource(R.string.cd_ready_for_handshake),
contentDescription = "Routed",
modifier = Modifier.size(14.dp),
tint = colorScheme.onSurface.copy(alpha = 0.6f)
)
}
isNostrPeer -> {
Icon(
imageVector = Icons.Filled.Public,
contentDescription = stringResource(R.string.cd_nostr_reachable),
modifier = Modifier.size(14.dp),
tint = Color(0xFF9C27B0)
)
}
}
Text(
@@ -31,7 +31,7 @@ import android.content.Intent
import android.net.Uri
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import java.text.SimpleDateFormat
import java.util.*
import com.bitchat.android.ui.media.VoiceNotePlayer
@@ -58,7 +58,7 @@ import androidx.compose.ui.res.stringResource
fun MessagesList(
messages: List<BitchatMessage>,
currentUserNickname: String,
meshService: BluetoothMeshService,
meshService: MeshService,
modifier: Modifier = Modifier,
forceScrollToBottom: Boolean = false,
onScrolledUpChanged: ((Boolean) -> Unit)? = null,
@@ -137,7 +137,7 @@ fun MessagesList(
fun MessageItem(
message: BitchatMessage,
currentUserNickname: String,
meshService: BluetoothMeshService,
meshService: MeshService,
messages: List<BitchatMessage> = emptyList(),
onNicknameClick: ((String) -> Unit)? = null,
onMessageLongPress: ((BitchatMessage) -> Unit)? = null,
@@ -201,7 +201,7 @@ fun MessageItem(
message: BitchatMessage,
messages: List<BitchatMessage>,
currentUserNickname: String,
meshService: BluetoothMeshService,
meshService: MeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
@@ -3,9 +3,9 @@ package com.bitchat.android.ui
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.mesh.PeerFingerprintManager
import com.bitchat.android.mesh.MeshService
import java.security.MessageDigest
import com.bitchat.android.mesh.BluetoothMeshService
import java.util.*
import android.util.Log
@@ -42,7 +42,7 @@ class PrivateChatManager(
// MARK: - Private Chat Lifecycle
fun startPrivateChat(peerID: String, meshService: BluetoothMeshService): Boolean {
fun startPrivateChat(peerID: String, meshService: MeshService): Boolean {
if (isPeerBlocked(peerID)) {
val peerNickname = getPeerNickname(peerID, meshService)
val systemMessage = BitchatMessage(
@@ -188,7 +188,7 @@ class PrivateChatManager(
// MARK: - Block/Unblock Operations
fun blockPeer(peerID: String, meshService: BluetoothMeshService): Boolean {
fun blockPeer(peerID: String, meshService: MeshService): Boolean {
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
if (fingerprint != null) {
dataManager.addBlockedUser(fingerprint)
@@ -212,7 +212,7 @@ class PrivateChatManager(
return false
}
fun unblockPeer(peerID: String, meshService: BluetoothMeshService): Boolean {
fun unblockPeer(peerID: String, meshService: MeshService): Boolean {
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
if (fingerprint != null && dataManager.isUserBlocked(fingerprint)) {
dataManager.removeBlockedUser(fingerprint)
@@ -230,7 +230,7 @@ class PrivateChatManager(
return false
}
fun blockPeerByNickname(targetName: String, meshService: BluetoothMeshService): Boolean {
fun blockPeerByNickname(targetName: String, meshService: MeshService): Boolean {
val peerID = getPeerIDForNickname(targetName, meshService)
if (peerID != null) {
@@ -247,7 +247,7 @@ class PrivateChatManager(
}
}
fun unblockPeerByNickname(targetName: String, meshService: BluetoothMeshService): Boolean {
fun unblockPeerByNickname(targetName: String, meshService: MeshService): Boolean {
val peerID = getPeerIDForNickname(targetName, meshService)
if (peerID != null) {
@@ -333,7 +333,7 @@ class PrivateChatManager(
* Send read receipts for all unread messages from a specific peer
* Called when the user focuses on a private chat
*/
fun sendReadReceiptsForPeer(peerID: String, meshService: BluetoothMeshService) {
fun sendReadReceiptsForPeer(peerID: String, meshService: MeshService) {
// Collect candidate messages: all incoming messages from this peer in the conversation
val chats = try { state.getPrivateChatsValue() } catch (_: Exception) { emptyMap<String, List<BitchatMessage>>() }
val messages = chats[peerID].orEmpty()
@@ -343,9 +343,7 @@ class PrivateChatManager(
}
val myNickname = state.getNicknameValue() ?: "unknown"
val aware = try { com.bitchat.android.wifiaware.WifiAwareController.getService() } catch (_: Exception) { null }
val hasMesh = try { meshService.getPeerInfo(peerID)?.isConnected == true && meshService.hasEstablishedSession(peerID) } catch (_: Exception) { false }
val hasAware = try { aware?.getPeerInfo(peerID)?.isConnected == true && aware.hasEstablishedSession(peerID) } catch (_: Exception) { false }
var sentCount = 0
messages.forEach { msg ->
// Only for incoming messages from this peer
@@ -354,9 +352,6 @@ class PrivateChatManager(
if (hasMesh) {
meshService.sendReadReceipt(msg.id, peerID, myNickname)
sentCount += 1
} else if (hasAware) {
aware?.sendReadReceipt(msg.id, peerID, myNickname)
sentCount += 1
}
} catch (e: Exception) {
Log.w(TAG, "Failed to send read receipt for message ${msg.id}: ${e.message}")
@@ -388,7 +383,7 @@ class PrivateChatManager(
* Establish Noise session if needed before starting private chat
* Uses same lexicographical logic as MessageHandler.handleNoiseIdentityAnnouncement
*/
private fun establishNoiseSessionIfNeeded(peerID: String, meshService: BluetoothMeshService) {
private fun establishNoiseSessionIfNeeded(peerID: String, meshService: MeshService) {
if (noiseSessionDelegate.hasEstablishedSession(peerID)) {
Log.d(TAG, "Noise session already established with $peerID")
return
@@ -421,11 +416,11 @@ class PrivateChatManager(
// MARK: - Utility Functions
private fun getPeerIDForNickname(nickname: String, meshService: BluetoothMeshService): String? {
private fun getPeerIDForNickname(nickname: String, meshService: MeshService): String? {
return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key
}
private fun getPeerNickname(peerID: String, meshService: BluetoothMeshService): String {
private fun getPeerNickname(peerID: String, meshService: MeshService): String {
return meshService.getPeerNicknames()[peerID] ?: peerID
}
@@ -134,7 +134,7 @@ fun SecurityVerificationSheet(
displayName = displayName,
accent = accent,
canStartHandshake = fingerprint == null && selectedPeerID.matches(peerHexRegex),
onStartHandshake = { viewModel.meshService.initiateNoiseHandshake(selectedPeerID) },
onStartHandshake = { viewModel.initiateMeshHandshake(selectedPeerID) },
onVerify = { fp -> viewModel.verifyFingerprintValue(fp) },
onUnverify = { fp -> viewModel.unverifyFingerprintValue(fp) }
)
@@ -4,7 +4,7 @@ import android.content.Context
import com.bitchat.android.R
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.noise.NoiseSession
import com.bitchat.android.nostr.GeohashAliasRegistry
@@ -26,14 +26,14 @@ import java.util.concurrent.ConcurrentHashMap
class VerificationHandler(
private val context: Context,
private val scope: CoroutineScope,
private val getMeshService: () -> BluetoothMeshService,
private val getMeshService: () -> MeshService,
private val identityManager: SecureIdentityStateManager,
private val state: ChatState,
private val notificationManager: NotificationManager,
private val messageManager: MessageManager
) {
// Helper to get current mesh service (may change after panic clear)
private val meshService: BluetoothMeshService
private val meshService: MeshService
get() = getMeshService()
private val _verifiedFingerprints = MutableStateFlow<Set<String>>(emptySet())
@@ -193,7 +193,7 @@ fun VerificationSheet(
val fingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
if (peerID != null) {
val fingerprint = viewModel.meshService.getPeerFingerprint(peerID!!)
val fingerprint = viewModel.getMeshPeerFingerprint(peerID!!)
if (fingerprint != null && fingerprints.contains(fingerprint)) {
Spacer(modifier = Modifier.height(16.dp))
Button(
@@ -21,7 +21,7 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import androidx.compose.material3.ColorScheme
import java.text.SimpleDateFormat
@@ -30,7 +30,7 @@ import java.text.SimpleDateFormat
fun AudioMessageItem(
message: BitchatMessage,
currentUserNickname: String,
meshService: BluetoothMeshService,
meshService: MeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
@@ -27,7 +27,7 @@ import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.text.font.FontFamily
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import androidx.compose.material3.ColorScheme
@@ -39,7 +39,7 @@ fun ImageMessageItem(
message: BitchatMessage,
messages: List<BitchatMessage>,
currentUserNickname: String,
meshService: BluetoothMeshService,
meshService: MeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
@@ -151,6 +151,7 @@ object WifiAwareController {
service = startedService
_running.value = true
}
try { com.bitchat.android.service.MeshServiceHolder.unifiedMeshService?.refreshDelegates() } catch (_: Exception) { }
try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("WiFi Aware started")) } catch (_: Exception) {}
} else {
if (reusableService == null) {
@@ -1103,6 +1103,14 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
meshCore.sendReadReceipt(messageID, recipientPeerID, readerNickname)
}
override fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
meshCore.sendVerifyChallenge(peerID, noiseKeyHex, nonceA)
}
override fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
meshCore.sendVerifyResponse(peerID, noiseKeyHex, nonceA)
}
/**
* Broadcasts a file (TLV payload) to all peers. Uses protocol version 2 to support
* large payloads and generates a deterministic transferId (sha256 of payload) for UI/state.
@@ -1205,6 +1213,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
*/
override fun getIdentityFingerprint(): String = meshCore.getIdentityFingerprint()
override fun getStaticNoisePublicKey(): ByteArray? = meshCore.getStaticNoisePublicKey()
/**
* @return true if the UI should show an “encrypted” indicator for this peer.
*/