mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
* Make image tap-to-reveal a Button so the DM sheet can't swallow it Received images in a DM rendered as a grey "tap to reveal" box that never revealed: the DM sheet wraps the whole conversation in a high-priority swipe-to-close DragGesture (ContentSheetViews), and an ancestor high-priority gesture starves descendant TapGestures — the image's reveal/open tap never fired. Button actions survive that suppression (the sheet's own header buttons work for the same reason), so the tap now lives on a plain-style Button wrapping the image; swipe-to-hide stays attached as a simultaneous gesture. Fixes #1388 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Move the cancel control out of the reveal Button Nested buttons don't get reliable independent hit testing, and the outer reveal tap is a no-op while sending, so the in-flight cancel x could become untappable. The cancel overlay now sits on the Button rather than inside its label. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Root cause: received media stuck in .sending — grey box, dead tap Field testing showed the Button conversion alone didn't fix #1388: a received DM image still rendered as a flat grey box with a dead tap. The real culprit is BitchatMessage's initializer, which defaults every private message without an explicit status to `.sending`. BLEFileTransferHandler built incoming media messages without one, so every received private image/voice note was permanently "sending": mediaSendState returned progress 0 → BlockRevealMask rendered 0% of the image (the flat grey box is the blur overlay over an empty mask), and the reveal tap was disabled by the isSending guard — regardless of which gesture carried it. Two layers: - BLEFileTransferHandler now stamps incoming private media `.delivered(to: <local nickname>, at: <packet time>)`, matching the received-text path in ChatPrivateConversationCoordinator. - MediaMessageView treats received messages as never-sending, so no other construction path can reproduce the grey-box state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
273 lines
11 KiB
Swift
273 lines
11 KiB
Swift
import BitFoundation
|
|
import Foundation
|
|
import Testing
|
|
@testable import bitchat
|
|
|
|
struct BLEFileTransferHandlerTests {
|
|
private final class Recorder {
|
|
var localNickname = "Me"
|
|
var peers: [PeerID: BLEPeerInfo] = [:]
|
|
var signedName: String?
|
|
var saveResult: URL? = URL(fileURLWithPath: "/tmp/files/incoming/sample.pdf")
|
|
|
|
var signedNameQueries: [PeerID] = []
|
|
var trackedPackets: [BitchatPacket] = []
|
|
var quotaReservations: [Int] = []
|
|
var saveCalls: [(data: Data, preferredName: String?, subdirectory: String, fallbackExtension: String?, defaultPrefix: String)] = []
|
|
var lastSeenUpdates: [PeerID] = []
|
|
var deliveredMessages: [BitchatMessage] = []
|
|
}
|
|
|
|
private let localPeerID = PeerID(str: "0102030405060708")
|
|
private let remotePeerID = PeerID(str: "1122334455667788")
|
|
|
|
private func makeHandler(recorder: Recorder) -> BLEFileTransferHandler {
|
|
let environment = BLEFileTransferHandlerEnvironment(
|
|
localPeerID: { [localPeerID] in localPeerID },
|
|
localNickname: { recorder.localNickname },
|
|
peersSnapshot: { recorder.peers },
|
|
signedSenderDisplayName: { _, peerID in
|
|
recorder.signedNameQueries.append(peerID)
|
|
return recorder.signedName
|
|
},
|
|
trackPacketSeen: { packet in
|
|
recorder.trackedPackets.append(packet)
|
|
},
|
|
enforceStorageQuota: { reservingBytes in
|
|
recorder.quotaReservations.append(reservingBytes)
|
|
},
|
|
saveIncomingFile: { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in
|
|
recorder.saveCalls.append((data, preferredName, subdirectory, fallbackExtension, defaultPrefix))
|
|
return recorder.saveResult
|
|
},
|
|
updatePeerLastSeen: { peerID in
|
|
recorder.lastSeenUpdates.append(peerID)
|
|
},
|
|
deliverMessage: { message in
|
|
recorder.deliveredMessages.append(message)
|
|
}
|
|
)
|
|
return BLEFileTransferHandler(environment: environment)
|
|
}
|
|
|
|
@Test
|
|
func broadcastFileFromVerifiedPeerIsSavedAndDelivered() throws {
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
|
let handler = makeHandler(recorder: recorder)
|
|
let content = Data("%PDF-1.7".utf8)
|
|
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: content)
|
|
|
|
handler.handle(packet, from: remotePeerID)
|
|
|
|
#expect(recorder.signedNameQueries.isEmpty)
|
|
#expect(recorder.trackedPackets.count == 1)
|
|
#expect(recorder.quotaReservations == [content.count])
|
|
#expect(recorder.saveCalls.count == 1)
|
|
#expect(recorder.saveCalls.first?.data == content)
|
|
#expect(recorder.saveCalls.first?.preferredName == "sample")
|
|
#expect(recorder.saveCalls.first?.subdirectory == "files/incoming")
|
|
#expect(recorder.saveCalls.first?.fallbackExtension == "pdf")
|
|
#expect(recorder.saveCalls.first?.defaultPrefix == "file")
|
|
#expect(recorder.lastSeenUpdates.isEmpty)
|
|
#expect(recorder.deliveredMessages.count == 1)
|
|
let message = recorder.deliveredMessages.first
|
|
#expect(message?.sender == "Alice")
|
|
#expect(message?.content == "[file] sample.pdf")
|
|
#expect(message?.isPrivate == false)
|
|
#expect(message?.senderPeerID == remotePeerID)
|
|
#expect(message?.timestamp == Date(timeIntervalSince1970: 900))
|
|
#expect(message?.deliveryStatus == nil)
|
|
}
|
|
|
|
@Test
|
|
func selfEchoIsDropped() throws {
|
|
let recorder = Recorder()
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = try makeFileTransferPacket(sender: localPeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8), ttl: 3)
|
|
|
|
handler.handle(packet, from: localPeerID)
|
|
|
|
expectNoSideEffects(recorder)
|
|
}
|
|
|
|
@Test
|
|
func unknownPeerWithoutValidSignatureIsDropped() throws {
|
|
let recorder = Recorder()
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8))
|
|
|
|
handler.handle(packet, from: remotePeerID)
|
|
|
|
#expect(recorder.signedNameQueries == [remotePeerID])
|
|
#expect(recorder.trackedPackets.isEmpty)
|
|
#expect(recorder.deliveredMessages.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func connectedUnverifiedPeerIsAccepted() throws {
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8))
|
|
|
|
handler.handle(packet, from: remotePeerID)
|
|
|
|
// Unlike public messages, file transfers accept connected-but-unverified peers.
|
|
#expect(recorder.signedNameQueries.isEmpty)
|
|
#expect(recorder.deliveredMessages.count == 1)
|
|
#expect(recorder.deliveredMessages.first?.sender == "Bob")
|
|
}
|
|
|
|
@Test
|
|
func fileDirectedToAnotherPeerIsIgnored() throws {
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = try makeFileTransferPacket(
|
|
sender: remotePeerID,
|
|
mimeType: "application/pdf",
|
|
content: Data("%PDF-1.7".utf8),
|
|
recipientID: Data(hexString: "AABBCCDDEEFF0011")
|
|
)
|
|
|
|
handler.handle(packet, from: remotePeerID)
|
|
|
|
#expect(recorder.trackedPackets.isEmpty)
|
|
#expect(recorder.quotaReservations.isEmpty)
|
|
#expect(recorder.saveCalls.isEmpty)
|
|
#expect(recorder.deliveredMessages.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func privateFileUpdatesLastSeenAndDeliversPrivateMessage() throws {
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = try makeFileTransferPacket(
|
|
sender: remotePeerID,
|
|
mimeType: "application/pdf",
|
|
content: Data("%PDF-1.7".utf8),
|
|
recipientID: Data(hexString: localPeerID.id)
|
|
)
|
|
|
|
handler.handle(packet, from: remotePeerID)
|
|
|
|
// Directed transfers are not tracked for gossip sync.
|
|
#expect(recorder.trackedPackets.isEmpty)
|
|
#expect(recorder.lastSeenUpdates == [remotePeerID])
|
|
#expect(recorder.deliveredMessages.count == 1)
|
|
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
|
// Must be explicit: BitchatMessage defaults private messages to
|
|
// .sending, which the media views render as an in-flight send
|
|
// (empty reveal mask, disabled reveal tap).
|
|
#expect(recorder.deliveredMessages.first?.deliveryStatus == .delivered(to: "Me", at: Date(timeIntervalSince1970: 900)))
|
|
}
|
|
|
|
@Test
|
|
func malformedPayloadIsTrackedForSyncButDropped() {
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = BitchatPacket(
|
|
type: MessageType.fileTransfer.rawValue,
|
|
senderID: Data(hexString: remotePeerID.id) ?? Data(),
|
|
recipientID: nil,
|
|
timestamp: 900_000,
|
|
payload: Data([0x01, 0x02, 0x03]),
|
|
signature: nil,
|
|
ttl: TransportConfig.messageTTLDefault
|
|
)
|
|
|
|
handler.handle(packet, from: remotePeerID)
|
|
|
|
// Sync tracking happens before payload validation, matching the original order.
|
|
#expect(recorder.trackedPackets.count == 1)
|
|
#expect(recorder.quotaReservations.isEmpty)
|
|
#expect(recorder.saveCalls.isEmpty)
|
|
#expect(recorder.deliveredMessages.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func unsupportedMimeIsDroppedBeforeQuotaAndSave() throws {
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: nil, content: Data([0x4D, 0x5A, 0x00, 0x00]))
|
|
|
|
handler.handle(packet, from: remotePeerID)
|
|
|
|
#expect(recorder.trackedPackets.count == 1)
|
|
#expect(recorder.quotaReservations.isEmpty)
|
|
#expect(recorder.saveCalls.isEmpty)
|
|
#expect(recorder.deliveredMessages.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func saveFailureSkipsDelivery() throws {
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
|
recorder.saveResult = nil
|
|
let handler = makeHandler(recorder: recorder)
|
|
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8))
|
|
|
|
handler.handle(packet, from: remotePeerID)
|
|
|
|
#expect(recorder.quotaReservations.count == 1)
|
|
#expect(recorder.saveCalls.count == 1)
|
|
#expect(recorder.lastSeenUpdates.isEmpty)
|
|
#expect(recorder.deliveredMessages.isEmpty)
|
|
}
|
|
|
|
private func expectNoSideEffects(_ recorder: Recorder) {
|
|
#expect(recorder.signedNameQueries.isEmpty)
|
|
#expect(recorder.trackedPackets.isEmpty)
|
|
#expect(recorder.quotaReservations.isEmpty)
|
|
#expect(recorder.saveCalls.isEmpty)
|
|
#expect(recorder.lastSeenUpdates.isEmpty)
|
|
#expect(recorder.deliveredMessages.isEmpty)
|
|
}
|
|
|
|
private func makePeerInfo(
|
|
_ peerID: PeerID,
|
|
nickname: String,
|
|
isVerified: Bool,
|
|
isConnected: Bool = true
|
|
) -> BLEPeerInfo {
|
|
BLEPeerInfo(
|
|
peerID: peerID,
|
|
nickname: nickname,
|
|
isConnected: isConnected,
|
|
noisePublicKey: nil,
|
|
signingPublicKey: nil,
|
|
isVerifiedNickname: isVerified,
|
|
lastSeen: Date(timeIntervalSince1970: 999)
|
|
)
|
|
}
|
|
|
|
private func makeFileTransferPacket(
|
|
sender: PeerID,
|
|
mimeType: String?,
|
|
content: Data,
|
|
ttl: UInt8 = TransportConfig.messageTTLDefault,
|
|
recipientID: Data? = nil
|
|
) throws -> BitchatPacket {
|
|
let filePacket = BitchatFilePacket(
|
|
fileName: "sample",
|
|
fileSize: UInt64(content.count),
|
|
mimeType: mimeType,
|
|
content: content
|
|
)
|
|
let payload = try #require(filePacket.encode())
|
|
return BitchatPacket(
|
|
type: MessageType.fileTransfer.rawValue,
|
|
senderID: Data(hexString: sender.id) ?? Data(),
|
|
recipientID: recipientID,
|
|
timestamp: 900_000,
|
|
payload: payload,
|
|
signature: nil,
|
|
ttl: ttl
|
|
)
|
|
}
|
|
}
|