[codex] Refactor BLE outbound scheduling and Noise queues (#1306)

* Refactor BLE transport event handling

* Make image output paths unique

* Keep queued Nostr read receipts alive

* Refine BLE ingress fanout

* Rediscover BLE service after invalidation

* Extract BLE notification retry buffer

* Extract BLE inbound write buffer

* Extract BLE fragment assembly buffer

* Tidy secure log handling from device run

* Extract BLE outbound fragment scheduler

* Harden app CI media tests

* Redact BLE message content from logs

* Extract BLE Noise session queues

* Fix BLE read receipt UI updates

* Allow self-authored RSR ingress replies

* Harden read receipt queue test timing

* Extract BLE outbound policy and incoming file storage

* Avoid duplicate BLE link snapshots during send

* Canonicalize Nostr relay URLs

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2026-05-31 14:16:11 +02:00
committed by GitHub
co-authored by jack
parent df36b19afe
commit 9e84f5e822
19 changed files with 1084 additions and 629 deletions
+51
View File
@@ -516,6 +516,57 @@ struct ChatViewModelNoisePayloadTests {
#expect(delivered)
}
@Test @MainActor
func didReceiveNoisePayload_readReceiptUpdatesBeforePeerNicknameIsKnown() async {
let (viewModel, _) = makeTestableViewModel()
let peerID = PeerID(str: "0000000000000005")
let message = BitchatMessage(
id: "pm-read-before-name",
sender: viewModel.nickname,
content: "Waiting on read receipt",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: "Peer",
senderPeerID: viewModel.meshService.myPeerID,
mentions: nil,
deliveryStatus: .sent
)
viewModel.privateChats[peerID] = [message]
viewModel.didReceiveNoisePayload(
from: peerID,
type: .readReceipt,
payload: Data("pm-read-before-name".utf8),
timestamp: Date()
)
let privateChatUpdated = await TestHelpers.waitUntil({
guard let status = viewModel.privateChats[peerID]?.first?.deliveryStatus else { return false }
if case .read = status {
return true
}
return false
}, timeout: TestConstants.defaultTimeout)
let conversationStoreUpdated = await TestHelpers.waitUntil({
let messages = viewModel.conversationStore.directMessages(
for: peerID,
identityResolver: viewModel.identityResolver
)
guard let status = messages.first?.deliveryStatus else { return false }
if case .read = status {
return true
}
return false
}, timeout: TestConstants.defaultTimeout)
#expect(privateChatUpdated)
#expect(conversationStoreUpdated)
}
}
// MARK: - Formatting Tests
+14 -8
View File
@@ -11,6 +11,10 @@ private func makeTemporaryFileURL(_ name: String) -> URL {
FileManager.default.temporaryDirectory.appendingPathComponent(name)
}
private func makeTemporaryDirectoryURL(_ name: String) -> URL {
FileManager.default.temporaryDirectory.appendingPathComponent(name, isDirectory: true)
}
#if os(iOS)
private func makePlatformImage(size: CGSize) -> UIImage {
UIGraphicsImageRenderer(size: size).image { context in
@@ -55,11 +59,14 @@ struct ImageUtilsTests {
@Test
func processImage_writesCompressedJpeg() throws {
let image = makePlatformImage(size: CGSize(width: 1024, height: 768))
let outputURL = try ImageUtils.processImage(image, maxDimension: 256)
defer { try? FileManager.default.removeItem(at: outputURL) }
let outputDirectory = makeTemporaryDirectoryURL("image-output-\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: outputDirectory) }
let outputURL = try ImageUtils.processImage(image, maxDimension: 256, outputDirectory: outputDirectory)
let data = try Data(contentsOf: outputURL)
#expect(outputURL.deletingLastPathComponent() == outputDirectory)
#expect(outputURL.pathExtension.lowercased() == "jpg")
#expect(data.starts(with: Data([0xFF, 0xD8])))
#expect(data.count > 0)
@@ -68,12 +75,11 @@ struct ImageUtilsTests {
@Test
func processImage_usesUniqueOutputURLs() throws {
let image = makePlatformImage(size: CGSize(width: 64, height: 64))
let firstURL = try ImageUtils.processImage(image, maxDimension: 64)
let secondURL = try ImageUtils.processImage(image, maxDimension: 64)
defer {
try? FileManager.default.removeItem(at: firstURL)
try? FileManager.default.removeItem(at: secondURL)
}
let outputDirectory = makeTemporaryDirectoryURL("image-output-\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: outputDirectory) }
let firstURL = try ImageUtils.processImage(image, maxDimension: 64, outputDirectory: outputDirectory)
let secondURL = try ImageUtils.processImage(image, maxDimension: 64, outputDirectory: outputDirectory)
#expect(firstURL != secondURL)
#expect(FileManager.default.fileExists(atPath: firstURL.path))
@@ -10,7 +10,9 @@ final class GeoRelayDirectoryTests: XCTestCase {
relay url,lat,lon
wss://one.example/,10,20
https://one.example,10,20
wss://one.example:443/,10,20
http://two.example/,11,21
wss://two.example:443,11,21
invalid row
ws://three.example,not-a-lat,22
"""
@@ -0,0 +1,34 @@
import Foundation
import Testing
@testable import bitchat
struct BLENoisePayloadFactoryTests {
@Test
func privateMessagePayloadPrefixesTLVWithNoiseType() throws {
let payload = try #require(BLENoisePayloadFactory.privateMessage(content: "secret", messageID: "pm-1"))
#expect(payload.first == NoisePayloadType.privateMessage.rawValue)
let packet = try #require(PrivateMessagePacket.decode(from: Data(payload.dropFirst())))
#expect(packet.messageID == "pm-1")
#expect(packet.content == "secret")
}
@Test
func receiptPayloadsUseMessageIDBytes() {
let read = BLENoisePayloadFactory.readReceipt(originalMessageID: "read-id")
let delivered = BLENoisePayloadFactory.delivered(messageID: "delivered-id")
#expect(read.first == NoisePayloadType.readReceipt.rawValue)
#expect(String(data: Data(read.dropFirst()), encoding: .utf8) == "read-id")
#expect(delivered.first == NoisePayloadType.delivered.rawValue)
#expect(String(data: Data(delivered.dropFirst()), encoding: .utf8) == "delivered-id")
}
@Test
func typedPayloadKeepsOpaqueDataUnchanged() {
let payload = BLENoisePayloadFactory.typedPayload(.verifyChallenge, payload: Data([0xCA, 0xFE]))
#expect(payload == Data([NoisePayloadType.verifyChallenge.rawValue, 0xCA, 0xFE]))
}
}
@@ -0,0 +1,67 @@
import BitFoundation
import Foundation
import Testing
@testable import bitchat
struct BLENoiseSessionQueuesTests {
@Test
func privateMessagesDrainInPeerOrderAndClearOnlyThatPeer() {
let firstPeer = PeerID(str: "aaaaaaaaaaaaaaaa")
let secondPeer = PeerID(str: "bbbbbbbbbbbbbbbb")
var queues = BLENoiseSessionQueues()
queues.appendPrivateMessage(content: "first", messageID: "m1", for: firstPeer)
queues.appendPrivateMessage(content: "second", messageID: "m2", for: firstPeer)
queues.appendPrivateMessage(content: "other", messageID: "m3", for: secondPeer)
let drained = queues.takePrivateMessages(for: firstPeer)
#expect(drained == [
BLEPendingPrivateMessage(content: "first", messageID: "m1"),
BLEPendingPrivateMessage(content: "second", messageID: "m2")
])
#expect(queues.takePrivateMessages(for: firstPeer).isEmpty)
#expect(queues.takePrivateMessages(for: secondPeer) == [
BLEPendingPrivateMessage(content: "other", messageID: "m3")
])
}
@Test
func prependPrivateMessagesRestoresFailedMessagesAheadOfNewerOnes() {
let peerID = PeerID(str: "aaaaaaaaaaaaaaaa")
var queues = BLENoiseSessionQueues()
queues.appendPrivateMessage(content: "new", messageID: "m2", for: peerID)
queues.prependPrivateMessages([
BLEPendingPrivateMessage(content: "retry", messageID: "m1")
], for: peerID)
#expect(queues.takePrivateMessages(for: peerID).map(\.messageID) == ["m1", "m2"])
}
@Test
func typedPayloadsDrainIndependentlyFromPrivateMessages() {
let peerID = PeerID(str: "aaaaaaaaaaaaaaaa")
var queues = BLENoiseSessionQueues()
queues.appendPrivateMessage(content: "queued", messageID: "m1", for: peerID)
queues.appendTypedPayload(Data([0x01]), for: peerID)
queues.appendTypedPayload(Data([0x02]), for: peerID)
#expect(queues.takeTypedPayloads(for: peerID) == [Data([0x01]), Data([0x02])])
#expect(queues.takeTypedPayloads(for: peerID).isEmpty)
#expect(queues.takePrivateMessages(for: peerID).map(\.messageID) == ["m1"])
}
@Test
func removeAllClearsBothQueueTypes() {
let peerID = PeerID(str: "aaaaaaaaaaaaaaaa")
var queues = BLENoiseSessionQueues()
queues.appendPrivateMessage(content: "queued", messageID: "m1", for: peerID)
queues.appendTypedPayload(Data([0x01]), for: peerID)
queues.removeAll()
#expect(queues.isEmpty)
}
}
@@ -0,0 +1,148 @@
import BitFoundation
import Foundation
import Testing
@testable import bitchat
struct BLEOutboundFragmentTransferSchedulerTests {
@Test
func submitStartsPublicMessageWithoutTransferReservation() {
var scheduler = BLEOutboundFragmentTransferScheduler()
let request = makeRequest(type: MessageType.message.rawValue, transferId: nil)
let result = scheduler.submit(request, maxConcurrentTransfers: 1)
if case let .start(_, reservedTransferId) = result {
#expect(reservedTransferId == nil)
#expect(scheduler.activeCount == 0)
#expect(scheduler.pendingCount == 0)
} else {
Issue.record("Expected non-file fragments to start without reserving a transfer slot")
}
}
@Test
func submitQueuesFileTransferWhenSlotsAreFull() {
var scheduler = BLEOutboundFragmentTransferScheduler()
let first = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "first")
let second = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "second")
guard case let .start(_, firstReservation?) = scheduler.submit(first, maxConcurrentTransfers: 1) else {
Issue.record("Expected first file transfer to reserve a slot")
return
}
#expect(firstReservation == "first")
let result = scheduler.submit(second, maxConcurrentTransfers: 1)
if case let .queued(_, transferId, position) = result {
#expect(transferId == "second")
#expect(position == .back)
#expect(scheduler.activeCount == 1)
#expect(scheduler.pendingCount == 1)
} else {
Issue.record("Expected second file transfer to queue while slots are full")
}
}
@Test
func submitQueuesDuplicateActiveTransferAtFront() {
var scheduler = BLEOutboundFragmentTransferScheduler()
let request = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "same")
_ = scheduler.submit(request, maxConcurrentTransfers: 2)
let result = scheduler.submit(request, maxConcurrentTransfers: 2)
if case let .queued(_, transferId, position) = result {
#expect(transferId == "same")
#expect(position == .front)
#expect(scheduler.activeCount == 1)
#expect(scheduler.pendingCount == 1)
} else {
Issue.record("Expected duplicate active transfer to queue at the front")
}
}
@Test
func cancelActiveTransferReturnsScheduledWorkItems() {
var scheduler = BLEOutboundFragmentTransferScheduler()
let request = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "active")
_ = scheduler.submit(request, maxConcurrentTransfers: 1)
let workItem = DispatchWorkItem {}
let didActivate = scheduler.activateReservedTransfer(id: "active", totalFragments: 2, workItems: [workItem])
#expect(didActivate)
if case let .active(transferId, workItems) = scheduler.cancelTransfer("active") {
#expect(transferId == "active")
#expect(workItems.count == 1)
#expect(scheduler.activeCount == 0)
} else {
Issue.record("Expected active transfer cancellation to return its work items")
}
}
@Test
func completedTransferFreesSlotForPendingTransfer() {
var scheduler = BLEOutboundFragmentTransferScheduler()
let first = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "first")
let second = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "second")
_ = scheduler.submit(first, maxConcurrentTransfers: 1)
let didActivate = scheduler.activateReservedTransfer(id: "first", totalFragments: 2, workItems: [])
#expect(didActivate)
_ = scheduler.submit(second, maxConcurrentTransfers: 1)
#expect(scheduler.markFragmentSent(transferId: "first") == .progress(sentFragments: 1, totalFragments: 2))
#expect(scheduler.markFragmentSent(transferId: "first") == .complete(sentFragments: 2, totalFragments: 2))
let starts = scheduler.reservePendingStarts(maxConcurrentTransfers: 1)
#expect(starts.count == 1)
if case let .start(_, reservedTransferId?) = starts.first {
#expect(reservedTransferId == "second")
#expect(scheduler.activeCount == 1)
#expect(scheduler.pendingCount == 0)
} else {
Issue.record("Expected pending transfer to reserve the freed slot")
}
}
@Test
func removeAllReturnsActiveWorkItemsAndDropsPendingTransfers() {
var scheduler = BLEOutboundFragmentTransferScheduler()
let active = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "active")
let pending = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "pending")
let workItem = DispatchWorkItem {}
_ = scheduler.submit(active, maxConcurrentTransfers: 1)
let didActivate = scheduler.activateReservedTransfer(id: "active", totalFragments: 1, workItems: [workItem])
#expect(didActivate)
_ = scheduler.submit(pending, maxConcurrentTransfers: 1)
let removed = scheduler.removeAll()
#expect(removed.count == 1)
#expect(removed.first?.id == "active")
#expect(removed.first?.workItems.count == 1)
#expect(scheduler.activeCount == 0)
#expect(scheduler.pendingCount == 0)
}
private func makeRequest(type: UInt8, transferId: String?) -> BLEOutboundFragmentTransferRequest {
BLEOutboundFragmentTransferRequest(
packet: BitchatPacket(
type: type,
senderID: Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77]),
recipientID: nil,
timestamp: 0x0102030405,
payload: Data((transferId ?? "payload").utf8),
signature: nil,
ttl: 3
),
pad: false,
maxChunk: nil,
directedPeer: nil,
transferId: transferId
)
}
}
@@ -208,7 +208,7 @@ final class NostrRelayManagerTests: XCTestCase {
let relayTwo = "wss://relay-two.example"
let context = makeContext(permission: .denied)
context.manager.ensureConnections(to: [relayOne, relayOne, relayTwo])
context.manager.ensureConnections(to: [relayOne, "wss://relay-one.example:443/", "WSS://RELAY-TWO.EXAMPLE:443"])
let connected = await waitUntil {
Set(context.manager.getRelayStatuses().map(\.url)) == Set([relayOne, relayTwo]) &&