diff --git a/bitchat/Models/BitchatFilePacket.swift b/bitchat/Models/BitchatFilePacket.swift index ba7cd377..6e52ee2e 100644 --- a/bitchat/Models/BitchatFilePacket.swift +++ b/bitchat/Models/BitchatFilePacket.swift @@ -2,8 +2,8 @@ // BitchatFilePacket.swift // bitchat // -// TLV encoder/decoder for file transfer payloads (audio/images) -// Types: 0x01 FILE_NAME (utf8), 0x02 FILE_SIZE (8 bytes, BE), 0x03 MIME_TYPE (utf8), 0x04 CONTENT (raw) +// TLV encoder/decoder for file transfer payloads (images, audio, and generic files) +// v2 Spec: 0x01 FILE_NAME (utf8), 0x02 FILE_SIZE (4 bytes, BE), 0x03 MIME_TYPE (utf8), 0x04 CONTENT (4-byte len) // import Foundation @@ -18,29 +18,51 @@ struct BitchatFilePacket { } let fileName: String - let fileSize: UInt64 + let fileSize: UInt32 let mimeType: String let content: Data func encode() -> Data? { var out = Data() - func appendTLV(_ type: TLVType, _ value: Data) { + + // Standard TLV helper for FILE_NAME and MIME_TYPE (2-byte length) + func appendStandardTLV(_ type: TLVType, _ value: Data) { out.append(type.rawValue) let len = UInt16(min(value.count, 0xFFFF)) out.append(UInt8((len >> 8) & 0xFF)) out.append(UInt8(len & 0xFF)) out.append(value.prefix(Int(len))) } + // FILE_NAME - if let nameData = fileName.data(using: .utf8) { appendTLV(.fileName, nameData) } - // FILE_SIZE (8 bytes, BE) - var sizeBE = Data() - for i in (0..<8).reversed() { sizeBE.append(UInt8((fileSize >> (i * 8)) & 0xFF)) } - appendTLV(.fileSize, sizeBE) + if let nameData = fileName.data(using: .utf8) { + appendStandardTLV(.fileName, nameData) + } + + // FILE_SIZE (4 bytes, UInt32 BE) - v2 spec + out.append(TLVType.fileSize.rawValue) + out.append(UInt8(0)) // Length high byte = 0 + out.append(UInt8(4)) // Length low byte = 4 + let size32 = UInt32(min(fileSize, UInt32.max)) + out.append(UInt8((size32 >> 24) & 0xFF)) + out.append(UInt8((size32 >> 16) & 0xFF)) + out.append(UInt8((size32 >> 8) & 0xFF)) + out.append(UInt8(size32 & 0xFF)) + // MIME_TYPE - if let mimeData = mimeType.data(using: .utf8) { appendTLV(.mimeType, mimeData) } - // CONTENT - appendTLV(.content, content) + if let mimeData = mimeType.data(using: .utf8) { + appendStandardTLV(.mimeType, mimeData) + } + + // CONTENT (4-byte length) - v2 spec + out.append(TLVType.content.rawValue) + let contentLen = UInt32(content.count) + out.append(UInt8((contentLen >> 24) & 0xFF)) + out.append(UInt8((contentLen >> 16) & 0xFF)) + out.append(UInt8((contentLen >> 8) & 0xFF)) + out.append(UInt8(contentLen & 0xFF)) + out.append(content) + return out } @@ -56,37 +78,55 @@ struct BitchatFilePacket { guard let d = read(2) else { return nil } return (UInt16(d[0]) << 8) | UInt16(d[1]) } + func read32() -> UInt32? { + guard let d = read(4) else { return nil } + return (UInt32(d[0]) << 24) | (UInt32(d[1]) << 16) | (UInt32(d[2]) << 8) | UInt32(d[3]) + } var name: String? - var size: UInt64? + var size: UInt32? var mime: String? - var content = Data() + var contentData = Data() while idx < data.count { - guard let t = read8(), let tlvType = TLVType(rawValue: t), let len = read16() else { return nil } - let l = Int(len) - guard let val = read(l) else { return nil } + guard let t = read8(), let tlvType = TLVType(rawValue: t) else { return nil } + + // CONTENT uses 4-byte length; others use 2-byte length + let len: Int + if tlvType == .content { + guard let len32 = read32() else { return nil } + len = Int(len32) + } else { + guard let len16 = read16() else { return nil } + len = Int(len16) + } + + guard len >= 0, idx + len <= data.count else { return nil } + let val = data.subdata(in: idx..<(idx+len)) + idx += len + switch tlvType { case .fileName: name = String(data: val, encoding: .utf8) case .fileSize: - guard l == 8 else { return nil } - var s: UInt64 = 0 - for b in val { s = (s << 8) | UInt64(b) } + guard len == 4 else { return nil } // v2 spec: 4 bytes + var s: UInt32 = 0 + for b in val { s = (s << 8) | UInt32(b) } size = s case .mimeType: mime = String(data: val, encoding: .utf8) case .content: - content = val + // Expect single CONTENT TLV; if multiple, concatenate defensively + contentData.append(val) } } // Validate - guard !content.isEmpty else { return nil } - let finalSize = size ?? UInt64(content.count) + guard !contentData.isEmpty else { return nil } + let finalSize = size ?? UInt32(contentData.count) let finalName = name ?? "file" let finalMime = mime ?? "application/octet-stream" - return BitchatFilePacket(fileName: finalName, fileSize: finalSize, mimeType: finalMime, content: content) + return BitchatFilePacket(fileName: finalName, fileSize: finalSize, mimeType: finalMime, content: contentData) } } diff --git a/bitchat/ViewModels/ChatViewModel+Images.swift b/bitchat/ViewModels/ChatViewModel+Images.swift index 226b2643..0ec3ae0a 100644 --- a/bitchat/ViewModels/ChatViewModel+Images.swift +++ b/bitchat/ViewModels/ChatViewModel+Images.swift @@ -25,7 +25,7 @@ extension ChatViewModel { let name = fileURL.lastPathComponent let mime = "image/jpeg" - let tlv = BitchatFilePacket(fileName: name, fileSize: UInt64(data.count), mimeType: mime, content: data) + let tlv = BitchatFilePacket(fileName: name, fileSize: UInt32(data.count), mimeType: mime, content: data) guard let payload = tlv.encode() else { return } let transferId = payload.sha256Hex() diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 187a8441..54e9f077 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -3771,7 +3771,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } @MainActor - private func peerColor(for message: BitchatMessage, isDark: Bool) -> Color { + func peerColor(for message: BitchatMessage, isDark: Bool) -> Color { if let spid = message.senderPeerID { if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") { let bare: String = { @@ -6060,7 +6060,7 @@ private func checkForMentions(_ message: BitchatMessage) { guard let data = try? Data(contentsOf: fileURL) else { return } let name = fileURL.lastPathComponent let mime = "audio/mp4" - let tlv = BitchatFilePacket(fileName: name, fileSize: UInt64(data.count), mimeType: mime, content: data) + let tlv = BitchatFilePacket(fileName: name, fileSize: UInt32(data.count), mimeType: mime, content: data) guard let payload = tlv.encode() else { return } let transferId = payload.sha256Hex() @@ -6104,102 +6104,8 @@ private func checkForMentions(_ message: BitchatMessage) { meshService.sendFileTransferTLV(payload, recipientPeerID: nil, transferId: transferId, messageID: messageID) } } - - // MARK: - Images (Send) - #if os(iOS) - @MainActor - func sendImage(_ image: UIImage) { - // Downscale and encode JPEG - guard let data = downscaleJPEG(image, maxDimension: 512, quality: 0.85) else { return } - - // Persist to app files (outgoing) - guard let fileURL = saveOutgoingImage(data: data) else { return } - - let name = fileURL.lastPathComponent - let mime = "image/jpeg" - let tlv = BitchatFilePacket(fileName: name, fileSize: UInt64(data.count), mimeType: mime, content: data) - guard let payload = tlv.encode() else { return } - let transferId = payload.sha256Hex() - - let contentMarker = "[image] \(fileURL.path)" - let now = Date() - - if let peer = selectedPrivateChatPeer { - let messageID = UUID().uuidString - let msg = BitchatMessage( - id: messageID, - sender: nickname, - content: contentMarker, - timestamp: now, - isRelay: false, - originalSender: nil, - isPrivate: true, - recipientNickname: meshService.peerNickname(peerID: peer), - senderPeerID: meshService.myPeerID, - mentions: nil, - deliveryStatus: .sending - ) - appendToPrivateChat(peerID: peer, message: msg) - meshService.sendFileTransferTLV(payload, recipientPeerID: peer, transferId: transferId, messageID: messageID) - } else { - let messageID = UUID().uuidString - let msg = BitchatMessage( - id: messageID, - sender: nickname, - content: contentMarker, - timestamp: now, - isRelay: false, - originalSender: nil, - isPrivate: false, - recipientNickname: nil, - senderPeerID: meshService.myPeerID, - mentions: nil, - deliveryStatus: .sending - ) - publicBuffer.append(msg) - schedulePublicFlush() - meshService.sendFileTransferTLV(payload, recipientPeerID: nil, transferId: transferId, messageID: messageID) - } - } - #endif - - #if os(iOS) - private func downscaleJPEG(_ image: UIImage, maxDimension: CGFloat, quality: CGFloat) -> Data? { - let size = image.size - guard size.width > 0 && size.height > 0 else { return image.jpegData(compressionQuality: quality) } - let maxSide = max(size.width, size.height) - let scale = min(1.0, maxDimension / maxSide) - let target = CGSize(width: floor(size.width * scale), height: floor(size.height * scale)) - - let rendererFormat = UIGraphicsImageRendererFormat.default() - rendererFormat.scale = 1 - let renderer = UIGraphicsImageRenderer(size: target, format: rendererFormat) - let scaled = renderer.image { ctx in - UIColor.clear.setFill() - ctx.fill(CGRect(origin: .zero, size: target)) - image.draw(in: CGRect(origin: .zero, size: target)) - } - return scaled.jpegData(compressionQuality: quality) - } - - private func saveOutgoingImage(data: Data) -> URL? { - do { - let fm = FileManager.default - let base = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) - let folder = base.appendingPathComponent("bitchat_images/outgoing", isDirectory: true) - if !fm.fileExists(atPath: folder.path) { - try fm.createDirectory(at: folder, withIntermediateDirectories: true) - } - let ts = Int(Date().timeIntervalSince1970) - let fileURL = folder.appendingPathComponent("img_\(ts)_\(UUID().uuidString.prefix(8)).jpg") - try data.write(to: fileURL, options: .atomic) - return fileURL - } catch { - SecureLogger.error("❌ Failed to save outgoing image: \(error)", category: .session) - return nil - } - } - #endif + + // Images code moved to ChatViewModel+Images.swift @MainActor private func appendToPrivateChat(peerID: String, message: BitchatMessage) { diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 121607d1..0228c61a 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -328,6 +328,9 @@ struct ContentView: View { let isImage = message.content.hasPrefix("[image] ") if isVoice { + // Header line above media: sender + timestamp + MessageHeaderLine(message: message) + .environmentObject(viewModel) // Hide the raw marker text; render custom voice view HStack(alignment: .center, spacing: 8) { let path = String(message.content.dropFirst("[voice] ".count)) @@ -349,40 +352,13 @@ struct ContentView: View { .padding(.leading, 4) } } else if isImage { + // Header line above image: sender + timestamp + MessageHeaderLine(message: message) + .environmentObject(viewModel) // Hide the raw marker text; render image view let path = String(message.content.dropFirst("[image] ".count)) - #if os(iOS) - if let uiImage = UIImage(contentsOfFile: path) { - VStack(alignment: .leading, spacing: 4) { - // Image with rounded corners and left alignment - Image(uiImage: uiImage) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: 200, maxHeight: 200) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .shadow(radius: 2) - - // Delivery status for our own private images - if message.isPrivate && message.sender == viewModel.nickname, - let status = message.deliveryStatus { - DeliveryStatusView(status: status, colorScheme: colorScheme) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } else { - // Fallback for invalid image files - Text("⚠️ Image unavailable") - .font(.system(size: 14, design: .monospaced)) - .foregroundColor(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - } - #else - // macOS: show path for now since NSImage loading is different - Text("📷 Image: \(path)") - .font(.system(size: 14, design: .monospaced)) - .foregroundColor(textColor) - .frame(maxWidth: .infinity, alignment: .leading) - #endif + ImageMessageRow(path: path, message: message) + .environmentObject(viewModel) } else { HStack(alignment: .top, spacing: 0) { let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty