Merge remote-tracking branch 'origin/feat/geo-board' into feat/integration-all

# Conflicts:
#	bitchat/Services/BLE/BLEOutboundPacketPolicy.swift
#	bitchat/Services/BLE/BLEService.swift
#	bitchat/Services/Transport.swift
#	bitchat/Sync/GossipSyncManager.swift
#	bitchat/Sync/SyncTypeFlags.swift
#	bitchat/ViewModels/ChatViewModel.swift
#	localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift
This commit is contained in:
jack
2026-07-06 22:29:16 +02:00
19 changed files with 2122 additions and 10 deletions
@@ -0,0 +1,130 @@
//
// GossipSyncBoardTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
import Testing
@testable import bitchat
/// Board posts ride gossip sync through a provider that queries the board
/// store, so retention (expiry, tombstones, caps) has a single owner.
struct GossipSyncBoardTests {
private let myPeerID = PeerID(str: "0102030405060708")
private func makeBoardPacket(timestamp: UInt64) throws -> BitchatPacket {
BitchatPacket(
type: MessageType.boardPost.rawValue,
senderID: try #require(Data(hexString: "aabbccddeeff0011")),
recipientID: nil,
timestamp: timestamp,
payload: Data([0x42]),
signature: nil,
ttl: 7
)
}
private func quietConfig() -> GossipSyncManager.Config {
var config = GossipSyncManager.Config()
config.messageSyncIntervalSeconds = 0
config.fragmentSyncIntervalSeconds = 0
config.fileTransferSyncIntervalSeconds = 0
return config
}
@Test func boardRequestIsServedFromProvider() async throws {
let manager = GossipSyncManager(myPeerID: myPeerID, config: quietConfig(), requestSyncManager: RequestSyncManager())
let delegate = RecordingBoardDelegate()
manager.delegate = delegate
let boardPacket = try makeBoardPacket(timestamp: UInt64(Date().timeIntervalSince1970 * 1000))
manager.boardPacketsProvider = { return [boardPacket] }
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board)
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
let sent = try #require(delegate.packets.first)
#expect(sent.type == MessageType.boardPost.rawValue)
#expect(sent.isRSR)
}
@Test func nonBoardRequestDoesNotServeBoardPackets() async throws {
let manager = GossipSyncManager(myPeerID: myPeerID, config: quietConfig(), requestSyncManager: RequestSyncManager())
let delegate = RecordingBoardDelegate()
manager.delegate = delegate
let boardPacket = try makeBoardPacket(timestamp: UInt64(Date().timeIntervalSince1970 * 1000))
manager.boardPacketsProvider = { return [boardPacket] }
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .publicMessages)
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
// Follow with a board request; only its response should arrive, which
// also proves the first request produced nothing.
let boardRequest = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board)
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: boardRequest)
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
#expect(delegate.packets.count == 1)
#expect(delegate.packets.first?.type == MessageType.boardPost.rawValue)
}
@Test func maintenanceEmitsBoardRoundOnlyWithProvider() throws {
var config = quietConfig()
config.boardSyncIntervalSeconds = 1
// Without a provider the board schedule stays silent.
let unwired = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager())
let unwiredDelegate = RecordingBoardDelegate()
unwired.delegate = unwiredDelegate
unwired._performMaintenanceSynchronously(now: Date())
#expect(unwiredDelegate.packets.isEmpty)
// With a provider, maintenance sends a board-typed request.
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager())
let delegate = RecordingBoardDelegate()
manager.delegate = delegate
manager.boardPacketsProvider = { return [] }
manager._performMaintenanceSynchronously(now: Date())
#expect(delegate.packets.count == 1)
let payload = try #require(delegate.packets.first?.payload)
let request = try #require(RequestSyncPacket.decode(from: payload))
#expect(request.types == .board)
}
}
private final class RecordingBoardDelegate: GossipSyncManager.Delegate {
private let lock = NSLock()
private var _packets: [BitchatPacket] = []
var packets: [BitchatPacket] {
lock.lock()
defer { lock.unlock() }
return _packets
}
func sendPacket(_ packet: BitchatPacket) {
lock.lock()
_packets.append(packet)
lock.unlock()
}
func sendPacket(to peerID: PeerID, packet: BitchatPacket) {
lock.lock()
_packets.append(packet)
lock.unlock()
}
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
packet
}
func getConnectedPeers() -> [PeerID] {
[]
}
}
@@ -0,0 +1,78 @@
//
// SyncTypeFlagsBoardTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
import Testing
@testable import bitchat
/// The board sync flag is the first bit outside the original single byte of
/// type flags. These tests pin down the wire compatibility contract: the
/// types TLV has been a variable-length (1-8 byte) little-endian bitfield
/// since type-aware sync, so widening to two bytes must decode everywhere
/// and unknown bits must be ignored, not rejected.
struct SyncTypeFlagsBoardTests {
@Test func boardFlagEncodesIntoSecondByte() throws {
let data = try #require(SyncTypeFlags.board.toData())
// Little-endian: low byte first, board bit (bit 8) in byte 2.
#expect(data == Data([0x00, 0x01]))
}
@Test func boardFlagRoundTrips() throws {
let flags = SyncTypeFlags(messageTypes: [.message, .boardPost])
let data = try #require(flags.toData())
let decoded = try #require(SyncTypeFlags.decode(data))
#expect(decoded.contains(.message))
#expect(decoded.contains(.boardPost))
#expect(!decoded.contains(.fragment))
#expect(Set(decoded.toMessageTypes()) == Set([.message, .boardPost]))
}
/// An old decoder is modeled by bits it has no mapping for: the shared
/// decode path accepts the bytes and simply maps unknown bits to no
/// message type, so a board-only request reads as "nothing I can serve".
@Test func unknownBitsDecodeToNoTypes() throws {
// Bits 9-15 are unassigned; a future (or unknown) two-byte bitfield
// must decode without error and yield no known types.
let decoded = try #require(SyncTypeFlags.decode(Data([0x00, 0xFE])))
#expect(decoded.toMessageTypes().isEmpty)
for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost] {
#expect(!decoded.contains(type))
}
}
@Test func mixedKnownAndUnknownBitsKeepKnownTypes() throws {
// Known low-byte flags survive alongside unknown high bits.
let decoded = try #require(SyncTypeFlags.decode(Data([0x03, 0xFE])))
#expect(decoded.contains(.announce))
#expect(decoded.contains(.message))
#expect(Set(decoded.toMessageTypes()) == Set([.announce, .message]))
}
@Test func requestSyncPacketRoundTripsBoardFlag() throws {
let request = RequestSyncPacket(
p: 4,
m: 128,
data: Data([0xAB, 0xCD]),
types: SyncTypeFlags(messageTypes: [.boardPost])
)
let decoded = try #require(RequestSyncPacket.decode(from: request.encode()))
let types = try #require(decoded.types)
#expect(types.contains(.boardPost))
#expect(!types.contains(.message))
}
@Test func singleByteLegacyEncodingStillDecodes() throws {
// Requests from old clients keep the one-byte bitfield.
let decoded = try #require(SyncTypeFlags.decode(Data([0x03])))
#expect(decoded.contains(.announce))
#expect(decoded.contains(.message))
#expect(!decoded.contains(.boardPost))
}
}