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
This commit is contained in:
jack
2025-07-06 21:25:41 +02:00
parent 10656b49eb
commit 78cb8b1039
8 changed files with 651 additions and 64 deletions
+24 -27
View File
@@ -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 = "<group>"; };
136696FC4436A02D98CE6A77 /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = "<group>"; };
1EB3A8FE16333ED12FCB8ACB /* BloomFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloomFilterTests.swift; sourceTree = "<group>"; };
229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatProtocol.swift; sourceTree = "<group>"; };
@@ -81,9 +84,9 @@
67A85BFDDE65B4CD8BDF6DDB /* MessageRetentionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRetentionService.swift; sourceTree = "<group>"; };
6DC1563390A15C042D059CF9 /* EncryptionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionService.swift; sourceTree = "<group>"; };
763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfoView.swift; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = "<group>"; };
AA4D7595A613F7ED3B386132 /* MessageRetryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRetryService.swift; sourceTree = "<group>"; };
@@ -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;
+2 -14
View File
@@ -26,19 +26,7 @@
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
<string>bluetooth-peripheral</string>
</array>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>Black</string>
</dict>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>NSUserNotificationAlertStyle</key>
<string>alert</string>
</dict>
</plist>
+69 -2
View File
@@ -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
}
}
+101 -1
View File
@@ -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
+273
View File
@@ -0,0 +1,273 @@
//
// DeliveryTracker.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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<String>()
private var sentAckIDs = Set<String>()
// 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<String> = [] // 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()
}
}
}
+56 -3
View File
@@ -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
}
}
}
}
+63 -1
View File
@@ -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")
}
}
}
+63 -16
View File
@@ -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)