Add BLE file transfer support and media UX

This commit is contained in:
jack
2025-10-15 00:37:40 +01:00
committed by islam
parent 5267489fa2
commit 2cd90ff813
19 changed files with 4147 additions and 373 deletions
+462
View File
@@ -87,6 +87,7 @@ import Tor
#if os(iOS)
import UIKit
#endif
import UniformTypeIdentifiers
/// Manages the application state and business logic for BitChat.
/// Acts as the primary coordinator between UI components and backend services,
@@ -430,6 +431,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Delivery tracking
private var cancellables = Set<AnyCancellable>()
private var transferIdToMessageID: [String: String] = [:]
private var messageIDToTransferId: [String: String] = [:]
// MARK: - QR Verification (pending state)
private struct PendingVerification {
@@ -644,6 +647,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Set up Noise encryption callbacks
setupNoiseCallbacks()
TransferProgressManager.shared.publisher
.receive(on: DispatchQueue.main)
.sink { [weak self] event in
self?.handleTransferEvent(event)
}
.store(in: &cancellables)
// Observe location channel selection
LocationChannelManager.shared.$selectedChannel
.receive(on: DispatchQueue.main)
@@ -2381,6 +2391,411 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
}
// MARK: - Media Transfers
private enum MediaSendError: Error {
case encodingFailed
case tooLarge
case copyFailed
}
@MainActor
func sendVoiceNote(at url: URL) {
let targetPeer = selectedPrivateChatPeer
let message = enqueueMediaMessage(content: "[voice] \(url.path)", targetPeer: targetPeer)
let messageID = message.id
Task.detached(priority: .userInitiated) { [weak self] in
guard let self = self else { return }
do {
let data = try Data(contentsOf: url)
guard data.count <= FileTransferLimits.maxVoiceNoteBytes else {
SecureLogger.warning("Voice note exceeds size limit (\(data.count) bytes)", category: .session)
try? FileManager.default.removeItem(at: url)
await MainActor.run {
self.handleMediaSendFailure(messageID: messageID, reason: "Voice note too large")
}
return
}
let packet = BitchatFilePacket(
fileName: url.lastPathComponent,
fileSize: UInt64(data.count),
mimeType: "audio/mp4",
content: data
)
guard let payload = packet.encode() else { throw MediaSendError.encodingFailed }
let transferId = payload.sha256Hex()
await MainActor.run {
self.registerTransfer(transferId: transferId, messageID: messageID)
if let peerID = targetPeer {
self.meshService.sendFilePrivate(packet, to: peerID)
} else {
self.meshService.sendFileBroadcast(packet)
}
}
} catch {
SecureLogger.error("Voice note send failed: \(error)", category: .session)
await MainActor.run {
self.handleMediaSendFailure(messageID: messageID, reason: "Failed to send voice note")
}
}
}
}
@MainActor
func sendImage(from sourceURL: URL) {
let targetPeer = selectedPrivateChatPeer
Task.detached(priority: .userInitiated) { [weak self] in
guard let self = self else { return }
var processedURL: URL?
do {
let outputURL = try ImageUtils.processImage(at: sourceURL)
processedURL = outputURL
let data = try Data(contentsOf: outputURL)
guard data.count <= FileTransferLimits.maxImageBytes else {
SecureLogger.warning("Processed image exceeds size limit (\(data.count) bytes)", category: .session)
await MainActor.run {
self.addSystemMessage("Image is too large to send.")
}
try? FileManager.default.removeItem(at: outputURL)
return
}
let packet = BitchatFilePacket(
fileName: outputURL.lastPathComponent,
fileSize: UInt64(data.count),
mimeType: "image/jpeg",
content: data
)
guard let payload = packet.encode() else { throw MediaSendError.encodingFailed }
let transferId = payload.sha256Hex()
await MainActor.run {
let message = self.enqueueMediaMessage(content: "[image] \(outputURL.path)", targetPeer: targetPeer)
let messageID = message.id
self.registerTransfer(transferId: transferId, messageID: messageID)
if let peerID = targetPeer {
self.meshService.sendFilePrivate(packet, to: peerID)
} else {
self.meshService.sendFileBroadcast(packet)
}
}
} catch {
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
await MainActor.run {
self.addSystemMessage("Failed to prepare image for sending.")
}
if let url = processedURL {
try? FileManager.default.removeItem(at: url)
}
}
}
}
@MainActor
func sendFileAttachment(from sourceURL: URL) {
let targetPeer = selectedPrivateChatPeer
Task.detached(priority: .userInitiated) { [weak self] in
guard let self = self else { return }
var destinationURL: URL?
do {
let data = try Data(contentsOf: sourceURL)
guard FileTransferLimits.isValidPayload(data.count) else {
throw MediaSendError.tooLarge
}
let destination = try self.prepareOutgoingFileCopy(from: sourceURL, data: data)
destinationURL = destination
let packet = BitchatFilePacket(
fileName: destination.lastPathComponent,
fileSize: UInt64(data.count),
mimeType: self.mimeType(for: destination),
content: data
)
guard let payload = packet.encode() else {
try? FileManager.default.removeItem(at: destination)
throw MediaSendError.encodingFailed
}
let transferId = payload.sha256Hex()
await MainActor.run {
let message = self.enqueueMediaMessage(content: "[file] \(destination.path)", targetPeer: targetPeer)
let messageID = message.id
self.registerTransfer(transferId: transferId, messageID: messageID)
if let peerID = targetPeer {
self.meshService.sendFilePrivate(packet, to: peerID)
} else {
self.meshService.sendFileBroadcast(packet)
}
}
} catch MediaSendError.tooLarge {
await MainActor.run {
self.addSystemMessage("File is too large to send.")
}
} catch {
if let destination = destinationURL {
try? FileManager.default.removeItem(at: destination)
}
SecureLogger.error("File attachment send failed: \(error)", category: .session)
await MainActor.run {
self.addSystemMessage("Failed to send file.")
}
}
}
}
@MainActor
func cancelMediaSend(messageID: String) {
if let transferId = messageIDToTransferId[messageID] {
meshService.cancelTransfer(transferId)
}
clearTransferMapping(for: messageID)
removeMessage(withID: messageID, cleanupFile: true)
}
@MainActor
func deleteMediaMessage(messageID: String) {
clearTransferMapping(for: messageID)
removeMessage(withID: messageID, cleanupFile: true)
}
@MainActor
private func enqueueMediaMessage(content: String, targetPeer: String?) -> BitchatMessage {
let timestamp = Date()
let message: BitchatMessage
if let peerID = targetPeer {
message = BitchatMessage(
sender: nickname,
content: content,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: nicknameForPeer(peerID),
senderPeerID: meshService.myPeerID,
deliveryStatus: .sending
)
var chats = privateChats
chats[peerID, default: []].append(message)
privateChats = chats
trimPrivateChatMessagesIfNeeded(for: peerID)
} else {
let (displayName, senderPeerID) = currentPublicSender()
message = BitchatMessage(
sender: displayName,
content: content,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: senderPeerID,
deliveryStatus: .sending
)
messages.append(message)
switch activeChannel {
case .mesh:
meshTimeline.append(message)
trimMeshTimelineIfNeeded()
case .location(let ch):
var arr = geoTimelines[ch.geohash] ?? []
arr.append(message)
if arr.count > geoTimelineCap {
arr = Array(arr.suffix(geoTimelineCap))
}
geoTimelines[ch.geohash] = arr
}
trimMessagesIfNeeded()
}
let key = normalizedContentKey(message.content)
recordContentKey(key, timestamp: timestamp)
objectWillChange.send()
return message
}
private func currentPublicSender() -> (name: String, peerID: String) {
var displaySender = nickname
var senderPeerID = meshService.myPeerID
if case .location(let ch) = activeChannel,
let identity = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
let suffix = String(identity.publicKeyHex.suffix(4))
displaySender = nickname + "#" + suffix
let shortKey = identity.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength)
senderPeerID = "nostr:\(shortKey)"
}
return (displaySender, senderPeerID)
}
@MainActor
private func nicknameForPeer(_ peerID: String) -> String {
if let name = meshService.peerNickname(peerID: peerID) {
return name
}
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
!favorite.peerNickname.isEmpty {
return favorite.peerNickname
}
if let noiseKey = Data(hexString: peerID),
let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
!favorite.peerNickname.isEmpty {
return favorite.peerNickname
}
return "user"
}
@MainActor
private func registerTransfer(transferId: String, messageID: String) {
transferIdToMessageID[transferId] = messageID
messageIDToTransferId[messageID] = transferId
}
@MainActor
private func clearTransferMapping(for messageID: String) {
if let transferId = messageIDToTransferId.removeValue(forKey: messageID) {
transferIdToMessageID.removeValue(forKey: transferId)
}
}
@MainActor
private func handleMediaSendFailure(messageID: String, reason: String) {
updateMessageDeliveryStatus(messageID, status: .failed(reason: reason))
clearTransferMapping(for: messageID)
}
@MainActor
private func handleTransferEvent(_ event: TransferProgressManager.Event) {
switch event {
case .started(let id, let total):
guard let messageID = transferIdToMessageID[id] else { return }
updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: 0, total: total))
case .updated(let id, let sent, let total):
guard let messageID = transferIdToMessageID[id] else { return }
updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: sent, total: total))
case .completed(let id, _):
guard let messageID = transferIdToMessageID[id] else { return }
updateMessageDeliveryStatus(messageID, status: .sent)
clearTransferMapping(for: messageID)
case .cancelled(let id, _, _):
guard let messageID = transferIdToMessageID[id] else { return }
clearTransferMapping(for: messageID)
removeMessage(withID: messageID, cleanupFile: true)
}
}
private func cleanupLocalFile(forMessage message: BitchatMessage) {
let prefixes = ["[voice] ", "[image] ", "[file] "]
guard let prefix = prefixes.first(where: { message.content.hasPrefix($0) }) else { return }
let path = String(message.content.dropFirst(prefix.count))
if FileManager.default.fileExists(atPath: path) {
try? FileManager.default.removeItem(atPath: path)
}
}
private func prepareOutgoingFileCopy(from sourceURL: URL, data: Data) throws -> URL {
let base = try applicationFilesDirectory().appendingPathComponent("files/outgoing", isDirectory: true)
try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
let rawName = sourceURL.lastPathComponent
let sanitized = sanitizeOutgoingFileName(rawName.isEmpty ? "file" : rawName)
var destination = base.appendingPathComponent(sanitized)
var counter = 1
while FileManager.default.fileExists(atPath: destination.path) {
let baseName = (sanitized as NSString).deletingPathExtension
let ext = (sanitized as NSString).pathExtension
let newName: String
if ext.isEmpty {
newName = "\(baseName) (\(counter))"
} else {
newName = "\(baseName) (\(counter)).\(ext)"
}
destination = base.appendingPathComponent(newName)
counter += 1
}
try data.write(to: destination, options: .atomic)
return destination
}
private func sanitizeOutgoingFileName(_ name: String) -> String {
var candidate = name
candidate = candidate.replacingOccurrences(of: "\\", with: "/")
candidate = candidate.components(separatedBy: "/").last ?? name
candidate = candidate.trimmingCharacters(in: .whitespacesAndNewlines)
if candidate.isEmpty { candidate = "file" }
let invalid = CharacterSet(charactersIn: "<>:\"|?*")
candidate = candidate.components(separatedBy: invalid).joined(separator: "_")
if candidate.isEmpty { candidate = "file" }
if (candidate as NSString).pathExtension.isEmpty {
candidate += ".bin"
}
return candidate
}
private func mimeType(for url: URL) -> String {
let ext = url.pathExtension.lowercased()
if !ext.isEmpty,
let type = UTType(filenameExtension: ext),
let mime = type.preferredMIMEType {
return mime
}
return "application/octet-stream"
}
private func applicationFilesDirectory() throws -> URL {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let filesDir = base.appendingPathComponent("files", isDirectory: true)
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
return filesDir
}
@MainActor
private func removeMessage(withID messageID: String, cleanupFile: Bool = false) {
var removedMessage: BitchatMessage?
if let idx = messages.firstIndex(where: { $0.id == messageID }) {
removedMessage = messages.remove(at: idx)
}
meshTimeline.removeAll { $0.id == messageID }
for key in Array(geoTimelines.keys) {
var entries = geoTimelines[key] ?? []
if let idx = entries.firstIndex(where: { $0.id == messageID }) {
removedMessage = removedMessage ?? entries[idx]
entries.remove(at: idx)
if entries.isEmpty {
geoTimelines.removeValue(forKey: key)
} else {
geoTimelines[key] = entries
}
}
}
var chats = privateChats
for (peerID, items) in chats {
let filtered = items.filter { $0.id != messageID }
if filtered.count != items.count {
if filtered.isEmpty {
chats.removeValue(forKey: peerID)
} else {
chats[peerID] = filtered
}
if removedMessage == nil {
removedMessage = items.first(where: { $0.id == messageID })
}
}
}
privateChats = chats
if cleanupFile, let message = removedMessage {
cleanupLocalFile(forMessage: message)
}
objectWillChange.send()
}
// MARK: - Geohash DMs initiation
@MainActor
func startGeohashDM(withPubkeyHex hex: String) {
@@ -3655,6 +4070,53 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
return result
}
@MainActor
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
let isSelf: Bool = {
if let spid = message.senderPeerID {
if case .location(let ch) = activeChannel, spid.hasPrefix("nostr:") {
if let myGeo = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
return spid == "nostr:\(myGeo.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength))"
}
}
return spid == meshService.myPeerID
}
if message.sender == nickname { return true }
if message.sender.hasPrefix(nickname + "#") { return true }
return false
}()
let isDark = colorScheme == .dark
let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark)
if message.sender == "system" {
var style = AttributeContainer()
style.foregroundColor = baseColor
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
return AttributedString(message.sender).mergingAttributes(style)
}
var result = AttributedString()
let (baseName, suffix) = message.sender.splitSuffix()
var senderStyle = AttributeContainer()
senderStyle.foregroundColor = baseColor
senderStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .medium, design: .monospaced)
if let spid = message.senderPeerID,
let url = URL(string: "bitchat://user/\(spid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid)") {
senderStyle.link = url
}
result.append(AttributedString("<@").mergingAttributes(senderStyle))
result.append(AttributedString(baseName).mergingAttributes(senderStyle))
if !suffix.isEmpty {
var suffixStyle = senderStyle
suffixStyle.foregroundColor = baseColor.opacity(0.6)
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
}
result.append(AttributedString("> ").mergingAttributes(senderStyle))
return result
}
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
var result = AttributedString()