Implement favorites, notifications, and improve fragment handling

- Add favorites functionality for peers with persistent storage
  - Star icon to toggle favorites in sidebar
  - Favorites appear at top of peer list
  - Storage persists across app launches using peer ID

- Add local notifications for mentions and private messages
  - Notifications appear when app is in background
  - Separate notification types for mentions vs private messages
  - Request notification permissions on app launch

- Fix sidebar header alignment to match main toolbar (44pt)
  - Add background color to sidebar header for visual consistency

- Improve voice note fragment transmission
  - Send fragments in batches of 5 with delays to prevent congestion
  - Better logging to debug fragment transmission issues
  - Reduce delay between fragments from 50ms to batch-based timing

- Fix other UI issues
  - Ensure mic recording ripples originate from mic button
  - Update sidebar to swipe from right edge smoothly
This commit is contained in:
jack
2025-07-03 22:13:28 +02:00
parent 94a9888ef5
commit 2993fc3e06
11 changed files with 816 additions and 221 deletions
+26 -7
View File
@@ -11,13 +11,17 @@
1D9674FA5F998503831DC281 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08E03AA0C63E97C91749AEC /* ContentView.swift */; };
4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.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 */; };
739429DFDE5C5829CF70DA7D /* EncryptionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC1563390A15C042D059CF9 /* EncryptionService.swift */; };
7576A357B278E5733E9D9F33 /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */; };
7A50E2F04A3515A7E90EEAE4 /* BluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */; };
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 */; };
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 */; };
D450CF41F207BDE1A1AAA56E /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */; };
D948085736ED8E736C1DE3B0 /* BluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */; };
@@ -28,11 +32,13 @@
/* Begin PBXFileReference section */
229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatProtocol.swift; 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>"; };
7EEBDA723E1CFD88758DA4AC /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
997D512074C64904D75DDD40 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = "<group>"; };
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>"; };
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>"; };
@@ -99,6 +105,8 @@
D98A3186D7E4C72E35BDF7FE /* Services */ = {
isa = PBXGroup;
children = (
B90B7FFCA6DA409F3CB78704 /* AudioPlaybackService.swift */,
6C8801D22A8F987A7696BB90 /* AudioRecordingService.swift */,
D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */,
6DC1563390A15C042D059CF9 /* EncryptionService.swift */,
);
@@ -154,10 +162,11 @@
LastUpgradeCheck = 1430;
TargetAttributes = {
0576A29205865664C0937536 = {
DevelopmentTeam = "";
DevelopmentTeam = L3N5LHJD5Y;
ProvisioningStyle = Automatic;
};
AF077EA0474EDEDE2C72716C = {
DevelopmentTeam = L3N5LHJD5Y;
ProvisioningStyle = Automatic;
};
};
@@ -205,6 +214,8 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
6E628AC6B7E1754F3BD5090D /* AudioPlaybackService.swift in Sources */,
A7F37567974153A90E8C9F12 /* AudioRecordingService.swift in Sources */,
4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */,
6E7761E21C99F28AE2F9BE5F /* BitchatApp.swift in Sources */,
923027D6F2F417AFA2488127 /* BitchatProtocol.swift in Sources */,
@@ -219,6 +230,8 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
940A9CE820FBF517F0C7AA7E /* AudioPlaybackService.swift in Sources */,
817B221A5676873EAF5C5FA7 /* AudioRecordingService.swift in Sources */,
F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */,
10E68BB889356219189E38EC /* BitchatApp.swift in Sources */,
6DE056E1EE9850E9FBF50157 /* BitchatProtocol.swift in Sources */,
@@ -237,9 +250,10 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGNING_ALLOWED = YES;
CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = L3N5LHJD5Y;
ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = bitchat/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
@@ -248,7 +262,7 @@
"@executable_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
PRODUCT_BUNDLE_IDENTIFIER = com.bitchat.app;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.app;
PRODUCT_NAME = bitchat;
SDKROOT = iphoneos;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
@@ -262,9 +276,10 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGNING_ALLOWED = YES;
CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = L3N5LHJD5Y;
ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = bitchat/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
@@ -273,7 +288,7 @@
"@executable_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
PRODUCT_BUNDLE_IDENTIFIER = com.bitchat.app;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.app;
PRODUCT_NAME = bitchat;
SDKROOT = iphoneos;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
@@ -287,6 +302,8 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGNING_ALLOWED = YES;
CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
ENABLE_PREVIEWS = YES;
@@ -341,7 +358,7 @@
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = "";
DEVELOPMENT_TEAM = L3N5LHJD5Y;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -369,6 +386,8 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGNING_ALLOWED = YES;
CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
ENABLE_PREVIEWS = YES;
@@ -423,7 +442,7 @@
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = "";
DEVELOPMENT_TEAM = L3N5LHJD5Y;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
+5 -5
View File
@@ -28,16 +28,16 @@
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
<key>NSMicrophoneUsageDescription</key>
<string>bitchat needs access to your microphone to record voice notes.</string>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>Black</string>
</dict>
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
<string>bluetooth-peripheral</string>
</array>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>Black</string>
</dict>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
+3
View File
@@ -11,6 +11,9 @@ enum MessageType: UInt8 {
case leave = 0x07
case privateMessage = 0x08
case voiceNote = 0x09
case fragmentStart = 0x0A // First fragment of a large message
case fragmentContinue = 0x0B // Continuation fragment
case fragmentEnd = 0x0C // Last fragment
}
struct BitchatPacket: Codable {
@@ -31,13 +31,21 @@ class AudioPlaybackService: NSObject, ObservableObject {
// 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()
+78 -23
View File
@@ -8,7 +8,8 @@ class AudioRecordingService: NSObject, ObservableObject {
private var audioRecorder: AVAudioRecorder?
private var recordingTimer: Timer?
private var recordingStartTime: Date?
private let maxDuration: TimeInterval = 10.0
private let maxDuration: TimeInterval = 30.0
private var recordingCompletion: ((Result<URL, Error>) -> Void)?
override init() {
super.init()
@@ -30,6 +31,24 @@ class AudioRecordingService: NSObject, ObservableObject {
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")
@@ -42,27 +61,40 @@ class AudioRecordingService: NSObject, ObservableObject {
]
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?.record()
audioRecorder?.prepareToRecord() // Important: prepare before recording
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 audioRecorder?.record() == true {
isRecording = true
recordingStartTime = Date()
recordingTime = 0
if self.recordingTime >= self.maxDuration {
self.stopRecording { result in
completion(result)
// 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))
}
}
@@ -73,29 +105,46 @@ class AudioRecordingService: NSObject, ObservableObject {
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 _ = recordingTime
let recordedDuration = recordingTime
// Reset for next recording
audioRecorder = nil
recordingTime = 0
recordingStartTime = nil
recordingCompletion = 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))
// 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))
}
} catch {
completion(.failure(error))
}
}
@@ -116,6 +165,8 @@ class AudioRecordingService: NSObject, ObservableObject {
enum AudioError: LocalizedError {
case notRecording
case emptyRecording
case microphonePermissionDenied
case recordingFailed
var errorDescription: String? {
switch self {
@@ -123,6 +174,10 @@ class AudioRecordingService: NSObject, ObservableObject {
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"
}
}
}
+255 -5
View File
@@ -7,6 +7,16 @@ import AppKit
import UIKit
#endif
// Extension for hex encoding
extension Data {
func hexEncodedString() -> String {
if self.isEmpty {
return ""
}
return self.map { String(format: "%02x", $0) }.joined()
}
}
class BluetoothMeshService: NSObject {
static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C")
static let characteristicUUID = CBUUID(string: "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
@@ -31,6 +41,11 @@ class BluetoothMeshService: NSObject {
private var announcedToPeers = Set<String>() // Track which peers we've announced to
private var announcedPeers = Set<String>() // Track peers who have already been announced
// Fragment handling
private var incomingFragments: [String: [Int: Data]] = [:] // fragmentID -> [index: data]
private var fragmentMetadata: [String: (originalType: UInt8, totalFragments: Int, timestamp: Date)] = [:]
private let maxFragmentSize = 400 // Leave room for protocol overhead
let myPeerID: String
override init() {
@@ -183,12 +198,14 @@ class BluetoothMeshService: NSObject {
messageQueue.async { [weak self] in
guard let self = self else { return }
print("[VOICE] Sending voice note: audio size = \(audioData.count) bytes, duration = \(duration)s")
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)",
content: "🎤 \(String(format: "%.1f", duration))s",
timestamp: Date(),
isRelay: false,
originalSender: nil,
@@ -197,6 +214,7 @@ class BluetoothMeshService: NSObject {
)
if let messageData = message.toBinaryPayload() {
print("[VOICE] Message payload size: \(messageData.count) bytes")
let packet = BitchatPacket(
type: MessageType.voiceNote.rawValue,
ttl: self.maxTTL,
@@ -204,7 +222,18 @@ class BluetoothMeshService: NSObject {
payload: messageData
)
self.broadcastPacket(packet)
if let packetData = packet.toBinaryData() {
print("[VOICE] Final packet size: \(packetData.count) bytes")
if packetData.count > 512 {
print("[VOICE] WARNING: Packet size exceeds typical BLE MTU of 512 bytes!")
print("[VOICE] Fragmenting voice note into smaller packets...")
self.sendFragmentedPacket(packet)
} else {
self.broadcastPacket(packet)
}
} else {
self.broadcastPacket(packet)
}
}
}
}
@@ -340,7 +369,16 @@ class BluetoothMeshService: NSObject {
private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: String, peripheral: CBPeripheral? = nil) {
messageQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
guard packet.ttl > 0 else { return }
guard packet.ttl > 0 else {
print("[PACKET] Dropping packet with TTL 0")
return
}
// Validate packet has payload
guard !packet.payload.isEmpty else {
print("[PACKET] Dropping packet with empty payload")
return
}
let messageID = "\(packet.timestamp)-\(String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) ?? "")"
guard !processedMessages.contains(messageID) else {
@@ -562,11 +600,208 @@ class BluetoothMeshService: NSObject {
}
}
case .fragmentStart, .fragmentContinue, .fragmentEnd:
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)")
// Validate fragment has minimum required size
if packet.payload.count < 13 {
print("[PACKET] Fragment payload too small: \(packet.payload.count) bytes, dropping")
return
}
handleFragment(packet, from: peerID)
// Relay fragments if TTL > 0
var relayPacket = packet
relayPacket.ttl -= 1
if relayPacket.ttl > 0 {
print("[PACKET] Relaying fragment with TTL: \(relayPacket.ttl)")
self.broadcastPacket(relayPacket)
}
default:
break
}
}
}
private func sendFragmentedPacket(_ packet: BitchatPacket) {
guard let fullData = packet.toBinaryData() else { return }
// Generate a fixed 8-byte fragment ID
var fragmentID = Data(count: 8)
fragmentID.withUnsafeMutableBytes { bytes in
arc4random_buf(bytes.baseAddress, 8)
}
let fragments = stride(from: 0, to: fullData.count, by: maxFragmentSize).map { offset in
fullData[offset..<min(offset + maxFragmentSize, fullData.count)]
}
print("[FRAGMENT] Splitting into \(fragments.count) fragments of max \(maxFragmentSize) bytes")
print("[FRAGMENT] Fragment ID: \(fragmentID.hexEncodedString())")
print("[FRAGMENT] Original packet size: \(fullData.count) bytes")
// Send fragments in batches to avoid congestion
let batchSize = 5
let delayBetweenFragments: TimeInterval = 0.05 // 50ms between fragments
let delayBetweenBatches: TimeInterval = 0.2 // 200ms between batches
for (index, fragmentData) in fragments.enumerated() {
var fragmentPayload = Data()
// Fragment header: fragmentID (8) + index (2) + total (2) + originalType (1) + data
fragmentPayload.append(fragmentID)
fragmentPayload.append(UInt8((index >> 8) & 0xFF))
fragmentPayload.append(UInt8(index & 0xFF))
fragmentPayload.append(UInt8((fragments.count >> 8) & 0xFF))
fragmentPayload.append(UInt8(fragments.count & 0xFF))
fragmentPayload.append(packet.type)
fragmentPayload.append(fragmentData)
let fragmentType: MessageType
if index == 0 {
fragmentType = .fragmentStart
} else if index == fragments.count - 1 {
fragmentType = .fragmentEnd
} else {
fragmentType = .fragmentContinue
}
let fragmentPacket = BitchatPacket(
type: fragmentType.rawValue,
ttl: packet.ttl,
senderID: myPeerID,
payload: fragmentPayload
)
// Calculate delay based on batch
let batchNumber = index / batchSize
let indexInBatch = index % batchSize
let totalDelay = (Double(batchNumber) * delayBetweenBatches) + (Double(indexInBatch) * delayBetweenFragments)
// Send fragments on background queue with calculated delay
messageQueue.asyncAfter(deadline: .now() + totalDelay) { [weak self] in
self?.broadcastPacket(fragmentPacket)
print("[FRAGMENT] Sent fragment \(index + 1)/\(fragments.count) type: \(fragmentType) (batch \(batchNumber + 1))")
}
}
let totalTime = Double((fragments.count - 1) / batchSize) * delayBetweenBatches + Double((fragments.count - 1) % batchSize) * delayBetweenFragments
print("[FRAGMENT] Total send time: \(totalTime)s for \(fragments.count) fragments")
}
private func handleFragment(_ packet: BitchatPacket, from peerID: String) {
print("[FRAGMENT] Starting to handle fragment, payload size: \(packet.payload.count)")
guard packet.payload.count >= 13 else {
print("[FRAGMENT] Payload too small: \(packet.payload.count) bytes (need at least 13)")
return
}
// Convert to array for safer access
let payloadArray = Array(packet.payload)
var offset = 0
// Extract fragment ID as binary data (8 bytes)
guard payloadArray.count >= 8 else {
print("[FRAGMENT] Payload too small for fragment ID")
return
}
let fragmentIDData = Data(payloadArray[0..<8])
let fragmentID = fragmentIDData.hexEncodedString()
print("[FRAGMENT] Fragment ID: \(fragmentID)")
offset = 8
// Safely extract index
guard payloadArray.count >= offset + 2 else {
print("[FRAGMENT] Not enough data for index at offset \(offset)")
return
}
let index = Int(payloadArray[offset]) << 8 | Int(payloadArray[offset + 1])
offset += 2
print("[FRAGMENT] Index: \(index)")
// Safely extract total
guard payloadArray.count >= offset + 2 else {
print("[FRAGMENT] Not enough data for total at offset \(offset)")
return
}
let total = Int(payloadArray[offset]) << 8 | Int(payloadArray[offset + 1])
offset += 2
print("[FRAGMENT] Total fragments: \(total)")
// Safely extract original type
guard payloadArray.count >= offset + 1 else {
print("[FRAGMENT] Not enough data for type at offset \(offset)")
return
}
let originalType = payloadArray[offset]
offset += 1
print("[FRAGMENT] Original type: \(originalType)")
// Extract fragment data
let fragmentData: Data
if payloadArray.count > offset {
fragmentData = Data(payloadArray[offset...])
} else {
fragmentData = Data()
}
print("[FRAGMENT] Received fragment \(index + 1)/\(total) for ID: \(fragmentID), data size: \(fragmentData.count)")
// Initialize fragment collection if needed
if incomingFragments[fragmentID] == nil {
incomingFragments[fragmentID] = [:]
fragmentMetadata[fragmentID] = (originalType, total, Date())
print("[FRAGMENT] Started collecting fragments for ID: \(fragmentID), expecting \(total) fragments")
}
// Store fragment
incomingFragments[fragmentID]?[index] = fragmentData
print("[FRAGMENT] Progress for ID \(fragmentID): \(incomingFragments[fragmentID]?.count ?? 0)/\(total) fragments collected")
// Check if we have all fragments
if let fragments = incomingFragments[fragmentID],
fragments.count == total {
// Reassemble the original packet
var reassembledData = Data()
for i in 0..<total {
if let fragment = fragments[i] {
reassembledData.append(fragment)
} else {
print("[FRAGMENT] Missing fragment \(i) for ID: \(fragmentID)")
return
}
}
print("[FRAGMENT] Successfully reassembled \(total) fragments into \(reassembledData.count) bytes")
// Parse and handle the reassembled packet
if let reassembledPacket = BitchatPacket.from(reassembledData) {
// Clean up
incomingFragments.removeValue(forKey: fragmentID)
fragmentMetadata.removeValue(forKey: fragmentID)
// Handle the reassembled packet
handleReceivedPacket(reassembledPacket, from: peerID, peripheral: nil)
}
}
// Clean up old fragments (older than 30 seconds)
let cutoffTime = Date().addingTimeInterval(-30)
for (fragID, metadata) in fragmentMetadata {
if metadata.timestamp < cutoffTime {
incomingFragments.removeValue(forKey: fragID)
fragmentMetadata.removeValue(forKey: fragID)
print("[FRAGMENT] Cleaned up expired fragments for ID: \(fragID)")
}
}
}
}
extension BluetoothMeshService: CBCentralManagerDelegate {
@@ -717,8 +952,13 @@ extension BluetoothMeshService: CBPeripheralDelegate {
}
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
guard let data = characteristic.value,
let packet = BitchatPacket.from(data) else {
guard let data = characteristic.value else {
print("[PERIPHERAL] No data in characteristic")
return
}
guard let packet = BitchatPacket.from(data) else {
print("[PERIPHERAL] Failed to parse packet from data of size: \(data.count)")
return
}
@@ -726,6 +966,14 @@ extension BluetoothMeshService: CBPeripheralDelegate {
let localPeerID = connectedPeripherals.first(where: { $0.value == peripheral })?.key ?? "unknown"
let packetSenderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) ?? "unknown"
print("[PERIPHERAL] Received data from localPeerID: \(localPeerID), packetSenderID: \(packetSenderID), packet type: \(packet.type)")
// Handle fragments directly if it's a fragment type
if packet.type == MessageType.fragmentStart.rawValue ||
packet.type == MessageType.fragmentContinue.rawValue ||
packet.type == MessageType.fragmentEnd.rawValue {
print("[PERIPHERAL] Processing fragment directly")
}
handleReceivedPacket(packet, from: packetSenderID, peripheral: peripheral)
}
@@ -797,6 +1045,8 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
// Try to identify peer from packet
let peerID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) ?? "unknown"
print("[PERIPHERAL_MANAGER] Received write from peer: \(peerID), packet type: \(packet.type)")
// Store the central for updates
if !subscribedCentrals.contains(request.central) {
subscribedCentrals.append(request.central)
@@ -0,0 +1,69 @@
import Foundation
#if os(iOS)
import UIKit
import UserNotifications
#else
import UserNotifications
#endif
class NotificationService {
static let shared = NotificationService()
private init() {}
func requestAuthorization() {
#if os(iOS)
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
if granted {
print("[NOTIFICATIONS] Permission granted")
} else if let error = error {
print("[NOTIFICATIONS] Permission error: \(error)")
}
}
#endif
}
func sendLocalNotification(title: String, body: String, identifier: String) {
#if os(iOS)
guard UIApplication.shared.applicationState == .background else {
print("[NOTIFICATIONS] App is in foreground, skipping notification")
return
}
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
let request = UNNotificationRequest(
identifier: identifier,
content: content,
trigger: nil // Deliver immediately
)
UNUserNotificationCenter.current().add(request) { error in
if let error = error {
print("[NOTIFICATIONS] Error sending notification: \(error)")
} else {
print("[NOTIFICATIONS] Notification sent: \(title)")
}
}
#endif
}
func sendMentionNotification(from sender: String, message: String) {
let title = "Mentioned by \(sender)"
let body = message
let identifier = "mention-\(UUID().uuidString)"
sendLocalNotification(title: title, body: body, identifier: identifier)
}
func sendPrivateMessageNotification(from sender: String, message: String) {
let title = "Private message from \(sender)"
let body = message
let identifier = "private-\(UUID().uuidString)"
sendLocalNotification(title: title, body: body, identifier: identifier)
}
}
+67 -5
View File
@@ -31,15 +31,22 @@ class ChatViewModel: ObservableObject {
let audioPlayer = AudioPlaybackService()
private let userDefaults = UserDefaults.standard
private let nicknameKey = "bitchat.nickname"
private let favoritesKey = "bitchat.favorites"
private var nicknameSaveTimer: Timer?
private var notificationTimer: Timer?
@Published var favoritePeers: Set<String> = []
init() {
loadNickname()
loadFavorites()
meshService.delegate = self
// Start mesh service immediately
meshService.startServices()
// Request notification permission
NotificationService.shared.requestAuthorization()
}
private func loadNickname() {
@@ -56,6 +63,30 @@ class ChatViewModel: ObservableObject {
userDefaults.synchronize() // Force immediate save
}
private func loadFavorites() {
if let savedFavorites = userDefaults.stringArray(forKey: favoritesKey) {
favoritePeers = Set(savedFavorites)
}
}
private func saveFavorites() {
userDefaults.set(Array(favoritePeers), forKey: favoritesKey)
userDefaults.synchronize()
}
func toggleFavorite(peerID: String) {
if favoritePeers.contains(peerID) {
favoritePeers.remove(peerID)
} else {
favoritePeers.insert(peerID)
}
saveFavorites()
}
func isFavorite(peerID: String) -> Bool {
return favoritePeers.contains(peerID)
}
func sendMessage(_ content: String) {
guard !content.isEmpty else { return }
@@ -200,10 +231,9 @@ class ChatViewModel: ObservableObject {
let partial = String(beforeCursor[range]).lowercased()
// Get all available nicknames
// Get all available nicknames (excluding self)
let peerNicknames = meshService.getPeerNicknames()
var allNicknames = Array(peerNicknames.values)
allNicknames.append(nickname) // Include self
let allNicknames = Array(peerNicknames.values)
// Filter suggestions
let suggestions = allNicknames.filter { nick in
@@ -279,6 +309,34 @@ class ChatViewModel: ObservableObject {
senderStyle.font = .system(size: 12, weight: .medium, design: .monospaced)
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
let contentText = message.content
var processedContent = AttributedString()
@@ -376,8 +434,10 @@ extension ChatViewModel: BitchatDelegate {
// Check if we're mentioned
let isMentioned = message.mentions?.contains(nickname) ?? false
// Different haptic feedback for mentions, private messages, and regular messages
// Send notifications for mentions and private messages when app is in background
if isMentioned && message.sender != nickname {
NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content)
// Very prominent haptic for @mentions - triple tap with heavy impact
let impactFeedback = UIImpactFeedbackGenerator(style: .heavy)
impactFeedback.prepare()
@@ -390,6 +450,8 @@ extension ChatViewModel: BitchatDelegate {
impactFeedback.impactOccurred()
}
} else if message.isPrivate && message.sender != nickname {
NotificationService.shared.sendPrivateMessageNotification(from: message.sender, message: message.content)
// Heavy haptic for private messages - more pronounced
let impactFeedback = UIImpactFeedbackGenerator(style: .heavy)
impactFeedback.prepare()
@@ -477,7 +539,7 @@ extension ChatViewModel: BitchatDelegate {
func sendVoiceNote(_ audioData: Data, duration: TimeInterval) {
let message = BitchatMessage(
sender: nickname,
content: "🎤 Voice note (\(String(format: "%.1f", duration))s)",
content: "🎤 \(String(format: "%.1f", duration))s",
timestamp: Date(),
isRelay: false,
originalSender: nil,
+302 -93
View File
@@ -8,6 +8,10 @@ struct ContentView: View {
@Environment(\.colorScheme) var colorScheme
@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 sidebarDragOffset: CGFloat = 0
private var backgroundColor: Color {
colorScheme == .dark ? Color.black : Color.white
@@ -23,15 +27,134 @@ struct ContentView: View {
var body: some View {
ZStack {
VStack(spacing: 0) {
headerView
Divider()
messagesView
Divider()
inputView
// Main content
GeometryReader { geometry in
ZStack {
VStack(spacing: 0) {
headerView
Divider()
messagesView
Divider()
inputView
}
.background(backgroundColor)
.foregroundColor(textColor)
.gesture(
DragGesture()
.onChanged { value in
// Only respond to leftward swipes when sidebar is closed
// or rightward swipes when sidebar is open
if !showSidebar && value.translation.width < 0 {
sidebarDragOffset = max(value.translation.width, -geometry.size.width * 0.7)
} else if showSidebar && value.translation.width > 0 {
sidebarDragOffset = min(-geometry.size.width * 0.7 + value.translation.width, 0)
}
}
.onEnded { value in
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
if !showSidebar {
// Opening gesture (swipe left)
if value.translation.width < -100 || (value.translation.width < -50 && value.velocity.width < -500) {
showSidebar = true
sidebarDragOffset = 0
} else {
sidebarDragOffset = 0
}
} else {
// Closing gesture (swipe right)
if value.translation.width > 100 || (value.translation.width > 50 && value.velocity.width > 500) {
showSidebar = false
sidebarDragOffset = 0
} else {
sidebarDragOffset = 0
}
}
}
}
)
// Sidebar overlay
HStack(spacing: 0) {
// Tap to dismiss area
Color.black.opacity(showSidebar ? 0.3 : 0.3 * (-sidebarDragOffset / (geometry.size.width * 0.7)))
.onTapGesture {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
showSidebar = false
sidebarDragOffset = 0
}
}
sidebarView
.frame(width: geometry.size.width * 0.7)
.transition(.move(edge: .trailing))
}
.offset(x: showSidebar ? -sidebarDragOffset : geometry.size.width - sidebarDragOffset)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: showSidebar)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: sidebarDragOffset)
}
}
// 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
if viewModel.showAutocomplete && !viewModel.autocompleteSuggestions.isEmpty {
VStack {
Spacer()
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(viewModel.autocompleteSuggestions.enumerated()), id: \.element) { index, suggestion in
Button(action: {
_ = viewModel.completeNickname(suggestion, in: &messageText)
}) {
HStack {
Text("@\(suggestion)")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(index == viewModel.selectedAutocompleteIndex ? backgroundColor : textColor)
Spacer()
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(index == viewModel.selectedAutocompleteIndex ? textColor : Color.clear)
}
.buttonStyle(.plain)
}
}
.background(backgroundColor)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(secondaryTextColor.opacity(0.5), lineWidth: 1)
)
.frame(maxWidth: 200, alignment: .leading)
.offset(x: 100) // Align with input field
.padding(.bottom, 45) // Position just above input
.padding(.horizontal, 12)
}
}
.background(backgroundColor)
.foregroundColor(textColor)
// Private message notification overlay
if let notification = viewModel.privateMessageNotification {
@@ -108,25 +231,18 @@ struct ContentView: View {
.opacity(0)
} else {
// Public chat header
HStack(spacing: 8) {
HStack(spacing: 4) {
Text("bitchat")
.font(.system(size: 18, weight: .medium, design: .monospaced))
.foregroundColor(textColor)
// Peer status section
peerStatusView
}
Spacer()
HStack(spacing: 4) {
Text("name:")
.font(.system(size: 11, design: .monospaced))
.font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
TextField("nickname", text: $viewModel.nickname)
.textFieldStyle(.plain)
.font(.system(size: 12, design: .monospaced))
.font(.system(size: 14, design: .monospaced))
.frame(maxWidth: 100)
.foregroundColor(textColor)
.onChange(of: viewModel.nickname) { _ in
@@ -136,6 +252,20 @@ struct ContentView: View {
viewModel.saveNickname()
}
}
Spacer()
// People counter
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
@@ -184,7 +314,7 @@ struct ContentView: View {
HStack(spacing: 2) {
// Text
let otherPeersCount = viewModel.connectedPeers.filter { $0 != viewModel.meshService.myPeerID }.count
Text(viewModel.isConnected ? "\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people")" : "scanning")
Text(viewModel.isConnected ? "\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people")" : "alone :/")
#if os(iOS)
.font(.system(size: 12, design: .monospaced))
#else
@@ -229,32 +359,16 @@ struct ContentView: View {
// Check if current user is mentioned
let isMentioned = message.mentions?.contains(viewModel.nickname) ?? false
if message.voiceNoteData != nil {
// Voice note message
HStack(spacing: 8) {
Button(action: {
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)
.onTapGesture {
if message.voiceNoteData != nil {
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)
@@ -285,61 +399,21 @@ struct ContentView: View {
}
private var inputView: some View {
ZStack(alignment: .bottom) {
VStack(spacing: 0) {
// Autocomplete suggestions overlay
if viewModel.showAutocomplete && !viewModel.autocompleteSuggestions.isEmpty {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(viewModel.autocompleteSuggestions.enumerated()), id: \.element) { index, suggestion in
Button(action: {
_ = viewModel.completeNickname(suggestion, in: &messageText)
}) {
HStack {
Text("@\(suggestion)")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(index == viewModel.selectedAutocompleteIndex ? backgroundColor : textColor)
Spacer()
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(index == viewModel.selectedAutocompleteIndex ? textColor : Color.clear)
}
.buttonStyle(.plain)
}
}
.background(backgroundColor)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(secondaryTextColor.opacity(0.5), lineWidth: 1)
)
.frame(maxWidth: 200, alignment: .leading)
.padding(.leading, 100) // Align with input field
.padding(.bottom, 4)
}
Spacer()
}
HStack(spacing: 4) {
Text("[\(viewModel.formatTimestamp(Date()))]")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(secondaryTextColor)
.lineLimit(1)
.fixedSize()
.padding(.leading, 12)
HStack(alignment: .center, spacing: 4) {
if viewModel.selectedPrivateChatPeer != nil {
Text("<\(viewModel.nickname)> →")
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundColor(Color.orange)
.lineLimit(1)
.fixedSize()
.padding(.leading, 12)
} else {
Text("<\(viewModel.nickname)>")
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundColor(textColor)
.lineLimit(1)
.fixedSize()
.padding(.leading, 12)
}
TextField("", text: $messageText)
@@ -358,9 +432,29 @@ struct ContentView: View {
// Push to talk button
Button(action: {}) {
Image(systemName: "mic.circle.fill")
.font(.system(size: 20))
.foregroundColor(isRecordingVoice ? Color.red : textColor)
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
)
}
}
}
}
.buttonStyle(.plain)
.simultaneousGesture(
@@ -378,16 +472,15 @@ struct ContentView: View {
)
Button(action: sendMessage) {
Image(systemName: "arrow.right.circle.fill")
Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 20))
.foregroundColor(textColor)
}
.buttonStyle(.plain)
.padding(.trailing, 12)
}
.padding(.vertical, 10)
.padding(.vertical, 8)
.background(backgroundColor.opacity(0.95))
}
.onAppear {
isTextFieldFocused = true
}
@@ -400,6 +493,9 @@ struct ContentView: View {
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)
@@ -412,15 +508,18 @@ struct ContentView: View {
}
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
if let audioData = try? Data(contentsOf: audioURL) {
let duration = viewModel.audioRecorder.recordingTime
viewModel.sendVoiceNote(audioData, duration: duration)
// 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)
@@ -430,4 +529,114 @@ struct ContentView: View {
}
}
}
private var sidebarView: some View {
HStack(spacing: 0) {
// Grey vertical bar for visual continuity
Rectangle()
.fill(Color.gray.opacity(0.3))
.frame(width: 1)
VStack(alignment: .leading, spacing: 0) {
// Header - match main toolbar height
HStack {
Text("connected")
.font(.system(size: 16, weight: .bold, design: .monospaced))
.foregroundColor(textColor)
Spacer()
}
.frame(height: 44) // Match header height
.padding(.horizontal, 12)
.background(backgroundColor.opacity(0.95))
Divider()
// People list
ScrollView {
VStack(alignment: .leading, spacing: 8) {
if viewModel.connectedPeers.isEmpty {
Text("No one connected")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.padding(.horizontal)
} else {
let peerNicknames = viewModel.meshService.getPeerNicknames()
let peerRSSI = viewModel.meshService.getPeerRSSI()
let myPeerID = viewModel.meshService.myPeerID
// Sort peers: favorites first, then alphabetically by nickname
let sortedPeers = viewModel.connectedPeers.filter { $0 != myPeerID }.sorted { peer1, peer2 in
let isFav1 = viewModel.isFavorite(peerID: peer1)
let isFav2 = viewModel.isFavorite(peerID: peer2)
if isFav1 != isFav2 {
return isFav1 // Favorites come first
}
let name1 = peerNicknames[peer1] ?? "person-\(peer1.prefix(4))"
let name2 = peerNicknames[peer2] ?? "person-\(peer2.prefix(4))"
return name1 < name2
}
ForEach(sortedPeers, id: \.self) { peerID in
let displayName = peerNicknames[peerID] ?? "person-\(peerID.prefix(4))"
let rssi = peerRSSI[peerID]?.intValue ?? -100
let isFavorite = viewModel.isFavorite(peerID: peerID)
HStack(spacing: 8) {
// Signal strength indicator
Circle()
.fill(viewModel.getRSSIColor(rssi: rssi, colorScheme: colorScheme))
.frame(width: 8, height: 8)
// Favorite star
Button(action: {
viewModel.toggleFavorite(peerID: peerID)
}) {
Image(systemName: isFavorite ? "star.fill" : "star")
.font(.system(size: 12))
.foregroundColor(isFavorite ? Color.yellow : secondaryTextColor)
}
.buttonStyle(.plain)
// Peer name button
Button(action: {
if peerNicknames[peerID] != nil {
viewModel.startPrivateChat(with: peerID)
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
showSidebar = false
sidebarDragOffset = 0
}
}
}) {
HStack {
Text(displayName)
.font(.system(size: 14, design: .monospaced))
.foregroundColor(peerNicknames[peerID] != nil ? textColor : secondaryTextColor)
Spacer()
if viewModel.unreadPrivateMessages.contains(peerID) {
Circle()
.fill(Color.orange)
.frame(width: 8, height: 8)
}
}
}
.buttonStyle(.plain)
.disabled(peerNicknames[peerID] == nil)
}
.padding(.horizontal)
.padding(.vertical, 8)
}
}
}
.padding(.vertical, 8)
}
Spacer()
}
.background(backgroundColor)
}
}
}
-82
View File
@@ -1,82 +0,0 @@
# Bitchat Issues Analysis
## Issue 1: Background Messages on iOS Stopped Working
### Root Cause
The Info.plist file is missing the required `UIBackgroundModes` configuration for Bluetooth background operation. Without this, iOS will suspend the app when it goes to background, preventing BLE message delivery.
### Required Info.plist Entries
```xml
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
<string>bluetooth-peripheral</string>
</array>
```
### Protocol Size Analysis
The binary protocol is significantly smaller than JSON:
- **Message packet**: 71 bytes (binary) vs 189 bytes (JSON) - 62% reduction
- **Announce packet**: 29 bytes (binary) vs 96 bytes (JSON) - 69% reduction
While the binary protocol is more efficient, the size reduction alone shouldn't break background delivery. The missing background modes configuration is the primary issue.
### Additional Considerations
- In `BluetoothMeshService.swift`, messages are sent with `.withResponse` type (line 281), which is good for reliability
- The app uses both central and peripheral modes, so both background modes are needed
## Issue 2: RSSI Indicator Dots Not Showing
### Analysis
The RSSI dots ARE implemented in `ContentView.swift` (lines 164-175), but there are several issues:
1. **RSSI Collection**: RSSI values are being collected and stored in the `peerRSSI` dictionary (line 504, 659)
2. **UI Implementation**: The dots are properly implemented with color coding based on signal strength
3. **Timing Issue**: The RSSI is read after connection (line 527) and periodically updated (line 665)
### Potential Issues
- The RSSI might not be available immediately when the peer list is displayed
- The peer ID mapping might be using temporary IDs initially (line 650-656 shows handling of temp IDs)
- The UI might not be updating when RSSI values are set
## Issue 3: Peer Nicknames Not Showing Until Message
### Analysis
The nickname flow works as follows:
1. **Connection**: When peers connect, they exchange keys first
2. **Announce Timing**: Announce packets are sent:
- After key exchange as central (lines 599-617)
- After key exchange as peripheral (lines 719-737)
- Targeted announce when receiving key exchange (line 362)
3. **Display Logic**: In `ContentView.swift` (line 155), peers show as "person-[ID]" until nickname is received
### Issues Found
1. **Race Condition**: The announce packet is sent immediately after key exchange, but there's a 0.1s delay for the broadcast announce (line 602)
2. **Peer ID Mapping**: Temporary peripheral IDs are used until the real peer ID is received (lines 521-524)
3. **Announce Tracking**: The `announcedToPeers` set prevents re-announcing (line 221), which could be problematic if the first announce fails
### The Real Problem
Looking at lines 384-391 and 648-656, there's a complex mapping issue where peripherals are initially stored with their system UUID, then remapped when the real peer ID is learned. This remapping happens during announce or key exchange, but the UI might be showing peers before this remapping completes.
## Recommendations
### 1. Fix Background Modes (Priority 1)
Add the missing background modes to Info.plist to restore background BLE functionality.
### 2. Fix RSSI Display (Priority 2)
- Ensure RSSI reading starts immediately after getting the real peer ID
- Force UI update when RSSI values change
- Consider showing a placeholder dot (gray) while RSSI is being read
### 3. Fix Nickname Display (Priority 3)
- Send multiple announce packets with retries to ensure delivery
- Consider sending announce both as broadcast and targeted to ensure all peers receive it
- Improve the peer ID mapping to handle the transition from temp ID to real ID more smoothly
- Add debugging to track announce packet delivery success
### 4. Additional Improvements
- Add retry logic for critical packets (announce, key exchange)
- Implement packet acknowledgment for announce messages
- Add connection state tracking to better handle the peer discovery flow
+3 -1
View File
@@ -9,7 +9,7 @@ options:
settings:
MARKETING_VERSION: 1.0.0
CURRENT_PROJECT_VERSION: 1
DEVELOPMENT_TEAM: ""
DEVELOPMENT_TEAM: "L3N5LHJD5Y"
targets:
bitchat:
@@ -46,5 +46,7 @@ targets:
MACOSX_DEPLOYMENT_TARGET: 13.0
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: YES
CODE_SIGN_STYLE: Automatic
CODE_SIGNING_REQUIRED: YES
CODE_SIGNING_ALLOWED: YES
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: YES