From 78cb8b1039cc3186b7c7715a9d90dc07053777ec Mon Sep 17 00:00:00 2001 From: jack Date: Sun, 6 Jul 2025 21:25:41 +0200 Subject: [PATCH] Fix delivery confirmation for private messages - Pass message ID from ChatViewModel to BluetoothMeshService - Preserve original message ID when creating ACK messages - Move delivery tracking to ChatViewModel for consistent IDs - Update project.yml to match current project structure --- bitchat.xcodeproj/project.pbxproj | 51 ++-- bitchat/Info.plist | 16 +- bitchat/Protocols/BitchatProtocol.swift | 71 ++++- bitchat/Services/BluetoothMeshService.swift | 102 +++++++- bitchat/Services/DeliveryTracker.swift | 273 ++++++++++++++++++++ bitchat/ViewModels/ChatViewModel.swift | 59 ++++- bitchat/Views/ContentView.swift | 64 ++++- project.yml | 79 ++++-- 8 files changed, 651 insertions(+), 64 deletions(-) create mode 100644 bitchat/Services/DeliveryTracker.swift diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index f76dc55c..7bce8ffb 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 63; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -28,6 +28,7 @@ 7DCA0DBCB8884E3B31C7BCE3 /* CompressionUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F149C43D1915831B60FE09 /* CompressionUtil.swift */; }; 7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */; }; 846E2B446E36639159704730 /* BloomFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB3A8FE16333ED12FCB8ACB /* BloomFilterTests.swift */; }; + 8DCEEA289EF7C49E7CD38B08 /* DeliveryTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12B9C3EDF3BC73D3BC106DA4 /* DeliveryTracker.swift */; }; 8F0BFC2D2B2A5E7B70C3B485 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53D535D9CE0B875F47402290 /* BinaryProtocolTests.swift */; }; 8F737CE0435792CC2AD65FCB /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136696FC4436A02D98CE6A77 /* KeychainManager.swift */; }; 923027D6F2F417AFA2488127 /* BitchatProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */; }; @@ -40,6 +41,7 @@ C0A80BA73EC1A372B9338E3C /* BatteryOptimizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED176FF3B274E35C2D827894 /* BatteryOptimizer.swift */; }; C91FDE97070433E6CFE95C55 /* BloomFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB3A8FE16333ED12FCB8ACB /* BloomFilterTests.swift */; }; C99763A4761567F587D21688 /* MessageRetryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA4D7595A613F7ED3B386132 /* MessageRetryService.swift */; }; + CD0AE423F03AC52BAFC16834 /* DeliveryTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12B9C3EDF3BC73D3BC106DA4 /* DeliveryTracker.swift */; }; CDAD6629EB69916B95C80DAF /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53D535D9CE0B875F47402290 /* BinaryProtocolTests.swift */; }; CEAE115C9C3EB3C4ED82F128 /* MessageRetryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA4D7595A613F7ED3B386132 /* MessageRetryService.swift */; }; D450CF41F207BDE1A1AAA56E /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */; }; @@ -70,6 +72,7 @@ /* Begin PBXFileReference section */ 03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 12B9C3EDF3BC73D3BC106DA4 /* DeliveryTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeliveryTracker.swift; sourceTree = ""; }; 136696FC4436A02D98CE6A77 /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = ""; }; 1EB3A8FE16333ED12FCB8ACB /* BloomFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloomFilterTests.swift; sourceTree = ""; }; 229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatProtocol.swift; sourceTree = ""; }; @@ -81,9 +84,9 @@ 67A85BFDDE65B4CD8BDF6DDB /* MessageRetentionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRetentionService.swift; sourceTree = ""; }; 6DC1563390A15C042D059CF9 /* EncryptionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionService.swift; sourceTree = ""; }; 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfoView.swift; sourceTree = ""; }; - 7EEBDA723E1CFD88758DA4AC /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; }; 8DE9CDF66D4E52D268851048 /* MessagePaddingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagePaddingTests.swift; sourceTree = ""; }; - 997D512074C64904D75DDD40 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 8F3A7C058C2C8E1A06C8CF8B /* bitchat_macOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat_macOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 96D0D41CA19EE5A772AA8434 /* bitchat_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = bitchat_iOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = ""; }; AA4D7595A613F7ED3B386132 /* MessageRetryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRetryService.swift; sourceTree = ""; }; @@ -144,8 +147,8 @@ 9F37F9F2C353B58AC809E93B /* Products */ = { isa = PBXGroup; children = ( - 997D512074C64904D75DDD40 /* bitchat.app */, - 7EEBDA723E1CFD88758DA4AC /* bitchat.app */, + 96D0D41CA19EE5A772AA8434 /* bitchat_iOS.app */, + 8F3A7C058C2C8E1A06C8CF8B /* bitchat_macOS.app */, C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */, 03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */, ); @@ -187,6 +190,7 @@ isa = PBXGroup; children = ( D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */, + 12B9C3EDF3BC73D3BC106DA4 /* DeliveryTracker.swift */, 6DC1563390A15C042D059CF9 /* EncryptionService.swift */, 136696FC4436A02D98CE6A77 /* KeychainManager.swift */, 67A85BFDDE65B4CD8BDF6DDB /* MessageRetentionService.swift */, @@ -214,7 +218,7 @@ packageProductDependencies = ( ); productName = bitchat_macOS; - productReference = 7EEBDA723E1CFD88758DA4AC /* bitchat.app */; + productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat_macOS.app */; productType = "com.apple.product-type.application"; }; 47FF23248747DD7CB666CB91 /* bitchatTests_macOS */ = { @@ -268,7 +272,7 @@ packageProductDependencies = ( ); productName = bitchat_iOS; - productReference = 997D512074C64904D75DDD40 /* bitchat.app */; + productReference = 96D0D41CA19EE5A772AA8434 /* bitchat_iOS.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -298,6 +302,7 @@ ); mainGroup = 18198ED912AAF495D8AF7763; minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 54; projectDirPath = ""; projectRoot = ""; targets = ( @@ -342,6 +347,7 @@ D450CF41F207BDE1A1AAA56E /* ChatViewModel.swift in Sources */, B0CA7796B2B2AC2B33F84548 /* CompressionUtil.swift in Sources */, 92D34E7A07C990C8A815B0CE /* ContentView.swift in Sources */, + 8DCEEA289EF7C49E7CD38B08 /* DeliveryTracker.swift in Sources */, 739429DFDE5C5829CF70DA7D /* EncryptionService.swift in Sources */, FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */, 4E778E5A414571ACAC2A0F01 /* MessageRetentionService.swift in Sources */, @@ -364,6 +370,7 @@ 7576A357B278E5733E9D9F33 /* ChatViewModel.swift in Sources */, 7DCA0DBCB8884E3B31C7BCE3 /* CompressionUtil.swift in Sources */, 1D9674FA5F998503831DC281 /* ContentView.swift in Sources */, + CD0AE423F03AC52BAFC16834 /* DeliveryTracker.swift in Sources */, DDA1DFAB1FF7AADE52DC0F53 /* EncryptionService.swift in Sources */, 8F737CE0435792CC2AD65FCB /* KeychainManager.swift in Sources */, 4274B6016F755946FBF2513E /* MessageRetentionService.swift in Sources */, @@ -425,6 +432,7 @@ "@loader_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.tests; + PRODUCT_NAME = bitchatTests; SDKROOT = iphoneos; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -444,6 +452,7 @@ "@loader_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.tests; + PRODUCT_NAME = bitchatTests; SDKROOT = iphoneos; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -464,6 +473,7 @@ ); MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.tests; + PRODUCT_NAME = bitchatTests; SDKROOT = macosx; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bitchat.app/Contents/MacOS/bitchat"; @@ -479,25 +489,19 @@ CODE_SIGNING_REQUIRED = YES; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = L3N5LHJD5Y; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = bitchat/Info.plist; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; - SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; @@ -514,6 +518,7 @@ ); MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.tests; + PRODUCT_NAME = bitchatTests; SDKROOT = macosx; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bitchat.app/Contents/MacOS/bitchat"; @@ -529,25 +534,19 @@ CODE_SIGNING_REQUIRED = YES; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = L3N5LHJD5Y; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = bitchat/Info.plist; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; - SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; @@ -560,9 +559,9 @@ CODE_SIGNING_REQUIRED = YES; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = bitchat/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -571,7 +570,6 @@ PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_NAME = bitchat; SDKROOT = macosx; - SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_VERSION = 5.0; }; name = Release; @@ -610,7 +608,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 5; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -643,9 +641,9 @@ CODE_SIGNING_REQUIRED = YES; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = bitchat/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -654,7 +652,6 @@ PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_NAME = bitchat; SDKROOT = macosx; - SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_VERSION = 5.0; }; name = Debug; @@ -693,7 +690,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 5; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; diff --git a/bitchat/Info.plist b/bitchat/Info.plist index 6b60e722..d619eacb 100644 --- a/bitchat/Info.plist +++ b/bitchat/Info.plist @@ -26,19 +26,7 @@ bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users. NSBluetoothPeripheralUsageDescription bitchat uses Bluetooth to discover and connect with other bitchat users nearby. - UIBackgroundModes - - bluetooth-central - bluetooth-peripheral - - UILaunchScreen - - UIColorName - Black - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - + NSUserNotificationAlertStyle + alert diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index b907c062..0eb515bb 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -74,6 +74,8 @@ enum MessageType: UInt8 { case fragmentEnd = 0x07 case roomAnnounce = 0x08 // Announce password-protected room status case roomRetention = 0x09 // Announce room retention status + case deliveryAck = 0x0A // Acknowledge message received + case deliveryStatusRequest = 0x0B // Request delivery status update } // Special recipient ID for broadcast messages @@ -127,6 +129,57 @@ struct BitchatPacket: Codable { } } +// Delivery acknowledgment structure +struct DeliveryAck: Codable { + let originalMessageID: String + let ackID: String + let recipientID: String // Who received it + let recipientNickname: String + let timestamp: Date + let hopCount: UInt8 // How many hops to reach recipient + + init(originalMessageID: String, recipientID: String, recipientNickname: String, hopCount: UInt8) { + self.originalMessageID = originalMessageID + self.ackID = UUID().uuidString + self.recipientID = recipientID + self.recipientNickname = recipientNickname + self.timestamp = Date() + self.hopCount = hopCount + } + + func encode() -> Data? { + try? JSONEncoder().encode(self) + } + + static func decode(from data: Data) -> DeliveryAck? { + try? JSONDecoder().decode(DeliveryAck.self, from: data) + } +} + +// Delivery status for messages +enum DeliveryStatus: Codable, Equatable { + case sending + case sent // Left our device + case delivered(to: String, at: Date) // Confirmed by recipient + case failed(reason: String) + case partiallyDelivered(reached: Int, total: Int) // For rooms + + var displayText: String { + switch self { + case .sending: + return "Sending..." + case .sent: + return "Sent" + case .delivered(let nickname, _): + return "Delivered to \(nickname)" + case .failed(let reason): + return "Failed: \(reason)" + case .partiallyDelivered(let reached, let total): + return "Delivered to \(reached)/\(total)" + } + } +} + struct BitchatMessage: Codable, Equatable { let id: String let sender: String @@ -141,9 +194,10 @@ struct BitchatMessage: Codable, Equatable { let room: String? // Room hashtag (e.g., "#general") let encryptedContent: Data? // For password-protected rooms let isEncrypted: Bool // Flag to indicate if content is encrypted + var deliveryStatus: DeliveryStatus? // Delivery tracking - init(sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, room: String? = nil, encryptedContent: Data? = nil, isEncrypted: Bool = false) { - self.id = UUID().uuidString + init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, room: String? = nil, encryptedContent: Data? = nil, isEncrypted: Bool = false, deliveryStatus: DeliveryStatus? = nil) { + self.id = id ?? UUID().uuidString self.sender = sender self.content = content self.timestamp = timestamp @@ -156,6 +210,7 @@ struct BitchatMessage: Codable, Equatable { self.room = room self.encryptedContent = encryptedContent self.isEncrypted = isEncrypted + self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil) } } @@ -171,6 +226,10 @@ protocol BitchatDelegate: AnyObject { // Optional method to check if a fingerprint belongs to a favorite peer func isFavorite(fingerprint: String) -> Bool + + // Delivery confirmation methods + func didReceiveDeliveryAck(_ ack: DeliveryAck) + func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) } // Provide default implementation to make it effectively optional @@ -195,4 +254,12 @@ extension BitchatDelegate { // Default returns nil (unable to decrypt) return nil } + + func didReceiveDeliveryAck(_ ack: DeliveryAck) { + // Default empty implementation + } + + func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) { + // Default empty implementation + } } \ No newline at end of file diff --git a/bitchat/Services/BluetoothMeshService.swift b/bitchat/Services/BluetoothMeshService.swift index 09ca7157..b1655223 100644 --- a/bitchat/Services/BluetoothMeshService.swift +++ b/bitchat/Services/BluetoothMeshService.swift @@ -558,14 +558,18 @@ class BluetoothMeshService: NSObject { } - func sendPrivateMessage(_ content: String, to recipientPeerID: String, recipientNickname: String) { + func sendPrivateMessage(_ content: String, to recipientPeerID: String, recipientNickname: String, messageID: String? = nil) { messageQueue.async { [weak self] in guard let self = self else { return } let nickname = self.delegate as? ChatViewModel let senderNick = nickname?.nickname ?? self.myPeerID + // Use provided message ID or generate a new one + let msgID = messageID ?? UUID().uuidString + let message = BitchatMessage( + id: msgID, sender: senderNick, content: content, timestamp: Date(), @@ -634,6 +638,8 @@ class BluetoothMeshService: NSObject { self?.recentlySentMessages.remove(msgID) } + // Message tracking is now done in ChatViewModel to ensure consistent message IDs + // Add random delay for timing obfuscation let delay = self.randomDelay() DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in @@ -666,6 +672,41 @@ class BluetoothMeshService: NSObject { } } + func sendDeliveryAck(_ ack: DeliveryAck, to recipientID: String) { + messageQueue.async { [weak self] in + guard let self = self else { return } + + // Encode the ACK + guard let ackData = ack.encode() else { + print("[DeliveryTracker] Failed to encode ACK") + return + } + + // Encrypt ACK for the original sender + let encryptedPayload: Data + do { + encryptedPayload = try self.encryptionService.encrypt(ackData, for: recipientID) + } catch { + print("[DeliveryTracker] Failed to encrypt ACK: \(error)") + return + } + + // Create ACK packet with direct routing to original sender + let packet = BitchatPacket( + type: MessageType.deliveryAck.rawValue, + senderID: Data(self.myPeerID.utf8), + recipientID: Data(recipientID.utf8), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: encryptedPayload, + signature: nil, // ACKs don't need signatures + ttl: 3 // Limited TTL for ACKs + ) + + // Send immediately without delay (ACKs should be fast) + self.broadcastPacket(packet) + } + } + func announcePasswordProtectedRoom(_ room: String, isProtected: Bool = true, creatorID: String? = nil, keyCommitment: String? = nil) { messageQueue.async { [weak self] in guard let self = self else { return } @@ -1371,6 +1412,7 @@ class BluetoothMeshService: NSObject { } let messageWithPeerID = BitchatMessage( + id: message.id, // Preserve the original message ID sender: message.sender, content: finalContent, timestamp: message.timestamp, @@ -1393,6 +1435,22 @@ class BluetoothMeshService: NSObject { DispatchQueue.main.async { self.delegate?.didReceiveMessage(messageWithPeerID) } + + // Generate and send ACK for room messages if we're mentioned or it's a small room + let viewModel = self.delegate as? ChatViewModel + let myNickname = viewModel?.nickname ?? self.myPeerID + if let _ = message.room, + let mentions = message.mentions, + (mentions.contains(myNickname) || self.activePeers.count < 10) { + if let ack = DeliveryTracker.shared.generateAck( + for: messageWithPeerID, + myPeerID: self.myPeerID, + myNickname: myNickname, + hopCount: UInt8(self.maxTTL - packet.ttl) + ) { + self.sendDeliveryAck(ack, to: senderID) + } + } } // Relay broadcast messages @@ -1476,6 +1534,7 @@ class BluetoothMeshService: NSObject { peerNicknamesLock.unlock() let messageWithPeerID = BitchatMessage( + id: message.id, // Preserve the original message ID sender: message.sender, content: message.content, timestamp: message.timestamp, @@ -1496,6 +1555,18 @@ class BluetoothMeshService: NSObject { DispatchQueue.main.async { self.delegate?.didReceiveMessage(messageWithPeerID) } + + // Generate and send ACK for private messages + let viewModel = self.delegate as? ChatViewModel + let myNickname = viewModel?.nickname ?? self.myPeerID + if let ack = DeliveryTracker.shared.generateAck( + for: messageWithPeerID, + myPeerID: self.myPeerID, + myNickname: myNickname, + hopCount: UInt8(self.maxTTL - packet.ttl) + ) { + self.sendDeliveryAck(ack, to: senderID) + } } } else if packet.ttl > 0 { @@ -1845,6 +1916,35 @@ class BluetoothMeshService: NSObject { } } + case .deliveryAck: + // Handle delivery acknowledgment + if let recipientIDData = packet.recipientID, + let recipientID = String(data: recipientIDData.trimmingNullBytes(), encoding: .utf8), + recipientID == myPeerID { + // This ACK is for us - decrypt it + if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) { + do { + let decryptedData = try encryptionService.decrypt(packet.payload, from: senderID) + if let ack = DeliveryAck.decode(from: decryptedData) { + // Process the ACK + DeliveryTracker.shared.processDeliveryAck(ack) + + // Notify delegate + DispatchQueue.main.async { + self.delegate?.didReceiveDeliveryAck(ack) + } + } + } catch { + // Failed to decrypt ACK - might be from unknown sender + } + } + } else if packet.ttl > 0 { + // Relay the ACK if not for us + var relayPacket = packet + relayPacket.ttl -= 1 + self.broadcastPacket(relayPacket) + } + case .roomRetention: if let payloadStr = String(data: packet.payload, encoding: .utf8) { // Parse payload: room|enabled|creatorID diff --git a/bitchat/Services/DeliveryTracker.swift b/bitchat/Services/DeliveryTracker.swift new file mode 100644 index 00000000..79f23acc --- /dev/null +++ b/bitchat/Services/DeliveryTracker.swift @@ -0,0 +1,273 @@ +// +// DeliveryTracker.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Combine + +class DeliveryTracker { + static let shared = DeliveryTracker() + + // Track pending deliveries + private var pendingDeliveries: [String: PendingDelivery] = [:] + private let pendingLock = NSLock() + + // Track received ACKs to prevent duplicates + private var receivedAckIDs = Set() + private var sentAckIDs = Set() + + // Timeout configuration + private let privateMessageTimeout: TimeInterval = 30 // 30 seconds + private let roomMessageTimeout: TimeInterval = 60 // 1 minute + private let favoriteTimeout: TimeInterval = 300 // 5 minutes for favorites + + // Retry configuration + private let maxRetries = 3 + private let retryDelay: TimeInterval = 5 // Base retry delay + + // Publishers for UI updates + let deliveryStatusUpdated = PassthroughSubject<(messageID: String, status: DeliveryStatus), Never>() + + // Cleanup timer + private var cleanupTimer: Timer? + + struct PendingDelivery { + let messageID: String + let sentAt: Date + let recipientID: String + let recipientNickname: String + let retryCount: Int + let isRoomMessage: Bool + let isFavorite: Bool + var ackedBy: Set = [] // For tracking partial room delivery + let expectedRecipients: Int // For room messages + var timeoutTimer: Timer? + + var isTimedOut: Bool { + let timeout: TimeInterval = isFavorite ? 300 : (isRoomMessage ? 60 : 30) + return Date().timeIntervalSince(sentAt) > timeout + } + + var shouldRetry: Bool { + return retryCount < 3 && isFavorite && !isRoomMessage + } + } + + private init() { + startCleanupTimer() + } + + deinit { + cleanupTimer?.invalidate() + } + + // MARK: - Public Methods + + func trackMessage(_ message: BitchatMessage, recipientID: String, recipientNickname: String, isFavorite: Bool = false, expectedRecipients: Int = 1) { + pendingLock.lock() + defer { pendingLock.unlock() } + + // Don't track broadcasts or certain message types + guard message.isPrivate || message.room != nil else { return } + + let delivery = PendingDelivery( + messageID: message.id, + sentAt: Date(), + recipientID: recipientID, + recipientNickname: recipientNickname, + retryCount: 0, + isRoomMessage: message.room != nil, + isFavorite: isFavorite, + expectedRecipients: expectedRecipients, + timeoutTimer: nil + ) + + pendingDeliveries[message.id] = delivery + + // Update status to sent + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.updateDeliveryStatus(message.id, status: .sent) + } + + // Schedule timeout + scheduleTimeout(for: message.id) + } + + func processDeliveryAck(_ ack: DeliveryAck) { + pendingLock.lock() + defer { pendingLock.unlock() } + + // Prevent duplicate ACK processing + guard !receivedAckIDs.contains(ack.ackID) else { return } + receivedAckIDs.insert(ack.ackID) + + // Find the pending delivery + guard var delivery = pendingDeliveries[ack.originalMessageID] else { + // Message might have already been delivered or timed out + return + } + + // Cancel timeout timer + delivery.timeoutTimer?.invalidate() + + if delivery.isRoomMessage { + // Track partial delivery for room messages + delivery.ackedBy.insert(ack.recipientID) + pendingDeliveries[ack.originalMessageID] = delivery + + let deliveredCount = delivery.ackedBy.count + let totalExpected = delivery.expectedRecipients + + if deliveredCount >= totalExpected || deliveredCount >= max(1, totalExpected / 2) { + // Consider delivered if we got ACKs from at least half the expected recipients + updateDeliveryStatus(ack.originalMessageID, status: .delivered(to: "\(deliveredCount) members", at: Date())) + pendingDeliveries.removeValue(forKey: ack.originalMessageID) + } else { + // Update partial delivery status + updateDeliveryStatus(ack.originalMessageID, status: .partiallyDelivered(reached: deliveredCount, total: totalExpected)) + } + } else { + // Direct message - mark as delivered + updateDeliveryStatus(ack.originalMessageID, status: .delivered(to: ack.recipientNickname, at: Date())) + pendingDeliveries.removeValue(forKey: ack.originalMessageID) + } + } + + func generateAck(for message: BitchatMessage, myPeerID: String, myNickname: String, hopCount: UInt8) -> DeliveryAck? { + // Don't ACK our own messages + guard message.senderPeerID != myPeerID else { return nil } + + // Don't ACK broadcasts or system messages + guard message.isPrivate || message.room != nil else { return nil } + + // Don't ACK if we've already sent an ACK for this message + guard !sentAckIDs.contains(message.id) else { return nil } + sentAckIDs.insert(message.id) + + return DeliveryAck( + originalMessageID: message.id, + recipientID: myPeerID, + recipientNickname: myNickname, + hopCount: hopCount + ) + } + + func clearDeliveryStatus(for messageID: String) { + pendingLock.lock() + defer { pendingLock.unlock() } + + if let delivery = pendingDeliveries[messageID] { + delivery.timeoutTimer?.invalidate() + } + pendingDeliveries.removeValue(forKey: messageID) + } + + // MARK: - Private Methods + + private func updateDeliveryStatus(_ messageID: String, status: DeliveryStatus) { + DispatchQueue.main.async { [weak self] in + self?.deliveryStatusUpdated.send((messageID: messageID, status: status)) + } + } + + private func scheduleTimeout(for messageID: String) { + guard let delivery = pendingDeliveries[messageID] else { return } + + let timeout = delivery.isFavorite ? favoriteTimeout : + (delivery.isRoomMessage ? roomMessageTimeout : privateMessageTimeout) + + let timer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { [weak self] _ in + self?.handleTimeout(messageID: messageID) + } + + pendingLock.lock() + if var updatedDelivery = pendingDeliveries[messageID] { + updatedDelivery.timeoutTimer = timer + pendingDeliveries[messageID] = updatedDelivery + } + pendingLock.unlock() + } + + private func handleTimeout(messageID: String) { + pendingLock.lock() + defer { pendingLock.unlock() } + + guard let delivery = pendingDeliveries[messageID] else { return } + + if delivery.shouldRetry { + // Retry for favorites + retryDelivery(messageID: messageID) + } else { + // Mark as failed + let reason = delivery.isRoomMessage ? "No response from room members" : "Message not delivered" + updateDeliveryStatus(messageID, status: .failed(reason: reason)) + pendingDeliveries.removeValue(forKey: messageID) + } + } + + private func retryDelivery(messageID: String) { + guard let delivery = pendingDeliveries[messageID] else { return } + + // Increment retry count + let newDelivery = PendingDelivery( + messageID: delivery.messageID, + sentAt: delivery.sentAt, + recipientID: delivery.recipientID, + recipientNickname: delivery.recipientNickname, + retryCount: delivery.retryCount + 1, + isRoomMessage: delivery.isRoomMessage, + isFavorite: delivery.isFavorite, + ackedBy: delivery.ackedBy, + expectedRecipients: delivery.expectedRecipients, + timeoutTimer: nil + ) + + pendingDeliveries[messageID] = newDelivery + + // Exponential backoff for retry + let delay = retryDelay * pow(2, Double(delivery.retryCount)) + + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + // Trigger resend through delegate or notification + NotificationCenter.default.post( + name: Notification.Name("bitchat.retryMessage"), + object: nil, + userInfo: ["messageID": messageID] + ) + + // Schedule new timeout + self?.scheduleTimeout(for: messageID) + } + } + + private func startCleanupTimer() { + cleanupTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in + self?.cleanupOldDeliveries() + } + } + + private func cleanupOldDeliveries() { + pendingLock.lock() + defer { pendingLock.unlock() } + + let now = Date() + let maxAge: TimeInterval = 3600 // 1 hour + + // Clean up old pending deliveries + pendingDeliveries = pendingDeliveries.filter { (_, delivery) in + now.timeIntervalSince(delivery.sentAt) < maxAge + } + + // Clean up old ACK IDs (keep last 1000) + if receivedAckIDs.count > 1000 { + receivedAckIDs.removeAll() + } + if sentAckIDs.count > 1000 { + sentAckIDs.removeAll() + } + } +} \ No newline at end of file diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 8c587202..a539d460 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -68,6 +68,9 @@ class ChatViewModel: ObservableObject { // Messages are naturally ephemeral - no persistent storage + // Delivery tracking + private var deliveryTrackerCancellable: AnyCancellable? + init() { loadNickname() loadFavorites() @@ -87,6 +90,13 @@ class ChatViewModel: ObservableObject { // Request notification permission NotificationService.shared.requestAuthorization() + + // Subscribe to delivery status updates + deliveryTrackerCancellable = DeliveryTracker.shared.deliveryStatusUpdated + .receive(on: DispatchQueue.main) + .sink { [weak self] (messageID, status) in + self?.updateMessageDeliveryStatus(messageID, status: status) + } } private func loadNickname() { @@ -847,7 +857,8 @@ class ChatViewModel: ObservableObject { originalSender: nil, isPrivate: true, recipientNickname: recipientNickname, - senderPeerID: meshService.myPeerID + senderPeerID: meshService.myPeerID, + deliveryStatus: .sending ) // Add to our private chat history @@ -856,11 +867,15 @@ class ChatViewModel: ObservableObject { } privateChats[peerID]?.append(message) + // Track the message for delivery confirmation + let isFavorite = isFavorite(peerID: peerID) + DeliveryTracker.shared.trackMessage(message, recipientID: peerID, recipientNickname: recipientNickname, isFavorite: isFavorite) + // Trigger UI update objectWillChange.send() - // Send via mesh - meshService.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname) + // Send via mesh with the same message ID + meshService.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: message.id) } func startPrivateChat(with peerID: String) { @@ -2030,4 +2045,42 @@ extension ChatViewModel: BitchatDelegate { return favoritePeers.contains(fingerprint) } + func didReceiveDeliveryAck(_ ack: DeliveryAck) { + // Find the message and update its delivery status + updateMessageDeliveryStatus(ack.originalMessageID, status: .delivered(to: ack.recipientNickname, at: ack.timestamp)) + } + + func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) { + updateMessageDeliveryStatus(messageID, status: status) + } + + private func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) { + // Update in main messages + if let index = messages.firstIndex(where: { $0.id == messageID }) { + var updatedMessage = messages[index] + updatedMessage.deliveryStatus = status + messages[index] = updatedMessage + } + + // Update in private chats + for (peerID, var chatMessages) in privateChats { + if let index = chatMessages.firstIndex(where: { $0.id == messageID }) { + var updatedMessage = chatMessages[index] + updatedMessage.deliveryStatus = status + chatMessages[index] = updatedMessage + privateChats[peerID] = chatMessages + } + } + + // Update in room messages + for (room, var roomMsgs) in roomMessages { + if let index = roomMsgs.firstIndex(where: { $0.id == messageID }) { + var updatedMessage = roomMsgs[index] + updatedMessage.deliveryStatus = status + roomMsgs[index] = updatedMessage + roomMessages[room] = roomMsgs + } + } + } + } diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 13549a06..e96c73e4 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -453,7 +453,7 @@ struct ContentView: View { .textSelection(.enabled) } else { // Regular messages with tappable sender name - HStack(alignment: .firstTextBaseline, spacing: 0) { + HStack(alignment: .center, spacing: 0) { // Timestamp Text("[\(viewModel.formatTimestamp(message.timestamp))] ") .font(.system(size: 14, design: .monospaced)) @@ -491,6 +491,14 @@ struct ContentView: View { isMentioned: isMentioned ) + // Delivery status indicator for private messages + if message.isPrivate && message.sender == viewModel.nickname, + let status = message.deliveryStatus { + DeliveryStatusView(status: status, colorScheme: colorScheme) + .padding(.leading, 4) + .alignmentGuide(.firstTextBaseline) { _ in 12 } + } + Spacer() } } @@ -1061,3 +1069,57 @@ struct MessageContentView: View { return segments } } + +// Delivery status indicator view +struct DeliveryStatusView: View { + let status: DeliveryStatus + let colorScheme: ColorScheme + + private var textColor: Color { + colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) + } + + private var secondaryTextColor: Color { + colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8) + } + + var body: some View { + switch status { + case .sending: + Image(systemName: "circle") + .font(.system(size: 10)) + .foregroundColor(secondaryTextColor.opacity(0.6)) + + case .sent: + Image(systemName: "checkmark") + .font(.system(size: 10)) + .foregroundColor(secondaryTextColor.opacity(0.6)) + + case .delivered(let nickname, _): + HStack(spacing: -2) { + Image(systemName: "checkmark") + .font(.system(size: 10)) + Image(systemName: "checkmark") + .font(.system(size: 10)) + } + .foregroundColor(textColor.opacity(0.8)) + .help("Delivered to \(nickname)") + + case .failed(let reason): + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 10)) + .foregroundColor(Color.red.opacity(0.8)) + .help("Failed: \(reason)") + + case .partiallyDelivered(let reached, let total): + HStack(spacing: 1) { + Image(systemName: "checkmark") + .font(.system(size: 10)) + Text("\(reached)/\(total)") + .font(.system(size: 10, design: .monospaced)) + } + .foregroundColor(secondaryTextColor.opacity(0.6)) + .help("Delivered to \(reached) of \(total) members") + } + } +} diff --git a/project.yml b/project.yml index 6741aebd..9eebbd20 100644 --- a/project.yml +++ b/project.yml @@ -5,17 +5,62 @@ options: iOS: 16.0 macOS: 13.0 createIntermediateGroups: true + groupSortPosition: bottom settings: MARKETING_VERSION: 1.0.0 - CURRENT_PROJECT_VERSION: 1 + CURRENT_PROJECT_VERSION: 5 targets: - bitchat: + bitchat_iOS: type: application - platform: [iOS, macOS] + platform: iOS sources: - - bitchat + - path: bitchat + excludes: + - "**/.DS_Store" + createIntermediateGroups: true + resources: + - bitchat/Assets.xcassets + info: + path: bitchat/Info.plist + properties: + CFBundleDisplayName: bitchat + CFBundleShortVersionString: $(MARKETING_VERSION) + CFBundleVersion: $(CURRENT_PROJECT_VERSION) + NSBluetoothAlwaysUsageDescription: bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users. + NSBluetoothPeripheralUsageDescription: bitchat uses Bluetooth to discover and connect with other bitchat users nearby. + NSUserNotificationAlertStyle: alert + UIBackgroundModes: + - bluetooth-central + - bluetooth-peripheral + - remote-notification + UILaunchScreen: + UIColorName: Black + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + settings: + PRODUCT_NAME: bitchat + PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat + INFOPLIST_FILE: bitchat/Info.plist + ENABLE_PREVIEWS: YES + SWIFT_VERSION: 5.0 + IPHONEOS_DEPLOYMENT_TARGET: 16.0 + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO + CODE_SIGN_STYLE: Automatic + CODE_SIGNING_REQUIRED: YES + CODE_SIGNING_ALLOWED: YES + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: YES + + bitchat_macOS: + type: application + platform: macOS + sources: + - path: bitchat + excludes: + - "**/.DS_Store" + createIntermediateGroups: true resources: - bitchat/Assets.xcassets info: @@ -27,35 +72,33 @@ targets: LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET) NSBluetoothAlwaysUsageDescription: bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users. NSBluetoothPeripheralUsageDescription: bitchat uses Bluetooth to discover and connect with other bitchat users nearby. - UIBackgroundModes: - - bluetooth-central - - bluetooth-peripheral - UILaunchScreen: - UIColorName: Black - UISupportedInterfaceOrientations: - - UIInterfaceOrientationPortrait + NSUserNotificationAlertStyle: alert settings: + PRODUCT_NAME: bitchat PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat INFOPLIST_FILE: bitchat/Info.plist ENABLE_PREVIEWS: YES SWIFT_VERSION: 5.0 - IPHONEOS_DEPLOYMENT_TARGET: 16.0 MACOSX_DEPLOYMENT_TARGET: 13.0 - SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: YES CODE_SIGN_STYLE: Automatic CODE_SIGNING_REQUIRED: YES CODE_SIGNING_ALLOWED: YES ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: YES + ENABLE_HARDENED_RUNTIME: YES bitchatTests_iOS: type: bundle.unit-test platform: iOS sources: - - bitchatTests + - path: bitchatTests + excludes: + - "**/.DS_Store" + createIntermediateGroups: true dependencies: - target: bitchat_iOS settings: + PRODUCT_NAME: bitchatTests PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat.tests INFOPLIST_FILE: bitchatTests/Info.plist SWIFT_VERSION: 5.0 @@ -67,13 +110,17 @@ targets: type: bundle.unit-test platform: macOS sources: - - bitchatTests + - path: bitchatTests + excludes: + - "**/.DS_Store" + createIntermediateGroups: true dependencies: - target: bitchat_macOS settings: + PRODUCT_NAME: bitchatTests PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat.tests INFOPLIST_FILE: bitchatTests/Info.plist SWIFT_VERSION: 5.0 MACOSX_DEPLOYMENT_TARGET: 13.0 TEST_HOST: $(BUILT_PRODUCTS_DIR)/bitchat.app/Contents/MacOS/bitchat - BUNDLE_LOADER: $(TEST_HOST) + BUNDLE_LOADER: $(TEST_HOST) \ No newline at end of file