Merge branch 'main' into gossip-routing-2

This commit is contained in:
callebtc
2026-01-07 05:52:25 +07:00
121 changed files with 9291 additions and 1713 deletions
@@ -84,6 +84,10 @@ class BluetoothConnectionManager(
// Public property for address-peer mapping
val addressPeerMap get() = connectionTracker.addressPeerMap
// Expose first-announce helpers to higher layers
fun noteAnnounceReceived(address: String) { connectionTracker.noteAnnounceReceived(address) }
fun hasSeenFirstAnnounce(address: String): Boolean = connectionTracker.hasSeenFirstAnnounce(address)
init {
powerManager.delegate = this
@@ -145,6 +149,7 @@ class BluetoothConnectionManager(
try {
isActive = true
Log.d(TAG, "ConnectionManager activated (permissions and adapter OK)")
// set the adapter's name to our 8-character peerID for iOS privacy, TODO: Make this configurable
// try {
@@ -175,6 +180,7 @@ class BluetoothConnectionManager(
this@BluetoothConnectionManager.isActive = false
return@launch
}
Log.d(TAG, "GATT Server started")
} else {
Log.i(TAG, "GATT Server disabled by debug settings; not starting")
}
@@ -185,6 +191,7 @@ class BluetoothConnectionManager(
this@BluetoothConnectionManager.isActive = false
return@launch
}
Log.d(TAG, "GATT Client started")
} else {
Log.i(TAG, "GATT Client disabled by debug settings; not starting")
}
@@ -210,6 +217,7 @@ class BluetoothConnectionManager(
isActive = false
connectionScope.launch {
Log.d(TAG, "Stopping client/server and power components...")
// Stop component managers
clientManager.stop()
serverManager.stop()
@@ -226,14 +234,19 @@ class BluetoothConnectionManager(
Log.i(TAG, "All Bluetooth services stopped")
}
}
/**
* Set app background state for power optimization
*/
fun setAppBackgroundState(inBackground: Boolean) {
powerManager.setAppBackgroundState(inBackground)
}
/**
* Indicates whether this instance can be safely reused for a future start.
* Returns false if its coroutine scope has been cancelled.
*/
fun isReusable(): Boolean {
val active = connectionScope.isActive
if (!active) {
Log.d(TAG, "BluetoothConnectionManager isReusable=false (scope cancelled)")
}
return active
}
/**
* Broadcast packet to connected devices with connection limit enforcement
* Automatically fragments large packets to fit within BLE MTU limits
@@ -30,6 +30,8 @@ class BluetoothConnectionTracker(
private val connectedDevices = ConcurrentHashMap<String, DeviceConnection>()
private val subscribedDevices = CopyOnWriteArrayList<BluetoothDevice>()
val addressPeerMap = ConcurrentHashMap<String, String>()
// Track whether we have seen the first ANNOUNCE on a given device connection
private val firstAnnounceSeen = ConcurrentHashMap<String, Boolean>()
// RSSI tracking from scan results (for devices we discover but may connect as servers)
private val scanRSSI = ConcurrentHashMap<String, Int>()
@@ -91,6 +93,8 @@ class BluetoothConnectionTracker(
Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}")
connectedDevices[deviceAddress] = deviceConn
pendingConnections.remove(deviceAddress)
// Mark as awaiting first ANNOUNCE on this connection
firstAnnounceSeen[deviceAddress] = false
}
/**
@@ -194,7 +198,8 @@ class BluetoothConnectionTracker(
}
// Update connection attempt atomically
val attempts = (currentAttempt?.attempts ?: 0) + 1
// If the previous attempt window expired, reset backoff to 1; otherwise increment
val attempts = if (currentAttempt?.isExpired() == true) 1 else (currentAttempt?.attempts ?: 0) + 1
pendingConnections[deviceAddress] = ConnectionAttempt(attempts)
Log.d(TAG, "Tracker: Added pending connection for $deviceAddress (attempts: $attempts)")
return true
@@ -279,7 +284,7 @@ class BluetoothConnectionTracker(
subscribedDevices.removeAll { it.address == deviceAddress }
addressPeerMap.remove(deviceAddress)
}
pendingConnections.remove(deviceAddress)
firstAnnounceSeen.remove(deviceAddress)
Log.d(TAG, "Cleaned up device connection for $deviceAddress")
}
@@ -313,6 +318,21 @@ class BluetoothConnectionTracker(
addressPeerMap.clear()
pendingConnections.clear()
scanRSSI.clear()
firstAnnounceSeen.clear()
}
/**
* Mark that we have received the first ANNOUNCE over this device connection.
*/
fun noteAnnounceReceived(deviceAddress: String) {
firstAnnounceSeen[deviceAddress] = true
}
/**
* Check whether the first ANNOUNCE has been seen for a device connection.
*/
fun hasSeenFirstAnnounce(deviceAddress: String): Boolean {
return firstAnnounceSeen[deviceAddress] == true
}
/**
@@ -7,12 +7,15 @@ import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.protocol.MessagePadding
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.model.NoisePayload
import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.protocol.SpecialRecipients
import com.bitchat.android.model.RequestSyncPacket
import com.bitchat.android.sync.GossipSyncManager
import com.bitchat.android.util.toHexString
import com.bitchat.android.services.VerificationService
import kotlinx.coroutines.*
import java.util.*
import kotlin.math.sign
@@ -52,6 +55,12 @@ class BluetoothMeshService(private val context: Context) {
internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access
private val packetProcessor = PacketProcessor(myPeerID)
private lateinit var gossipSyncManager: GossipSyncManager
// Service-level notification manager for background (no-UI) DMs
private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager(
context.applicationContext,
androidx.core.app.NotificationManagerCompat.from(context.applicationContext),
com.bitchat.android.util.NotificationIntervalManager()
)
// Service state management
private var isActive = false
@@ -61,8 +70,12 @@ class BluetoothMeshService(private val context: Context) {
// Coroutines
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// Tracks whether this instance has been terminated via stopServices()
private var terminated = false
init {
Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID")
VerificationService.configure(encryptionService)
setupDelegates()
messageHandler.packetProcessor = packetProcessor
//startPeriodicDebugLogging()
@@ -98,6 +111,7 @@ class BluetoothMeshService(private val context: Context) {
return signPacketBeforeBroadcast(packet)
}
}
Log.d(TAG, "Delegates set up; GossipSyncManager initialized")
}
/**
@@ -105,6 +119,7 @@ class BluetoothMeshService(private val context: Context) {
*/
private fun startPeriodicDebugLogging() {
serviceScope.launch {
Log.d(TAG, "Starting periodic debug logging loop")
while (isActive) {
try {
delay(10000) // 10 seconds
@@ -116,6 +131,7 @@ class BluetoothMeshService(private val context: Context) {
Log.e(TAG, "Error in periodic debug logging: ${e.message}")
}
}
Log.d(TAG, "Periodic debug logging loop ended (isActive=$isActive)")
}
}
@@ -124,6 +140,7 @@ class BluetoothMeshService(private val context: Context) {
*/
private fun sendPeriodicBroadcastAnnounce() {
serviceScope.launch {
Log.d(TAG, "Starting periodic announce loop")
while (isActive) {
try {
delay(30000) // 30 seconds
@@ -132,6 +149,7 @@ class BluetoothMeshService(private val context: Context) {
Log.e(TAG, "Error in periodic broadcast announce: ${e.message}")
}
}
Log.d(TAG, "Periodic announce loop ended (isActive=$isActive)")
}
}
@@ -139,6 +157,7 @@ class BluetoothMeshService(private val context: Context) {
* Setup delegate connections between components
*/
private fun setupDelegates() {
Log.d(TAG, "Setting up component delegates")
// Provide nickname resolver to BLE broadcaster for detailed logs
try {
connectionManager.setNicknameResolver { pid -> peerManager.getPeerNickname(pid) }
@@ -146,6 +165,9 @@ class BluetoothMeshService(private val context: Context) {
// PeerManager delegates to main mesh service delegate
peerManager.delegate = object : PeerManagerDelegate {
override fun onPeerListUpdated(peerIDs: List<String>) {
// Update process-wide state first
try { com.bitchat.android.services.AppStateStore.setPeers(peerIDs) } catch (_: Exception) { }
// Then notify UI delegate if attached
delegate?.didUpdatePeerList(peerIDs)
}
override fun onPeerRemoved(peerID: String) {
@@ -165,6 +187,7 @@ class BluetoothMeshService(private val context: Context) {
override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) {
// Send announcement and cached messages after key exchange
serviceScope.launch {
Log.d(TAG, "Key exchange completed with $peerID; sending follow-ups")
delay(100)
sendAnnouncementToPeer(peerID)
@@ -352,7 +375,36 @@ class BluetoothMeshService(private val context: Context) {
// Callbacks
override fun onMessageReceived(message: BitchatMessage) {
// Always reflect into process-wide store so UI can hydrate after recreation
try {
when {
message.isPrivate -> {
val peer = message.senderPeerID ?: ""
if (peer.isNotEmpty()) com.bitchat.android.services.AppStateStore.addPrivateMessage(peer, message)
}
message.channel != null -> {
com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel!!, message)
}
else -> {
com.bitchat.android.services.AppStateStore.addPublicMessage(message)
}
}
} catch (_: Exception) { }
// And forward to UI delegate if attached
delegate?.didReceiveMessage(message)
// If no UI delegate attached (app closed), show DM notification via service manager
if (delegate == null && message.isPrivate) {
try {
val senderPeerID = message.senderPeerID
if (senderPeerID != null) {
val nick = try { peerManager.getPeerNickname(senderPeerID) } catch (_: Exception) { null } ?: senderPeerID
val preview = com.bitchat.android.ui.NotificationTextUtils.buildPrivateMessagePreview(message)
serviceNotificationManager.setAppBackgroundState(true)
serviceNotificationManager.showPrivateMessageNotification(senderPeerID, nick, preview)
}
} catch (_: Exception) { }
}
}
override fun onChannelLeave(channel: String, fromPeer: String) {
@@ -366,6 +418,14 @@ class BluetoothMeshService(private val context: Context) {
override fun onReadReceiptReceived(messageID: String, peerID: String) {
delegate?.didReceiveReadReceipt(messageID, peerID)
}
override fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
delegate?.didReceiveVerifyChallenge(peerID, payload, timestampMs)
}
override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
}
}
// PacketProcessor delegates
@@ -408,25 +468,17 @@ class BluetoothMeshService(private val context: Context) {
val deviceAddress = routed.relayAddress
val pid = routed.peerID
if (deviceAddress != null && pid != null) {
// Only set mapping if not already mapped
if (!connectionManager.addressPeerMap.containsKey(deviceAddress)) {
// First ANNOUNCE over a device connection defines a direct neighbor.
if (!connectionManager.hasSeenFirstAnnounce(deviceAddress)) {
// Bind or rebind this device address to the announcing peer
connectionManager.addressPeerMap[deviceAddress] = pid
Log.d(TAG, "Mapped device $deviceAddress to peer $pid on ANNOUNCE")
connectionManager.noteAnnounceReceived(deviceAddress)
Log.d(TAG, "Mapped device $deviceAddress to peer $pid on FIRST-ANNOUNCE for this connection")
// Mark this peer as directly connected for UI
try {
peerManager.getPeerInfo(pid)?.let {
// Set direct connection flag
// (This will also trigger a peer list update)
peerManager.setDirectConnection(pid, true)
// Also push reactive directness state to UI (best-effort)
try {
// Note: UI observes via didUpdatePeerList, but we can also update ChatState on a timer
} catch (_: Exception) { }
}
} catch (_: Exception) { }
// Mark as directly connected (upgrades from routed if needed)
try { peerManager.setDirectConnection(pid, true) } catch (_: Exception) { }
// Schedule initial sync for this new directly connected peer only
// Initial sync for this newly direct peer
try { gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { }
}
}
@@ -489,12 +541,23 @@ class BluetoothMeshService(private val context: Context) {
// BluetoothConnectionManager delegates
connectionManager.delegate = object : BluetoothConnectionManagerDelegate {
override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) {
// Log incoming for debug graphs (do not double-count anywhere else)
try {
val nick = getPeerNicknames()[peerID]
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming(
packetType = packet.type.toString(),
fromPeerID = peerID,
fromNickname = nick,
fromDeviceAddress = device?.address
)
} catch (_: Exception) { }
packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address))
}
override fun onDeviceConnected(device: android.bluetooth.BluetoothDevice) {
// Send initial announcements after services are ready
serviceScope.launch {
Log.d(TAG, "Device connected: ${device.address}; scheduling announce")
delay(200)
sendBroadcastAnnounce()
}
@@ -509,6 +572,7 @@ class BluetoothMeshService(private val context: Context) {
}
override fun onDeviceDisconnected(device: android.bluetooth.BluetoothDevice) {
Log.d(TAG, "Device disconnected: ${device.address}")
val addr = device.address
// Remove mapping and, if that was the last direct path for the peer, clear direct flag
val peer = connectionManager.addressPeerMap[addr]
@@ -547,6 +611,11 @@ class BluetoothMeshService(private val context: Context) {
Log.w(TAG, "Mesh service already active, ignoring duplicate start request")
return
}
if (terminated) {
// This instance's scope was cancelled previously; refuse to start to avoid using dead scopes.
Log.e(TAG, "Mesh service instance was terminated; create a new instance instead of restarting")
return
}
Log.i(TAG, "Starting Bluetooth mesh service with peer ID: $myPeerID")
@@ -558,6 +627,7 @@ class BluetoothMeshService(private val context: Context) {
Log.d(TAG, "Started periodic broadcast announcements (every 30 seconds)")
// Start periodic syncs
gossipSyncManager.start()
Log.d(TAG, "GossipSyncManager started")
} else {
Log.e(TAG, "Failed to start Bluetooth services")
}
@@ -579,11 +649,14 @@ class BluetoothMeshService(private val context: Context) {
sendLeaveAnnouncement()
serviceScope.launch {
Log.d(TAG, "Stopping subcomponents and cancelling scope...")
delay(200) // Give leave message time to send
// Stop all components
gossipSyncManager.stop()
Log.d(TAG, "GossipSyncManager stopped")
connectionManager.stopServices()
Log.d(TAG, "BluetoothConnectionManager stop requested")
peerManager.shutdown()
fragmentManager.shutdown()
securityManager.shutdown()
@@ -591,9 +664,24 @@ class BluetoothMeshService(private val context: Context) {
messageHandler.shutdown()
packetProcessor.shutdown()
// Mark this instance as terminated and cancel its scope so it won't be reused
terminated = true
serviceScope.cancel()
Log.i(TAG, "BluetoothMeshService terminated and scope cancelled")
}
}
/**
* Whether this instance can be safely reused. Returns false after stopServices() or if
* any critical internal scope has been cancelled.
*/
fun isReusable(): Boolean {
val reusable = !terminated && serviceScope.isActive && connectionManager.isReusable()
if (!reusable) {
Log.d(TAG, "isReusable=false (terminated=$terminated, scopeActive=${serviceScope.isActive}, connReusable=${connectionManager.isReusable()})")
}
return reusable
}
/**
* Send public message
@@ -813,7 +901,7 @@ class BluetoothMeshService(private val context: Context) {
fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) {
serviceScope.launch {
Log.d(TAG, "📖 Sending read receipt for message $messageID to $recipientPeerID")
// Route geohash read receipts via MessageRouter instead of here
val geo = runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance() }.getOrNull()
val isGeoAlias = try {
@@ -824,8 +912,15 @@ class BluetoothMeshService(private val context: Context) {
geo.sendReadReceipt(com.bitchat.android.model.ReadReceipt(messageID), recipientPeerID)
return@launch
}
try {
// Avoid duplicate read receipts: check persistent store first
val seenStore = try { com.bitchat.android.services.SeenMessageStore.getInstance(context.applicationContext) } catch (_: Exception) { null }
if (seenStore?.hasRead(messageID) == true) {
Log.d(TAG, "Skipping read receipt for $messageID - already marked read")
return@launch
}
// Create read receipt payload using NoisePayloadType exactly like iOS
val readReceiptPayload = com.bitchat.android.model.NoisePayload(
type = com.bitchat.android.model.NoisePayloadType.READ_RECEIPT,
@@ -851,12 +946,59 @@ class BluetoothMeshService(private val context: Context) {
val signedPacket = signPacketBeforeBroadcast(packet)
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID")
// Persist as read after successful send
try { seenStore?.markRead(messageID) } catch (_: Exception) { }
} catch (e: Exception) {
Log.e(TAG, "Failed to send read receipt to $recipientPeerID: ${e.message}")
}
}
}
// MARK: QR Verification over Noise
fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
val tlv = VerificationService.buildVerifyChallenge(noiseKeyHex, nonceA)
val payload = NoisePayload(
type = NoisePayloadType.VERIFY_CHALLENGE,
data = tlv
)
sendNoisePayloadToPeer(payload, peerID, "verify challenge")
}
fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
val tlv = VerificationService.buildVerifyResponse(noiseKeyHex, nonceA) ?: return
val payload = NoisePayload(
type = NoisePayloadType.VERIFY_RESPONSE,
data = tlv
)
sendNoisePayloadToPeer(payload, peerID, "verify response")
}
private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String, label: String) {
serviceScope.launch {
try {
val encrypted = encryptionService.encrypt(payload.encode(), recipientPeerID)
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(recipientPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = encrypted,
signature = null,
ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
)
val signedPacket = signPacketBeforeBroadcast(packet)
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
Log.d(TAG, "📤 Sent $label to $recipientPeerID (${payload.data.size} bytes)")
} catch (e: Exception) {
Log.e(TAG, "Failed to send $label to $recipientPeerID: ${e.message}")
}
}
}
/**
* Send broadcast announce with TLV-encoded identity announcement - exactly like iOS
@@ -864,7 +1006,7 @@ class BluetoothMeshService(private val context: Context) {
fun sendBroadcastAnnounce() {
Log.d(TAG, "Sending broadcast announce")
serviceScope.launch {
val nickname = delegate?.getNickname() ?: myPeerID
val nickname = try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { myPeerID }
// Get the static public key for the announcement
val staticKey = encryptionService.getStaticPublicKey()
@@ -927,7 +1069,7 @@ class BluetoothMeshService(private val context: Context) {
fun sendAnnouncementToPeer(peerID: String) {
if (peerManager.hasAnnouncedToPeer(peerID)) return
val nickname = delegate?.getNickname() ?: myPeerID
val nickname = try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { myPeerID }
// Get the static public key for the announcement
val staticKey = encryptionService.getStaticPublicKey()
@@ -1003,12 +1145,11 @@ class BluetoothMeshService(private val context: Context) {
* Send leave announcement
*/
private fun sendLeaveAnnouncement() {
val nickname = delegate?.getNickname() ?: myPeerID
val packet = BitchatPacket(
type = MessageType.LEAVE.value,
ttl = MAX_TTL,
senderID = myPeerID,
payload = nickname.toByteArray()
payload = byteArrayOf()
)
// Sign the packet before broadcasting
@@ -1055,6 +1196,13 @@ class BluetoothMeshService(private val context: Context) {
return peerManager.getFingerprintForPeer(peerID)
}
/**
* Get current active peer count (for status/notifications)
*/
fun getActivePeerCount(): Int {
return try { peerManager.getActivePeerCount() } catch (_: Exception) { 0 }
}
/**
* Get peer info for verification purposes
*/
@@ -1081,6 +1229,10 @@ class BluetoothMeshService(private val context: Context) {
fun getIdentityFingerprint(): String {
return encryptionService.getIdentityFingerprint()
}
fun getStaticNoisePublicKey(): ByteArray? {
return encryptionService.getStaticPublicKey()
}
/**
* Check if encryption icon should be shown for a peer
@@ -1253,6 +1405,8 @@ interface BluetoothMeshDelegate {
fun didReceiveChannelLeave(channel: String, fromPeer: String)
fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String)
fun didReceiveReadReceipt(messageID: String, recipientPeerID: String)
fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long)
fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long)
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
fun getNickname(): String?
fun isFavorite(peerID: String): Boolean
@@ -73,9 +73,17 @@ class BluetoothPacketBroadcaster(
try {
val fromNick = incomingPeer?.let { nicknameResolver?.invoke(it) }
val toNick = toPeer?.let { nicknameResolver?.invoke(it) }
val isRelay = (incomingAddr != null || incomingPeer != null)
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logPacketRelayDetailed(
val manager = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance()
// Always log outgoing for the actual transmission target
manager.logOutgoing(
packetType = typeName,
toPeerID = toPeer,
toNickname = toNick,
toDeviceAddress = toDeviceAddress,
previousHopPeerID = incomingPeer
)
// Keep the verbose relay message for human readability
manager.logPacketRelayDetailed(
packetType = typeName,
senderPeerID = senderPeerID,
senderNickname = senderNick,
@@ -86,7 +94,7 @@ class BluetoothPacketBroadcaster(
toNickname = toNick,
toDeviceAddress = toDeviceAddress,
ttl = ttl,
isRelay = isRelay
isRelay = true
)
} catch (_: Exception) {
// Silently ignore debug logging failures
@@ -33,9 +33,9 @@ class BluetoothPermissionManager(private val context: Context) {
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION
))
return permissions.all {
ActivityCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
}
}
}
@@ -157,6 +157,14 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
// Simplified: Call delegate with messageID and peerID directly
delegate?.onReadReceiptReceived(messageID, peerID)
}
com.bitchat.android.model.NoisePayloadType.VERIFY_CHALLENGE -> {
Log.d(TAG, "🔐 Verify challenge received from $peerID (${noisePayload.data.size} bytes)")
delegate?.onVerifyChallengeReceived(peerID, noisePayload.data, packet.timestamp.toLong())
}
com.bitchat.android.model.NoisePayloadType.VERIFY_RESPONSE -> {
Log.d(TAG, "🔐 Verify response received from $peerID (${noisePayload.data.size} bytes)")
delegate?.onVerifyResponseReceived(peerID, noisePayload.data, packet.timestamp.toLong())
}
}
} catch (e: Exception) {
@@ -618,4 +626,6 @@ interface MessageHandlerDelegate {
fun onChannelLeave(channel: String, fromPeer: String)
fun onDeliveryAckReceived(messageID: String, peerID: String)
fun onReadReceiptReceived(messageID: String, peerID: String)
fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long)
fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long)
}
@@ -299,8 +299,7 @@ class PeerManager {
*/
fun isPeerActive(peerID: String): Boolean {
val info = peers[peerID] ?: return false
val now = System.currentTimeMillis()
return (now - info.lastSeen) <= stalePeerTimeoutMs && info.isConnected
return info.isConnected
}
/**
@@ -328,8 +327,7 @@ class PeerManager {
* Get list of active peer IDs
*/
fun getActivePeerIDs(): List<String> {
val now = System.currentTimeMillis()
return peers.filterValues { (now - it.lastSeen) <= stalePeerTimeoutMs && it.isConnected }
return peers.filterValues { it.isConnected }
.keys
.toList()
.sorted()
@@ -7,7 +7,13 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import kotlinx.coroutines.*
import kotlin.math.max
@@ -15,7 +21,7 @@ import kotlin.math.max
* Power-aware Bluetooth management for bitchat
* Adjusts scanning, advertising, and connection behavior based on battery state
*/
class PowerManager(private val context: Context) {
class PowerManager(private val context: Context) : LifecycleEventObserver {
companion object {
private const val TAG = "PowerManager"
@@ -49,7 +55,7 @@ class PowerManager(private val context: Context) {
private var currentMode = PowerMode.BALANCED
private var isCharging = false
private var batteryLevel = 100
private var isAppInBackground = false
private var isAppInBackground = true
private val powerScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var dutyCycleJob: Job? = null
@@ -87,6 +93,16 @@ class PowerManager(private val context: Context) {
init {
registerBatteryReceiver()
// Register for process lifecycle events on the main thread
Handler(Looper.getMainLooper()).post {
try {
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
} catch (e: Exception) {
Log.e(TAG, "Failed to register lifecycle observer: ${e.message}")
}
}
updatePowerMode()
}
@@ -99,13 +115,30 @@ class PowerManager(private val context: Context) {
Log.i(TAG, "Stopping power management")
powerScope.cancel()
unregisterBatteryReceiver()
// Unregister lifecycle observer
Handler(Looper.getMainLooper()).post {
try {
ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
} catch (e: Exception) {
Log.e(TAG, "Failed to remove lifecycle observer: ${e.message}")
}
}
}
fun setAppBackgroundState(inBackground: Boolean) {
if (isAppInBackground != inBackground) {
isAppInBackground = inBackground
Log.d(TAG, "App background state changed: $inBackground")
updatePowerMode()
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
when (event) {
Lifecycle.Event.ON_START -> {
Log.d(TAG, "Process lifecycle: ON_START (App coming to foreground)")
isAppInBackground = false
updatePowerMode()
}
Lifecycle.Event.ON_STOP -> {
Log.d(TAG, "Process lifecycle: ON_STOP (App going to background)")
isAppInBackground = true
updatePowerMode()
}
else -> {}
}
}
@@ -133,7 +166,7 @@ class PowerManager(private val context: Context) {
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
PowerMode.ULTRA_LOW_POWER -> builder
.setScanMode(ScanSettings.SCAN_MODE_OPPORTUNISTIC)
.setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
.setMatchMode(ScanSettings.MATCH_MODE_STICKY)
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
}
@@ -224,26 +257,29 @@ class PowerManager(private val context: Context) {
}
private fun updatePowerMode() {
val newMode = when {
// Always use performance mode when charging (unless in background too long)
// Determine the base mode from battery/charging state only
val baseMode = when {
// Charging in foreground may use performance
isCharging && !isAppInBackground -> PowerMode.PERFORMANCE
// Critical battery - use ultra low power
// Critical battery - force ultra low power regardless of foreground/background
batteryLevel <= CRITICAL_BATTERY -> PowerMode.ULTRA_LOW_POWER
// Low battery - use power saver
// Low battery - prefer power saver
batteryLevel <= LOW_BATTERY -> PowerMode.POWER_SAVER
// Background app with medium battery - use power saver
isAppInBackground && batteryLevel <= MEDIUM_BATTERY -> PowerMode.POWER_SAVER
// Background app with good battery - use balanced
isAppInBackground -> PowerMode.BALANCED
// Foreground with good battery - use balanced
// Otherwise balanced
else -> PowerMode.BALANCED
}
// If app is in background (including when running as a foreground service),
// cap the power mode to at least POWER_SAVER. Preserve ULTRA_LOW_POWER.
val newMode = if (isAppInBackground) {
if (baseMode == PowerMode.ULTRA_LOW_POWER) PowerMode.ULTRA_LOW_POWER else PowerMode.POWER_SAVER
} else {
baseMode
}
if (newMode != currentMode) {
val oldMode = currentMode
currentMode = newMode
@@ -50,35 +50,36 @@ class SecurityManager(private val encryptionService: EncryptionService, private
return false
}
// Validate packet payload
if (packet.payload.isEmpty()) {
Log.d(TAG, "Dropping packet with empty payload")
return false
}
// Replay attack protection (same 5-minute window as iOS)
val currentTime = System.currentTimeMillis()
val packetTime = packet.timestamp.toLong()
val timeDiff = kotlin.math.abs(currentTime - packetTime)
// if (timeDiff > MESSAGE_TIMEOUT) {
// Log.d(TAG, "Dropping old packet from $peerID, time diff: ${timeDiff/1000}s")
// return false
// }
val messageType = MessageType.fromValue(packet.type)
// Duplicate detection
val messageID = generateMessageID(packet, peerID)
if (processedMessages.contains(messageID)) {
Log.d(TAG, "Dropping duplicate packet: $messageID")
return false
}
if (processedMessages.contains(messageID)) {
// Check for ANNOUNCE exception: allow if it looks like a direct neighbor (max TTL)
// This ensures we catch the "first announce" on a new connection for binding,
// while still dropping looped/relayed duplicates.
val isFreshAnnounce = messageType == MessageType.ANNOUNCE &&
packet.ttl >= com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
if (!isFreshAnnounce) {
Log.d(TAG, "Dropping duplicate packet: $messageID")
return false
}
Log.d(TAG, "Allowing duplicate ANNOUNCE from direct neighbor: $messageID")
}
// Add to processed messages
processedMessages.add(messageID)
messageTimestamps[messageID] = currentTime
// NEW: Signature verification logging (not rejecting yet)
verifyPacketSignatureWithLogging(packet, peerID)
// Enforce mandatory signature verification
if (!verifyPacketSignature(packet, peerID)) {
Log.w(TAG, "Dropping packet from $peerID due to signature verification failure")
return false
}
Log.d(TAG, "Packet validation passed for $peerID, messageID: $messageID")
return true
@@ -231,34 +232,58 @@ class SecurityManager(private val encryptionService: EncryptionService, private
}
/**
* Verify packet signature using peer's signing public key and log the result
* Verify packet signature using peer's signing public key
* Returns true only if signature is present and valid
*/
private fun verifyPacketSignatureWithLogging(packet: BitchatPacket, peerID: String) {
private fun verifyPacketSignature(packet: BitchatPacket, peerID: String): Boolean {
try {
// Check if packet has a signature
// only verify ANNOUNCE, MESSAGE, and FILE_TRANSFER
if (MessageType.fromValue(packet.type) !in setOf(
MessageType.ANNOUNCE,
MessageType.MESSAGE,
MessageType.FILE_TRANSFER
)) {
return true
}
// 1. Mandatory Signature Check
if (packet.signature == null) {
Log.d(TAG, "📝 Signature check for $peerID: NO_SIGNATURE (packet type ${packet.type})")
return
Log.w(TAG, " Signature check for $peerID: NO_SIGNATURE (packet type ${packet.type})")
return false
}
// Try to get peer's signing public key from peer info
val peerInfo = delegate?.getPeerInfo(peerID)
val signingPublicKey = peerInfo?.signingPublicKey
// 2. Get Signing Public Key
var signingPublicKey: ByteArray? = null
if (MessageType.fromValue(packet.type) == MessageType.ANNOUNCE) {
// Special Case: ANNOUNCE packets carry their own signing key
try {
val announcement = com.bitchat.android.model.IdentityAnnouncement.decode(packet.payload)
signingPublicKey = announcement?.signingPublicKey
} catch (e: Exception) {
Log.w(TAG, "Failed to decode announcement for key extraction: ${e.message}")
}
} else {
// Standard Case: Get key from known peer info
val peerInfo = delegate?.getPeerInfo(peerID)
signingPublicKey = peerInfo?.signingPublicKey
}
if (signingPublicKey == null) {
Log.d(TAG, "📝 Signature check for $peerID: NO_SIGNING_KEY (packet type ${packet.type})")
return
// If we don't have a key (and it's not an announce), we can't verify.
// For security, we must reject packets from unknown peers unless it's an announce.
Log.w(TAG, "❌ Signature check for $peerID: NO_SIGNING_KEY_AVAILABLE (packet type ${packet.type})")
return false
}
// Get the canonical packet data for signature verification (without signature)
// 3. Get Canonical Data
val packetDataForSigning = packet.toBinaryDataForSigning()
if (packetDataForSigning == null) {
Log.w(TAG, "📝 Signature check for $peerID: ENCODING_ERROR (packet type ${packet.type})")
return
Log.w(TAG, " Signature check for $peerID: ENCODING_ERROR (packet type ${packet.type})")
return false
}
// Verify the signature using the peer's signing public key
val signature = packet.signature!! // We already checked for null above
// 4. Verify Signature
val signature = packet.signature!!
val isSignatureValid = encryptionService.verifyEd25519Signature(
signature,
packetDataForSigning,
@@ -266,13 +291,16 @@ class SecurityManager(private val encryptionService: EncryptionService, private
)
if (isSignatureValid) {
Log.d(TAG, "📝 Signature check for $peerID: ✅ VALID (packet type ${packet.type})")
// Log.v(TAG, "✅ Signature verified for $peerID (type ${packet.type})")
return true
} else {
Log.w(TAG, "📝 Signature check for $peerID: ❌ INVALID (packet type ${packet.type})")
Log.w(TAG, " Signature INVALID for $peerID (type ${packet.type})")
return false
}
} catch (e: Exception) {
Log.w(TAG, "📝 Signature check for $peerID: ERROR - ${e.message} (packet type ${packet.type})")
Log.e(TAG, " Signature verification error for $peerID: ${e.message}")
return false
}
}