mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 09:05:20 +00:00
Wi-Fi bulk transport: AWDL data plane for large media with BLE fallback
BLE stays the control plane: a sender with a queued file > 64 KiB to a directly connected peer advertising the wifiBulk capability sends a bulkTransferOffer (0x04) inside the established Noise session — transferID, file size, payload SHA-256, a fresh 32-byte token, and a random per-transfer Bonjour instance name. The receiver answers bulkTransferResponse (0x05) with its own token half, then both sides meet on a per-transfer _bitchat-bulk._tcp channel over peer-to-peer Wi-Fi (AWDL). The TCP stream is secured independently of TLS: both tokens traveled inside Noise, so only the two peers can derive the ChaChaPoly channel key (HKDF-SHA256, domain "bitchat-bulk-v1", transferID as salt). Frames are length-prefixed sealed boxes with structured direction+counter nonces; the first frame must prove knowledge of the key or the client is disconnected, and the final hash is verified against the offer before delivery. Decline, timeout, or any mid-transfer error falls back to BLE fragmentation exactly once, driving the same TransferProgressManager stream so the UI is unchanged. Wi-Fi-negotiated transfers may carry up to 8 MiB (new FileTransferLimits.maxWifiBulkPayloadBytes, enforced by the receiver from the accepted offer); the BLE path keeps its existing caps. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
//
|
||||
// WifiBulkPolicyTests.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
|
||||
|
||||
@Suite("WifiBulk policy")
|
||||
struct WifiBulkPolicyTests {
|
||||
private func candidate(
|
||||
payloadBytes: Int = 300_000,
|
||||
capabilities: PeerCapabilities = [.wifiBulk],
|
||||
direct: Bool = true,
|
||||
session: Bool = true
|
||||
) -> WifiBulkPolicy.SendCandidate {
|
||||
WifiBulkPolicy.SendCandidate(
|
||||
payloadBytes: payloadBytes,
|
||||
peerCapabilities: capabilities,
|
||||
isDirectlyConnected: direct,
|
||||
hasEstablishedNoiseSession: session
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Eligible large transfer to a direct wifiBulk peer is offered")
|
||||
func eligibleTransferOffered() {
|
||||
#expect(WifiBulkPolicy.shouldOffer(candidate(), enabled: true))
|
||||
}
|
||||
|
||||
@Test("Fallback matrix: every failed gate keeps the transfer on BLE")
|
||||
func fallbackMatrix() {
|
||||
// Feature disabled.
|
||||
#expect(!WifiBulkPolicy.shouldOffer(candidate(), enabled: false))
|
||||
// Small file: negotiation overhead not worth it.
|
||||
#expect(!WifiBulkPolicy.shouldOffer(candidate(payloadBytes: 64 * 1024), enabled: true))
|
||||
// Peer doesn't advertise the capability.
|
||||
#expect(!WifiBulkPolicy.shouldOffer(candidate(capabilities: []), enabled: true))
|
||||
#expect(!WifiBulkPolicy.shouldOffer(candidate(capabilities: [.prekeys, .gateway]), enabled: true))
|
||||
// Multi-hop recipient (reachable but not directly connected).
|
||||
#expect(!WifiBulkPolicy.shouldOffer(candidate(direct: false), enabled: true))
|
||||
// No established Noise session to carry the offer.
|
||||
#expect(!WifiBulkPolicy.shouldOffer(candidate(session: false), enabled: true))
|
||||
// Payload beyond even the Wi-Fi ceiling.
|
||||
#expect(!WifiBulkPolicy.shouldOffer(
|
||||
candidate(payloadBytes: FileTransferLimits.maxWifiBulkPayloadBytes + 1),
|
||||
enabled: true
|
||||
))
|
||||
}
|
||||
|
||||
@Test("Offer threshold is strictly greater than the minimum")
|
||||
func offerThresholdBoundary() {
|
||||
#expect(!WifiBulkPolicy.shouldOffer(
|
||||
candidate(payloadBytes: TransportConfig.wifiBulkMinPayloadBytes),
|
||||
enabled: true
|
||||
))
|
||||
#expect(WifiBulkPolicy.shouldOffer(
|
||||
candidate(payloadBytes: TransportConfig.wifiBulkMinPayloadBytes + 1),
|
||||
enabled: true
|
||||
))
|
||||
}
|
||||
|
||||
private func offer(fileSize: UInt64) -> WifiBulkOffer {
|
||||
WifiBulkOffer(
|
||||
transferID: Data(repeating: 1, count: WifiBulkWire.transferIDLength),
|
||||
fileSize: fileSize,
|
||||
payloadHash: Data(repeating: 2, count: WifiBulkWire.hashLength),
|
||||
token: Data(repeating: 3, count: WifiBulkWire.tokenLength),
|
||||
serviceName: "0011223344556677"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Receiver accepts an in-cap offer from a direct peer")
|
||||
func receiverAccepts() {
|
||||
#expect(WifiBulkPolicy.shouldAccept(
|
||||
offer: offer(fileSize: 1_000_000),
|
||||
senderIsDirectlyConnected: true,
|
||||
activeIncomingTransfers: 0,
|
||||
enabled: true
|
||||
))
|
||||
}
|
||||
|
||||
@Test("Receiver enforces its own size cap, not the sender's word")
|
||||
func receiverSizeCap() {
|
||||
#expect(!WifiBulkPolicy.shouldAccept(
|
||||
offer: offer(fileSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes) + 1),
|
||||
senderIsDirectlyConnected: true,
|
||||
activeIncomingTransfers: 0,
|
||||
enabled: true
|
||||
))
|
||||
#expect(WifiBulkPolicy.shouldAccept(
|
||||
offer: offer(fileSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes)),
|
||||
senderIsDirectlyConnected: true,
|
||||
activeIncomingTransfers: 0,
|
||||
enabled: true
|
||||
))
|
||||
#expect(!WifiBulkPolicy.shouldAccept(
|
||||
offer: offer(fileSize: 0),
|
||||
senderIsDirectlyConnected: true,
|
||||
activeIncomingTransfers: 0,
|
||||
enabled: true
|
||||
))
|
||||
}
|
||||
|
||||
@Test("Receiver declines when disabled, indirect, or saturated")
|
||||
func receiverDeclines() {
|
||||
#expect(!WifiBulkPolicy.shouldAccept(
|
||||
offer: offer(fileSize: 1000),
|
||||
senderIsDirectlyConnected: true,
|
||||
activeIncomingTransfers: 0,
|
||||
enabled: false
|
||||
))
|
||||
#expect(!WifiBulkPolicy.shouldAccept(
|
||||
offer: offer(fileSize: 1000),
|
||||
senderIsDirectlyConnected: false,
|
||||
activeIncomingTransfers: 0,
|
||||
enabled: true
|
||||
))
|
||||
#expect(!WifiBulkPolicy.shouldAccept(
|
||||
offer: offer(fileSize: 1000),
|
||||
senderIsDirectlyConnected: true,
|
||||
activeIncomingTransfers: TransportConfig.wifiBulkMaxConcurrentIncoming,
|
||||
enabled: true
|
||||
))
|
||||
}
|
||||
|
||||
@Test("This build advertises the wifiBulk capability when enabled")
|
||||
func localCapabilityAdvertised() {
|
||||
#expect(PeerCapabilities.localSupported.contains(.wifiBulk) == TransportConfig.wifiBulkEnabled)
|
||||
}
|
||||
|
||||
@Test("File packets above the BLE cap encode/decode only with the Wi-Fi limit")
|
||||
func filePacketWifiLimit() throws {
|
||||
let content = Data(repeating: 0x5A, count: 2 * 1024 * 1024) // 2 MiB
|
||||
let packet = BitchatFilePacket(fileName: "big.jpg", fileSize: UInt64(content.count), mimeType: "image/jpeg", content: content)
|
||||
|
||||
// BLE cap unchanged.
|
||||
#expect(packet.encode() == nil)
|
||||
|
||||
let encoded = try #require(packet.encode(limit: FileTransferLimits.maxWifiBulkPayloadBytes))
|
||||
#expect(BitchatFilePacket.decode(encoded) == nil) // BLE-cap decode still rejects
|
||||
let decoded = try #require(BitchatFilePacket.decode(encoded, limit: FileTransferLimits.maxWifiBulkPayloadBytes))
|
||||
#expect(decoded.content == content)
|
||||
#expect(decoded.fileName == "big.jpg")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user