11 · Android
android/app/src/main/kotlin/com/sophax/sophaxchat/ChatManager.kt
1509 lines · Kotlin
1package com.sophax.sophaxchat
2
3import android.content.Context
4import androidx.security.crypto.EncryptedSharedPreferences
5import androidx.security.crypto.MasterKey
6import com.google.crypto.tink.subtle.ChaCha20Poly1305
7import com.sophax.sophaxchat.crypto.DHKeyPair
8import com.sophax.sophaxchat.crypto.DoubleRatchet
9import com.sophax.sophaxchat.crypto.GroupInfo
10import com.sophax.sophaxchat.crypto.GroupInvitePayload
11import com.sophax.sophaxchat.crypto.IdentityManager
12import com.sophax.sophaxchat.crypto.PreKeyManager
13import com.sophax.sophaxchat.crypto.SenderKeyState
14import com.sophax.sophaxchat.crypto.X3DH
15import com.sophax.sophaxchat.crypto.PreKeyBundleLocal
16import com.sophax.sophaxchat.crypto.toHex
17import com.sophax.sophaxchat.network.DHTBootstrap
18import com.sophax.sophaxchat.network.DHTContact
19import com.sophax.sophaxchat.network.DHTEngine
20import com.sophax.sophaxchat.network.DHTStorage
21import com.sophax.sophaxchat.network.LanDiscovery
22import com.sophax.sophaxchat.network.LanDiscoveryListener
23import com.sophax.sophaxchat.network.NearbyManager
24import com.sophax.sophaxchat.network.NearbyManagerListener
25import com.sophax.sophaxchat.network.TcpTransport
26import com.sophax.sophaxchat.network.TcpTransportListener
27import com.sophax.sophaxchat.network.WifiDirectManager
28import com.sophax.sophaxchat.protocol.*
29import com.sophax.sophaxchat.storage.AttachmentStore
30import com.sophax.sophaxchat.storage.MessageDirection
31import com.sophax.sophaxchat.storage.MessageStatus
32import com.sophax.sophaxchat.storage.MessageStore
33import com.sophax.sophaxchat.storage.StoredMessage
34import kotlinx.coroutines.CoroutineScope
35import kotlinx.coroutines.Dispatchers
36import kotlinx.coroutines.SupervisorJob
37import kotlinx.coroutines.cancel
38import kotlinx.coroutines.launch
39import kotlinx.serialization.encodeToString
40import kotlinx.serialization.json.Json
41import kotlinx.serialization.decodeFromString
42import java.security.SecureRandom
43import java.util.Date
44import java.util.UUID
45import java.util.concurrent.ConcurrentHashMap
46
47// ---------------------------------------------------------------------------
48// Delegate
49// ---------------------------------------------------------------------------
50
51interface ChatManagerDelegate {
52 fun didDiscoverPeer(peer: KnownPeer)
53 fun peerDidDisconnect(peerID: String)
54 fun didReceiveMessage(message: StoredMessage, fromPeerID: String)
55 fun didReceiveGroupMessage(message: StoredMessage, group: GroupInfo)
56 fun messageDelivered(messageID: String, toPeerID: String)
57 fun didEncounterError(error: Exception)
58 fun didUpdateTypingState(peerID: String, isTyping: Boolean)
59 fun didReceiveReaction(conversationID: String, messageID: String, emoji: String?, senderID: String = conversationID)
60 /** The group creator dissolved the group; the local client must drop it. */
61 fun groupDeletedWithID(groupID: String)
62 /** A group message carried avatar data for a peer not yet in the avatar cache. */
63 fun didReceiveAvatarData(data: ByteArray, fromPeerID: String)
64 /** An incoming editMessage updated a stored message body. */
65 fun didEditMessage(conversationID: String)
66 /** A trusted peer requested a remote account wipe. */
67 fun didReceiveRemoteWipeRequest()
68}
69
70// ---------------------------------------------------------------------------
71// ChatManager — coordinates crypto, transport, and storage
72// Port of iOS ChatManager.swift (Phase 2 scope: 1-to-1 messaging + relay)
73// ---------------------------------------------------------------------------
74
75/** Returns true when Google Play Services with Nearby Connections is available at runtime. */
76private fun isNearbyAvailable(context: Context): Boolean = runCatching {
77 context.packageManager.getPackageInfo("com.google.android.gms", 0)
78 Class.forName("com.google.android.gms.nearby.Nearby")
79 true
80}.getOrDefault(false)
81
82class ChatManager(
83 private val context: Context,
84 val identity: IdentityManager,
85 private val preKeys: PreKeyManager,
86 private val messageStore: MessageStore
87) {
88 var delegate: ChatManagerDelegate? = null
89 var myTCPAddress: String? = null // "host:port" advertised to peers
90
91 private val json = Json { ignoreUnknownKeys = true }
92
93 // Active Double Ratchet sessions — peerID → session
94 private val sessions = ConcurrentHashMap<String, DoubleRatchet>()
95 private val sessionLock = Any()
96
97 // Known peer identities (verified)
98 private val knownPeers = ConcurrentHashMap<String, KnownPeer>()
99
100 // -----------------------------------------------------------------------
101 // DHT engine (Kademlia peer discovery over Tor)
102 // -----------------------------------------------------------------------
103
104 private var dhtEngine: DHTEngine? = null
105 private val dhtStorage by lazy { DHTStorage(context) }
106 private val dhtScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
107 /** address → queued DHT messages waiting for TCP connection. */
108 private val pendingDHTMessages = ConcurrentHashMap<String, ArrayDeque<WireMessage>>()
109 /** senderID → (windowStart, count) for 30 DHT req/min rate limit. */
110 private val dhtRateLimiters = ConcurrentHashMap<String, Pair<Long, Int>>()
111 private var lastDHTPublish: Long = 0L
112
113 // -----------------------------------------------------------------------
114 // Linked devices — peerIDs of other devices belonging to the same user.
115 // Persisted in EncryptedSharedPreferences as a JSON string list.
116 // -----------------------------------------------------------------------
117 val linkedDeviceIDs = ConcurrentHashMap<String, Boolean>()
118 private val LINKED_DEVICES_KEY = "linked_device_ids"
119
120 private fun saveLinkedDevices() {
121 val ids = linkedDeviceIDs.keys().toList()
122 groupPrefs.edit().putString(LINKED_DEVICES_KEY, json.encodeToString(ids)).apply()
123 }
124
125 private fun loadLinkedDevices() {
126 val raw = groupPrefs.getString(LINKED_DEVICES_KEY, null) ?: return
127 val ids = try { json.decodeFromString<List<String>>(raw) } catch (e: Exception) { return }
128 ids.forEach { linkedDeviceIDs[it] = true }
129 }
130
131 /** Unlink a device — it will no longer receive message copies. */
132 fun unlinkDevice(peerID: String) {
133 linkedDeviceIDs.remove(peerID)
134 saveLinkedDevices()
135 }
136
137 /** Returns the list of KnownPeer objects for linked devices. */
138 fun linkedDevicesList(): List<KnownPeer> =
139 linkedDeviceIDs.keys().toList().mapNotNull { knownPeers[it] }
140
141 /**
142 * Generate a JSON payload (base64) that encodes this device's PreKeyBundle.
143 * Encode as a QR code on screen; the other device scans it.
144 */
145 fun generateDeviceLinkPayload(): ByteArray {
146 val bundle = preKeys.generateBundle(myTCPAddress)
147 val msg = DeviceLinkRequestMessage(
148 deviceLabel = android.os.Build.MODEL,
149 bundle = bundle
150 )
151 return json.encodeToString(msg).toByteArray()
152 }
153
154 /**
155 * Accept a device link from scanned QR data.
156 * Parses the DeviceLinkRequestMessage, stores the bundle, registers as linked,
157 * then sends a reciprocal deviceLinkRequest so the other device gets our bundle too.
158 */
159 fun acceptDeviceLink(data: ByteArray) {
160 val msg = try { json.decodeFromString<DeviceLinkRequestMessage>(String(data)) }
161 catch (e: Exception) { return }
162 val bundle = msg.bundle
163 val peerID = bundle.peerID
164
165 peerBundles[peerID] = bundle
166
167 val peer = KnownPeer(
168 id = peerID, username = msg.deviceLabel,
169 signingKeyPublic = bundle.signingKeyPublic,
170 dhKeyPublic = bundle.dhIdentityKeyPublic,
171 safetyNumber = IdentityManager.safetyNumber(
172 identity.publicIdentity.signingKeyPublic, bundle.signingKeyPublic,
173 identity.publicIdentity.dhKeyPublic, bundle.dhIdentityKeyPublic
174 ),
175 lastSeen = Date(), isOnline = false
176 )
177 knownPeers[peerID] = peer
178 linkedDeviceIDs[peerID] = true
179 saveLinkedDevices()
180 delegate?.didDiscoverPeer(peer)
181
182 // Send reciprocal link request
183 val myMsg = DeviceLinkRequestMessage(
184 deviceLabel = android.os.Build.MODEL,
185 bundle = preKeys.generateBundle(myTCPAddress)
186 )
187 val wire = builder().build(WireMessageType.deviceLinkRequest.name, myMsg)
188 sendOrRoute(wire, peerID)
189 }
190
191 // -----------------------------------------------------------------------
192 // Store-and-forward queue — holds sealed messages for offline peers.
193 // Key = targetPeerID. Max 50 messages/peer, TTL 48h.
194 // -----------------------------------------------------------------------
195 private val storeAndForwardQueue = ConcurrentHashMap<String, MutableList<StoreAndForwardRequest>>()
196 private val SAF_MAX_PER_PEER = 50
197 private val SAF_TTL_MS = 48L * 60 * 60 * 1000 // 48 hours in ms
198
199 // -----------------------------------------------------------------------
200 // Dead drops — sealed mesh-flood messages for offline recipients.
201 // seenDeadDropIDs: id → received-at epoch ms (for dedup + expiry).
202 // -----------------------------------------------------------------------
203 private val deadDrops = mutableListOf<DeadDropEnvelope>()
204 private val deadDropLock = Any()
205 private val seenDeadDropIDs = ConcurrentHashMap<String, Long>()
206
207 // -----------------------------------------------------------------------
208 // Remote Wipe — trusted peers can request account wipe.
209 // Persisted in groupPrefs under "trusted_wipe_peers".
210 // -----------------------------------------------------------------------
211 private val trustedWipePeers: MutableSet<String> = loadTrustedWipePeers()
212 private val seenWipeRequestIDs = ConcurrentHashMap<String, Boolean>()
213
214 // Received peer bundles (for initiating X3DH)
215 private val peerBundles = ConcurrentHashMap<String, PreKeyBundle>()
216
217 // Avatar cache: peerID → JPEG bytes (≤8 KB). Populated from piggybacked group messages.
218 val peerAvatarData = ConcurrentHashMap<String, ByteArray>()
219
220 // Messages queued while waiting for a peer's bundle: Pair(body, messageID)
221 private val pendingQueue = ConcurrentHashMap<String, ArrayDeque<Pair<String, String>>>()
222
223 // Nearby endpoint ID → peerID (learned from Hello)
224 private val endpointToPeerID = ConcurrentHashMap<String, String>()
225 private val peerIDToEndpoint = ConcurrentHashMap<String, String>()
226
227 // TCP connection: peerID → address (already tracked by TcpTransport)
228 private val tcpPeerIDs = ConcurrentHashMap<String, Boolean>()
229 /** address → peerID, populated when TCP handshake completes. */
230 private val tcpAddressToPeerID = ConcurrentHashMap<String, String>()
231
232 // Tracks when each DR session was established (epoch ms).
233 // Used to reject replayed or duplicate initiateSession messages.
234 private val sessionCreatedAt = ConcurrentHashMap<String, Long>()
235
236 // -----------------------------------------------------------------------
237 // Group state (EncryptedSharedPreferences)
238 // -----------------------------------------------------------------------
239
240 private val groupPrefs by lazy {
241 val masterKey = MasterKey.Builder(context)
242 .setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
243 EncryptedSharedPreferences.create(
244 context, "sophaxchat_groups", masterKey,
245 EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
246 EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
247 )
248 }
249
250 private fun loadGroups(): List<GroupInfo> {
251 val raw = groupPrefs.getString("groups_json", null) ?: return emptyList()
252 return try { json.decodeFromString(raw) } catch (e: Exception) { emptyList() }
253 }
254
255 private fun saveGroups(groups: List<GroupInfo>) {
256 groupPrefs.edit().putString("groups_json", json.encodeToString(groups)).apply()
257 }
258
259 private fun loadSenderKey(groupID: String, peerID: String): SenderKeyState? {
260 val raw = groupPrefs.getString("skey_${groupID}_${peerID}", null) ?: return null
261 return try { json.decodeFromString(raw) } catch (e: Exception) { null }
262 }
263
264 private fun saveSenderKey(groupID: String, peerID: String, state: SenderKeyState) {
265 groupPrefs.edit()
266 .putString("skey_${groupID}_${peerID}", json.encodeToString(state))
267 .apply()
268 }
269
270 private fun deleteSenderKeys(groupID: String) {
271 val editor = groupPrefs.edit()
272 groupPrefs.all.keys
273 .filter { it.startsWith("skey_${groupID}_") }
274 .forEach { editor.remove(it) }
275 editor.apply()
276 }
277
278 /** In-memory group list, kept in sync with prefs. */
279 private val groups = ConcurrentHashMap<String, GroupInfo>().also { map ->
280 loadGroups().forEach { map[it.id] = it }
281 }
282
283 fun groupsList(): List<GroupInfo> = groups.values.toList()
284
285 val attachmentStore = AttachmentStore(context)
286
287 // -----------------------------------------------------------------------
288 // Transports
289 // -----------------------------------------------------------------------
290
291 // Nearby Connections (GMS) — null on GrapheneOS / LineageOS without GMS
292 val nearby: NearbyManager? = if (isNearbyAvailable(context))
293 NearbyManager(context).also { it.listener = nearbyListener } else null
294
295 // Wi-Fi Direct — used when GMS is absent; pure Android SDK, no external dependency
296 private val wifiDirect: WifiDirectManager? = if (nearby == null)
297 WifiDirectManager(context).also { it.onPeerFound = { addr -> tcp.connect(addr) } } else null
298
299 val tcp = TcpTransport(helloProvider = { buildHello() }).also { it.listener = tcpListener }
300
301 // mDNS discovery — auto-connects to Android and iOS peers on the same WiFi
302 private val lan = LanDiscovery(context).also {
303 it.listener = object : LanDiscoveryListener {
304 override fun onLanPeerFound(address: String) {
305 tcp.connect(address)
306 }
307 }
308 }
309
310 // -----------------------------------------------------------------------
311 // Start / Stop
312 // -----------------------------------------------------------------------
313
314 fun start() {
315 preKeys.rotateIfNeeded()
316 loadLinkedDevices()
317 val myPeerID = identity.publicIdentity.peerID
318 val displayName = "sx-${myPeerID.take(12)}"
319 nearby?.start(displayName) ?: wifiDirect?.start(myPeerID)
320 tcp.start()
321 lan.start(myPeerID)
322 }
323
324 fun stop() {
325 nearby?.stop() ?: wifiDirect?.stop()
326 tcp.stop()
327 lan.stop()
328 stopDHT()
329 }
330
331 // -----------------------------------------------------------------------
332 // DHT lifecycle
333 // -----------------------------------------------------------------------
334
335 fun startDHT() {
336 val wireBuilder = WireMessageBuilder(identity)
337 val engine = DHTEngine(
338 identity = identity,
339 wireBuilder = wireBuilder,
340 scope = dhtScope,
341 send = { msg, contact -> sendDHTMessage(msg, contact) }
342 )
343 dhtEngine = engine
344
345 // Bootstrap priority: (1) persisted k-buckets, (2) knownPeers with tcpAddress,
346 // (3) hardcoded DHTBootstrap.nodes
347 val persisted = dhtStorage.load()
348 val fromPeers = knownPeers.values.mapNotNull { peer ->
349 val addr = peer.tcpAddress ?: return@mapNotNull null
350 val host = addr.substringBeforeLast(':')
351 if (!host.endsWith(".onion")) return@mapNotNull null
352 val port = addr.substringAfterLast(':').toIntOrNull() ?: return@mapNotNull null
353 DHTContact(
354 nodeID = peer.id.padEnd(64, '0'),
355 onionAddress = host,
356 port = port
357 )
358 }
359 val bootstrapContacts = (persisted + fromPeers + DHTBootstrap.nodes).distinctBy { it.nodeID }
360 engine.start(bootstrapContacts)
361
362 // Publish our PreKeyBundle immediately after bootstrap so peers can find us,
363 // then re-publish every 24 hours.
364 dhtScope.launch {
365 publishDHTBundle(engine)
366 while (true) {
367 kotlinx.coroutines.delay(DHTEngine.PUBLISH_INTERVAL)
368 publishDHTBundle(engine)
369 }
370 }
371
372 // Schedule k-bucket snapshot every 30 minutes
373 dhtScope.launch {
374 while (true) {
375 kotlinx.coroutines.delay(30L * 60 * 1000)
376 dhtEngine?.let { eng ->
377 val snapshot = eng.table.snapshot()
378 dhtStorage.save(snapshot)
379 }
380 }
381 }
382 }
383
384 private suspend fun publishDHTBundle(engine: DHTEngine) {
385 val bundle = runCatching { preKeys.generateBundle(myTCPAddress) }.getOrNull() ?: return
386 engine.publishSelf(bundle)
387 }
388
389 private fun stopDHT() {
390 dhtEngine?.stop()
391 dhtEngine = null
392 dhtScope.cancel()
393 }
394
395 private suspend fun sendDHTMessage(msg: WireMessage, contact: DHTContact) {
396 val address = "${contact.onionAddress}:${contact.port}"
397 val peerID = tcpAddressToPeerID[address]
398 if (peerID != null && tcp.isConnected(peerID)) {
399 runCatching { tcp.send(msg, peerID) }
400 } else {
401 pendingDHTMessages.getOrPut(address) { ArrayDeque() }.addLast(msg)
402 tcp.connect(address)
403 }
404 }
405
406 /** Lookup a peer by their 16-char peerID via DHT.
407 * Returns a KnownPeer populated from the discovered PreKeyBundle, or null. */
408 suspend fun lookupPeer(peerID: String): KnownPeer? {
409 val engine = dhtEngine ?: return null
410 val (_, bundle) = engine.lookup(peerID) ?: return null
411 val existing = knownPeers[bundle.peerID]
412 if (existing != null) return existing
413 val peer = KnownPeer(
414 id = bundle.peerID,
415 username = bundle.username,
416 signingKeyPublic = bundle.signingKeyPublic,
417 dhKeyPublic = bundle.dhIdentityKeyPublic,
418 safetyNumber = bundle.peerID,
419 tcpAddress = bundle.tcpAddress
420 )
421 knownPeers[peer.id] = peer
422 return peer
423 }
424
425 // -----------------------------------------------------------------------
426 // Send message
427 // -----------------------------------------------------------------------
428
429 fun sendTyping(toPeerID: String) {
430 val wire = builder().build(WireMessageType.typing.name, TypingMessage(isTyping = true))
431 sendOrRoute(wire, toPeerID)
432 }
433
434 fun sendMessage(toPeerID: String, body: String, expiresAt: Long? = null) {
435 val messageID = UUID.randomUUID().toString()
436
437 val stored = StoredMessage(
438 id = messageID, peerID = toPeerID,
439 direction = MessageDirection.sent.name, body = body,
440 status = MessageStatus.sending.name, expiresAt = expiresAt
441 )
442 messageStore.store(stored)
443
444 val wire = buildOutboundWire(toPeerID, body, messageID, expiresAt) ?: return
445 sendOrRoute(wire, toPeerID)
446 }
447
448 // -----------------------------------------------------------------------
449 // Build outbound wire (X3DH or DR encrypt)
450 // -----------------------------------------------------------------------
451
452 private fun buildOutboundWire(peerID: String, body: String, messageID: String, expiresAt: Long? = null): WireMessage? {
453 val content = MessageContent(body = body, timestamp = Date(),
454 expiresAt = expiresAt?.let { Date(it) })
455 val contentBytes = json.encodeToString(content).toByteArray()
456
457 // Case 1: existing DR session
458 // encrypt() mutates DR state — hold the lock for the entire operation.
459 synchronized(sessionLock) { sessions[peerID] }?.let { dr ->
460 return try {
461 val ratchetMessage = synchronized(sessionLock) { dr.encrypt(contentBytes) }.toWire()
462 val payload = ChatMessagePayload(ratchetMessage = ratchetMessage, messageID = messageID)
463 builder().build(WireMessageType.message.name, payload)
464 } catch (e: Exception) { delegate?.didEncounterError(e); null }
465 }
466
467 // Case 2: initiate X3DH (have bundle, no session)
468 val bundle = peerBundles[peerID] ?: run {
469 // No bundle yet — queue message body until peer's bundle arrives
470 pendingQueue.getOrPut(peerID) { ArrayDeque() }.add(Pair(body, messageID))
471 return null
472 }
473
474 return try {
475 val local = PreKeyBundleLocal(
476 dhIdentityKeyPublic = bundle.dhIdentityKeyPublic,
477 signedPreKeyPublic = bundle.signedPreKeyPublic,
478 oneTimePreKeyPublic = bundle.oneTimePreKeyPublic,
479 oneTimePreKeyId = bundle.oneTimePreKeyId?.toUInt()
480 )
481 val x3dhResult = X3DH.initiateSender(identity.dhIdentityKeyPair, local)
482 val dr = DoubleRatchet.initAsInitiator(x3dhResult.sharedSecret, bundle.signedPreKeyPublic)
483 synchronized(sessionLock) { sessions[peerID] = dr }
484
485 val initMsg = InitiateSessionMessage(
486 senderBundle = preKeys.generateBundle(myTCPAddress),
487 ephemeralPublicKey = x3dhResult.ephemeralPublicKey,
488 usedSignedPreKeyId = bundle.signedPreKeyId,
489 usedOneTimePreKeyId = x3dhResult.usedOneTimePreKeyId?.toLong(),
490 initialMessage = dr.encrypt(contentBytes).toWire()
491 )
492 builder().build(WireMessageType.initiateSession.name, initMsg)
493 } catch (e: Exception) { delegate?.didEncounterError(e); null }
494 }
495
496 // -----------------------------------------------------------------------
497 // Route message: TCP → Nearby → Relay
498 // -----------------------------------------------------------------------
499
500 private fun sendOrRoute(wire: WireMessage, toPeerID: String) {
501 // Build relay envelope lazily (only if needed)
502 fun relayWire(): WireMessage {
503 val relay = RelayEnvelope(
504 id = UUID.randomUUID().toString(),
505 targetPeerID = toPeerID,
506 originPeerID = identity.publicIdentity.peerID,
507 ttl = RelayEnvelope.MAX_TTL,
508 hopCount = 0,
509 message = wire
510 )
511 return builder().build(WireMessageType.relay.name, relay)
512 }
513
514 when {
515 // 1. TCP direct (highest priority — works on GMS and non-GMS alike)
516 tcp.isConnected(toPeerID) ->
517 tcp.send(wire, toPeerID)
518
519 // 2. Nearby direct (GMS devices only)
520 nearby != null && peerIDToEndpoint[toPeerID] != null ->
521 nearby.send(wire, peerIDToEndpoint[toPeerID] ?: return)
522
523 // 3a. Relay via Nearby mesh (GMS devices)
524 nearby != null && nearby.connectedEndpointIDs().isNotEmpty() ->
525 nearby.broadcast(relayWire())
526
527 // 3b. Relay via TCP connections (non-GMS: WifiDirect / mDNS peers)
528 tcp.connectedPeerIDs().isNotEmpty() ->
529 tcp.broadcast(relayWire(), excluding = null)
530 }
531 // If no path available, message is silently dropped until reconnection.
532 // (Persistent offline queue is a future enhancement.)
533 }
534
535 // -----------------------------------------------------------------------
536 // Group messaging
537 // -----------------------------------------------------------------------
538
539 fun createGroup(name: String, memberPeerIDs: List<String>): GroupInfo {
540 val myID = identity.publicIdentity.peerID
541 val allMembers = (memberPeerIDs + myID).distinct()
542 val group = GroupInfo(name = name, memberIDs = allMembers, creatorID = myID)
543
544 // Generate my sender chain key
545 val chainKey = ByteArray(32).also { SecureRandom().nextBytes(it) }
546 val myState = SenderKeyState(chainKey = chainKey)
547 saveSenderKey(group.id, myID, myState)
548
549 // Persist group
550 groups[group.id] = group
551 saveGroups(groups.values.toList())
552
553 // Send invite to each member via DR-encrypted .message
554 val invite = GroupInvitePayload(
555 groupID = group.id,
556 groupName = group.name,
557 memberIDs = allMembers,
558 creatorID = myID,
559 senderChainKey = chainKey,
560 senderIteration = 0L
561 )
562 val inviteBytes = json.encodeToString(invite).toByteArray()
563 memberPeerIDs.forEach { peerID ->
564 val wire = buildOutboundGroupInvite(peerID, inviteBytes) ?: return@forEach
565 sendOrRoute(wire, peerID)
566 }
567 return group
568 }
569
570 fun sendGroupMessage(body: String, group: GroupInfo) {
571 val myID = identity.publicIdentity.peerID
572 val state = loadSenderKey(group.id, myID) ?: run {
573 val fresh = SenderKeyState(ByteArray(32).also { SecureRandom().nextBytes(it) })
574 saveSenderKey(group.id, myID, fresh)
575 fresh
576 }
577
578 val (messageKey, nextState) = state.ratchet()
579 saveSenderKey(group.id, myID, nextState)
580
581 val ciphertext = chachaPoly(messageKey).encrypt(body.toByteArray(), group.id.toByteArray())
582 val messageID = UUID.randomUUID().toString()
583
584 val gwm = GroupWireMessage(
585 groupID = group.id,
586 messageID = messageID,
587 senderPeerID = myID,
588 senderUsername = identity.username,
589 timestamp = Date(),
590 ciphertext = ciphertext,
591 senderKeyIteration = state.iteration
592 )
593
594 // Store locally
595 val stored = StoredMessage(
596 id = messageID, peerID = group.conversationID,
597 direction = MessageDirection.sent.name,
598 body = body, status = MessageStatus.delivered.name
599 )
600 messageStore.store(stored)
601
602 broadcastToGroup(group, gwm)
603 }
604
605 fun sendReaction(toPeerID: String, messageID: String, emoji: String?, isGroup: Boolean, groupID: String?) {
606 if (isGroup && groupID != null) {
607 val r = GroupReactionMessage(groupID = groupID, targetMessageID = messageID, emoji = emoji)
608 val wire = builder().build(WireMessageType.groupReaction.name, r)
609 groups[groupID]?.memberIDs
610 ?.filter { it != identity.publicIdentity.peerID }
611 ?.forEach { sendOrRoute(wire, it) }
612 } else {
613 val r = ReactionMessage(targetMessageID = messageID, emoji = emoji)
614 val wire = buildReactionWire(toPeerID, r) ?: return
615 sendOrRoute(wire, toPeerID)
616 }
617 }
618
619 private fun buildReactionWire(peerID: String, reaction: ReactionMessage): WireMessage? {
620 val bytes = json.encodeToString(reaction).toByteArray()
621 synchronized(sessionLock) { sessions[peerID] }?.let { dr ->
622 return try {
623 val ratchetMessage = synchronized(sessionLock) { dr.encrypt(bytes) }.toWire()
624 val payload = ChatMessagePayload(
625 ratchetMessage = ratchetMessage,
626 messageID = UUID.randomUUID().toString()
627 )
628 builder().build(WireMessageType.reaction.name, payload)
629 } catch (e: Exception) { delegate?.didEncounterError(e); null }
630 }
631 return null
632 }
633
634 /** Creator-only: dissolve the group for all members. */
635 fun deleteGroup(group: GroupInfo) {
636 val myID = identity.publicIdentity.peerID
637 if (group.creatorID != myID) return
638 val msg = GroupDeletedMessage(group.id, myID)
639 val wire = builder().build(WireMessageType.groupDeleted.name, msg)
640 group.memberIDs.filter { it != myID }.forEach { peerID -> sendOrRoute(wire, peerID) }
641 groups.remove(group.id)
642 deleteSenderKeys(group.id)
643 messageStore.deleteConversation(group.conversationID)
644 saveGroups(groups.values.toList())
645 }
646
647 fun leaveGroup(group: GroupInfo) {
648 val myID = identity.publicIdentity.peerID
649 val remaining = group.memberIDs.filter { it != myID }
650 val left = GroupMemberLeftMessage(group.id, myID, remaining)
651 val wire = builder().build(WireMessageType.groupMemberLeft.name, left)
652 remaining.forEach { peerID -> sendOrRoute(wire, peerID) }
653 groups.remove(group.id)
654 deleteSenderKeys(group.id)
655 saveGroups(groups.values.toList())
656 }
657
658 private fun handleGroupMessage(message: WireMessage) {
659 val gwm = try { json.decodeFromString<GroupWireMessage>(String(message.payload)) }
660 catch (e: Exception) { return }
661
662 val group = groups[gwm.groupID] ?: return
663 val senderID = gwm.senderPeerID
664
665 // Cache piggybacked avatar (≤8 KB guard)
666 gwm.senderAvatarData?.takeIf { it.size <= 8_192 && !peerAvatarData.containsKey(senderID) }?.let {
667 peerAvatarData[senderID] = it
668 delegate?.didReceiveAvatarData(it, senderID)
669 }
670
671 val targetIteration = gwm.senderKeyIteration ?: 0L
672 val state = loadSenderKey(gwm.groupID, senderID) ?: return
673
674 val (messageKey, nextState) = try { state.advanceTo(targetIteration) }
675 catch (e: Exception) { return }
676 saveSenderKey(gwm.groupID, senderID, nextState)
677
678 val plaintext = try {
679 chachaPoly(messageKey).decrypt(gwm.ciphertext, group.id.toByteArray())
680 } catch (e: Exception) { return }
681
682 val body = String(plaintext)
683 val stored = StoredMessage(
684 id = gwm.messageID, peerID = group.conversationID,
685 direction = MessageDirection.received.name,
686 body = body, status = MessageStatus.delivered.name
687 )
688 messageStore.store(stored)
689 delegate?.didReceiveGroupMessage(stored, group)
690 }
691
692 private fun handleGroupDeleted(message: WireMessage) {
693 val msg = try { json.decodeFromString<GroupDeletedMessage>(String(message.payload)) }
694 catch (e: Exception) { return }
695 val group = groups[msg.groupID] ?: return
696 // Only accept from the known group creator
697 if (message.senderID != group.creatorID) return
698 groups.remove(msg.groupID)
699 deleteSenderKeys(msg.groupID)
700 messageStore.deleteConversation(group.conversationID)
701 saveGroups(groups.values.toList())
702 delegate?.groupDeletedWithID(msg.groupID)
703 }
704
705 private fun handleGroupMemberLeft(message: WireMessage) {
706 val msg = try { json.decodeFromString<GroupMemberLeftMessage>(String(message.payload)) }
707 catch (e: Exception) { return }
708
709 val group = groups[msg.groupID] ?: return
710 val updated = group.copy(memberIDs = msg.remainingMemberIDs)
711 groups[msg.groupID] = updated
712 saveGroups(groups.values.toList())
713 deleteSenderKeys("${msg.groupID}_${msg.leavingPeerID}")
714 }
715
716 private fun buildOutboundGroupInvite(peerID: String, inviteBytes: ByteArray): WireMessage? {
717 // Wrap the invite in a MessageContent with type="groupInvite" and send as DR-encrypted .message
718 val content = MessageContent(
719 body = "",
720 type = "groupInvite",
721 groupInviteData = inviteBytes,
722 timestamp = Date()
723 )
724 val contentBytes = json.encodeToString(content).toByteArray()
725 return synchronized(sessionLock) { sessions[peerID] }?.let { dr ->
726 try {
727 val ratchetMessage = synchronized(sessionLock) { dr.encrypt(contentBytes) }.toWire()
728 val payload = ChatMessagePayload(
729 ratchetMessage = ratchetMessage,
730 messageID = UUID.randomUUID().toString()
731 )
732 builder().build(WireMessageType.message.name, payload)
733 } catch (e: Exception) { null }
734 }
735 }
736
737 private fun broadcastToGroup(group: GroupInfo, gwm: GroupWireMessage) {
738 val wire = builder().build(WireMessageType.groupMessage.name, gwm)
739 val myID = identity.publicIdentity.peerID
740 group.memberIDs.filter { it != myID }.forEach { peerID ->
741 sendOrRoute(wire, peerID)
742 }
743 }
744
745 private fun chachaPoly(key: ByteArray) = ChaCha20Poly1305(key)
746
747 // -----------------------------------------------------------------------
748 // Handle incoming wire message
749 // -----------------------------------------------------------------------
750
751 private fun handleIncomingWireMessage(message: WireMessage, fromTransportID: String, isTCP: Boolean) {
752 // Verify signature if we know the peer
753 val knownPeer = knownPeers[message.senderID]
754 if (knownPeer != null) {
755 val valid = try {
756 WireMessageBuilder.verify(message, knownPeer.signingKeyPublic, identity)
757 } catch (e: Exception) { false }
758 if (!valid) return // drop unsigned/tampered message
759 }
760
761 when (message.type) {
762 WireMessageType.hello.name -> handleHello(message, fromTransportID, isTCP)
763 WireMessageType.initiateSession.name -> handleInitiateSession(message)
764 WireMessageType.message.name -> handleMessage(message)
765 WireMessageType.ack.name -> handleAck(message)
766 WireMessageType.relay.name -> handleRelay(message, fromTransportID, isTCP)
767 WireMessageType.groupMessage.name -> handleGroupMessage(message)
768 WireMessageType.groupMemberLeft.name -> handleGroupMemberLeft(message)
769 WireMessageType.groupDeleted.name -> handleGroupDeleted(message)
770 WireMessageType.typing.name -> handleTyping(message)
771 WireMessageType.reaction.name -> handleReaction(message)
772 WireMessageType.groupReaction.name -> handleGroupReaction(message)
773 WireMessageType.senderKeyRequest.name -> handleSenderKeyRequest(message)
774 WireMessageType.storeAndForward.name -> handleStoreAndForward(message)
775 WireMessageType.storeAndForwardDelivery.name -> handleStoreAndForwardDelivery(message, fromTransportID, isTCP)
776 WireMessageType.deadDrop.name -> handleDeadDrop(message, fromTransportID, isTCP)
777 WireMessageType.deviceLinkRequest.name -> handleDeviceLinkRequest(message)
778 WireMessageType.deviceSyncMessage.name -> handleDeviceSyncMessage(message)
779 WireMessageType.remoteWipe.name -> handleRemoteWipe(message)
780 WireMessageType.readReceipt.name -> handleReadReceipt(message)
781 WireMessageType.groupReadReceipt.name -> handleGroupReadReceipt(message)
782 WireMessageType.editMessage.name -> handleEditMessage(message)
783 WireMessageType.groupEditMessage.name -> handleGroupEditMessage(message)
784 // DHT messages — rate-limited, dispatched to DHTEngine
785 WireMessageType.dhtPing.name,
786 WireMessageType.dhtPong.name,
787 WireMessageType.dhtFindNode.name,
788 WireMessageType.dhtFindNodeResp.name,
789 WireMessageType.dhtStore.name,
790 WireMessageType.dhtFindValue.name,
791 WireMessageType.dhtFindValueResp.name -> {
792 if (isDHTRateLimitOk(message.senderID)) {
793 val engine = dhtEngine
794 if (engine != null) {
795 dhtScope.launch { engine.handleMessage(message, message.senderID) }
796 }
797 }
798 }
799 // readReceipt, groupReadReceipt, editMessage, groupEditMessage,
800 // channelAnnouncement are accepted but not yet acted upon.
801 }
802 }
803
804 private fun isDHTRateLimitOk(senderID: String): Boolean {
805 val now = System.currentTimeMillis()
806 val windowMs = 60_000L
807 val maxPerWindow = 30
808 val (windowStart, count) = dhtRateLimiters[senderID] ?: Pair(now, 0)
809 return if (now - windowStart > windowMs) {
810 dhtRateLimiters[senderID] = Pair(now, 1)
811 true
812 } else if (count < maxPerWindow) {
813 dhtRateLimiters[senderID] = Pair(windowStart, count + 1)
814 true
815 } else {
816 false
817 }
818 }
819
820 // -----------------------------------------------------------------------
821 // Hello
822 // -----------------------------------------------------------------------
823
824 private fun handleHello(message: WireMessage, fromTransportID: String, isTCP: Boolean) {
825 val hello = try { json.decodeFromString<HelloMessage>(String(message.payload)) }
826 catch (e: Exception) { return }
827 val bundle = hello.bundle
828 val peerID = bundle.peerID
829
830 peerBundles[peerID] = bundle
831
832 // Map transport ID → peerID
833 if (isTCP) {
834 tcpPeerIDs[peerID] = true
835 } else {
836 endpointToPeerID[fromTransportID] = peerID
837 peerIDToEndpoint[peerID] = fromTransportID
838 }
839
840 // Upsert known peer
841 if (knownPeers[peerID] == null) {
842 val peer = KnownPeer(
843 id = peerID, username = bundle.username,
844 signingKeyPublic = bundle.signingKeyPublic,
845 dhKeyPublic = bundle.dhIdentityKeyPublic,
846 safetyNumber = IdentityManager.safetyNumber(
847 identity.publicIdentity.signingKeyPublic, bundle.signingKeyPublic,
848 identity.publicIdentity.dhKeyPublic, bundle.dhIdentityKeyPublic
849 ),
850 lastSeen = Date(), isOnline = true, isDirectlyConnected = true,
851 tcpAddress = bundle.tcpAddress
852 )
853 knownPeers[peerID] = peer
854 delegate?.didDiscoverPeer(peer)
855 } else {
856 knownPeers[peerID] = knownPeers[peerID]?.copy(isOnline = true, lastSeen = Date()) ?: return
857 }
858
859 // Drain pending queue — now that we have the bundle, build and send each queued message
860 pendingQueue.remove(peerID)?.forEach { (pendingBody, msgID) ->
861 val wire = buildOutboundWire(peerID, pendingBody, msgID) ?: return@forEach
862 sendOrRoute(wire, peerID)
863 }
864 }
865
866 // -----------------------------------------------------------------------
867 // InitiateSession (X3DH responder)
868 // -----------------------------------------------------------------------
869
870 private fun handleInitiateSession(message: WireMessage) {
871 val msg = try { json.decodeFromString<InitiateSessionMessage>(String(message.payload)) }
872 catch (e: Exception) { return }
873 val peerID = msg.senderBundle.peerID
874
875 // Reject replayed or duplicate initiateSession: only accept if the incoming
876 // message is strictly newer than the session we already have.
877 val incomingTs = message.timestamp.time
878 val existingTs = sessionCreatedAt[peerID]
879 if (existingTs != null && incomingTs <= existingTs) return
880
881 try {
882 // Consume OTP if used
883 val otpk: DHKeyPair? = msg.usedOneTimePreKeyId?.let { preKeys.consumeOneTimePreKey(it) }
884
885 // X3DH receiver
886 val sharedSecret = X3DH.initiateReceiver(
887 recipientIdentityDH = identity.dhIdentityKeyPair,
888 recipientSignedPreKey = preKeys.signedPreKeyPair,
889 recipientOneTimePreKey = otpk,
890 senderIdentityDHKeyBytes = msg.senderBundle.dhIdentityKeyPublic,
891 senderEphemeralKeyBytes = msg.ephemeralPublicKey
892 )
893
894 val dr = DoubleRatchet.initAsResponder(sharedSecret, preKeys.signedPreKeyPair)
895 synchronized(sessionLock) {
896 sessions[peerID] = dr
897 sessionCreatedAt[peerID] = incomingTs
898 }
899
900 // Decrypt the first message — hold the lock so concurrent sends cannot
901 // race against the freshly installed session state.
902 val ratchetMsg = msg.initialMessage.fromWire()
903 val plaintext = synchronized(sessionLock) { dr.decrypt(ratchetMsg) }
904 val content = json.decodeFromString<MessageContent>(String(plaintext))
905
906 val stored = StoredMessage(
907 peerID = peerID, direction = MessageDirection.received.name,
908 body = content.body, status = MessageStatus.delivered.name
909 )
910 messageStore.store(stored)
911 delegate?.didReceiveMessage(stored, peerID)
912 forwardToLinkedDevices(stored)
913
914 // Send ACK
915 val ack = AckMessage(messageID = stored.id, status = "delivered")
916 val ackWire = builder().build(WireMessageType.ack.name, ack)
917 sendOrRoute(ackWire, peerID)
918
919 } catch (e: Exception) {
920 delegate?.didEncounterError(e)
921 }
922 }
923
924 // -----------------------------------------------------------------------
925 // Message (Double Ratchet decrypt)
926 // -----------------------------------------------------------------------
927
928 private fun handleMessage(message: WireMessage) {
929 val peerID = message.senderID
930 val payload = try { json.decodeFromString<ChatMessagePayload>(String(message.payload)) }
931 catch (e: Exception) { return }
932 val dr = synchronized(sessionLock) { sessions[peerID] } ?: return
933
934 try {
935 val plaintext = synchronized(sessionLock) { dr.decrypt(payload.ratchetMessage.fromWire()) }
936 val content = json.decodeFromString<MessageContent>(String(plaintext))
937
938 // Group invite — parse and register, do not display as chat message
939 if (content.type == "groupInvite") {
940 content.groupInviteData?.let { inviteBytes ->
941 val invite = try { json.decodeFromString<GroupInvitePayload>(String(inviteBytes)) }
942 catch (e: Exception) { null }
943 invite?.let {
944 val group = GroupInfo(
945 id = it.groupID, name = it.groupName,
946 memberIDs = it.memberIDs, creatorID = it.creatorID
947 )
948 groups[group.id] = group
949 saveGroups(groups.values.toList())
950 it.senderChainKey?.let { ck ->
951 saveSenderKey(it.groupID, it.creatorID,
952 SenderKeyState(chainKey = ck, iteration = it.senderIteration ?: 0L))
953 }
954 val myID = identity.publicIdentity.peerID
955 if (it.memberIDs.contains(myID) && loadSenderKey(group.id, myID) == null) {
956 val ck = ByteArray(32).also { k -> SecureRandom().nextBytes(k) }
957 saveSenderKey(group.id, myID, SenderKeyState(chainKey = ck))
958 }
959 }
960 }
961 return
962 }
963
964 val stored = StoredMessage(
965 id = payload.messageID, peerID = peerID,
966 direction = MessageDirection.received.name,
967 body = content.body, status = MessageStatus.delivered.name,
968 replyToID = content.replyToID,
969 expiresAt = content.expiresAt?.time
970 )
971 messageStore.store(stored)
972 delegate?.didReceiveMessage(stored, peerID)
973 forwardToLinkedDevices(stored)
974
975 // ACK
976 val ack = AckMessage(messageID = payload.messageID, status = "delivered")
977 val ackWire = builder().build(WireMessageType.ack.name, ack)
978 sendOrRoute(ackWire, peerID)
979
980 } catch (e: Exception) { delegate?.didEncounterError(e) }
981 }
982
983 // -----------------------------------------------------------------------
984 // Ack
985 // -----------------------------------------------------------------------
986
987 private fun handleAck(message: WireMessage) {
988 val ack = try { json.decodeFromString<AckMessage>(String(message.payload)) }
989 catch (e: Exception) { return }
990 messageStore.updateStatus(ack.messageID, message.senderID, MessageStatus.delivered)
991 delegate?.messageDelivered(ack.messageID, message.senderID)
992 }
993
994 // -----------------------------------------------------------------------
995 // Read receipts
996 // -----------------------------------------------------------------------
997
998 /** Mark all received messages in [peerID]'s conversation as read and notify the sender. */
999 fun markAsRead(peerID: String) {
1000 val unread = messageStore.loadMessages(peerID).filter {
1001 it.direction == "received" && it.status != MessageStatus.read.name
1002 }
1003 if (unread.isEmpty()) return
1004 messageStore.markAllRead(peerID)
1005 val receipt = ReadReceiptMessage(messageIDs = unread.map { it.id })
1006 val wire = builder().build(WireMessageType.readReceipt.name, receipt)
1007 sendOrRoute(wire, peerID)
1008 }
1009
1010 private fun handleReadReceipt(message: WireMessage) {
1011 val receipt = try { json.decodeFromString<ReadReceiptMessage>(String(message.payload)) }
1012 catch (e: Exception) { return }
1013 receipt.messageIDs.forEach { msgID ->
1014 messageStore.updateStatus(msgID, message.senderID, MessageStatus.read)
1015 }
1016 }
1017
1018 // -----------------------------------------------------------------------
1019 // Edit message
1020 // -----------------------------------------------------------------------
1021
1022 /** Send an in-place edit of a previously sent 1:1 message. */
1023 fun sendEditMessage(toPeerID: String, messageID: String, newBody: String) {
1024 val dr = synchronized(sessionLock) { sessions[toPeerID] } ?: return
1025 val editedAt = Date()
1026 val payload = EditMessagePayload(messageID = messageID, newBody = newBody, editedAt = editedAt)
1027 val encrypted = try {
1028 val plain = json.encodeToString(payload).toByteArray()
1029 json.encodeToString(ChatMessagePayload(ratchetMessage = synchronized(sessionLock) { dr.encrypt(plain) }.toWire()))
1030 } catch (e: Exception) { return }
1031 val wire = builder().build(WireMessageType.editMessage.name, encrypted)
1032 messageStore.editMessage(messageID, toPeerID, newBody, editedAt.time)
1033 sendOrRoute(wire, toPeerID)
1034 }
1035
1036 // -----------------------------------------------------------------------
1037 // Group read receipts
1038 // -----------------------------------------------------------------------
1039
1040 private fun handleGroupReadReceipt(message: WireMessage) {
1041 val receipt = try { json.decodeFromString<GroupReadReceiptMessage>(String(message.payload)) }
1042 catch (e: Exception) { return }
1043 val group = groups[receipt.groupID] ?: return
1044 messageStore.updateStatus(receipt.targetMessageID, group.conversationID, MessageStatus.read)
1045 }
1046
1047 /** Fan out an in-place edit to all group members. Plaintext — matches iOS GroupEditMessagePayload. */
1048 fun sendGroupEditMessage(group: GroupInfo, messageID: String, newBody: String) {
1049 val trimmed = newBody.trim()
1050 if (trimmed.isEmpty()) return
1051
1052 val convID = group.conversationID
1053 val existing = messageStore.loadMessages(convID).firstOrNull { it.id == messageID } ?: return
1054 if (existing.direction != MessageDirection.sent.name) return // only own messages
1055 if (existing.attachmentMimeType != null) return // no attachment edits
1056 if (System.currentTimeMillis() - existing.timestampMs > 5 * 60 * 1000) return // 5-min window
1057
1058 val editedAt = Date()
1059 val payload = GroupEditMessagePayload(groupID = group.id, messageID = messageID, newBody = trimmed, editedAt = editedAt)
1060 val wire = builder().build(WireMessageType.groupEditMessage.name, payload)
1061 messageStore.editMessage(messageID, convID, trimmed, editedAt.time)
1062 group.memberIDs.filter { it != identity.publicIdentity.peerID }
1063 .forEach { peerID -> sendOrRoute(wire, peerID) }
1064 }
1065
1066 private fun handleGroupEditMessage(message: WireMessage) {
1067 val payload = try { json.decodeFromString<GroupEditMessagePayload>(String(message.payload)) }
1068 catch (e: Exception) { return }
1069 val group = groups[payload.groupID] ?: return
1070 messageStore.editMessage(payload.messageID, group.conversationID, payload.newBody, payload.editedAt.time)
1071 delegate?.didEditMessage(group.conversationID)
1072 }
1073
1074 private fun handleEditMessage(message: WireMessage) {
1075 val senderID = message.senderID
1076 val dr = synchronized(sessionLock) { sessions[senderID] } ?: return
1077 val payload = try {
1078 val chatPayload = json.decodeFromString<ChatMessagePayload>(String(message.payload))
1079 val plain = synchronized(sessionLock) { dr.decrypt(chatPayload.ratchetMessage.fromWire()) }
1080 json.decodeFromString<EditMessagePayload>(String(plain))
1081 } catch (e: Exception) { return }
1082 messageStore.editMessage(payload.messageID, senderID, payload.newBody, payload.editedAt.time)
1083 delegate?.didEditMessage(senderID)
1084 }
1085
1086 // -----------------------------------------------------------------------
1087 // Typing
1088 // -----------------------------------------------------------------------
1089
1090 private fun handleTyping(message: WireMessage) {
1091 val typing = try { json.decodeFromString<TypingMessage>(String(message.payload)) }
1092 catch (e: Exception) { return }
1093 delegate?.didUpdateTypingState(message.senderID, typing.isTyping)
1094 }
1095
1096 private fun handleReaction(message: WireMessage) {
1097 val senderID = message.senderID
1098 val dr = synchronized(sessionLock) { sessions[senderID] } ?: return
1099 val plain = try {
1100 val payload = json.decodeFromString<ChatMessagePayload>(String(message.payload))
1101 synchronized(sessionLock) { dr.decrypt(payload.ratchetMessage.fromWire()) }
1102 } catch (e: Exception) { return }
1103 val r = try { json.decodeFromString<ReactionMessage>(String(plain)) } catch (e: Exception) { return }
1104 delegate?.didReceiveReaction(senderID, r.targetMessageID, r.emoji)
1105 }
1106
1107 private fun handleGroupReaction(message: WireMessage) {
1108 val r = try { json.decodeFromString<GroupReactionMessage>(String(message.payload)) }
1109 catch (e: Exception) { return }
1110 delegate?.didReceiveReaction(r.groupID, r.targetMessageID, r.emoji, senderID = message.senderID)
1111 }
1112
1113 // -----------------------------------------------------------------------
1114 // Relay
1115 // -----------------------------------------------------------------------
1116
1117 private fun handleRelay(message: WireMessage, fromTransportID: String, isTCP: Boolean) {
1118 val envelope = try { json.decodeFromString<RelayEnvelope>(String(message.payload)) }
1119 catch (e: Exception) { return }
1120 if (envelope.ttl == 0) return
1121
1122 val myPeerID = identity.publicIdentity.peerID
1123 if (envelope.targetPeerID == myPeerID) {
1124 // Destined for us — process inner message
1125 handleIncomingWireMessage(envelope.message, fromTransportID, isTCP)
1126 } else {
1127 // Forward: prefer Nearby (GMS), fall back to TCP broadcast (non-GMS / mDNS peers)
1128 val forwarded = envelope.forwarded()
1129 val forwardedWire = builder().build(WireMessageType.relay.name, forwarded)
1130 if (nearby != null) {
1131 val fromEndpoint = if (isTCP) null else fromTransportID
1132 nearby.broadcast(forwardedWire, excluding = fromEndpoint)
1133 } else {
1134 // Non-GMS path: relay over TCP connections (Wi-Fi Direct / mDNS)
1135 tcp.broadcast(forwardedWire, excluding = if (isTCP) fromTransportID else null)
1136 }
1137 }
1138 }
1139
1140 // -----------------------------------------------------------------------
1141 // Sender key request — re-distribute our current sender key for a group
1142 // -----------------------------------------------------------------------
1143
1144 private fun handleSenderKeyRequest(message: WireMessage) {
1145 val req = try { json.decodeFromString<SenderKeyRequestMessage>(String(message.payload)) }
1146 catch (e: Exception) { return }
1147 val myID = identity.publicIdentity.peerID
1148 // Only respond if the request targets us
1149 if (req.targetPeerID != myID) return
1150 val group = groups[req.groupID] ?: return
1151 val state = loadSenderKey(req.groupID, myID) ?: return
1152
1153 val invite = GroupInvitePayload(
1154 groupID = group.id,
1155 groupName = group.name,
1156 memberIDs = group.memberIDs,
1157 creatorID = group.creatorID,
1158 senderChainKey = state.chainKey,
1159 senderIteration = state.iteration
1160 )
1161 val wire = buildOutboundGroupInvite(message.senderID, json.encodeToString(invite).toByteArray())
1162 ?: return
1163 sendOrRoute(wire, message.senderID)
1164 }
1165
1166 // -----------------------------------------------------------------------
1167 // Device link — incoming link request (other device scanned our QR)
1168 // -----------------------------------------------------------------------
1169
1170 private fun handleDeviceLinkRequest(message: WireMessage) {
1171 val msg = try { json.decodeFromString<DeviceLinkRequestMessage>(String(message.payload)) }
1172 catch (e: Exception) { return }
1173 val bundle = msg.bundle
1174 val peerID = bundle.peerID
1175
1176 peerBundles[peerID] = bundle
1177
1178 val existing = knownPeers[peerID]
1179 if (existing == null) {
1180 val peer = KnownPeer(
1181 id = peerID, username = msg.deviceLabel,
1182 signingKeyPublic = bundle.signingKeyPublic,
1183 dhKeyPublic = bundle.dhIdentityKeyPublic,
1184 safetyNumber = IdentityManager.safetyNumber(
1185 identity.publicIdentity.signingKeyPublic, bundle.signingKeyPublic,
1186 identity.publicIdentity.dhKeyPublic, bundle.dhIdentityKeyPublic
1187 ),
1188 lastSeen = Date(), isOnline = true
1189 )
1190 knownPeers[peerID] = peer
1191 delegate?.didDiscoverPeer(peer)
1192 } else {
1193 knownPeers[peerID] = existing.copy(isOnline = true, lastSeen = Date())
1194 }
1195
1196 linkedDeviceIDs[peerID] = true
1197 saveLinkedDevices()
1198
1199 // Send a reciprocal link so the initiator knows us too
1200 val reply = DeviceLinkRequestMessage(
1201 deviceLabel = android.os.Build.MODEL,
1202 bundle = preKeys.generateBundle(myTCPAddress)
1203 )
1204 val wire = builder().build(WireMessageType.deviceLinkRequest.name, reply)
1205 sendOrRoute(wire, peerID)
1206 }
1207
1208 /** Receive a device-sync message from a linked device and store it locally. */
1209 private fun handleDeviceSyncMessage(message: WireMessage) {
1210 // Only accept from devices we explicitly linked
1211 if (!linkedDeviceIDs.containsKey(message.senderID)) return
1212 val sync = try { json.decodeFromString<DeviceSyncMessage>(String(message.payload)) }
1213 catch (e: Exception) { return }
1214 val stored = try { json.decodeFromString<StoredMessage>(String(sync.messageJSON)) }
1215 catch (e: Exception) { return }
1216 // Dedup
1217 if (messageStore.loadMessages(stored.peerID).any { it.id == stored.id }) return
1218 messageStore.store(stored)
1219 delegate?.didReceiveMessage(stored, stored.peerID)
1220 }
1221
1222 /**
1223 * Forward a message to all linked devices for cross-device sync.
1224 * Skips forwarding if the message came from a linked device (loop prevention).
1225 */
1226 private fun forwardToLinkedDevices(stored: StoredMessage) {
1227 if (linkedDeviceIDs.isEmpty()) return
1228 // Don't re-forward messages that originated from a linked device
1229 if (linkedDeviceIDs.containsKey(stored.peerID)) return
1230 val msgJson = json.encodeToString(stored).toByteArray()
1231 val sync = DeviceSyncMessage(messageJSON = msgJson)
1232 val wire = builder().build(WireMessageType.deviceSyncMessage.name, sync)
1233 linkedDeviceIDs.keys().toList().forEach { deviceID ->
1234 sendOrRoute(wire, deviceID)
1235 }
1236 }
1237
1238 // -----------------------------------------------------------------------
1239 // Store-and-forward
1240 // -----------------------------------------------------------------------
1241
1242 /** Receive a store-and-forward request: store the sealed message for later delivery. */
1243 private fun handleStoreAndForward(message: WireMessage) {
1244 val req = try { json.decodeFromString<StoreAndForwardRequest>(String(message.payload)) }
1245 catch (e: Exception) { return }
1246
1247 // Drop already-expired requests
1248 if (req.expiresAt.time < System.currentTimeMillis()) return
1249
1250 val queue = storeAndForwardQueue.getOrPut(req.targetPeerID) {
1251 java.util.Collections.synchronizedList(mutableListOf())
1252 }
1253 synchronized(queue) {
1254 // Dedup by messageID
1255 if (queue.any { it.messageID == req.messageID }) return
1256 // Enforce per-peer cap
1257 if (queue.size >= SAF_MAX_PER_PEER) queue.removeAt(0)
1258 queue.add(req)
1259 }
1260 }
1261
1262 /** Receive a store-and-forward delivery: process each contained sealed message. */
1263 private fun handleStoreAndForwardDelivery(message: WireMessage, fromTransportID: String, isTCP: Boolean) {
1264 val delivery = try { json.decodeFromString<StoreAndForwardDelivery>(String(message.payload)) }
1265 catch (e: Exception) { return }
1266 delivery.items.forEach { item ->
1267 val innerPayload = json.encodeToString(item.sealed).toByteArray()
1268 val inner = WireMessage(
1269 type = WireMessageType.sealed.name,
1270 payload = innerPayload,
1271 senderID = message.senderID,
1272 timestamp = message.timestamp,
1273 signature = message.signature
1274 )
1275 handleIncomingWireMessage(inner, fromTransportID, isTCP)
1276 }
1277 }
1278
1279 /** Receive a dead-drop: dedup, store, flood forward, and deliver if addressed to us. */
1280 private fun handleDeadDrop(message: WireMessage, fromTransportID: String, isTCP: Boolean) {
1281 val envelope = try { json.decodeFromString<DeadDropEnvelope>(String(message.payload)) }
1282 catch (e: Exception) { return }
1283
1284 // Drop expired
1285 if (envelope.expiresAt.time < System.currentTimeMillis()) return
1286
1287 // Dedup
1288 val now = System.currentTimeMillis()
1289 if (seenDeadDropIDs.putIfAbsent(envelope.id, now) != null) return
1290
1291 // Store for later delivery to offline peers
1292 synchronized(deadDropLock) { deadDrops.add(envelope) }
1293
1294 // Flood to other connected peers (TTL is tracked externally via expiresAt)
1295 val forwardWire = builder().build(WireMessageType.deadDrop.name, envelope)
1296 if (nearby != null) {
1297 val fromEndpoint = if (isTCP) null else fromTransportID
1298 nearby.broadcast(forwardWire, excluding = fromEndpoint)
1299 } else {
1300 tcp.broadcast(forwardWire, excluding = if (isTCP) fromTransportID else null)
1301 }
1302
1303 // Deliver if addressed to us
1304 val myPeerID = identity.publicIdentity.peerID
1305 if (envelope.targetPeerID == myPeerID) {
1306 val inner = WireMessage(
1307 type = WireMessageType.sealed.name,
1308 payload = json.encodeToString(envelope.sealed).toByteArray(),
1309 senderID = message.senderID,
1310 timestamp = message.timestamp,
1311 signature = message.signature
1312 )
1313 handleIncomingWireMessage(inner, fromTransportID, isTCP)
1314 }
1315 }
1316
1317 /**
1318 * Called when a peer connects (after Hello).
1319 * Drains the store-and-forward queue and dead drops addressed to this peer.
1320 */
1321 private fun deliverQueuedMessages(peerID: String) {
1322 // --- Store-and-forward queue ---
1323 val items = storeAndForwardQueue.remove(peerID)
1324 if (!items.isNullOrEmpty()) {
1325 val deliveryItems = items.map { req ->
1326 StoreAndForwardItem(messageID = req.messageID, sealed = req.sealed)
1327 }
1328 val delivery = StoreAndForwardDelivery(items = deliveryItems)
1329 val wire = builder().build(WireMessageType.storeAndForwardDelivery.name, delivery)
1330 sendOrRoute(wire, peerID)
1331 }
1332
1333 // --- Dead drops ---
1334 val now = System.currentTimeMillis()
1335 val toDeliver = synchronized(deadDropLock) {
1336 deadDrops.filter { it.targetPeerID == peerID && it.expiresAt.time > now }
1337 }
1338 toDeliver.forEach { envelope ->
1339 val wire = builder().build(WireMessageType.deadDrop.name, envelope)
1340 sendOrRoute(wire, peerID)
1341 }
1342 }
1343
1344 /** Remove expired store-and-forward items and dead drops. Call periodically. */
1345 private fun purgeExpiredQueueItems() {
1346 val now = System.currentTimeMillis()
1347 // Store-and-forward
1348 storeAndForwardQueue.forEach { (peerID, queue) ->
1349 synchronized(queue) { queue.removeAll { it.expiresAt.time <= now } }
1350 if (queue.isEmpty()) storeAndForwardQueue.remove(peerID)
1351 }
1352 // Dead drops
1353 synchronized(deadDropLock) {
1354 deadDrops.removeAll { it.expiresAt.time <= now }
1355 }
1356 // Seen IDs older than SAF_TTL_MS
1357 seenDeadDropIDs.entries.removeAll { (_, ts) -> now - ts > SAF_TTL_MS }
1358 }
1359
1360 // -----------------------------------------------------------------------
1361 // Remote Wipe
1362 // -----------------------------------------------------------------------
1363
1364 fun addTrustedWipePeer(peerID: String) {
1365 trustedWipePeers.add(peerID)
1366 saveTrustedWipePeers()
1367 }
1368
1369 fun removeTrustedWipePeer(peerID: String) {
1370 trustedWipePeers.remove(peerID)
1371 saveTrustedWipePeers()
1372 }
1373
1374 fun trustedWipePeersList(): List<String> = trustedWipePeers.toList()
1375
1376 fun sendRemoteWipe(toPeerID: String) {
1377 val wire = builder().build(WireMessageType.remoteWipe.name, RemoteWipeRequest())
1378 sendOrRoute(wire, toPeerID)
1379 }
1380
1381 private fun loadTrustedWipePeers(): MutableSet<String> {
1382 val raw = groupPrefs.getString("trusted_wipe_peers", null) ?: return mutableSetOf()
1383 return try { json.decodeFromString<List<String>>(raw).toMutableSet() }
1384 catch (_: Exception) { mutableSetOf() }
1385 }
1386
1387 private fun saveTrustedWipePeers() {
1388 groupPrefs.edit().putString("trusted_wipe_peers", json.encodeToString(trustedWipePeers.toList())).apply()
1389 }
1390
1391 private fun handleRemoteWipe(message: WireMessage) {
1392 // Only accept from trusted peers
1393 if (!trustedWipePeers.contains(message.senderID)) return
1394 val req = try { json.decodeFromString<RemoteWipeRequest>(String(message.payload)) }
1395 catch (_: Exception) { return }
1396 // Dedup — prevent replay
1397 if (seenWipeRequestIDs.putIfAbsent(req.requestID, true) != null) return
1398 delegate?.didReceiveRemoteWipeRequest()
1399 }
1400
1401 // -----------------------------------------------------------------------
1402 // Hello builder
1403 // -----------------------------------------------------------------------
1404
1405 private fun buildHello(): WireMessage {
1406 val bundle = preKeys.generateBundle(myTCPAddress)
1407 return builder().build(WireMessageType.hello.name, HelloMessage(bundle))
1408 }
1409
1410 private fun builder() = WireMessageBuilder(identity)
1411
1412 // -----------------------------------------------------------------------
1413 // Nearby listener
1414 // -----------------------------------------------------------------------
1415
1416 private val nearbyListener = object : NearbyManagerListener {
1417 override fun didDiscoverPeer(endpointId: String, displayName: String) {}
1418 override fun didLosePeer(endpointId: String) {}
1419 override fun didConnectToPeer(endpointId: String) {}
1420 override fun didDisconnectFromPeer(endpointId: String) {
1421 val peerID = endpointToPeerID.remove(endpointId)
1422 if (peerID != null) {
1423 peerIDToEndpoint.remove(peerID)
1424 knownPeers[peerID] = knownPeers[peerID]?.copy(isOnline = false) ?: return
1425 delegate?.peerDidDisconnect(peerID)
1426 }
1427 }
1428 override fun didReceiveMessage(message: WireMessage, fromEndpointId: String) {
1429 handleIncomingWireMessage(message, fromEndpointId, isTCP = false)
1430 }
1431 override fun sendDidFail(endpointId: String, error: Exception) {
1432 delegate?.didEncounterError(error)
1433 }
1434 override fun helloMessage(): WireMessage = buildHello()
1435 }
1436
1437 // -----------------------------------------------------------------------
1438 // TCP listener
1439 // -----------------------------------------------------------------------
1440
1441 private val tcpListener = object : TcpTransportListener {
1442 override fun didConnect(peerID: String, address: String) {
1443 if (address.isNotEmpty()) tcpAddressToPeerID[address] = peerID
1444 // Flush any queued DHT messages for this address
1445 val queued = pendingDHTMessages.remove(address) ?: return
1446 dhtScope.launch {
1447 for (msg in queued) {
1448 runCatching { tcp.send(msg, peerID) }
1449 }
1450 }
1451 }
1452 override fun didDisconnect(peerID: String) {
1453 tcpPeerIDs.remove(peerID)
1454 tcpAddressToPeerID.entries.removeIf { it.value == peerID }
1455 knownPeers[peerID] = knownPeers[peerID]?.copy(isOnline = false) ?: return
1456 delegate?.peerDidDisconnect(peerID)
1457 }
1458 override fun didReceiveMessage(message: WireMessage, fromPeerID: String) {
1459 handleIncomingWireMessage(message, fromPeerID, isTCP = true)
1460 }
1461 override fun didStartListening(port: Int) {}
1462 override fun didFailToSend(peerID: String, error: Exception) {
1463 delegate?.didEncounterError(error)
1464 }
1465 }
1466
1467 // -----------------------------------------------------------------------
1468 // Accessors for UI
1469 // -----------------------------------------------------------------------
1470
1471 fun knownPeersList(): List<KnownPeer> = knownPeers.values.toList()
1472 fun messages(peerID: String): List<StoredMessage> = messageStore.loadMessages(peerID)
1473}
1474
1475// ---------------------------------------------------------------------------
1476// Wire message builder (Android port)
1477// ---------------------------------------------------------------------------
1478
1479class WireMessageBuilder(private val identity: IdentityManager) {
1480 private val json = Json { ignoreUnknownKeys = true }
1481
1482 inline fun <reified T> build(type: String, payload: T): WireMessage {
1483 val payloadBytes = json.encodeToString(payload).toByteArray()
1484 val senderID = identity.publicIdentity.peerID
1485 val timestamp = Date()
1486 val unsigned = WireMessage(type, payloadBytes, senderID, timestamp, ByteArray(0))
1487 val signature = identity.sign(unsigned.signingBytes())
1488 return WireMessage(type, payloadBytes, senderID, timestamp, signature)
1489 }
1490
1491 companion object {
1492 fun verify(message: WireMessage, signingKeyPublic: ByteArray, identity: IdentityManager): Boolean =
1493 IdentityManager.verify(message.signature, message.signingBytes(), signingKeyPublic)
1494 }
1495}
1496
1497// ---------------------------------------------------------------------------
1498// Extension: convert between internal and wire RatchetMessage
1499// ---------------------------------------------------------------------------
1500
1501private fun com.sophax.sophaxchat.crypto.RatchetMessage.toWire() = RatchetMessage(
1502 encryptedHeader = this.encryptedHeader,
1503 ciphertext = this.ciphertext
1504)
1505
1506private fun RatchetMessage.fromWire() = com.sophax.sophaxchat.crypto.RatchetMessage(
1507 encryptedHeader = this.encryptedHeader,
1508 ciphertext = this.ciphertext
1509)