diff --git a/app/src/main/java/com/bitchat/android/model/RequestSyncPacket.kt b/app/src/main/java/com/bitchat/android/model/RequestSyncPacket.kt index ab52f071..c359aeb5 100644 --- a/app/src/main/java/com/bitchat/android/model/RequestSyncPacket.kt +++ b/app/src/main/java/com/bitchat/android/model/RequestSyncPacket.kt @@ -12,7 +12,9 @@ import com.bitchat.android.sync.SyncDefaults data class RequestSyncPacket( val p: Int, val m: Long, - val data: ByteArray + val data: ByteArray, + val wantedTypes: List? = null, + val minTimestamp: ULong? = null ) { fun encode(): ByteArray { val out = ArrayList() @@ -38,6 +40,28 @@ data class RequestSyncPacket( ) // data putTLV(0x03, data) + // wantedTypes + wantedTypes?.let { types -> + if (types.isNotEmpty()) { + val typesBytes = types.map { it.toByte() }.toByteArray() + putTLV(0x04, typesBytes) + } + } + // minTimestamp + minTimestamp?.let { ts -> + val tsLong = ts.toLong() + val tsBytes = byteArrayOf( + ((tsLong ushr 56) and 0xFF).toByte(), + ((tsLong ushr 48) and 0xFF).toByte(), + ((tsLong ushr 40) and 0xFF).toByte(), + ((tsLong ushr 32) and 0xFF).toByte(), + ((tsLong ushr 24) and 0xFF).toByte(), + ((tsLong ushr 16) and 0xFF).toByte(), + ((tsLong ushr 8) and 0xFF).toByte(), + (tsLong and 0xFF).toByte() + ) + putTLV(0x05, tsBytes) + } return out.toByteArray() } @@ -50,6 +74,8 @@ data class RequestSyncPacket( var p: Int? = null var m: Long? = null var payload: ByteArray? = null + var wantedTypes: List? = null + var minTimestamp: ULong? = null while (off + 3 <= data.size) { val t = (data[off].toInt() and 0xFF); off += 1 @@ -69,6 +95,22 @@ data class RequestSyncPacket( if (v.size > MAX_ACCEPT_FILTER_BYTES) return null payload = v } + 0x04 -> { + val typesList = mutableListOf() + for (b in v) { + typesList.add(b.toUByte()) + } + wantedTypes = typesList + } + 0x05 -> { + if (len == 8) { + var tsVal = 0L + for (i in 0 until 8) { + tsVal = (tsVal shl 8) or (v[i].toLong() and 0xFF) + } + minTimestamp = tsVal.toULong() + } + } } } @@ -76,7 +118,7 @@ data class RequestSyncPacket( val mm = m ?: return null val dd = payload ?: return null if (pp < 1 || mm <= 0L) return null - return RequestSyncPacket(pp, mm, dd) + return RequestSyncPacket(pp, mm, dd, wantedTypes, minTimestamp) } } } diff --git a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt index 42a10ceb..c4c39008 100644 --- a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt +++ b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt @@ -142,8 +142,8 @@ class GossipSyncManager( } } - private fun sendRequestSync() { - val payload = buildGcsPayload() + fun sendRequestSync(wantedTypes: List? = null, minTimestamp: ULong? = null) { + val payload = buildGcsPayload(wantedTypes, minTimestamp) val packet = BitchatPacket( type = MessageType.REQUEST_SYNC.value, @@ -157,8 +157,8 @@ class GossipSyncManager( delegate?.sendPacket(signed) } - private fun sendRequestSyncToPeer(peerID: String) { - val payload = buildGcsPayload() + fun sendRequestSyncToPeer(peerID: String, wantedTypes: List? = null, minTimestamp: ULong? = null) { + val payload = buildGcsPayload(wantedTypes, minTimestamp) val packet = BitchatPacket( type = MessageType.REQUEST_SYNC.value, @@ -183,26 +183,40 @@ class GossipSyncManager( return GCSFilter.contains(sorted, nonZeroV) } + // Determine types to include (default: ANNOUNCE and MESSAGE if wantedTypes is null or empty) + val targetTypes = if (request.wantedTypes == null || request.wantedTypes.isEmpty()) { + listOf(MessageType.ANNOUNCE.value, MessageType.MESSAGE.value) + } else { + request.wantedTypes + } + val minTs = request.minTimestamp ?: 0uL + // 1) Announcements: send latest per peerID if remote doesn't have them - for ((_, pair) in latestAnnouncementByPeer.entries) { - val (id, pkt) = pair - val idBytes = hexToBytes(id) - if (!mightContain(idBytes)) { - // Send original packet unchanged to requester only (keep local TTL) - val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS) - delegate?.sendPacketToPeer(fromPeerID, toSend) - Log.d(TAG, "Sent sync announce: Type ${toSend.type} from ${toSend.senderID.toHexString()} to $fromPeerID packet id ${idBytes.toHexString()}") + if (targetTypes.contains(MessageType.ANNOUNCE.value)) { + for ((_, pair) in latestAnnouncementByPeer.entries) { + val (id, pkt) = pair + if (pkt.timestamp < minTs) continue + val idBytes = hexToBytes(id) + if (!mightContain(idBytes)) { + // Send original packet unchanged to requester only (keep local TTL) + val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS) + delegate?.sendPacketToPeer(fromPeerID, toSend) + Log.d(TAG, "Sent sync announce: Type ${toSend.type} from ${toSend.senderID.toHexString()} to $fromPeerID packet id ${idBytes.toHexString()}") + } } } // 2) Broadcast messages: send all they lack - val toSendMsgs = synchronized(messages) { messages.values.toList() } - for (pkt in toSendMsgs) { - val idBytes = PacketIdUtil.computeIdBytes(pkt) - if (!mightContain(idBytes)) { - val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS) - delegate?.sendPacketToPeer(fromPeerID, toSend) - Log.d(TAG, "Sent sync message: Type ${toSend.type} to $fromPeerID packet id ${idBytes.toHexString()}") + if (targetTypes.contains(MessageType.MESSAGE.value)) { + val toSendMsgs = synchronized(messages) { messages.values.toList() } + for (pkt in toSendMsgs) { + if (pkt.timestamp < minTs) continue + val idBytes = PacketIdUtil.computeIdBytes(pkt) + if (!mightContain(idBytes)) { + val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS) + delegate?.sendPacketToPeer(fromPeerID, toSend) + Log.d(TAG, "Sent sync message: Type ${toSend.type} to $fromPeerID packet id ${idBytes.toHexString()}") + } } } } @@ -232,16 +246,35 @@ class GossipSyncManager( return out } - private fun buildGcsPayload(): ByteArray { + private fun buildGcsPayload(wantedTypes: List? = null, minTimestamp: ULong? = null): ByteArray { // Collect candidates: latest announcement per peer + recent broadcast messages val list = ArrayList() + // Determine types to include (default: ANNOUNCE and MESSAGE if wantedTypes is null or empty) + val targetTypes = if (wantedTypes == null || wantedTypes.isEmpty()) { + listOf(MessageType.ANNOUNCE.value, MessageType.MESSAGE.value) + } else { + wantedTypes + } + val minTs = minTimestamp ?: 0uL + // announcements - for ((_, pair) in latestAnnouncementByPeer) { - list.add(pair.second) + if (targetTypes.contains(MessageType.ANNOUNCE.value)) { + for ((_, pair) in latestAnnouncementByPeer) { + val pkt = pair.second + if (pkt.timestamp >= minTs) { + list.add(pkt) + } + } } // messages - synchronized(messages) { - list.addAll(messages.values) + if (targetTypes.contains(MessageType.MESSAGE.value)) { + synchronized(messages) { + for (pkt in messages.values) { + if (pkt.timestamp >= minTs) { + list.add(pkt) + } + } + } } // sort by timestamp desc, then take up to min(seenCapacity, fit capacity) list.sortByDescending { it.timestamp.toLong() } @@ -254,12 +287,12 @@ class GossipSyncManager( val takeN = minOf(nMax, cap, list.size) if (takeN <= 0) { val p0 = GCSFilter.deriveP(fpr) - return RequestSyncPacket(p = p0, m = 1, data = ByteArray(0)).encode() + return RequestSyncPacket(p = p0, m = 1, data = ByteArray(0), wantedTypes = wantedTypes, minTimestamp = minTimestamp).encode() } val ids = list.take(takeN).map { pkt -> PacketIdUtil.computeIdBytes(pkt) } val params = GCSFilter.buildFilter(ids, maxBytes, fpr) val mVal = if (params.m <= 0L) 1 else params.m - return RequestSyncPacket(p = params.p, m = mVal, data = params.data).encode() + return RequestSyncPacket(p = params.p, m = mVal, data = params.data, wantedTypes = wantedTypes, minTimestamp = minTimestamp).encode() } // Periodically remove stale announcements and all their messages diff --git a/app/src/test/java/com/bitchat/android/protocol/RequestSyncPacketTest.kt b/app/src/test/java/com/bitchat/android/protocol/RequestSyncPacketTest.kt new file mode 100644 index 00000000..fcc5d63c --- /dev/null +++ b/app/src/test/java/com/bitchat/android/protocol/RequestSyncPacketTest.kt @@ -0,0 +1,74 @@ +package com.bitchat.android.protocol + +import com.bitchat.android.model.RequestSyncPacket +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class RequestSyncPacketTest { + + @Test + fun testBaseFieldsRoundTrip() { + val original = RequestSyncPacket( + p = 7, + m = 12800L, + data = byteArrayOf(1, 2, 3, 4, 5) + ) + val encoded = original.encode() + val decoded = RequestSyncPacket.decode(encoded) + + assertNotNull(decoded) + assertEquals(7, decoded!!.p) + assertEquals(12800L, decoded.m) + assertTrue(original.data.contentEquals(decoded.data)) + assertNull(decoded.wantedTypes) + assertNull(decoded.minTimestamp) + } + + @Test + fun testUpgradedFieldsRoundTrip() { + val original = RequestSyncPacket( + p = 8, + m = 25600L, + data = byteArrayOf(10, 20, 30), + wantedTypes = listOf(0x01u, 0x02u), + minTimestamp = 1700000000000uL + ) + val encoded = original.encode() + val decoded = RequestSyncPacket.decode(encoded) + + assertNotNull(decoded) + assertEquals(8, decoded!!.p) + assertEquals(25600L, decoded.m) + assertTrue(original.data.contentEquals(decoded.data)) + assertNotNull(decoded.wantedTypes) + assertEquals(2, decoded.wantedTypes!!.size) + assertEquals(0x01u.toUByte(), decoded.wantedTypes!![0]) + assertEquals(0x02u.toUByte(), decoded.wantedTypes!![1]) + assertEquals(1700000000000uL, decoded.minTimestamp) + } + + @Test + fun testLegacyCompatibleDecode() { + // Construct a raw legacy payload manually without fields 0x04 or 0x05 + // Payload consists of: + // Type 0x01: length 1, value P (7) + // Type 0x02: length 4, value M (12800 -> 0x00003200) + // Type 0x03: length 3, value data (1, 2, 3) + val payload = byteArrayOf( + 0x01, 0x00, 0x01, 0x07, // P + 0x02, 0x00, 0x04, 0x00, 0x00, 0x32, 0x00, // M + 0x03, 0x00, 0x03, 0x01, 0x02, 0x03 // data + ) + + val decoded = RequestSyncPacket.decode(payload) + assertNotNull(decoded) + assertEquals(7, decoded!!.p) + assertEquals(12800L, decoded.m) + assertTrue(byteArrayOf(1, 2, 3).contentEquals(decoded.data)) + assertNull(decoded.wantedTypes) + assertNull(decoded.minTimestamp) + } +}