diff --git a/bitchat/Info.plist b/bitchat/Info.plist
index eb825de3..79b59dd7 100644
--- a/bitchat/Info.plist
+++ b/bitchat/Info.plist
@@ -26,6 +26,8 @@
bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.
NSBluetoothPeripheralUsageDescription
bitchat uses Bluetooth to discover and connect with other bitchat users nearby.
+ NSMicrophoneUsageDescription
+ bitchat needs access to your microphone to record voice notes.
UILaunchScreen
UIColorName
diff --git a/bitchat/Protocols/BinaryProtocol.swift b/bitchat/Protocols/BinaryProtocol.swift
index 2971ed6d..53214c75 100644
--- a/bitchat/Protocols/BinaryProtocol.swift
+++ b/bitchat/Protocols/BinaryProtocol.swift
@@ -172,7 +172,7 @@ extension BitchatMessage {
var data = Data()
// Message format:
- // - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID)
+ // - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions, bit 6: hasVoiceNote)
// - Timestamp: 8 bytes (seconds since epoch)
// - ID length: 1 byte
// - ID: variable
@@ -184,6 +184,8 @@ extension BitchatMessage {
// - Original sender length + data
// - Recipient nickname length + data
// - Sender peer ID length + data
+ // - Mentions array
+ // - Voice note duration (8 bytes, double) + data length (4 bytes) + data
var flags: UInt8 = 0
if isRelay { flags |= 0x01 }
@@ -192,6 +194,7 @@ extension BitchatMessage {
if recipientNickname != nil { flags |= 0x08 }
if senderPeerID != nil { flags |= 0x10 }
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
+ if voiceNoteData != nil { flags |= 0x40 }
data.append(flags)
@@ -258,6 +261,24 @@ 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
}
@@ -283,6 +304,7 @@ extension BitchatMessage {
let hasRecipientNickname = (flags & 0x08) != 0
let hasSenderPeerID = (flags & 0x10) != 0
let hasMentions = (flags & 0x20) != 0
+ let hasVoiceNote = (flags & 0x40) != 0
// Timestamp
guard offset + 8 <= dataCopy.count else {
@@ -380,6 +402,31 @@ extension BitchatMessage {
}
}
+ // Voice note data
+ var voiceNoteData: Data?
+ var voiceNoteDuration: TimeInterval?
+ if hasVoiceNote && offset + 12 <= dataCopy.count {
+ // Duration (8 bytes)
+ let durationData = dataCopy[offset.. 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) {
+ // Stop any current playback
+ stopPlayback()
+
+ // Create temporary file for audio data
+ let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("voice_note_\(messageID).m4a")
+
+ do {
+ try audioData.write(to: tempURL)
+ audioFiles[messageID] = tempURL
+
+ 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) {
+ 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()
+ }
+}
\ No newline at end of file
diff --git a/bitchat/Services/AudioRecordingService.swift b/bitchat/Services/AudioRecordingService.swift
new file mode 100644
index 00000000..036731ce
--- /dev/null
+++ b/bitchat/Services/AudioRecordingService.swift
@@ -0,0 +1,137 @@
+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 = 10.0
+
+ 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) -> Void) {
+ guard !isRecording else { return }
+
+ 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 {
+ audioRecorder = try AVAudioRecorder(url: audioFilename, settings: settings)
+ audioRecorder?.delegate = self
+ audioRecorder?.record()
+
+ isRecording = true
+ recordingStartTime = Date()
+ recordingTime = 0
+
+ // 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
+ completion(result)
+ }
+ }
+ }
+
+ } catch {
+ completion(.failure(error))
+ }
+ }
+
+ func stopRecording(completion: @escaping (Result) -> Void) {
+ guard isRecording, let recorder = audioRecorder else {
+ completion(.failure(AudioError.notRecording))
+ return
+ }
+
+ recorder.stop()
+ isRecording = false
+ recordingTimer?.invalidate()
+ recordingTimer = nil
+
+ let url = recorder.url
+ let _ = recordingTime
+
+ // Reset for next recording
+ audioRecorder = nil
+ recordingTime = 0
+ recordingStartTime = nil
+
+ // Verify file exists and has content
+ do {
+ let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
+ if let fileSize = attributes[.size] as? Int, fileSize > 0 {
+ completion(.success(url))
+ } else {
+ completion(.failure(AudioError.emptyRecording))
+ }
+ } catch {
+ 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
+
+ var errorDescription: String? {
+ switch self {
+ case .notRecording:
+ return "Not currently recording"
+ case .emptyRecording:
+ return "Recording produced no audio data"
+ }
+ }
+ }
+}
+
+extension AudioRecordingService: AVAudioRecorderDelegate {
+ func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) {
+ if !flag {
+ print("[AUDIO] Recording finished unsuccessfully")
+ }
+ }
+}
\ No newline at end of file
diff --git a/bitchat/Services/BluetoothMeshService.swift b/bitchat/Services/BluetoothMeshService.swift
index 096fa19c..b2887d78 100644
--- a/bitchat/Services/BluetoothMeshService.swift
+++ b/bitchat/Services/BluetoothMeshService.swift
@@ -179,6 +179,36 @@ class BluetoothMeshService: NSObject {
}
}
+ func sendVoiceNote(_ audioData: Data, duration: TimeInterval) {
+ messageQueue.async { [weak self] in
+ guard let self = self else { return }
+
+ let nickname = self.delegate as? ChatViewModel
+ let senderNick = nickname?.nickname ?? self.myPeerID
+
+ let message = BitchatMessage(
+ sender: senderNick,
+ content: "🎤 Voice note (\(String(format: "%.1f", duration))s)",
+ timestamp: Date(),
+ isRelay: false,
+ originalSender: nil,
+ voiceNoteData: audioData,
+ voiceNoteDuration: duration
+ )
+
+ if let messageData = message.toBinaryPayload() {
+ let packet = BitchatPacket(
+ type: MessageType.voiceNote.rawValue,
+ ttl: self.maxTTL,
+ senderID: self.myPeerID,
+ payload: messageData
+ )
+
+ self.broadcastPacket(packet)
+ }
+ }
+ }
+
func sendPrivateMessage(_ content: String, to recipientPeerID: String, recipientNickname: String) {
messageQueue.async { [weak self] in
guard let self = self else { return }
@@ -509,6 +539,29 @@ class BluetoothMeshService: NSObject {
}
}
+ 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)
+ }
+ }
+
default:
break
}
diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift
index 7bbe7bfb..3bc6ebbc 100644
--- a/bitchat/ViewModels/ChatViewModel.swift
+++ b/bitchat/ViewModels/ChatViewModel.swift
@@ -27,6 +27,8 @@ class ChatViewModel: ObservableObject {
@Published var privateMessageNotification: (sender: String, message: String)? = nil
let meshService = BluetoothMeshService()
+ let audioRecorder = AudioRecordingService()
+ let audioPlayer = AudioPlaybackService()
private let userDefaults = UserDefaults.standard
private let nicknameKey = "bitchat.nickname"
private var nicknameSaveTimer: Timer?
@@ -471,4 +473,30 @@ extension ChatViewModel: BitchatDelegate {
return Array(Set(mentions)) // Remove duplicates
}
+
+ func sendVoiceNote(_ audioData: Data, duration: TimeInterval) {
+ let message = BitchatMessage(
+ sender: nickname,
+ content: "🎤 Voice note (\(String(format: "%.1f", duration))s)",
+ timestamp: Date(),
+ isRelay: false,
+ originalSender: nil,
+ voiceNoteData: audioData,
+ voiceNoteDuration: duration
+ )
+ messages.append(message)
+
+ // 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)")
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift
index 2c1ab0ef..14a67db4 100644
--- a/bitchat/Views/ContentView.swift
+++ b/bitchat/Views/ContentView.swift
@@ -7,6 +7,7 @@ struct ContentView: View {
@FocusState private var isTextFieldFocused: Bool
@Environment(\.colorScheme) var colorScheme
@State private var showPeerList = false
+ @State private var isRecordingVoice = false
private var backgroundColor: Color {
colorScheme == .dark ? Color.black : Color.white
@@ -228,11 +229,32 @@ struct ContentView: View {
// Check if current user is mentioned
let isMentioned = message.mentions?.contains(viewModel.nickname) ?? false
- Text(viewModel.formatMessage(message, colorScheme: colorScheme))
- .font(.system(size: 14, design: .monospaced))
- .fontWeight(isMentioned ? .bold : .regular)
- .fixedSize(horizontal: false, vertical: true)
- .frame(maxWidth: .infinity, alignment: .leading)
+ if message.voiceNoteData != nil {
+ // Voice note message
+ HStack(spacing: 8) {
+ Button(action: {
+ viewModel.playVoiceNote(message: message)
+ }) {
+ Image(systemName: viewModel.audioPlayer.isPlaying && viewModel.audioPlayer.currentPlayingMessageID == message.id ? "pause.circle.fill" : "play.circle.fill")
+ .font(.system(size: 20))
+ .foregroundColor(textColor)
+ }
+ .buttonStyle(.plain)
+
+ Text(viewModel.formatMessage(message, colorScheme: colorScheme))
+ .font(.system(size: 14, design: .monospaced))
+ .fontWeight(isMentioned ? .bold : .regular)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ } else {
+ // Regular text message
+ Text(viewModel.formatMessage(message, colorScheme: colorScheme))
+ .font(.system(size: 14, design: .monospaced))
+ .fontWeight(isMentioned ? .bold : .regular)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
}
.padding(.horizontal, 12)
@@ -334,6 +356,27 @@ struct ContentView: View {
sendMessage()
}
+ // Push to talk button
+ Button(action: {}) {
+ Image(systemName: "mic.circle.fill")
+ .font(.system(size: 20))
+ .foregroundColor(isRecordingVoice ? Color.red : textColor)
+ }
+ .buttonStyle(.plain)
+ .simultaneousGesture(
+ DragGesture(minimumDistance: 0)
+ .onChanged { _ in
+ if !isRecordingVoice {
+ startVoiceRecording()
+ }
+ }
+ .onEnded { _ in
+ if isRecordingVoice {
+ stopVoiceRecording()
+ }
+ }
+ )
+
Button(action: sendMessage) {
Image(systemName: "arrow.right.circle.fill")
.font(.system(size: 20))
@@ -354,4 +397,37 @@ struct ContentView: View {
viewModel.sendMessage(messageText)
messageText = ""
}
+
+ private func startVoiceRecording() {
+ isRecordingVoice = true
+ #if os(iOS)
+ // Light haptic feedback
+ let impactFeedback = UIImpactFeedbackGenerator(style: .light)
+ impactFeedback.impactOccurred()
+ #endif
+
+ viewModel.audioRecorder.startRecording { result in
+ // Will handle result in stopVoiceRecording
+ }
+ }
+
+ private func stopVoiceRecording() {
+ isRecordingVoice = false
+
+ viewModel.audioRecorder.stopRecording { result in
+ switch result {
+ case .success(let audioURL):
+ // Read audio file and send as voice note
+ if let audioData = try? Data(contentsOf: audioURL) {
+ let duration = viewModel.audioRecorder.recordingTime
+ viewModel.sendVoiceNote(audioData, duration: duration)
+
+ // Clean up temporary file
+ try? FileManager.default.removeItem(at: audioURL)
+ }
+ case .failure(let error):
+ print("[AUDIO] Recording failed: \(error)")
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/project.yml b/project.yml
index 1ac80ac5..10c61dcf 100644
--- a/project.yml
+++ b/project.yml
@@ -26,6 +26,7 @@ targets:
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
NSBluetoothAlwaysUsageDescription: bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.
NSBluetoothPeripheralUsageDescription: bitchat uses Bluetooth to discover and connect with other bitchat users nearby.
+ NSMicrophoneUsageDescription: bitchat needs access to your microphone to record voice notes.
UIBackgroundModes:
- bluetooth-central
- bluetooth-peripheral