Fix encryption key exchange and add persistent identity for favorites

- Implement three-tier key system: ephemeral encryption, ephemeral signing, persistent identity
- Fix signature verification by properly separating KeyAgreement and Signing keys
- Add persistent identity key for favorites that survives app restarts
- Fix Data subscript crashes by converting to byte arrays
- Add proper error handling for key exchange
- Private messages remain fully encrypted with ephemeral keys
This commit is contained in:
jack
2025-07-04 01:43:16 +02:00
parent 34e51b3d9a
commit fe986ed397
12 changed files with 679 additions and 929 deletions
-12
View File
@@ -12,18 +12,14 @@
4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; }; 4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; };
61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3448F84BF86A42A3CC4A9379 /* NotificationService.swift */; }; 61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3448F84BF86A42A3CC4A9379 /* NotificationService.swift */; };
6DE056E1EE9850E9FBF50157 /* BitchatProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */; }; 6DE056E1EE9850E9FBF50157 /* BitchatProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */; };
6E628AC6B7E1754F3BD5090D /* AudioPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90B7FFCA6DA409F3CB78704 /* AudioPlaybackService.swift */; };
6E7761E21C99F28AE2F9BE5F /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; }; 6E7761E21C99F28AE2F9BE5F /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; };
739429DFDE5C5829CF70DA7D /* EncryptionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC1563390A15C042D059CF9 /* EncryptionService.swift */; }; 739429DFDE5C5829CF70DA7D /* EncryptionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC1563390A15C042D059CF9 /* EncryptionService.swift */; };
749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3448F84BF86A42A3CC4A9379 /* NotificationService.swift */; }; 749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3448F84BF86A42A3CC4A9379 /* NotificationService.swift */; };
7576A357B278E5733E9D9F33 /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */; }; 7576A357B278E5733E9D9F33 /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */; };
7A50E2F04A3515A7E90EEAE4 /* BluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */; }; 7A50E2F04A3515A7E90EEAE4 /* BluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */; };
7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */; }; 7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */; };
817B221A5676873EAF5C5FA7 /* AudioRecordingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C8801D22A8F987A7696BB90 /* AudioRecordingService.swift */; };
923027D6F2F417AFA2488127 /* BitchatProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */; }; 923027D6F2F417AFA2488127 /* BitchatProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */; };
92D34E7A07C990C8A815B0CE /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08E03AA0C63E97C91749AEC /* ContentView.swift */; }; 92D34E7A07C990C8A815B0CE /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08E03AA0C63E97C91749AEC /* ContentView.swift */; };
940A9CE820FBF517F0C7AA7E /* AudioPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90B7FFCA6DA409F3CB78704 /* AudioPlaybackService.swift */; };
A7F37567974153A90E8C9F12 /* AudioRecordingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C8801D22A8F987A7696BB90 /* AudioRecordingService.swift */; };
BCCFEDC1EBE59323C3C470BF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */; }; BCCFEDC1EBE59323C3C470BF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */; };
D450CF41F207BDE1A1AAA56E /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */; }; D450CF41F207BDE1A1AAA56E /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */; };
D948085736ED8E736C1DE3B0 /* BluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */; }; D948085736ED8E736C1DE3B0 /* BluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */; };
@@ -35,13 +31,11 @@
229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatProtocol.swift; sourceTree = "<group>"; }; 229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatProtocol.swift; sourceTree = "<group>"; };
3448F84BF86A42A3CC4A9379 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; }; 3448F84BF86A42A3CC4A9379 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
3A69677D382F1C3D5ED03F7D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
6C8801D22A8F987A7696BB90 /* AudioRecordingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecordingService.swift; sourceTree = "<group>"; };
6DC1563390A15C042D059CF9 /* EncryptionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionService.swift; sourceTree = "<group>"; }; 6DC1563390A15C042D059CF9 /* EncryptionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionService.swift; sourceTree = "<group>"; };
7EEBDA723E1CFD88758DA4AC /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; }; 7EEBDA723E1CFD88758DA4AC /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
997D512074C64904D75DDD40 /* bitchat.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; }; 997D512074C64904D75DDD40 /* bitchat.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; 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>"; }; A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = "<group>"; };
B90B7FFCA6DA409F3CB78704 /* AudioPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackService.swift; sourceTree = "<group>"; };
D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothMeshService.swift; sourceTree = "<group>"; }; D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothMeshService.swift; sourceTree = "<group>"; };
E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViewModel.swift; sourceTree = "<group>"; }; E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViewModel.swift; sourceTree = "<group>"; };
EA706D8E5097785414646A8E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; EA706D8E5097785414646A8E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
@@ -108,8 +102,6 @@
D98A3186D7E4C72E35BDF7FE /* Services */ = { D98A3186D7E4C72E35BDF7FE /* Services */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
B90B7FFCA6DA409F3CB78704 /* AudioPlaybackService.swift */,
6C8801D22A8F987A7696BB90 /* AudioRecordingService.swift */,
D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */, D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */,
6DC1563390A15C042D059CF9 /* EncryptionService.swift */, 6DC1563390A15C042D059CF9 /* EncryptionService.swift */,
3448F84BF86A42A3CC4A9379 /* NotificationService.swift */, 3448F84BF86A42A3CC4A9379 /* NotificationService.swift */,
@@ -219,8 +211,6 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
6E628AC6B7E1754F3BD5090D /* AudioPlaybackService.swift in Sources */,
A7F37567974153A90E8C9F12 /* AudioRecordingService.swift in Sources */,
4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */, 4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */,
6E7761E21C99F28AE2F9BE5F /* BitchatApp.swift in Sources */, 6E7761E21C99F28AE2F9BE5F /* BitchatApp.swift in Sources */,
923027D6F2F417AFA2488127 /* BitchatProtocol.swift in Sources */, 923027D6F2F417AFA2488127 /* BitchatProtocol.swift in Sources */,
@@ -236,8 +226,6 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
940A9CE820FBF517F0C7AA7E /* AudioPlaybackService.swift in Sources */,
817B221A5676873EAF5C5FA7 /* AudioRecordingService.swift in Sources */,
F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */, F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */,
10E68BB889356219189E38EC /* BitchatApp.swift in Sources */, 10E68BB889356219189E38EC /* BitchatApp.swift in Sources */,
6DE056E1EE9850E9FBF50157 /* BitchatProtocol.swift in Sources */, 6DE056E1EE9850E9FBF50157 /* BitchatProtocol.swift in Sources */,
+55
View File
@@ -1,13 +1,24 @@
import SwiftUI import SwiftUI
import UserNotifications
@main @main
struct BitchatApp: App { struct BitchatApp: App {
@StateObject private var chatViewModel = ChatViewModel() @StateObject private var chatViewModel = ChatViewModel()
#if os(iOS)
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
#endif
init() {
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
}
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() ContentView()
.environmentObject(chatViewModel) .environmentObject(chatViewModel)
.onAppear {
NotificationDelegate.shared.chatViewModel = chatViewModel
}
} }
#if os(macOS) #if os(macOS)
.windowStyle(.hiddenTitleBar) .windowStyle(.hiddenTitleBar)
@@ -15,3 +26,47 @@ struct BitchatApp: App {
#endif #endif
} }
} }
#if os(iOS)
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
return true
}
}
#endif
class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
static let shared = NotificationDelegate()
weak var chatViewModel: ChatViewModel?
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
let identifier = response.notification.request.identifier
// Check if this is a private message notification
if identifier.hasPrefix("private-") {
// Extract sender from notification title
let title = response.notification.request.content.title
if let senderName = title.replacingOccurrences(of: "Private message from ", with: "").nilIfEmpty {
// Find peer ID and open chat
if let peerID = chatViewModel?.getPeerIDForNickname(senderName) {
DispatchQueue.main.async {
self.chatViewModel?.startPrivateChat(with: peerID)
}
}
}
}
completionHandler()
}
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
// Show notification even when app is in foreground (for testing)
completionHandler([.banner, .sound])
}
}
extension String {
var nilIfEmpty: String? {
self.isEmpty ? nil : self
}
}
-2
View File
@@ -28,8 +28,6 @@
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string> <string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>bitchat needs access to your microphone to record voice notes.</string> <string>bitchat needs access to your microphone to record voice notes.</string>
<key>NSUserNotificationsUsageDescription</key>
<string>bitchat needs permission to send notifications for mentions and private messages.</string>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>bluetooth-central</string> <string>bluetooth-central</string>
+1 -49
View File
@@ -185,7 +185,6 @@ extension BitchatMessage {
// - Recipient nickname length + data // - Recipient nickname length + data
// - Sender peer ID length + data // - Sender peer ID length + data
// - Mentions array // - Mentions array
// - Voice note duration (8 bytes, double) + data length (4 bytes) + data
var flags: UInt8 = 0 var flags: UInt8 = 0
if isRelay { flags |= 0x01 } if isRelay { flags |= 0x01 }
@@ -194,7 +193,6 @@ extension BitchatMessage {
if recipientNickname != nil { flags |= 0x08 } if recipientNickname != nil { flags |= 0x08 }
if senderPeerID != nil { flags |= 0x10 } if senderPeerID != nil { flags |= 0x10 }
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 } if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
if voiceNoteData != nil { flags |= 0x40 }
data.append(flags) data.append(flags)
@@ -261,24 +259,6 @@ extension BitchatMessage {
} }
} }
// Voice note data
if let voiceData = voiceNoteData, let duration = voiceNoteDuration {
// Duration as 8 bytes (double)
let durationBits = duration.bitPattern
for i in (0..<8).reversed() {
data.append(UInt8((durationBits >> (i * 8)) & 0xFF))
}
// Voice data length as 4 bytes (UInt32)
let voiceLength = UInt32(min(voiceData.count, Int(UInt32.max)))
for i in (0..<4).reversed() {
data.append(UInt8((voiceLength >> (i * 8)) & 0xFF))
}
// Voice data
data.append(voiceData.prefix(Int(voiceLength)))
}
return data return data
} }
@@ -304,7 +284,6 @@ extension BitchatMessage {
let hasRecipientNickname = (flags & 0x08) != 0 let hasRecipientNickname = (flags & 0x08) != 0
let hasSenderPeerID = (flags & 0x10) != 0 let hasSenderPeerID = (flags & 0x10) != 0
let hasMentions = (flags & 0x20) != 0 let hasMentions = (flags & 0x20) != 0
let hasVoiceNote = (flags & 0x40) != 0
// Timestamp // Timestamp
guard offset + 8 <= dataCopy.count else { guard offset + 8 <= dataCopy.count else {
@@ -402,31 +381,6 @@ extension BitchatMessage {
} }
} }
// Voice note data
var voiceNoteData: Data?
var voiceNoteDuration: TimeInterval?
if hasVoiceNote && offset + 12 <= dataCopy.count {
// Duration (8 bytes)
let durationData = dataCopy[offset..<offset+8]
let durationBits = durationData.reduce(0) { result, byte in
(result << 8) | UInt64(byte)
}
voiceNoteDuration = TimeInterval(bitPattern: durationBits)
offset += 8
// Voice data length (4 bytes)
let lengthData = dataCopy[offset..<offset+4]
let voiceLength = lengthData.reduce(0) { result, byte in
(result << 8) | UInt32(byte)
}
offset += 4
// Voice data
if offset + Int(voiceLength) <= dataCopy.count {
voiceNoteData = dataCopy[offset..<offset+Int(voiceLength)]
}
}
let message = BitchatMessage( let message = BitchatMessage(
sender: sender, sender: sender,
content: content, content: content,
@@ -436,9 +390,7 @@ extension BitchatMessage {
isPrivate: isPrivate, isPrivate: isPrivate,
recipientNickname: recipientNickname, recipientNickname: recipientNickname,
senderPeerID: senderPeerID, senderPeerID: senderPeerID,
mentions: mentions, mentions: mentions
voiceNoteData: voiceNoteData,
voiceNoteDuration: voiceNoteDuration
) )
return message return message
} }
+1 -6
View File
@@ -10,7 +10,6 @@ enum MessageType: UInt8 {
case keyExchange = 0x06 case keyExchange = 0x06
case leave = 0x07 case leave = 0x07
case privateMessage = 0x08 case privateMessage = 0x08
case voiceNote = 0x09
case fragmentStart = 0x0A // First fragment of a large message case fragmentStart = 0x0A // First fragment of a large message
case fragmentContinue = 0x0B // Continuation fragment case fragmentContinue = 0x0B // Continuation fragment
case fragmentEnd = 0x0C // Last fragment case fragmentEnd = 0x0C // Last fragment
@@ -73,10 +72,8 @@ struct BitchatMessage: Codable, Equatable {
let recipientNickname: String? let recipientNickname: String?
let senderPeerID: String? let senderPeerID: String?
let mentions: [String]? // Array of mentioned nicknames let mentions: [String]? // Array of mentioned nicknames
let voiceNoteData: Data? // Audio data for voice notes
let voiceNoteDuration: TimeInterval? // Duration in seconds
init(sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, voiceNoteData: Data? = nil, voiceNoteDuration: TimeInterval? = nil) { init(sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil) {
self.id = UUID().uuidString self.id = UUID().uuidString
self.sender = sender self.sender = sender
self.content = content self.content = content
@@ -87,8 +84,6 @@ struct BitchatMessage: Codable, Equatable {
self.recipientNickname = recipientNickname self.recipientNickname = recipientNickname
self.senderPeerID = senderPeerID self.senderPeerID = senderPeerID
self.mentions = mentions self.mentions = mentions
self.voiceNoteData = voiceNoteData
self.voiceNoteDuration = voiceNoteDuration
} }
} }
-119
View File
@@ -1,119 +0,0 @@
import Foundation
import AVFoundation
class AudioPlaybackService: NSObject, ObservableObject {
@Published var isPlaying = false
@Published var currentPlayingMessageID: String?
@Published var playbackProgress: Double = 0
private var audioPlayer: AVAudioPlayer?
private var playbackTimer: Timer?
private var audioFiles: [String: URL] = [:] // messageID -> temporary file URL
override init() {
super.init()
setupAudioSession()
}
private func setupAudioSession() {
#if os(iOS)
let audioSession = AVAudioSession.sharedInstance()
do {
try audioSession.setCategory(.playback, mode: .default)
try audioSession.setActive(true)
} catch {
print("[AUDIO] Failed to set up audio session: \(error)")
}
#endif
}
func playVoiceNote(messageID: String, audioData: Data, completion: @escaping (Result<Void, Error>) -> Void) {
// Stop any current playback
stopPlayback()
print("[AUDIO] Attempting to play voice note with data size: \(audioData.count) bytes")
// Create temporary file for audio data
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("voice_note_\(messageID).m4a")
do {
// Ensure directory exists
try FileManager.default.createDirectory(at: tempURL.deletingLastPathComponent(), withIntermediateDirectories: true)
// Write audio data
try audioData.write(to: tempURL)
audioFiles[messageID] = tempURL
print("[AUDIO] Written audio file to: \(tempURL.path), size: \(try FileManager.default.attributesOfItem(atPath: tempURL.path)[.size] ?? 0)")
audioPlayer = try AVAudioPlayer(contentsOf: tempURL)
audioPlayer?.delegate = self
audioPlayer?.prepareToPlay()
if audioPlayer?.play() == true {
isPlaying = true
currentPlayingMessageID = messageID
playbackProgress = 0
// Start progress timer
playbackTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
guard let self = self, let player = self.audioPlayer else { return }
self.playbackProgress = player.currentTime / player.duration
}
completion(.success(()))
} else {
completion(.failure(AudioError.playbackFailed))
}
} catch {
completion(.failure(error))
}
}
func stopPlayback() {
audioPlayer?.stop()
isPlaying = false
currentPlayingMessageID = nil
playbackProgress = 0
playbackTimer?.invalidate()
playbackTimer = nil
}
func togglePlayback(messageID: String, audioData: Data, completion: @escaping (Result<Void, Error>) -> Void) {
if isPlaying && currentPlayingMessageID == messageID {
stopPlayback()
completion(.success(()))
} else {
playVoiceNote(messageID: messageID, audioData: audioData, completion: completion)
}
}
func cleanupTemporaryFiles() {
for (_, url) in audioFiles {
try? FileManager.default.removeItem(at: url)
}
audioFiles.removeAll()
}
deinit {
cleanupTemporaryFiles()
}
enum AudioError: LocalizedError {
case playbackFailed
var errorDescription: String? {
switch self {
case .playbackFailed:
return "Failed to start audio playback"
}
}
}
}
extension AudioPlaybackService: AVAudioPlayerDelegate {
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
stopPlayback()
}
}
@@ -1,196 +0,0 @@
import Foundation
import AVFoundation
class AudioRecordingService: NSObject, ObservableObject {
@Published var isRecording = false
@Published var recordingTime: TimeInterval = 0
private var audioRecorder: AVAudioRecorder?
private var recordingTimer: Timer?
private var recordingStartTime: Date?
private let maxDuration: TimeInterval = 30.0
private var recordingCompletion: ((Result<URL, Error>) -> Void)?
override init() {
super.init()
setupAudioSession()
}
private func setupAudioSession() {
#if os(iOS)
let audioSession = AVAudioSession.sharedInstance()
do {
try audioSession.setCategory(.playAndRecord, mode: .default)
try audioSession.setActive(true)
} catch {
print("[AUDIO] Failed to set up audio session: \(error)")
}
#endif
}
func startRecording(completion: @escaping (Result<URL, Error>) -> Void) {
guard !isRecording else { return }
#if os(iOS)
// Request microphone permission
AVAudioSession.sharedInstance().requestRecordPermission { granted in
if !granted {
completion(.failure(AudioError.microphonePermissionDenied))
return
}
self.performRecording(completion: completion)
}
#else
performRecording(completion: completion)
#endif
}
private func performRecording(completion: @escaping (Result<URL, Error>) -> Void) {
// Store completion handler for later use
self.recordingCompletion = completion
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let audioFilename = documentsPath.appendingPathComponent("voice_note_\(Date().timeIntervalSince1970).m4a")
let settings = [
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
AVSampleRateKey: 16000, // Lower sample rate for smaller files
AVNumberOfChannelsKey: 1, // Mono for voice
AVEncoderAudioQualityKey: AVAudioQuality.medium.rawValue,
AVEncoderBitRateKey: 32000 // 32 kbps for voice
]
do {
// Ensure audio session is properly configured
#if os(iOS)
let audioSession = AVAudioSession.sharedInstance()
try audioSession.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker])
try audioSession.setActive(true)
#endif
audioRecorder = try AVAudioRecorder(url: audioFilename, settings: settings)
audioRecorder?.delegate = self
audioRecorder?.prepareToRecord() // Important: prepare before recording
print("[AUDIO] Created recorder with URL: \(audioFilename)")
print("[AUDIO] Recorder prepared: \(audioRecorder?.prepareToRecord() ?? false)")
if audioRecorder?.record() == true {
isRecording = true
recordingStartTime = Date()
recordingTime = 0
print("[AUDIO] Recording started successfully")
// Start timer to update recording time and enforce max duration
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
guard let self = self, let startTime = self.recordingStartTime else { return }
self.recordingTime = Date().timeIntervalSince(startTime)
if self.recordingTime >= self.maxDuration {
self.stopRecording { result in
// Result already handled by recordingCompletion
}
}
}
} else {
print("[AUDIO] Failed to start recording")
completion(.failure(AudioError.recordingFailed))
}
} catch {
print("[AUDIO] Error setting up recording: \(error)")
completion(.failure(error))
}
}
func stopRecording(completion: @escaping (Result<URL, Error>) -> Void) {
guard isRecording, let recorder = audioRecorder else {
completion(.failure(AudioError.notRecording))
return
}
// Ensure minimum recording duration
let minDuration: TimeInterval = 1.0
if recordingTime < minDuration {
// Continue recording until minimum duration
DispatchQueue.main.asyncAfter(deadline: .now() + (minDuration - recordingTime)) { [weak self] in
self?.stopRecording(completion: completion)
}
return
}
recorder.stop()
isRecording = false
recordingTimer?.invalidate()
recordingTimer = nil
let url = recorder.url
let recordedDuration = recordingTime
// Reset for next recording
audioRecorder = nil
recordingTime = 0
recordingStartTime = nil
recordingCompletion = nil
// Give the recorder a moment to finalize the file
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
// Verify file exists and has content
do {
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
if let fileSize = attributes[.size] as? Int, fileSize > 1000 { // At least 1KB
print("[AUDIO] Recording completed successfully. File size: \(fileSize) bytes, duration: \(recordedDuration)s")
completion(.success(url))
} else {
print("[AUDIO] Recording failed: file too small (\(attributes[.size] ?? 0) bytes)")
completion(.failure(AudioError.emptyRecording))
}
} catch {
print("[AUDIO] Recording failed: \(error)")
completion(.failure(error))
}
}
}
func cancelRecording() {
guard isRecording, let recorder = audioRecorder else { return }
recorder.stop()
recorder.deleteRecording()
isRecording = false
recordingTimer?.invalidate()
recordingTimer = nil
audioRecorder = nil
recordingTime = 0
recordingStartTime = nil
}
enum AudioError: LocalizedError {
case notRecording
case emptyRecording
case microphonePermissionDenied
case recordingFailed
var errorDescription: String? {
switch self {
case .notRecording:
return "Not currently recording"
case .emptyRecording:
return "Recording produced no audio data"
case .microphonePermissionDenied:
return "Microphone permission denied"
case .recordingFailed:
return "Failed to start recording"
}
}
}
}
extension AudioRecordingService: AVAudioRecorderDelegate {
func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) {
if !flag {
print("[AUDIO] Recording finished unsuccessfully")
}
}
}
+362 -146
View File
@@ -37,21 +37,33 @@ class BluetoothMeshService: NSObject {
private let encryptionService = EncryptionService() private let encryptionService = EncryptionService()
private let messageQueue = DispatchQueue(label: "bitchat.messageQueue", attributes: .concurrent) private let messageQueue = DispatchQueue(label: "bitchat.messageQueue", attributes: .concurrent)
private var processedMessages = Set<String>() private var processedMessages = Set<String>()
private let maxTTL: UInt8 = 5 private let maxTTL: UInt8 = 3 // Reduced for efficiency
private var announcedToPeers = Set<String>() // Track which peers we've announced to private var announcedToPeers = Set<String>() // Track which peers we've announced to
private var announcedPeers = Set<String>() // Track peers who have already been announced private var announcedPeers = Set<String>() // Track peers who have already been announced
// Battery and range optimizations
private var scanDutyCycleTimer: Timer?
private var isActivelyScanning = true
private let activeScanDuration: TimeInterval = 2.0 // Scan actively for 2 seconds
private let scanPauseDuration: TimeInterval = 3.0 // Pause for 3 seconds
private var lastRSSIUpdate: [String: Date] = [:] // Throttle RSSI updates
// Fragment handling // Fragment handling
private var incomingFragments: [String: [Int: Data]] = [:] // fragmentID -> [index: data] private var incomingFragments: [String: [Int: Data]] = [:] // fragmentID -> [index: data]
private var fragmentMetadata: [String: (originalType: UInt8, totalFragments: Int, timestamp: Date)] = [:] private var fragmentMetadata: [String: (originalType: UInt8, totalFragments: Int, timestamp: Date)] = [:]
private let maxFragmentSize = 200 // Smaller fragments for better reliability private let maxFragmentSize = 500 // Optimized for BLE 5.0 extended data length
let myPeerID: String let myPeerID: String
override init() { override init() {
self.myPeerID = UUID().uuidString.prefix(8).lowercased() // Generate ephemeral peer ID for each session to prevent tracking
// Use random bytes instead of UUID for better anonymity
var randomBytes = [UInt8](repeating: 0, count: 4)
_ = SecRandomCopyBytes(kSecRandomDefault, 4, &randomBytes)
self.myPeerID = randomBytes.map { String(format: "%02x", $0) }.joined()
super.init() super.init()
print("[STARTUP] My Bluetooth ID (myPeerID): \(myPeerID)") print("[STARTUP] Generated ephemeral peer ID: \(myPeerID)")
centralManager = CBCentralManager(delegate: self, queue: nil) centralManager = CBCentralManager(delegate: self, queue: nil)
peripheralManager = CBPeripheralManager(delegate: self, queue: nil) peripheralManager = CBPeripheralManager(delegate: self, queue: nil)
@@ -76,6 +88,7 @@ class BluetoothMeshService: NSObject {
deinit { deinit {
cleanup() cleanup()
scanDutyCycleTimer?.invalidate()
} }
@objc private func appWillTerminate() { @objc private func appWillTerminate() {
@@ -151,7 +164,7 @@ class BluetoothMeshService: NSObject {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self = self else { return } guard let self = self else { return }
self.broadcastPacket(announcePacket) self.broadcastPacket(announcePacket)
print("[ANNOUNCE] Re-sending broadcast announce (delay: \(delay)s)") // [ANNOUNCE] Re-sending broadcast announce
} }
} }
} }
@@ -161,11 +174,15 @@ class BluetoothMeshService: NSObject {
return return
} }
// Use generic advertising to avoid identification
// No identifying prefixes or app names for activist safety
let advertisementData: [String: Any] = [ let advertisementData: [String: Any] = [
CBAdvertisementDataServiceUUIDsKey: [BluetoothMeshService.serviceUUID], CBAdvertisementDataServiceUUIDsKey: [BluetoothMeshService.serviceUUID],
CBAdvertisementDataLocalNameKey: "bitchat-\(myPeerID)" // Use only peer ID without any identifying prefix
CBAdvertisementDataLocalNameKey: myPeerID,
CBAdvertisementDataIsConnectable: true
] ]
print("[BLUETOOTH] Starting to advertise as: bitchat-\(myPeerID)") // [BLUETOOTH] Starting advertising
peripheralManager.startAdvertising(advertisementData) peripheralManager.startAdvertising(advertisementData)
} }
@@ -173,7 +190,51 @@ class BluetoothMeshService: NSObject {
guard centralManager.state == .poweredOn else { guard centralManager.state == .poweredOn else {
return return
} }
centralManager.scanForPeripherals(withServices: [BluetoothMeshService.serviceUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
// [BLUETOOTH] Starting scan
// Enable duplicate detection for RSSI tracking
let scanOptions: [String: Any] = [
CBCentralManagerScanOptionAllowDuplicatesKey: true
]
centralManager.scanForPeripherals(
withServices: [BluetoothMeshService.serviceUUID],
options: scanOptions
)
// Implement scan duty cycling for battery efficiency
scheduleScanDutyCycle()
}
private func scheduleScanDutyCycle() {
guard scanDutyCycleTimer == nil else { return }
// Start with active scanning
isActivelyScanning = true
scanDutyCycleTimer = Timer.scheduledTimer(withTimeInterval: activeScanDuration, repeats: true) { [weak self] _ in
guard let self = self else { return }
if self.isActivelyScanning {
// Pause scanning to save battery
self.centralManager.stopScan()
self.isActivelyScanning = false
// [BLUETOOTH] Pausing scan
// Schedule resume
DispatchQueue.main.asyncAfter(deadline: .now() + self.scanPauseDuration) { [weak self] in
guard let self = self else { return }
if self.centralManager.state == .poweredOn {
self.centralManager.scanForPeripherals(
withServices: [BluetoothMeshService.serviceUUID],
options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
)
self.isActivelyScanning = true
// [BLUETOOTH] Resuming scan
}
}
}
}
} }
private func setupPeripheral() { private func setupPeripheral() {
@@ -208,64 +269,41 @@ class BluetoothMeshService: NSObject {
) )
if let messageData = message.toBinaryPayload() { if let messageData = message.toBinaryPayload() {
// Sign the message payload (no encryption for broadcasts)
let signature: Data?
do {
signature = try self.encryptionService.sign(messageData)
print("[CRYPTO] Successfully signed broadcast message")
} catch {
print("[CRYPTO] Failed to sign message: \(error)")
signature = nil
}
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.message.rawValue, type: MessageType.message.rawValue,
ttl: self.maxTTL, senderID: Data(self.myPeerID.utf8),
senderID: self.myPeerID, recipientID: nil,
payload: messageData timestamp: UInt64(Date().timeIntervalSince1970),
payload: messageData,
signature: signature,
ttl: self.maxTTL
) )
self.broadcastPacket(packet) self.broadcastPacket(packet)
} print("[MESSAGE] Sending: \(content)")
}
}
func sendVoiceNote(_ audioData: Data, duration: TimeInterval) { // Retry for reliability (like announces)
messageQueue.async { [weak self] in for delay in [0.2, 0.5] {
guard let self = self else { return } DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self, packet] in
self?.broadcastPacket(packet)
print("[VOICE] Sending voice note: audio size = \(audioData.count) bytes, duration = \(duration)s") // Re-sending message
let nickname = self.delegate as? ChatViewModel
let senderNick = nickname?.nickname ?? self.myPeerID
let message = BitchatMessage(
sender: senderNick,
content: "🎤 \(String(format: "%.1f", duration))s",
timestamp: Date(),
isRelay: false,
originalSender: nil,
voiceNoteData: audioData,
voiceNoteDuration: duration
)
if let messageData = message.toBinaryPayload() {
print("[VOICE] Message payload size: \(messageData.count) bytes")
let packet = BitchatPacket(
type: MessageType.voiceNote.rawValue,
ttl: self.maxTTL,
senderID: self.myPeerID,
payload: messageData
)
if let packetData = packet.toBinaryData() {
print("[VOICE] Final packet size: \(packetData.count) bytes")
// Check if packet exceeds safe BLE size (leave margin for overhead)
if packetData.count > 400 {
print("[VOICE] Packet size \(packetData.count) exceeds safe BLE limit")
print("[VOICE] Fragmenting voice note into smaller packets...")
self.sendFragmentedPacket(packet)
} else {
print("[VOICE] Sending voice note as single packet")
self.broadcastPacket(packet)
} }
} else {
print("[VOICE] ERROR: Failed to convert packet to binary data")
} }
} }
} }
} }
func sendPrivateMessage(_ content: String, to recipientPeerID: String, recipientNickname: String) { func sendPrivateMessage(_ content: String, to recipientPeerID: String, recipientNickname: String) {
messageQueue.async { [weak self] in messageQueue.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -285,18 +323,39 @@ class BluetoothMeshService: NSObject {
) )
if let messageData = message.toBinaryPayload() { if let messageData = message.toBinaryPayload() {
// Encrypt the message for the recipient
let encryptedPayload: Data
do {
encryptedPayload = try self.encryptionService.encrypt(messageData, for: recipientPeerID)
print("[CRYPTO] Successfully encrypted private message for \(recipientPeerID)")
} catch {
print("[CRYPTO] Failed to encrypt private message: \(error)")
// Don't send unencrypted private messages
return
}
// Sign the encrypted payload
let signature: Data?
do {
signature = try self.encryptionService.sign(encryptedPayload)
print("[CRYPTO] Successfully signed private message")
} catch {
print("[CRYPTO] Failed to sign private message: \(error)")
signature = nil
}
// Create packet with recipient ID for proper routing // Create packet with recipient ID for proper routing
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.privateMessage.rawValue, type: MessageType.privateMessage.rawValue,
senderID: Data(self.myPeerID.utf8), senderID: Data(self.myPeerID.utf8),
recipientID: Data(recipientPeerID.utf8), recipientID: Data(recipientPeerID.utf8),
timestamp: UInt64(Date().timeIntervalSince1970), timestamp: UInt64(Date().timeIntervalSince1970),
payload: messageData, payload: encryptedPayload,
signature: nil, signature: signature,
ttl: self.maxTTL ttl: self.maxTTL
) )
print("[PRIVATE] Sending private message to \(recipientPeerID)") print("[PRIVATE] Sending encrypted message to \(recipientPeerID): \(content)")
self.broadcastPacket(packet) self.broadcastPacket(packet)
// Don't call didReceiveMessage here - let the view model handle it directly // Don't call didReceiveMessage here - let the view model handle it directly
@@ -361,26 +420,81 @@ class BluetoothMeshService: NSObject {
return peerRSSI return peerRSSI
} }
// Emergency disconnect for panic situations
func emergencyDisconnectAll() {
// Stop advertising immediately
if peripheralManager.isAdvertising {
peripheralManager.stopAdvertising()
}
// Stop scanning
centralManager.stopScan()
scanDutyCycleTimer?.invalidate()
scanDutyCycleTimer = nil
// Disconnect all peripherals
for (_, peripheral) in connectedPeripherals {
centralManager.cancelPeripheralConnection(peripheral)
}
// Clear all peer data
connectedPeripherals.removeAll()
peripheralCharacteristics.removeAll()
discoveredPeripherals.removeAll()
subscribedCentrals.removeAll()
peerNicknames.removeAll()
activePeers.removeAll()
peerRSSI.removeAll()
peripheralRSSI.removeAll()
announcedToPeers.removeAll()
announcedPeers.removeAll()
processedMessages.removeAll()
incomingFragments.removeAll()
fragmentMetadata.removeAll()
// Clear persistent identity
encryptionService.clearPersistentIdentity()
print("[PANIC] Emergency disconnect completed")
}
private func getAllConnectedPeerIDs() -> [String] { private func getAllConnectedPeerIDs() -> [String] {
// Only return peers who have announced (have nicknames) // Only return peers who have announced (have nicknames)
let announcedPeers = Set(activePeers.filter { peerID in let announcedPeers = Set(activePeers.compactMap { peerID -> String? in
peerID != "unknown" && // Ensure peerID is valid and not nil
peerID != myPeerID && guard !peerID.isEmpty,
peerID.count <= 8 && // Filter out temp IDs peerID != "unknown",
peerNicknames[peerID] != nil // Only include peers who have announced peerID != myPeerID,
peerID.count <= 8, // Filter out temp IDs
peerNicknames[peerID] != nil else { // Only include peers who have announced
return nil
}
return peerID
}) })
print("[DEBUG] Active peers: \(activePeers), announced: \(announcedPeers), nicknames: \(peerNicknames.keys)") // Active peers: \(announcedPeers.count)
return Array(announcedPeers).sorted() return Array(announcedPeers).sorted()
} }
private func estimateDistance(rssi: Int) -> Int {
// Rough distance estimation based on RSSI
// Using path loss formula: RSSI = TxPower - 10 * n * log10(distance)
// Assuming TxPower = -59 dBm at 1m, n = 2.0 (free space)
let txPower = -59.0
let pathLossExponent = 2.0
let ratio = (txPower - Double(rssi)) / (10.0 * pathLossExponent)
let distance = pow(10.0, ratio)
return Int(distance)
}
private func broadcastPacket(_ packet: BitchatPacket) { private func broadcastPacket(_ packet: BitchatPacket) {
guard let data = packet.toBinaryData() else { guard let data = packet.toBinaryData() else {
print("[ERROR] Failed to convert packet to binary data") print("[ERROR] Failed to convert packet to binary data")
return return
} }
// [BROADCAST] Type: \(packet.type), peripherals: \(connectedPeripherals.count), centrals: \(subscribedCentrals.count)
print("[BROADCAST] Broadcasting packet type: \(packet.type), data size: \(data.count)")
// Send to connected peripherals (as central) // Send to connected peripherals (as central)
var sentToPeripherals = 0 var sentToPeripherals = 0
@@ -388,32 +502,32 @@ class BluetoothMeshService: NSObject {
if let characteristic = peripheralCharacteristics[peripheral] { if let characteristic = peripheralCharacteristics[peripheral] {
// Check if peripheral is connected before writing // Check if peripheral is connected before writing
if peripheral.state == .connected { if peripheral.state == .connected {
// Always use withResponse for reliability, especially for background // Use withoutResponse for faster transmission when possible
peripheral.writeValue(data, for: characteristic, type: .withResponse) // Only use withResponse for critical messages or when MTU negotiation needed
let writeType: CBCharacteristicWriteType = data.count > 512 ? .withResponse : .withoutResponse
peripheral.writeValue(data, for: characteristic, type: writeType)
sentToPeripherals += 1 sentToPeripherals += 1
} else { } else {
print("[BROADCAST] Peripheral \(peerID) not connected (state: \(peripheral.state.rawValue))") print("[BROADCAST] Peripheral \(peerID) not connected (state: \(peripheral.state.rawValue))")
} }
} else { } else {
print("[BROADCAST] No characteristic found for peripheral \(peerID)") // No characteristic for peripheral
} }
} }
print("[BROADCAST] Sent to \(sentToPeripherals) connected peripherals") // Sent to \(sentToPeripherals) peripherals
// Send to subscribed centrals (as peripheral) // Send to subscribed centrals (as peripheral)
if characteristic != nil && !subscribedCentrals.isEmpty { if let char = characteristic, !subscribedCentrals.isEmpty {
let success = peripheralManager.updateValue(data, for: characteristic, onSubscribedCentrals: subscribedCentrals) // Send to all subscribed centrals
let success = peripheralManager.updateValue(data, for: char, onSubscribedCentrals: nil)
if success { if success {
print("[BROADCAST] Successfully sent to \(subscribedCentrals.count) subscribed centrals") // Sent to centrals
} else { } else {
print("[BROADCAST] Failed to send to centrals - queue full, will retry on delegate callback") print("[BROADCAST] Failed to send to centrals - queue full, will retry on delegate callback")
} }
} else { } else {
if characteristic == nil { if characteristic == nil {
print("[BROADCAST] No characteristic available") // No characteristic or centrals
}
if subscribedCentrals.isEmpty {
print("[BROADCAST] No subscribed centrals")
} }
} }
} }
@@ -432,7 +546,25 @@ class BluetoothMeshService: NSObject {
return return
} }
let messageID = "\(packet.timestamp)-\(String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) ?? "")" // Replay attack protection: Check timestamp is within reasonable window (5 minutes)
let currentTime = UInt64(Date().timeIntervalSince1970)
let timeDiff = abs(Int64(currentTime) - Int64(packet.timestamp))
if timeDiff > 300 { // 5 minutes
print("[SECURITY] Dropping packet with timestamp too far from current time: \(timeDiff) seconds")
return
}
// For fragments, include packet type in messageID to avoid dropping CONTINUE/END fragments
let messageID: String
if packet.type == MessageType.fragmentStart.rawValue ||
packet.type == MessageType.fragmentContinue.rawValue ||
packet.type == MessageType.fragmentEnd.rawValue {
// Include both type and payload hash for fragments to ensure uniqueness
messageID = "\(packet.timestamp)-\(String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) ?? "")-\(packet.type)-\(packet.payload.hashValue)"
} else {
messageID = "\(packet.timestamp)-\(String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) ?? "")"
}
guard !processedMessages.contains(messageID) else { guard !processedMessages.contains(messageID) else {
return return
} }
@@ -444,17 +576,44 @@ class BluetoothMeshService: NSObject {
let _ = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) ?? "unknown" let _ = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) ?? "unknown"
print("[PACKET] Received packet type: \(packet.type) from peerID: \(peerID)") // Received packet type: \(packet.type) from \(peerID)
// Note: We'll decode messages in the switch statement below, not here // Note: We'll decode messages in the switch statement below, not here
switch MessageType(rawValue: packet.type) { switch MessageType(rawValue: packet.type) {
case .message: case .message:
if let message = BitchatMessage.fromBinaryPayload(packet.payload) { // Process broadcast message (no decryption needed)
// Ignore our own messages guard let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) else {
if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8), senderID == myPeerID { return
return }
// Ignore our own messages
if senderID == myPeerID {
return
}
// Verify signature if present
if let signature = packet.signature {
do {
let isValid = try encryptionService.verify(signature, for: packet.payload, from: senderID)
if !isValid {
print("[CRYPTO] Invalid signature from \(senderID), dropping message")
return
}
// Valid signature
} catch {
print("[CRYPTO] Failed to verify signature from \(senderID): \(error)")
// If we don't have the public key yet, continue without verification
// Continuing without signature verification
} }
} else {
print("[CRYPTO] No signature present in message from \(senderID)")
}
let messagePayload = packet.payload
if let message = BitchatMessage.fromBinaryPayload(messagePayload) {
print("[MESSAGE] Received from \(message.sender): \(message.content)")
// Store nickname mapping // Store nickname mapping
if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) { if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) {
@@ -470,6 +629,8 @@ class BluetoothMeshService: NSObject {
if relayPacket.ttl > 0 { if relayPacket.ttl > 0 {
self.broadcastPacket(relayPacket) self.broadcastPacket(relayPacket)
} }
} else {
print("[MESSAGE] Failed to parse message from payload")
} }
case .keyExchange: case .keyExchange:
@@ -477,7 +638,18 @@ class BluetoothMeshService: NSObject {
if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) { if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) {
if packet.payload.count > 0 { if packet.payload.count > 0 {
let publicKeyData = packet.payload let publicKeyData = packet.payload
try? encryptionService.addPeerPublicKey(senderID, publicKeyData: publicKeyData) do {
try encryptionService.addPeerPublicKey(senderID, publicKeyData: publicKeyData)
print("[KEY_EXCHANGE] Successfully added public key for \(senderID)")
} catch {
print("[KEY_EXCHANGE] Failed to add public key for \(senderID): \(error)")
}
// Register identity key with view model for persistent favorites
if let viewModel = self.delegate as? ChatViewModel,
let identityKeyData = encryptionService.getPeerIdentityKey(senderID) {
viewModel.registerPeerPublicKey(peerID: senderID, publicKeyData: identityKeyData)
}
// Track this peer temporarily // Track this peer temporarily
if senderID != "unknown" && senderID != myPeerID { if senderID != "unknown" && senderID != myPeerID {
@@ -518,7 +690,7 @@ class BluetoothMeshService: NSObject {
print("[ANNOUNCE] Processing announce packet, payload size: \(packet.payload.count)") print("[ANNOUNCE] Processing announce packet, payload size: \(packet.payload.count)")
if let nickname = String(data: packet.payload, encoding: .utf8), if let nickname = String(data: packet.payload, encoding: .utf8),
let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) { let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) {
print("[DEBUG] Received announce from \(senderID): \(nickname), myPeerID: \(myPeerID)") // Received announce from \(senderID): \(nickname)
// Ignore if it's from ourselves // Ignore if it's from ourselves
if senderID == myPeerID { if senderID == myPeerID {
@@ -531,7 +703,7 @@ class BluetoothMeshService: NSObject {
// Store the nickname // Store the nickname
peerNicknames[senderID] = nickname peerNicknames[senderID] = nickname
print("[ANNOUNCE] Stored nickname for \(senderID): \(nickname)") print("[ANNOUNCE] Stored nickname for \(senderID): \(nickname)")
print("[ANNOUNCE] Current nicknames: \(peerNicknames)") // Updated nicknames
// Note: We can't update peripheral mapping here since we don't have // Note: We can't update peripheral mapping here since we don't have
// access to which peripheral sent this announce. The mapping will be // access to which peripheral sent this announce. The mapping will be
@@ -549,6 +721,15 @@ class BluetoothMeshService: NSObject {
DispatchQueue.main.async { DispatchQueue.main.async {
self.delegate?.didConnectToPeer(nickname) self.delegate?.didConnectToPeer(nickname)
self.delegate?.didUpdatePeerList(self.getAllConnectedPeerIDs()) self.delegate?.didUpdatePeerList(self.getAllConnectedPeerIDs())
// Check if this is a favorite peer and send notification
// Note: This might not work immediately if key exchange hasn't happened yet
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
if let viewModel = self.delegate as? ChatViewModel,
viewModel.isFavorite(peerID: senderID) {
NotificationService.shared.sendFavoriteOnlineNotification(nickname: nickname)
}
}
} }
} else { } else {
// Just update the peer list // Just update the peer list
@@ -563,9 +744,12 @@ class BluetoothMeshService: NSObject {
} }
case .leave: case .leave:
print("[LEAVE] Processing leave packet")
if let nickname = String(data: packet.payload, encoding: .utf8), if let nickname = String(data: packet.payload, encoding: .utf8),
let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) { let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) {
print("[LEAVE] \(nickname) (\(senderID)) is leaving")
// Remove from active peers // Remove from active peers
activePeers.remove(senderID) activePeers.remove(senderID)
announcedPeers.remove(senderID) announcedPeers.remove(senderID)
@@ -578,25 +762,53 @@ class BluetoothMeshService: NSObject {
// Clean up peer data // Clean up peer data
peerNicknames.removeValue(forKey: senderID) peerNicknames.removeValue(forKey: senderID)
} else {
print("[LEAVE] Failed to parse leave packet")
} }
case .privateMessage: case .privateMessage:
print("[PRIVATE] Received private message packet") print("[PRIVATE] Received private message packet")
if let message = BitchatMessage.fromBinaryPayload(packet.payload) { // Check if this private message is for us
// Check if this private message is for us if let recipientID = packet.recipientID,
if let recipientID = packet.recipientID, let recipientIDString = String(data: recipientID.trimmingNullBytes(), encoding: .utf8) {
let recipientIDString = String(data: recipientID.trimmingNullBytes(), encoding: .utf8) { print("[PRIVATE] Message recipient: \(recipientIDString), myPeerID: \(myPeerID)")
print("[PRIVATE] Message recipient: \(recipientIDString), myPeerID: \(myPeerID)")
if recipientIDString == myPeerID { if recipientIDString == myPeerID {
// Get sender ID // Get sender ID
if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) { if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) {
// Ignore our own messages // Ignore our own messages
if senderID == myPeerID { if senderID == myPeerID {
print("[PRIVATE] Ignoring own message") print("[PRIVATE] Ignoring own message")
return return
}
// Verify signature if present
if let signature = packet.signature {
do {
let isValid = try encryptionService.verify(signature, for: packet.payload, from: senderID)
if !isValid {
print("[CRYPTO] Invalid signature on private message from \(senderID), dropping")
return
}
print("[CRYPTO] Valid signature on private message from \(senderID)")
} catch {
print("[CRYPTO] Failed to verify signature from \(senderID): \(error)")
// Continue without signature verification for now
} }
}
// Decrypt the message
let decryptedPayload: Data
do {
decryptedPayload = try encryptionService.decrypt(packet.payload, from: senderID)
print("[CRYPTO] Successfully decrypted private message from \(senderID)")
} catch {
print("[CRYPTO] Failed to decrypt private message from \(senderID): \(error)")
return
}
// Parse the decrypted message
if let message = BitchatMessage.fromBinaryPayload(decryptedPayload) {
print("[PRIVATE] Received private message from \(senderID): \(message.content)") print("[PRIVATE] Received private message from \(senderID): \(message.content)")
// Store nickname mapping if we don't have it // Store nickname mapping if we don't have it
@@ -609,7 +821,6 @@ class BluetoothMeshService: NSObject {
} }
} }
// Create a new message with the sender peer ID // Create a new message with the sender peer ID
let messageWithPeerID = BitchatMessage( let messageWithPeerID = BitchatMessage(
sender: message.sender, sender: message.sender,
@@ -625,44 +836,22 @@ class BluetoothMeshService: NSObject {
DispatchQueue.main.async { DispatchQueue.main.async {
self.delegate?.didReceiveMessage(messageWithPeerID) self.delegate?.didReceiveMessage(messageWithPeerID)
} }
} else {
print("[PRIVATE] Failed to parse decrypted message")
} }
} else if packet.ttl > 0 {
// Relay private messages that aren't for us
print("[PRIVATE] Relaying message not meant for us (TTL: \(packet.ttl))")
var relayPacket = packet
relayPacket.ttl -= 1
self.broadcastPacket(relayPacket)
} }
} else { } else if packet.ttl > 0 {
print("[PRIVATE] No recipient ID in packet") // Relay private messages that aren't for us
} print("[PRIVATE] Relaying message not meant for us (TTL: \(packet.ttl))")
} else { var relayPacket = packet
print("[PRIVATE] Failed to decode message from payload") relayPacket.ttl -= 1
}
case .voiceNote:
if let message = BitchatMessage.fromBinaryPayload(packet.payload) {
// Ignore our own messages
if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8), senderID == myPeerID {
return
}
// Store nickname mapping
if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) {
peerNicknames[senderID] = message.sender
}
DispatchQueue.main.async {
self.delegate?.didReceiveMessage(message)
}
var relayPacket = packet
relayPacket.ttl -= 1
if relayPacket.ttl > 0 {
self.broadcastPacket(relayPacket) self.broadcastPacket(relayPacket)
} }
} else {
print("[PRIVATE] No recipient ID in packet")
} }
case .fragmentStart, .fragmentContinue, .fragmentEnd: case .fragmentStart, .fragmentContinue, .fragmentEnd:
let fragmentTypeStr = packet.type == 10 ? "START" : (packet.type == 11 ? "CONTINUE" : "END") let fragmentTypeStr = packet.type == 10 ? "START" : (packet.type == 11 ? "CONTINUE" : "END")
print("[PACKET] Handling fragment type: \(fragmentTypeStr) (\(packet.type)), payload size: \(packet.payload.count), from: \(peerID)") print("[PACKET] Handling fragment type: \(fragmentTypeStr) (\(packet.type)), payload size: \(packet.payload.count), from: \(peerID)")
@@ -706,8 +895,9 @@ class BluetoothMeshService: NSObject {
print("[FRAGMENT] Fragment ID: \(fragmentID.hexEncodedString())") print("[FRAGMENT] Fragment ID: \(fragmentID.hexEncodedString())")
print("[FRAGMENT] Original packet size: \(fullData.count) bytes") print("[FRAGMENT] Original packet size: \(fullData.count) bytes")
// Send fragments with delays to avoid congestion // Optimize fragment transmission for speed
let delayBetweenFragments: TimeInterval = 0.1 // 100ms between fragments // Use minimal delay for BLE 5.0 which supports better throughput
let delayBetweenFragments: TimeInterval = 0.02 // 20ms between fragments for faster transmission
for (index, fragmentData) in fragments.enumerated() { for (index, fragmentData) in fragments.enumerated() {
var fragmentPayload = Data() var fragmentPayload = Data()
@@ -876,22 +1066,44 @@ extension BluetoothMeshService: CBCentralManagerDelegate {
} }
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
// Optimize for 300m range - only connect to strong enough signals
let rssiValue = RSSI.intValue
// Filter out very weak signals (below -90 dBm) to save battery
guard rssiValue > -90 else { return }
// Throttle RSSI updates to save CPU
let peripheralID = peripheral.identifier.uuidString
if let lastUpdate = lastRSSIUpdate[peripheralID],
Date().timeIntervalSince(lastUpdate) < 1.0 {
return // Skip update if less than 1 second since last update
}
lastRSSIUpdate[peripheralID] = Date()
// Store RSSI by peripheral ID for later use // Store RSSI by peripheral ID for later use
peripheralRSSI[peripheral.identifier.uuidString] = RSSI peripheralRSSI[peripheralID] = RSSI
// Extract peer ID and store RSSI // Extract peer ID from name (no prefix for stealth)
if let name = peripheral.name, name.hasPrefix("bitchat-") { if let name = peripheral.name, name.count == 8 {
let peerID = String(name.dropFirst(8)) // Assume 8-character names are peer IDs
let peerID = name
peerRSSI[peerID] = RSSI peerRSSI[peerID] = RSSI
print("[BLUETOOTH] Discovered peer: \(peerID) with RSSI: \(RSSI), my ID: \(myPeerID)") print("[BLUETOOTH] Discovered potential peer: \(peerID) with RSSI: \(RSSI) dBm (range: ~\(estimateDistance(rssi: rssiValue))m)")
} }
// Connect to any device we discover - we'll filter by service later // Connect to any device we discover - we'll filter by service later
if !discoveredPeripherals.contains(peripheral) { if !discoveredPeripherals.contains(peripheral) {
discoveredPeripherals.append(peripheral) discoveredPeripherals.append(peripheral)
peripheral.delegate = self peripheral.delegate = self
central.connect(peripheral, options: nil)
// Use optimized connection parameters for better range
let connectionOptions: [String: Any] = [
CBConnectPeripheralOptionNotifyOnConnectionKey: true,
CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
CBConnectPeripheralOptionNotifyOnNotificationKey: true
]
central.connect(peripheral, options: connectionOptions)
} }
} }
@@ -962,8 +1174,12 @@ extension BluetoothMeshService: CBPeripheralDelegate {
peripheral.setNotifyValue(true, for: characteristic) peripheral.setNotifyValue(true, for: characteristic)
peripheralCharacteristics[peripheral] = characteristic peripheralCharacteristics[peripheral] = characteristic
// Request maximum MTU for faster data transfer
// iOS supports up to 512 bytes with BLE 5.0
peripheral.maximumWriteValueLength(for: .withoutResponse)
// Send key exchange and announce immediately without any delay // Send key exchange and announce immediately without any delay
let publicKeyData = self.encryptionService.publicKey.rawRepresentation let publicKeyData = self.encryptionService.getCombinedPublicKeyData()
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.keyExchange.rawValue, type: MessageType.keyExchange.rawValue,
ttl: 1, ttl: 1,
@@ -973,7 +1189,7 @@ extension BluetoothMeshService: CBPeripheralDelegate {
if let data = packet.toBinaryData() { if let data = packet.toBinaryData() {
peripheral.writeValue(data, for: characteristic, type: .withResponse) peripheral.writeValue(data, for: characteristic, type: .withResponse)
print("[KEY_EXCHANGE] Sent key exchange to peripheral as central") // Sent key exchange
} }
// Send announce packet immediately after key exchange // Send announce packet immediately after key exchange
@@ -990,7 +1206,7 @@ extension BluetoothMeshService: CBPeripheralDelegate {
payload: Data(vm.nickname.utf8) payload: Data(vm.nickname.utf8)
) )
self.broadcastPacket(announcePacket) self.broadcastPacket(announcePacket)
print("[KEY_EXCHANGE] Sent announce broadcast (delay: \(delay)s)") // [KEY_EXCHANGE] Sent announce broadcast
} }
} }
@@ -1040,7 +1256,7 @@ extension BluetoothMeshService: CBPeripheralDelegate {
if let error = error { if let error = error {
print("[PERIPHERAL] Write failed: \(error)") print("[PERIPHERAL] Write failed: \(error)")
} else { } else {
print("[PERIPHERAL] Write completed successfully") // Write completed
} }
} }
@@ -1122,7 +1338,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
if peerID != "unknown" && peerID != myPeerID { if peerID != "unknown" && peerID != myPeerID {
// Send key exchange back if we haven't already // Send key exchange back if we haven't already
if packet.type == MessageType.keyExchange.rawValue { if packet.type == MessageType.keyExchange.rawValue {
let publicKeyData = self.encryptionService.publicKey.rawRepresentation let publicKeyData = self.encryptionService.getCombinedPublicKeyData()
let responsePacket = BitchatPacket( let responsePacket = BitchatPacket(
type: MessageType.keyExchange.rawValue, type: MessageType.keyExchange.rawValue,
ttl: 1, ttl: 1,
@@ -1131,7 +1347,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
) )
if let data = responsePacket.toBinaryData() { if let data = responsePacket.toBinaryData() {
peripheral.updateValue(data, for: self.characteristic, onSubscribedCentrals: [request.central]) peripheral.updateValue(data, for: self.characteristic, onSubscribedCentrals: [request.central])
print("[KEY_EXCHANGE] Sent key exchange response as peripheral") // Sent key exchange response
} }
// Send announce immediately after key exchange // Send announce immediately after key exchange
@@ -1148,7 +1364,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
) )
if let data = announcePacket.toBinaryData() { if let data = announcePacket.toBinaryData() {
peripheral.updateValue(data, for: self.characteristic, onSubscribedCentrals: nil) peripheral.updateValue(data, for: self.characteristic, onSubscribedCentrals: nil)
print("[ANNOUNCE] Sent broadcast announce as peripheral (delay: \(delay)s)") // Sent announce
} }
} }
} }
@@ -1171,7 +1387,7 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
subscribedCentrals.append(central) subscribedCentrals.append(central)
// Send our public key to the newly connected central // Send our public key to the newly connected central
let publicKeyData = encryptionService.publicKey.rawRepresentation let publicKeyData = encryptionService.getCombinedPublicKeyData()
let keyPacket = BitchatPacket( let keyPacket = BitchatPacket(
type: MessageType.keyExchange.rawValue, type: MessageType.keyExchange.rawValue,
ttl: 1, ttl: 1,
+101 -16
View File
@@ -2,28 +2,114 @@ import Foundation
import CryptoKit import CryptoKit
class EncryptionService { class EncryptionService {
// Key agreement keys for encryption
private var privateKey: Curve25519.KeyAgreement.PrivateKey private var privateKey: Curve25519.KeyAgreement.PrivateKey
public let publicKey: Curve25519.KeyAgreement.PublicKey public let publicKey: Curve25519.KeyAgreement.PublicKey
// Signing keys for authentication
private var signingPrivateKey: Curve25519.Signing.PrivateKey
public let signingPublicKey: Curve25519.Signing.PublicKey
// Storage for peer keys
private var peerPublicKeys: [String: Curve25519.KeyAgreement.PublicKey] = [:] private var peerPublicKeys: [String: Curve25519.KeyAgreement.PublicKey] = [:]
private var peerSigningKeys: [String: Curve25519.Signing.PublicKey] = [:]
private var peerIdentityKeys: [String: Curve25519.Signing.PublicKey] = [:]
private var sharedSecrets: [String: SymmetricKey] = [:] private var sharedSecrets: [String: SymmetricKey] = [:]
// Persistent identity for favorites (separate from ephemeral keys)
private let identityKey: Curve25519.Signing.PrivateKey
public let identityPublicKey: Curve25519.Signing.PublicKey
init() { init() {
// Generate ephemeral key pairs for this session
self.privateKey = Curve25519.KeyAgreement.PrivateKey() self.privateKey = Curve25519.KeyAgreement.PrivateKey()
self.publicKey = privateKey.publicKey self.publicKey = privateKey.publicKey
self.signingPrivateKey = Curve25519.Signing.PrivateKey()
self.signingPublicKey = signingPrivateKey.publicKey
// Load or create persistent identity key
if let identityData = UserDefaults.standard.data(forKey: "bitchat.identityKey"),
let loadedKey = try? Curve25519.Signing.PrivateKey(rawRepresentation: identityData) {
self.identityKey = loadedKey
} else {
// First run - create and save identity key
self.identityKey = Curve25519.Signing.PrivateKey()
UserDefaults.standard.set(identityKey.rawRepresentation, forKey: "bitchat.identityKey")
}
self.identityPublicKey = identityKey.publicKey
} }
func addPeerPublicKey(_ peerID: String, publicKeyData: Data) throws { // Create combined public key data for exchange
let publicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: publicKeyData) func getCombinedPublicKeyData() -> Data {
peerPublicKeys[peerID] = publicKey var data = Data()
data.append(publicKey.rawRepresentation) // 32 bytes - ephemeral encryption key
data.append(signingPublicKey.rawRepresentation) // 32 bytes - ephemeral signing key
data.append(identityPublicKey.rawRepresentation) // 32 bytes - persistent identity key
return data // Total: 96 bytes
}
let sharedSecret = try privateKey.sharedSecretFromKeyAgreement(with: publicKey) // Add peer's combined public keys
let symmetricKey = sharedSecret.hkdfDerivedSymmetricKey( func addPeerPublicKey(_ peerID: String, publicKeyData: Data) throws {
using: SHA256.self, print("[CRYPTO] Received public key data of size: \(publicKeyData.count)")
salt: "bitchat-v1".data(using: .utf8)!,
sharedInfo: Data(), // Convert to array for safe access
outputByteCount: 32 let keyBytes = [UInt8](publicKeyData)
)
sharedSecrets[peerID] = symmetricKey if keyBytes.count == 96 {
// New format: 32 for key agreement + 32 for signing + 32 for identity
let keyAgreementData = Data(keyBytes[0..<32])
let signingKeyData = Data(keyBytes[32..<64])
let identityKeyData = Data(keyBytes[64..<96])
let publicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: keyAgreementData)
peerPublicKeys[peerID] = publicKey
let signingKey = try Curve25519.Signing.PublicKey(rawRepresentation: signingKeyData)
peerSigningKeys[peerID] = signingKey
let identityKey = try Curve25519.Signing.PublicKey(rawRepresentation: identityKeyData)
peerIdentityKeys[peerID] = identityKey
print("[CRYPTO] Stored all three keys for peer \(peerID)")
} else if keyBytes.count == 64 {
// Handle old format (64 bytes) for backward compatibility
print("[CRYPTO] Received old key format (64 bytes), no identity key")
let keyAgreementData = Data(keyBytes[0..<32])
let signingKeyData = Data(keyBytes[32..<64])
let publicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: keyAgreementData)
peerPublicKeys[peerID] = publicKey
let signingKey = try Curve25519.Signing.PublicKey(rawRepresentation: signingKeyData)
peerSigningKeys[peerID] = signingKey
} else {
print("[CRYPTO] Invalid public key data size: \(keyBytes.count)")
throw EncryptionError.invalidPublicKey
}
// Generate shared secret for encryption
if let publicKey = peerPublicKeys[peerID] {
let sharedSecret = try privateKey.sharedSecretFromKeyAgreement(with: publicKey)
let symmetricKey = sharedSecret.hkdfDerivedSymmetricKey(
using: SHA256.self,
salt: "bitchat-v1".data(using: .utf8)!,
sharedInfo: Data(),
outputByteCount: 32
)
sharedSecrets[peerID] = symmetricKey
}
}
// Get peer's persistent identity key for favorites
func getPeerIdentityKey(_ peerID: String) -> Data? {
return peerIdentityKeys[peerID]?.rawRepresentation
}
// Clear persistent identity (for panic mode)
func clearPersistentIdentity() {
UserDefaults.standard.removeObject(forKey: "bitchat.identityKey")
print("[CRYPTO] Cleared persistent identity key")
} }
func encrypt(_ data: Data, for peerID: String) throws -> Data { func encrypt(_ data: Data, for peerID: String) throws -> Data {
@@ -45,18 +131,17 @@ class EncryptionService {
} }
func sign(_ data: Data) throws -> Data { func sign(_ data: Data) throws -> Data {
let signingKey = try Curve25519.Signing.PrivateKey(rawRepresentation: privateKey.rawRepresentation) return try signingPrivateKey.signature(for: data)
return try signingKey.signature(for: data)
} }
func verify(_ signature: Data, for data: Data, from peerID: String) throws -> Bool { func verify(_ signature: Data, for data: Data, from peerID: String) throws -> Bool {
guard let peerPublicKey = peerPublicKeys[peerID] else { guard let verifyingKey = peerSigningKeys[peerID] else {
return false throw EncryptionError.noSharedSecret
} }
let verifyingKey = try Curve25519.Signing.PublicKey(rawRepresentation: peerPublicKey.rawRepresentation)
return verifyingKey.isValidSignature(signature, for: data) return verifyingKey.isValidSignature(signature, for: data)
} }
} }
enum EncryptionError: Error { enum EncryptionError: Error {
@@ -73,4 +73,12 @@ class NotificationService {
sendLocalNotification(title: title, body: body, identifier: identifier) sendLocalNotification(title: title, body: body, identifier: identifier)
} }
func sendFavoriteOnlineNotification(nickname: String) {
let title = "\(nickname) is online"
let body = "Your favorite peer just joined the chat"
let identifier = "favorite-online-\(UUID().uuidString)"
sendLocalNotification(title: title, body: body, identifier: identifier)
}
} }
+96 -106
View File
@@ -1,6 +1,7 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import Combine import Combine
import CryptoKit
#if os(iOS) #if os(iOS)
import UIKit import UIKit
#endif #endif
@@ -24,18 +25,19 @@ class ChatViewModel: ObservableObject {
@Published var showAutocomplete: Bool = false @Published var showAutocomplete: Bool = false
@Published var autocompleteRange: NSRange? = nil @Published var autocompleteRange: NSRange? = nil
@Published var selectedAutocompleteIndex: Int = 0 @Published var selectedAutocompleteIndex: Int = 0
@Published var privateMessageNotification: (sender: String, message: String)? = nil
let meshService = BluetoothMeshService() let meshService = BluetoothMeshService()
let audioRecorder = AudioRecordingService()
let audioPlayer = AudioPlaybackService()
private let userDefaults = UserDefaults.standard private let userDefaults = UserDefaults.standard
private let nicknameKey = "bitchat.nickname" private let nicknameKey = "bitchat.nickname"
private let favoritesKey = "bitchat.favorites" private let favoritesKey = "bitchat.favorites"
private var nicknameSaveTimer: Timer? private var nicknameSaveTimer: Timer?
private var notificationTimer: Timer?
@Published var favoritePeers: Set<String> = [] @Published var favoritePeers: Set<String> = [] // Now stores public key fingerprints instead of peer IDs
private var peerIDToPublicKeyFingerprint: [String: String] = [:] // Maps ephemeral peer IDs to persistent fingerprints
// Ephemeral message settings
private var messageAutoDeleteTimer: Timer?
private let messageRetentionTime: TimeInterval = 300 // 5 minutes
init() { init() {
loadNickname() loadNickname()
@@ -47,6 +49,9 @@ class ChatViewModel: ObservableObject {
// Request notification permission // Request notification permission
NotificationService.shared.requestAuthorization() NotificationService.shared.requestAuthorization()
// Start auto-delete timer for ephemeral messages
startAutoDeleteTimer()
} }
private func loadNickname() { private func loadNickname() {
@@ -78,16 +83,40 @@ class ChatViewModel: ObservableObject {
} }
func toggleFavorite(peerID: String) { func toggleFavorite(peerID: String) {
if favoritePeers.contains(peerID) { // Use public key fingerprints for persistent favorites
favoritePeers.remove(peerID) guard let fingerprint = peerIDToPublicKeyFingerprint[peerID] else {
print("[FAVORITES] No public key fingerprint for peer \(peerID)")
return
}
if favoritePeers.contains(fingerprint) {
favoritePeers.remove(fingerprint)
} else { } else {
favoritePeers.insert(peerID) favoritePeers.insert(fingerprint)
} }
saveFavorites() saveFavorites()
print("[FAVORITES] Toggled favorite for fingerprint: \(fingerprint)")
} }
func isFavorite(peerID: String) -> Bool { func isFavorite(peerID: String) -> Bool {
return favoritePeers.contains(peerID) guard let fingerprint = peerIDToPublicKeyFingerprint[peerID] else {
return false
}
return favoritePeers.contains(fingerprint)
}
// Called when we receive a peer's public key
func registerPeerPublicKey(peerID: String, publicKeyData: Data) {
// Create a fingerprint from the public key
let fingerprint = SHA256.hash(data: publicKeyData)
.compactMap { String(format: "%02x", $0) }
.joined()
.prefix(16) // Use first 16 chars for brevity
.lowercased()
peerIDToPublicKeyFingerprint[peerID] = String(fingerprint)
print("[FAVORITES] Registered fingerprint \(fingerprint) for peer \(peerID)")
} }
func sendMessage(_ content: String) { func sendMessage(_ content: String) {
@@ -168,17 +197,66 @@ class ChatViewModel: ObservableObject {
return nicknames.first(where: { $0.value == nickname })?.key return nicknames.first(where: { $0.value == nickname })?.key
} }
private func showPrivateMessageNotification(from sender: String, content: String) { // PANIC: Emergency data clearing for activist safety
// Show notification func panicClearAllData() {
privateMessageNotification = (sender: sender, message: content) // Clear all messages
messages.removeAll()
privateChats.removeAll()
unreadPrivateMessages.removeAll()
// Auto-dismiss after 3 seconds // Reset nickname to anonymous
notificationTimer?.invalidate() nickname = "anon\(Int.random(in: 1000...9999))"
notificationTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { [weak self] _ in saveNickname()
self?.privateMessageNotification = nil
// Clear favorites
favoritePeers.removeAll()
saveFavorites()
// Clear autocomplete state
autocompleteSuggestions.removeAll()
showAutocomplete = false
// Disconnect from all peers
meshService.emergencyDisconnectAll()
// Force UI update
objectWillChange.send()
print("[PANIC] All data cleared for safety")
}
// Ephemeral message auto-deletion
private func startAutoDeleteTimer() {
messageAutoDeleteTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
self?.deleteOldMessages()
} }
} }
private func deleteOldMessages() {
let cutoffTime = Date().addingTimeInterval(-messageRetentionTime)
// Delete old public messages
let beforeCount = messages.count
messages.removeAll { message in
message.timestamp < cutoffTime && message.sender != "system"
}
if messages.count < beforeCount {
print("[EPHEMERAL] Deleted \(beforeCount - messages.count) old messages")
}
// Delete old private messages
for (peerID, messageList) in privateChats {
let oldCount = messageList.count
privateChats[peerID] = messageList.filter { $0.timestamp >= cutoffTime }
if let newCount = privateChats[peerID]?.count, newCount < oldCount {
print("[EPHEMERAL] Deleted \(oldCount - newCount) old private messages from \(peerID)")
}
}
}
func formatTimestamp(_ date: Date) -> String { func formatTimestamp(_ date: Date) -> String {
let formatter = DateFormatter() let formatter = DateFormatter()
@@ -288,36 +366,6 @@ class ChatViewModel: ObservableObject {
} }
} }
func formatVoiceNoteContent(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
let isDark = colorScheme == .dark
let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
var result = AttributedString()
var contentStyle = AttributeContainer()
contentStyle.font = .system(size: 14, design: .monospaced)
contentStyle.foregroundColor = isDark ? Color.white : Color.black
// Parse the emoji and duration from content
let parts = message.content.components(separatedBy: " ")
if parts.count >= 2 {
// Emoji
result.append(AttributedString(parts[0] + " ").mergingAttributes(contentStyle))
// Play/pause button as text
let playSymbol = audioPlayer.isPlaying && audioPlayer.currentPlayingMessageID == message.id ? "" : ""
var playStyle = AttributeContainer()
playStyle.font = .system(size: 12, design: .monospaced)
playStyle.foregroundColor = primaryColor
result.append(AttributedString(playSymbol + " ").mergingAttributes(playStyle))
// Duration
result.append(AttributedString(parts[1]).mergingAttributes(contentStyle))
} else {
result.append(AttributedString(message.content).mergingAttributes(contentStyle))
}
return result
}
func formatMessageContent(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { func formatMessageContent(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
let isDark = colorScheme == .dark let isDark = colorScheme == .dark
@@ -403,33 +451,6 @@ class ChatViewModel: ObservableObject {
senderStyle.font = .system(size: 12, weight: .medium, design: .monospaced) senderStyle.font = .system(size: 12, weight: .medium, design: .monospaced)
result.append(sender.mergingAttributes(senderStyle)) result.append(sender.mergingAttributes(senderStyle))
// Special handling for voice notes
if message.voiceNoteData != nil {
// Add the content (emoji + duration) with play button integrated
var contentStyle = AttributeContainer()
contentStyle.font = .system(size: 14, design: .monospaced)
contentStyle.foregroundColor = isDark ? Color.white : Color.black
// Parse the emoji and duration from content
let parts = message.content.components(separatedBy: " ")
if parts.count >= 2 {
// Emoji
result.append(AttributedString(parts[0] + " ").mergingAttributes(contentStyle))
// Play/pause button as text
let playSymbol = audioPlayer.isPlaying && audioPlayer.currentPlayingMessageID == message.id ? "" : ""
var playStyle = AttributeContainer()
playStyle.font = .system(size: 12, design: .monospaced)
playStyle.foregroundColor = primaryColor
result.append(AttributedString(playSymbol + " ").mergingAttributes(playStyle))
// Duration
result.append(AttributedString(parts[1]).mergingAttributes(contentStyle))
} else {
result.append(AttributedString(message.content).mergingAttributes(contentStyle))
}
return result
}
// Process content to highlight mentions // Process content to highlight mentions
let contentText = message.content let contentText = message.content
@@ -510,8 +531,6 @@ extension ChatViewModel: BitchatDelegate {
if selectedPrivateChatPeer != peerID { if selectedPrivateChatPeer != peerID {
unreadPrivateMessages.insert(peerID) unreadPrivateMessages.insert(peerID)
// Show notification banner
showPrivateMessageNotification(from: message.sender, content: message.content)
} else { } else {
// We're viewing this chat, make sure unread is cleared // We're viewing this chat, make sure unread is cleared
unreadPrivateMessages.remove(peerID) unreadPrivateMessages.remove(peerID)
@@ -570,7 +589,7 @@ extension ChatViewModel: BitchatDelegate {
isConnected = true isConnected = true
let systemMessage = BitchatMessage( let systemMessage = BitchatMessage(
sender: "system", sender: "system",
content: "\(peerID) has joined the channel", content: "\(peerID) connected",
timestamp: Date(), timestamp: Date(),
isRelay: false, isRelay: false,
originalSender: nil originalSender: nil
@@ -584,7 +603,7 @@ extension ChatViewModel: BitchatDelegate {
func didDisconnectFromPeer(_ peerID: String) { func didDisconnectFromPeer(_ peerID: String) {
let systemMessage = BitchatMessage( let systemMessage = BitchatMessage(
sender: "system", sender: "system",
content: "\(peerID) has left the channel", content: "\(peerID) disconnected",
timestamp: Date(), timestamp: Date(),
isRelay: false, isRelay: false,
originalSender: nil originalSender: nil
@@ -633,33 +652,4 @@ extension ChatViewModel: BitchatDelegate {
return Array(Set(mentions)) // Remove duplicates return Array(Set(mentions)) // Remove duplicates
} }
func sendVoiceNote(_ audioData: Data, duration: TimeInterval) {
print("[VIEWMODEL] sendVoiceNote called with audio data: \(audioData.count) bytes, duration: \(duration)s")
let message = BitchatMessage(
sender: nickname,
content: "🎤 \(String(format: "%.1f", duration))s",
timestamp: Date(),
isRelay: false,
originalSender: nil,
voiceNoteData: audioData,
voiceNoteDuration: duration
)
messages.append(message)
print("[VIEWMODEL] Added voice note to local messages, sending via mesh...")
// Send via mesh
meshService.sendVoiceNote(audioData, duration: duration)
}
func playVoiceNote(message: BitchatMessage) {
guard let audioData = message.voiceNoteData else { return }
audioPlayer.togglePlayback(messageID: message.id, audioData: audioData) { result in
if case .failure(let error) = result {
print("[AUDIO] Failed to play voice note: \(error)")
}
}
}
} }
+54 -276
View File
@@ -7,9 +7,6 @@ struct ContentView: View {
@FocusState private var isTextFieldFocused: Bool @FocusState private var isTextFieldFocused: Bool
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@State private var showPeerList = false @State private var showPeerList = false
@State private var isRecordingVoice = false
@State private var recordingPulse = false
@State private var recordingScale: CGFloat = 1.0
@State private var showSidebar = false @State private var showSidebar = false
@State private var sidebarDragOffset: CGFloat = 0 @State private var sidebarDragOffset: CGFloat = 0
@@ -98,34 +95,6 @@ struct ContentView: View {
} }
} }
// Recording ripples overlay
if isRecordingVoice {
GeometryReader { geometry in
ZStack {
ForEach(0..<6) { index in
Circle()
.stroke(Color.red.opacity(0.4 - Double(index) * 0.05), lineWidth: 1.5)
.frame(width: 20, height: 20) // Start at mic button size
.scaleEffect(1 + (recordingScale - 1) * (1.0 - Double(index) * 0.1))
.opacity(max(0, 1.0 - ((recordingScale - 1) / 40.0) * (1.0 - Double(index) * 0.1)))
.position(
// Position at mic button location
x: geometry.size.width - 70, // Account for padding and button position
y: geometry.size.height - 32 // Account for input bar height
)
.animation(
Animation.easeOut(duration: 3.0)
.repeatForever(autoreverses: false)
.delay(Double(index) * 0.4),
value: recordingScale
)
}
}
}
.ignoresSafeArea()
.allowsHitTesting(false)
}
// Autocomplete overlay // Autocomplete overlay
if viewModel.showAutocomplete && !viewModel.autocompleteSuggestions.isEmpty { if viewModel.showAutocomplete && !viewModel.autocompleteSuggestions.isEmpty {
VStack { VStack {
@@ -159,39 +128,6 @@ struct ContentView: View {
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
} }
// Private message notification overlay
if let notification = viewModel.privateMessageNotification {
VStack {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Message from \(notification.sender)")
.font(.system(size: 14, weight: .medium, design: .monospaced))
.foregroundColor(.white)
Text(notification.message)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.white.opacity(0.9))
.lineLimit(2)
}
Spacer()
}
.padding()
.background(Color.orange)
.cornerRadius(8)
.shadow(radius: 4)
.padding(.horizontal)
.onTapGesture {
if let peerID = viewModel.getPeerIDForNickname(notification.sender) {
viewModel.startPrivateChat(with: peerID)
viewModel.privateMessageNotification = nil
}
}
Spacer()
}
.padding(.top, 60)
.transition(.move(edge: .top).combined(with: .opacity))
.animation(.easeInOut, value: viewModel.privateMessageNotification != nil)
}
} }
#if os(macOS) #if os(macOS)
.frame(minWidth: 600, minHeight: 400) .frame(minWidth: 600, minHeight: 400)
@@ -240,37 +176,51 @@ struct ContentView: View {
Text("bitchat*") Text("bitchat*")
.font(.system(size: 18, weight: .medium, design: .monospaced)) .font(.system(size: 18, weight: .medium, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
.onTapGesture(count: 3) {
Text("name:") // PANIC: Triple-tap to clear all data
.font(.system(size: 14, design: .monospaced)) viewModel.panicClearAllData()
.foregroundColor(secondaryTextColor)
TextField("nickname", text: $viewModel.nickname)
.textFieldStyle(.plain)
.font(.system(size: 14, design: .monospaced))
.frame(maxWidth: 100)
.foregroundColor(textColor)
.onChange(of: viewModel.nickname) { _ in
viewModel.saveNickname()
}
.onSubmit {
viewModel.saveNickname()
} }
HStack(spacing: 0) {
Text("@")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
TextField("nickname", text: $viewModel.nickname)
.textFieldStyle(.plain)
.font(.system(size: 14, design: .monospaced))
.frame(maxWidth: 100)
.foregroundColor(textColor)
.onChange(of: viewModel.nickname) { _ in
viewModel.saveNickname()
}
.onSubmit {
viewModel.saveNickname()
}
}
} }
Spacer() Spacer()
// People counter // People counter with unread indicator
let otherPeersCount = viewModel.connectedPeers.filter { $0 != viewModel.meshService.myPeerID }.count HStack(spacing: 4) {
Text(viewModel.isConnected ? "\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people")" : "alone :/") if !viewModel.unreadPrivateMessages.isEmpty {
.font(.system(size: 12, design: .monospaced)) Image(systemName: "envelope.fill")
.foregroundColor(viewModel.isConnected ? textColor : Color.red) .font(.system(size: 12))
.onTapGesture { .foregroundColor(Color.orange)
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
showSidebar.toggle()
sidebarDragOffset = 0
}
} }
let otherPeersCount = viewModel.connectedPeers.filter { $0 != viewModel.meshService.myPeerID }.count
Text(viewModel.isConnected ? "\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people")" : "alone :/")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(viewModel.isConnected ? textColor : Color.red)
}
.onTapGesture {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
showSidebar.toggle()
sidebarDragOffset = 0
}
}
} }
} }
.frame(height: 44) // Fixed height to prevent bouncing .frame(height: 44) // Fixed height to prevent bouncing
@@ -278,79 +228,6 @@ struct ContentView: View {
.background(backgroundColor.opacity(0.95)) .background(backgroundColor.opacity(0.95))
} }
private var peerStatusView: some View {
Menu {
if viewModel.connectedPeers.isEmpty {
Text("No people connected")
.font(.system(size: 12, design: .monospaced))
} else {
let peerNicknames = viewModel.meshService.getPeerNicknames()
let peerRSSI = viewModel.meshService.getPeerRSSI()
let myPeerID = viewModel.meshService.myPeerID
let _ = print("[UI DEBUG] connectedPeers: \(viewModel.connectedPeers), myPeerID: \(myPeerID), RSSI: \(peerRSSI)")
ForEach(viewModel.connectedPeers.filter { $0 != myPeerID }.sorted(), id: \.self) { peerID in
let displayName = peerNicknames[peerID] ?? "person-\(peerID.prefix(4))"
Button(action: {
// Only allow private chat if peer has announced
if peerNicknames[peerID] != nil {
viewModel.startPrivateChat(with: peerID)
}
}) {
HStack {
Text(displayName)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(peerNicknames[peerID] != nil ? textColor : secondaryTextColor)
Spacer()
if viewModel.unreadPrivateMessages.contains(peerID) {
Circle()
.fill(Color.orange)
.frame(width: 6, height: 6)
}
}
#if os(macOS)
.frame(minWidth: 120)
#endif
}
.buttonStyle(.plain)
.disabled(peerNicknames[peerID] == nil)
}
}
} label: {
HStack(spacing: 2) {
// Text
let otherPeersCount = viewModel.connectedPeers.filter { $0 != viewModel.meshService.myPeerID }.count
Text(viewModel.isConnected ? "\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people")" : "alone :/")
#if os(iOS)
.font(.system(size: 12, design: .monospaced))
#else
.font(.system(size: 14, design: .monospaced))
#endif
.foregroundColor(viewModel.isConnected ? textColor : Color.red)
#if os(iOS)
// Add chevron for iOS
Image(systemName: "chevron.down")
.font(.system(size: 10))
.foregroundColor(textColor.opacity(0.6))
#endif
// Notification indicator (on the right after default chevron)
if !viewModel.unreadPrivateMessages.isEmpty {
Circle()
.fill(Color.orange)
.frame(width: 5, height: 5)
}
}
.fixedSize()
}
#if os(macOS)
.menuStyle(.borderlessButton)
.menuIndicator(.visible)
#else
.menuStyle(.automatic)
#endif
}
private var messagesView: some View { private var messagesView: some View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
ScrollView { ScrollView {
@@ -401,20 +278,10 @@ struct ContentView: View {
Text(" ") Text(" ")
// Message content // Message content
if message.voiceNoteData != nil { // Regular text content
// Voice note with play button Text(viewModel.formatMessageContent(message, colorScheme: colorScheme))
Text(viewModel.formatVoiceNoteContent(message, colorScheme: colorScheme)) .font(.system(size: 14, design: .monospaced))
.font(.system(size: 14, design: .monospaced)) .fontWeight(isMentioned ? .bold : .regular)
.fontWeight(isMentioned ? .bold : .regular)
.onTapGesture {
viewModel.playVoiceNote(message: message)
}
} else {
// Regular text content
Text(viewModel.formatMessageContent(message, colorScheme: colorScheme))
.font(.system(size: 14, design: .monospaced))
.fontWeight(isMentioned ? .bold : .regular)
}
Spacer() Spacer()
} }
@@ -479,51 +346,10 @@ struct ContentView: View {
sendMessage() sendMessage()
} }
// Push to talk button
ZStack {
// Mic icon
Image(systemName: "mic.circle.fill")
.font(.system(size: 20))
.foregroundColor(isRecordingVoice ? Color.red.opacity(0.8) : textColor)
// Local ripples that start from the button itself
if isRecordingVoice {
ForEach(0..<4) { index in
Circle()
.stroke(Color.red.opacity(0.3), lineWidth: 1)
.frame(width: 20, height: 20)
.scaleEffect(1 + Double(index) * 0.5)
.opacity(isRecordingVoice ? 0.5 - Double(index) * 0.1 : 0)
.animation(
Animation.easeOut(duration: 1.5)
.repeatForever(autoreverses: false)
.delay(Double(index) * 0.2),
value: isRecordingVoice
)
}
}
}
.contentShape(Rectangle()) // Make entire area tappable
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
if !isRecordingVoice {
print("[UI] Starting voice recording")
startVoiceRecording()
}
}
.onEnded { _ in
if isRecordingVoice {
print("[UI] Stopping voice recording")
stopVoiceRecording()
}
}
)
Button(action: sendMessage) { Button(action: sendMessage) {
Image(systemName: "arrow.up.circle.fill") Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 20)) .font(.system(size: 20))
.foregroundColor(textColor) .foregroundColor(viewModel.selectedPrivateChatPeer != nil ? Color.orange : textColor)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.trailing, 12) .padding(.trailing, 12)
@@ -540,54 +366,6 @@ struct ContentView: View {
messageText = "" messageText = ""
} }
private func startVoiceRecording() {
isRecordingVoice = true
withAnimation(.easeOut(duration: 0.3)) {
recordingScale = 50.0 // Scale up to cover entire screen from mic button size
}
#if os(iOS)
// Light haptic feedback
let impactFeedback = UIImpactFeedbackGenerator(style: .light)
impactFeedback.impactOccurred()
#endif
viewModel.audioRecorder.startRecording { result in
if case .failure(let error) = result {
print("[UI] Failed to start recording: \(error)")
isRecordingVoice = false
recordingScale = 1.0
}
}
}
private func stopVoiceRecording() {
// Capture duration before stopping
let duration = viewModel.audioRecorder.recordingTime
isRecordingVoice = false
recordingScale = 1.0
viewModel.audioRecorder.stopRecording { result in
switch result {
case .success(let audioURL):
// Read audio file and send as voice note
do {
let audioData = try Data(contentsOf: audioURL)
print("[UI] Read audio file: \(audioData.count) bytes, duration: \(duration)s")
// Use the captured duration, ensure it's at least 0.1s
viewModel.sendVoiceNote(audioData, duration: max(0.1, duration))
// Clean up temporary file
try FileManager.default.removeItem(at: audioURL)
} catch {
print("[UI] Failed to read audio file: \(error)")
}
case .failure(let error):
print("[AUDIO] Recording failed: \(error)")
}
}
}
private var sidebarView: some View { private var sidebarView: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
// Grey vertical bar for visual continuity // Grey vertical bar for visual continuity
@@ -642,10 +420,16 @@ struct ContentView: View {
let isFavorite = viewModel.isFavorite(peerID: peerID) let isFavorite = viewModel.isFavorite(peerID: peerID)
HStack(spacing: 8) { HStack(spacing: 8) {
// Signal strength indicator // Signal strength indicator or unread message icon
Circle() if viewModel.unreadPrivateMessages.contains(peerID) {
.fill(viewModel.getRSSIColor(rssi: rssi, colorScheme: colorScheme)) Image(systemName: "envelope.fill")
.frame(width: 8, height: 8) .font(.system(size: 12))
.foregroundColor(Color.orange)
} else {
Circle()
.fill(viewModel.getRSSIColor(rssi: rssi, colorScheme: colorScheme))
.frame(width: 8, height: 8)
}
// Favorite star // Favorite star
Button(action: { Button(action: {
@@ -673,12 +457,6 @@ struct ContentView: View {
.foregroundColor(peerNicknames[peerID] != nil ? textColor : secondaryTextColor) .foregroundColor(peerNicknames[peerID] != nil ? textColor : secondaryTextColor)
Spacer() Spacer()
if viewModel.unreadPrivateMessages.contains(peerID) {
Circle()
.fill(Color.orange)
.frame(width: 8, height: 8)
}
} }
} }
.buttonStyle(.plain) .buttonStyle(.plain)