Compare commits

..
Author SHA1 Message Date
jack ca420567b3 Refactor sheet presentations to use parent-child pattern
Consolidates 9 competing sheet modifiers into a single unified system
with a SheetType enum and priority-based selection. Implements parent-child
presentation pattern for image picker as suggested by @qalandarov in PR #834:
- Main view presents image picker when NOT in a sheet
- DM/people sheet presents image picker when IN a sheet

Benefits:
- Simpler code without complex conditional bindings
- Better architecture matching UI hierarchy
- Easier to maintain with clear separation of concerns
- Type-safe enum-based sheet management

Addresses feedback on PR #834.
2025-10-19 14:58:31 +02:00
32 changed files with 1459 additions and 1844 deletions
@@ -98,8 +98,8 @@
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "BITCHAT_LOG_LEVEL"
value = "debug"
key = "-DBITCHAT_DEV_ALLOW_CLEARNET"
value = ""
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
+1 -1
View File
@@ -246,7 +246,7 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
// Get peer ID from userInfo
if let peerID = userInfo["peerID"] as? String {
// Don't show notification if the private chat is already open
if chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) {
if chatViewModel?.selectedPrivateChatPeer == peerID {
completionHandler([])
return
}
+5 -5
View File
@@ -13,10 +13,10 @@ enum ImageUtilsError: Error {
}
enum ImageUtils {
private static let compressionQuality: CGFloat = 0.82
private static let targetImageBytes: Int = 45_000
private static let compressionQuality: CGFloat = 0.85
private static let targetImageBytes: Int = 60_000
static func processImage(at url: URL, maxDimension: CGFloat = 448) throws -> URL {
static func processImage(at url: URL, maxDimension: CGFloat = 512) throws -> URL {
// Security H1: Check file size BEFORE reading into memory
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
guard let fileSize = attrs[.size] as? Int else {
@@ -38,7 +38,7 @@ enum ImageUtils {
}
#if os(iOS)
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448) throws -> URL {
static func processImage(_ image: UIImage, maxDimension: CGFloat = 512) throws -> URL {
return try autoreleasepool {
// Scale the image first
let scaled = scaledImage(image, maxDimension: maxDimension)
@@ -106,7 +106,7 @@ enum ImageUtils {
return data as Data
}
#else
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448) throws -> URL {
static func processImage(_ image: NSImage, maxDimension: CGFloat = 512) throws -> URL {
return try autoreleasepool {
let scaled = scaledImage(image, maxDimension: maxDimension)
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
+2 -3
View File
@@ -14,7 +14,6 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
private let paddingInterval: TimeInterval = 0.5
private let maxRecordingDuration: TimeInterval = 120
private var recorder: AVAudioRecorder?
private var currentURL: URL?
@@ -76,14 +75,14 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 16_000,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 16_000
AVEncoderBitRateKey: 20_000
]
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
audioRecorder.delegate = self
audioRecorder.isMeteringEnabled = true
audioRecorder.prepareToRecord()
audioRecorder.record(forDuration: maxRecordingDuration)
audioRecorder.record()
recorder = audioRecorder
currentURL = outputURL
+18 -8
View File
@@ -35,7 +35,7 @@ struct PeerID: Equatable, Hashable {
// Private so the callers have to go through a convenience init
private init(prefix: Prefix, bare: any StringProtocol) {
self.prefix = prefix
self.bare = String(bare).lowercased()
self.bare = String(bare)
}
}
@@ -76,12 +76,6 @@ extension PeerID {
init(hexData: Data) {
self.init(str: hexData.hexEncodedString())
}
/// Convenience init to "hide" hex-encoding implementation detail
init?(hexData: Data?) {
guard let hexData else { return nil }
self.init(hexData: hexData)
}
}
// MARK: - Noise Public Key Helpers
@@ -197,7 +191,9 @@ extension PeerID: Comparable {
}
}
// MARK: - CustomStringConvertible
// MARK: - String Interop Helpers
// MARK: CustomStringConvertible
extension PeerID: CustomStringConvertible {
/// So it returns the actual `id` like before even inside another String
@@ -205,3 +201,17 @@ extension PeerID: CustomStringConvertible {
id
}
}
// MARK: Custom Equatable w/ String & Optionality
// PeerID <> String
extension Optional where Wrapped == PeerID {
static func ==(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id == rhs }
static func !=(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id != rhs }
}
// String <> PeerID
extension Optional where Wrapped == String {
static func ==(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs == rhs?.id }
static func !=(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs != rhs?.id }
}
+1 -17
View File
@@ -8,14 +8,6 @@ struct RequestSyncPacket {
let p: Int
let m: UInt32
let data: Data
let types: SyncTypeFlags?
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil) {
self.p = p
self.m = m
self.data = data
self.types = types
}
func encode() -> Data {
var out = Data()
@@ -33,9 +25,6 @@ struct RequestSyncPacket {
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
// data
putTLV(0x03, data)
if let typesData = types?.toData() {
putTLV(0x04, typesData)
}
return out
}
@@ -44,7 +33,6 @@ struct RequestSyncPacket {
var p: Int? = nil
var m: UInt32? = nil
var payload: Data? = nil
var types: SyncTypeFlags? = nil
while off + 3 <= data.count {
let t = Int(data[off]); off += 1
@@ -64,16 +52,12 @@ struct RequestSyncPacket {
case 0x03:
if v.count > maxAcceptBytes { return nil }
payload = v
case 0x04:
if let decoded = SyncTypeFlags.decode(v) {
types = decoded
}
default:
break // forward compatible; ignore unknown TLVs
}
}
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types)
return RequestSyncPacket(p: pp, m: mm, data: dd)
}
}
+3 -12
View File
@@ -284,10 +284,7 @@ final class GeoRelayDirectory {
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.prefetchIfNeeded(force: true)
}
self?.prefetchIfNeeded(force: true)
}
observers.append(torReady)
@@ -297,10 +294,7 @@ final class GeoRelayDirectory {
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.prefetchIfNeeded()
}
self?.prefetchIfNeeded()
}
observers.append(didBecomeActive)
#elseif os(macOS)
@@ -309,10 +303,7 @@ final class GeoRelayDirectory {
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.prefetchIfNeeded()
}
self?.prefetchIfNeeded()
}
observers.append(didBecomeActive)
#endif
+2 -2
View File
@@ -178,7 +178,7 @@ protocol BitchatDelegate: AnyObject {
// Bluetooth state updates for user notifications
func didUpdateBluetoothState(_ state: CBManagerState)
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date)
}
// Provide default implementation to make it effectively optional
@@ -195,7 +195,7 @@ extension BitchatDelegate {
// Default empty implementation
}
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
// Default empty implementation
}
}
-195
View File
@@ -1,195 +0,0 @@
//
// MimeType.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import UniformTypeIdentifiers
// MARK: - Extensions for missing UTTypes
extension UTType {
static let webP = UTType(importedAs: "image/webp")
static let aac = UTType(importedAs: "audio/aac")
static let m4a = UTType(importedAs: "audio/m4a")
static let ogg = UTType(importedAs: "audio/ogg")
}
// MARK: - MimeType Enum
enum MimeType: CaseIterable, Hashable {
case jpeg
case jpg
case png
case gif
case webp
case mp4Audio
case m4a
case aac
case mpeg
case mp3
case wav
case xWav
case ogg
case pdf
case octetStream
var utType: UTType {
switch self {
case .jpeg, .jpg: .jpeg
case .png: .png
case .gif: .gif
case .webp: .webP
case .aac: .aac
case .m4a: .m4a
case .mp4Audio: .mpeg4Audio
case .mp3, .mpeg: .mp3
case .wav, .xWav: .wav
case .ogg: .ogg
case .pdf: .pdf
case .octetStream: .data
}
}
var category: Category {
switch self {
case .jpeg, .jpg, .png, .gif, .webp:
return .image
case .aac, .m4a, .mp4Audio, .mpeg, .mp3, .wav, .xWav, .ogg:
return .audio
case .pdf, .octetStream:
return .file
}
}
var mimeString: String {
switch self {
case .jpeg, .jpg: "image/jpeg"
case .png: "image/png"
case .gif: "image/gif"
case .webp: "image/webp"
case .mp4Audio: "audio/mp4"
case .m4a: "audio/m4a"
case .aac: "audio/aac"
case .mpeg: "audio/mpeg"
case .mp3: "audio/mp3"
case .wav: "audio/wav"
case .xWav: "audio/x-wav"
case .ogg: "audio/ogg"
case .pdf: "application/pdf"
case .octetStream: "application/octet-stream"
}
}
var defaultExtension: String {
switch self {
case .jpeg, .jpg: "jpg"
case .png: "png"
case .webp: "webp"
case .gif: "gif"
case .mp4Audio, .m4a, .aac: "m4a"
case .mpeg, .mp3: "mp3"
case .wav, .xWav: "wav"
case .ogg: "ogg"
case .pdf: "pdf"
case .octetStream: "bin"
}
}
static var allowed: Set<MimeType> = [
.jpeg, .jpg, .png, .gif, .webp,
.mp4Audio, .m4a, .aac, .mpeg, .mp3,
.wav, .xWav, .ogg,
.pdf, .octetStream
]
var isAllowed: Bool {
Self.allowed.contains(self)
}
// MARK: - Byte signature validation
func matches(data: Data) -> Bool {
guard !data.isEmpty else { return false }
// Generic type skip validation
if self == .octetStream { return true }
switch self {
case .jpeg, .jpg:
return data.count >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF
case .png:
return data.count >= 8 &&
data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 &&
data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A
case .gif:
return data.count >= 6 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 &&
data[3] == 0x38 && (data[4] == 0x37 || data[4] == 0x39) && data[5] == 0x61
case .webp:
return data.count >= 12 &&
data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50
case .m4a, .mp4Audio, .aac:
// AVAudioRecorder output varies by platform - be lenient
// Security: size already capped + sandboxed execution
return data.count > 100
case .mpeg, .mp3:
if data.count >= 3 && data[0] == 0x49 && data[1] == 0x44 && data[2] == 0x33 {
return true // ID3 header
}
return data.count >= 2 && data[0] == 0xFF && (data[1] & 0xE0) == 0xE0
case .wav, .xWav:
return data.count >= 12 &&
data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
data[8] == 0x57 && data[9] == 0x41 && data[10] == 0x56 && data[11] == 0x45
case .ogg:
return data.count >= 4 &&
data[0] == 0x4F && data[1] == 0x67 && data[2] == 0x67 && data[3] == 0x53
case .pdf:
return data.count >= 4 &&
data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46
default:
return false
}
}
// MARK: - Convenience Initializers
init?(_ mimeString: String?) {
guard let mimeString else { return nil }
let normalized = mimeString.lowercased()
// Direct match with our canonical list
if let match = MimeType.allCases.first(where: { $0.mimeString == normalized }) {
self = match
return
}
// Let UTType normalize aliases like "image/jpg", "audio/x-wav", etc.
if let type = UTType(mimeType: normalized),
let match = MimeType.allCases.first(where: { type.conforms(to: $0.utType) }) {
self = match
return
}
return nil
}
}
extension MimeType {
enum Category: String {
case audio, image, file
}
}
File diff suppressed because it is too large Load Diff
@@ -177,18 +177,18 @@ final class NoiseEncryptionService {
private let rekeyCheckInterval: TimeInterval = 60.0 // Check every minute
// Callbacks
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
// Add a handler for peer authentication
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) {
serviceQueue.async(flags: .barrier) { [weak self] in
self?.onPeerAuthenticatedHandlers.append(handler)
}
}
// Legacy support - setting this will add to the handlers array
var onPeerAuthenticated: ((PeerID, String) -> Void)? {
var onPeerAuthenticated: ((String, String) -> Void)? {
get { nil } // Always return nil for backward compatibility
set {
if let handler = newValue {
@@ -546,7 +546,7 @@ final class NoiseEncryptionService {
// Notify all handlers about authentication
serviceQueue.async { [weak self] in
self?.onPeerAuthenticatedHandlers.forEach { handler in
handler(peerID, fingerprint)
handler(peerID.id, fingerprint)
}
}
}
+43 -32
View File
@@ -29,30 +29,28 @@ final class NotificationService {
}
}
func sendLocalNotification(
title: String,
body: String,
identifier: String,
userInfo: [String: Any]? = nil,
interruptionLevel: UNNotificationInterruptionLevel = .active
) {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
content.interruptionLevel = interruptionLevel
if let userInfo = userInfo {
content.userInfo = userInfo
func sendLocalNotification(title: String, body: String, identifier: String, userInfo: [String: Any]? = nil) {
// For now, skip app state check entirely to avoid thread issues
// The NotificationDelegate will handle foreground presentation
DispatchQueue.main.async {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
if let userInfo = userInfo {
content.userInfo = userInfo
}
let request = UNNotificationRequest(
identifier: identifier,
content: content,
trigger: nil // Deliver immediately
)
UNUserNotificationCenter.current().add(request) { _ in
// Notification added
}
}
let request = UNNotificationRequest(
identifier: identifier,
content: content,
trigger: nil // Deliver immediately
)
UNUserNotificationCenter.current().add(request)
}
func sendMentionNotification(from sender: String, message: String) {
@@ -63,11 +61,11 @@ final class NotificationService {
sendLocalNotification(title: title, body: body, identifier: identifier)
}
func sendPrivateMessageNotification(from sender: String, message: String, peerID: PeerID) {
func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) {
let title = "🔒 DM from \(sender)"
let body = message
let identifier = "private-\(UUID().uuidString)"
let userInfo = ["peerID": peerID.id, "senderName": sender]
let userInfo = ["peerID": peerID, "senderName": sender]
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
}
@@ -85,12 +83,25 @@ final class NotificationService {
let title = "👥 bitchatters nearby!"
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
let identifier = "network-available-\(Date().timeIntervalSince1970)"
sendLocalNotification(
title: title,
body: body,
identifier: identifier,
interruptionLevel: .timeSensitive
)
// For network notifications, we want to show them even in foreground
// No app state check - let the notification delegate handle presentation
DispatchQueue.main.async {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
content.interruptionLevel = .timeSensitive // Make it more prominent
let request = UNNotificationRequest(
identifier: identifier,
content: content,
trigger: nil // Deliver immediately
)
UNUserNotificationCenter.current().add(request) { _ in
// Notification added
}
}
}
}
-11
View File
@@ -13,7 +13,6 @@ struct RelayController {
senderIsSelf: Bool,
isEncrypted: Bool,
isDirectedEncrypted: Bool,
isFragment: Bool,
isDirectedFragment: Bool,
isHandshake: Bool,
isAnnounce: Bool,
@@ -37,16 +36,6 @@ struct RelayController {
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
}
if isFragment {
let ttlLimit = min(ttlCap, TransportConfig.bleFragmentRelayTtlCap)
guard ttlLimit > 1 else {
return RelayDecision(shouldRelay: false, newTTL: ttlLimit, delayMs: 0)
}
let newTTL = ttlLimit &- 1
let delayMs = Int.random(in: TransportConfig.bleFragmentRelayMinDelayMs...TransportConfig.bleFragmentRelayMaxDelayMs)
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
}
// TTL clamping for broadcast
// - Dense graphs: keep lower but still allow multi-hop bridging
// - Announces get a bit more headroom
-5
View File
@@ -45,7 +45,6 @@ protocol Transport: AnyObject {
// Messaging
func sendMessage(_ content: String, mentions: [String])
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date)
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
@@ -66,10 +65,6 @@ extension Transport {
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
func cancelTransfer(_ transferId: String) {}
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
sendMessage(content, mentions: mentions)
}
}
protocol TransportPeerEventsDelegate: AnyObject {
-5
View File
@@ -8,10 +8,6 @@ enum TransportConfig {
static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
static let bleFragmentRelayTtlCap: UInt8 = 5 // Clamp fragment TTL to contain floods
// UI / Storage Caps
static let privateChatCap: Int = 1337
@@ -70,7 +66,6 @@ enum TransportConfig {
static let uiAnimationMediumSeconds: TimeInterval = 0.2
static let uiAnimationSidebarSeconds: TimeInterval = 0.25
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
static let uiMeshEmptyConfirmationSeconds: TimeInterval = 30.0
// BLE maintenance & thresholds
static let bleMaintenanceInterval: TimeInterval = 5.0
+99 -204
View File
@@ -8,55 +8,6 @@ final class GossipSyncManager {
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
}
private struct PacketStore {
private(set) var packets: [String: BitchatPacket] = [:]
private(set) var order: [String] = []
mutating func insert(idHex: String, packet: BitchatPacket, capacity: Int) {
guard capacity > 0 else { return }
if packets[idHex] != nil {
packets[idHex] = packet
return
}
packets[idHex] = packet
order.append(idHex)
while order.count > capacity {
let victim = order.removeFirst()
packets.removeValue(forKey: victim)
}
}
func allPackets(isFresh: (BitchatPacket) -> Bool) -> [BitchatPacket] {
order.compactMap { key in
guard let packet = packets[key], isFresh(packet) else { return nil }
return packet
}
}
mutating func remove(where shouldRemove: (BitchatPacket) -> Bool) {
var nextOrder: [String] = []
for key in order {
guard let packet = packets[key] else { continue }
if shouldRemove(packet) {
packets.removeValue(forKey: key)
} else {
nextOrder.append(key)
}
}
order = nextOrder
}
mutating func removeExpired(isFresh: (BitchatPacket) -> Bool) {
remove { !isFresh($0) }
}
}
private struct SyncSchedule {
let types: SyncTypeFlags
let interval: TimeInterval
var lastSent: Date
}
struct Config {
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
@@ -65,43 +16,25 @@ final class GossipSyncManager {
var maintenanceIntervalSeconds: TimeInterval = 30.0
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
var stalePeerTimeoutSeconds: TimeInterval = 60.0
var fragmentCapacity: Int = 600
var fileTransferCapacity: Int = 200
var fragmentSyncIntervalSeconds: TimeInterval = 30.0
var fileTransferSyncIntervalSeconds: TimeInterval = 60.0
var messageSyncIntervalSeconds: TimeInterval = 15.0
}
private let myPeerID: PeerID
private let config: Config
weak var delegate: Delegate?
// Storage: broadcast packets by type, and latest announce per sender
private var messages = PacketStore()
private var fragments = PacketStore()
private var fileTransfers = PacketStore()
private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:]
// Storage: broadcast messages (ordered by insert), and latest announce per sender
private var messages: [String: BitchatPacket] = [:] // idHex -> packet
private var messageOrder: [String] = []
private var latestAnnouncementByPeer: [String: (id: String, packet: BitchatPacket)] = [:]
// Timer
private var periodicTimer: DispatchSourceTimer?
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
private var lastStalePeerCleanup: Date = .distantPast
private var syncSchedules: [SyncSchedule] = []
init(myPeerID: PeerID, config: Config = Config()) {
self.myPeerID = myPeerID
self.config = config
var schedules: [SyncSchedule] = []
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
}
if config.fragmentCapacity > 0 && config.fragmentSyncIntervalSeconds > 0 {
schedules.append(SyncSchedule(types: .fragment, interval: config.fragmentSyncIntervalSeconds, lastSent: .distantPast))
}
if config.fileTransferCapacity > 0 && config.fileTransferSyncIntervalSeconds > 0 {
schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast))
}
syncSchedules = schedules
}
func start() {
@@ -122,18 +55,7 @@ final class GossipSyncManager {
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
guard let self = self else { return }
self.sendRequestSync(to: peerID, types: .publicMessages)
if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 {
self.queue.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.sendRequestSync(to: peerID, types: .fragment)
}
}
if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 {
self.queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in
self?.sendRequestSync(to: peerID, types: .fileTransfer)
}
}
self?.sendRequestSync(to: peerID)
}
}
@@ -165,45 +87,47 @@ final class GossipSyncManager {
}
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
guard let messageType = MessageType(rawValue: packet.type) else { return }
let mt = MessageType(rawValue: packet.type)
let isBroadcastRecipient: Bool = {
guard let r = packet.recipientID else { return true }
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
}()
let isBroadcastMessage = (mt == .message && isBroadcastRecipient)
let isAnnounce = (mt == .announce)
guard isBroadcastMessage || isAnnounce else { return }
switch messageType {
case .announce:
guard isPacketFresh(packet) else { return }
// Reject expired packets to prevent ghost peers and old messages
guard isPacketFresh(packet) else { return }
if isAnnounce {
guard isAnnouncementFresh(packet) else {
let sender = PeerID(hexData: packet.senderID)
removeState(for: sender)
let sender = packet.senderID.hexEncodedString().lowercased()
removeState(forNormalizedPeerID: sender)
return
}
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
let sender = PeerID(hexData: packet.senderID)
}
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
if isBroadcastMessage {
if messages[idHex] == nil {
messages[idHex] = packet
messageOrder.append(idHex)
// Enforce capacity
let cap = max(1, config.seenCapacity)
while messageOrder.count > cap {
let victim = messageOrder.removeFirst()
messages.removeValue(forKey: victim)
}
}
} else if isAnnounce {
let sender = packet.senderID.hexEncodedString().lowercased()
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
case .message:
guard isBroadcastRecipient else { return }
guard isPacketFresh(packet) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity))
case .fragment:
guard isBroadcastRecipient else { return }
guard isPacketFresh(packet) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
fragments.insert(idHex: idHex, packet: packet, capacity: max(1, config.fragmentCapacity))
case .fileTransfer:
guard isBroadcastRecipient else { return }
guard isPacketFresh(packet) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity))
default:
break
}
}
private func sendRequestSync(for types: SyncTypeFlags) {
let payload = buildGcsPayload(for: types)
private func sendRequestSync() {
let payload = buildGcsPayload()
let pkt = BitchatPacket(
type: MessageType.requestSync.rawValue,
senderID: Data(hexString: myPeerID.id) ?? Data(),
@@ -217,8 +141,8 @@ final class GossipSyncManager {
delegate?.sendPacket(signed)
}
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) {
let payload = buildGcsPayload(for: types)
private func sendRequestSync(to peerID: PeerID) {
let payload = buildGcsPayload()
var recipient = Data()
var temp = peerID.id
while temp.count >= 2 && recipient.count < 8 {
@@ -246,7 +170,6 @@ final class GossipSyncManager {
}
private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
let requestedTypes = (request.types ?? .publicMessages)
// Decode GCS into sorted set and prepare membership checker
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
func mightContain(_ id: Data) -> Bool {
@@ -254,100 +177,60 @@ final class GossipSyncManager {
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
}
if requestedTypes.contains(.announce) {
for (_, pair) in latestAnnouncementByPeer {
let (idHex, pkt) = pair
guard isPacketFresh(pkt) else { continue }
let idBytes = Data(hexString: idHex) ?? Data()
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
delegate?.sendPacket(to: peerID, packet: toSend)
}
// 1) Announcements: send latest per peer if requester lacks them (and not expired)
for (_, pair) in latestAnnouncementByPeer {
let (idHex, pkt) = pair
guard isPacketFresh(pkt) else { continue }
let idBytes = Data(hexString: idHex) ?? Data()
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
if requestedTypes.contains(.message) {
let toSendMsgs = messages.allPackets(isFresh: isPacketFresh)
for pkt in toSendMsgs {
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
}
if requestedTypes.contains(.fragment) {
let frags = fragments.allPackets(isFresh: isPacketFresh)
for pkt in frags {
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
}
if requestedTypes.contains(.fileTransfer) {
let files = fileTransfers.allPackets(isFresh: isPacketFresh)
for pkt in files {
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
delegate?.sendPacket(to: peerID, packet: toSend)
}
// 2) Broadcast messages: send all missing (and not expired)
let toSendMsgs = messageOrder.compactMap { messages[$0] }
for pkt in toSendMsgs {
guard isPacketFresh(pkt) else { continue }
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
}
// Build REQUEST_SYNC payload using current candidates and GCS params
private func buildGcsPayload(for types: SyncTypeFlags) -> Data {
private func buildGcsPayload() -> Data {
// Collect candidates: latest announce per peer + broadcast messages (only fresh)
var candidates: [BitchatPacket] = []
if types.contains(.announce) {
for (_, pair) in latestAnnouncementByPeer where isPacketFresh(pair.packet) {
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count)
for (_, pair) in latestAnnouncementByPeer {
if isPacketFresh(pair.packet) {
candidates.append(pair.packet)
}
}
if types.contains(.message) {
candidates.append(contentsOf: messages.allPackets(isFresh: isPacketFresh))
for id in messageOrder {
if let p = messages[id], isPacketFresh(p) {
candidates.append(p)
}
}
if types.contains(.fragment) {
candidates.append(contentsOf: fragments.allPackets(isFresh: isPacketFresh))
}
if types.contains(.fileTransfer) {
candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh))
}
if candidates.isEmpty {
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
return req.encode()
}
// Sort by timestamp desc
candidates.sort { $0.timestamp > $1.timestamp }
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
let nMax = GCSFilter.estimateMaxElements(sizeBytes: config.gcsMaxBytes, p: p)
let cap: Int
if types == .fragment {
cap = max(1, config.fragmentCapacity)
} else if types == .fileTransfer {
cap = max(1, config.fileTransferCapacity)
} else {
cap = max(1, config.seenCapacity)
}
let cap = max(1, config.seenCapacity)
let takeN = min(candidates.count, min(nMax, cap))
if takeN <= 0 {
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
let req = RequestSyncPacket(p: p, m: 1, data: Data())
return req.encode()
}
let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) }
let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr)
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types)
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data)
return req.encode()
}
@@ -358,21 +241,20 @@ final class GossipSyncManager {
isPacketFresh(pair.packet)
}
messages.removeExpired(isFresh: isPacketFresh)
fragments.removeExpired(isFresh: isPacketFresh)
fileTransfers.removeExpired(isFresh: isPacketFresh)
// Remove expired messages
let expiredMessageIds = messages.compactMap { id, pkt in
isPacketFresh(pkt) ? nil : id
}
for id in expiredMessageIds {
messages.removeValue(forKey: id)
messageOrder.removeAll { $0 == id }
}
}
private func performPeriodicMaintenance(now: Date = Date()) {
cleanupExpiredMessages()
cleanupStaleAnnouncementsIfNeeded(now: now)
for index in syncSchedules.indices {
guard syncSchedules[index].interval > 0 else { continue }
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
syncSchedules[index].lastSent = now
sendRequestSync(for: syncSchedules[index].types)
}
}
sendRequestSync()
}
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
@@ -388,27 +270,40 @@ final class GossipSyncManager {
let nowMs = UInt64(now.timeIntervalSince1970 * 1000)
guard nowMs >= timeoutMs else { return }
let cutoff = nowMs - timeoutMs
let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pair in
pair.packet.timestamp < cutoff ? peerID : nil
let stalePeerIDs = latestAnnouncementByPeer.compactMap { (peerHex, pair) -> String? in
pair.packet.timestamp < cutoff ? peerHex.lowercased() : nil
}
guard !stalePeerIDs.isEmpty else { return }
for peerKey in stalePeerIDs {
removeState(for: peerKey)
removeState(forNormalizedPeerID: peerKey)
}
}
// Explicit removal hook for LEAVE/stale peer
func removeAnnouncementForPeer(_ peerID: PeerID) {
queue.async { [weak self] in
self?.removeState(for: peerID)
self?._removeAnnouncementForPeer(peerID)
}
}
private func removeState(for peerID: PeerID) {
_ = latestAnnouncementByPeer.removeValue(forKey: peerID)
messages.remove { PeerID(hexData: $0.senderID) == peerID }
fragments.remove { PeerID(hexData: $0.senderID) == peerID }
fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID }
private func _removeAnnouncementForPeer(_ peerID: PeerID) {
let normalizedPeerID = peerID.id.lowercased()
removeState(forNormalizedPeerID: normalizedPeerID)
}
private func removeState(forNormalizedPeerID normalizedPeerID: String) {
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
// Remove messages from this peer
// Collect IDs to remove first to avoid concurrent modification
let messageIdsToRemove = messages.compactMap { (id, message) -> String? in
message.senderID.hexEncodedString().lowercased() == normalizedPeerID ? id : nil
}
// Remove messages and update messageOrder
for id in messageIdsToRemove {
messages.removeValue(forKey: id)
messageOrder.removeAll { $0 == id }
}
}
}
@@ -422,13 +317,13 @@ extension GossipSyncManager {
func _hasAnnouncement(for peerID: PeerID) -> Bool {
queue.sync {
latestAnnouncementByPeer[peerID] != nil
latestAnnouncementByPeer[peerID.id.lowercased()] != nil
}
}
func _messageCount(for peerID: PeerID) -> Int {
queue.sync {
messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count
messages.values.filter { $0.senderID.hexEncodedString().lowercased() == peerID.id.lowercased() }.count
}
}
}
-104
View File
@@ -1,104 +0,0 @@
import Foundation
/// Bitfield describing which message types are covered by a REQUEST_SYNC round.
/// Matches the Android mapping (bit index -> message type).
struct SyncTypeFlags: OptionSet {
let rawValue: UInt64
init(rawValue: UInt64) {
self.rawValue = rawValue & 0x00FF_FFFF_FFFF_FFFF // Trim to max 8 bytes
}
private static func bitIndex(for type: MessageType) -> Int? {
switch type {
case .announce: return 0
case .message: return 1
case .leave: return 2
case .noiseHandshake: return 3
case .noiseEncrypted: return 4
case .fragment: return 5
case .requestSync: return 6
case .fileTransfer: return 7
}
}
private static func type(forBit index: Int) -> MessageType? {
switch index {
case 0: return .announce
case 1: return .message
case 2: return .leave
case 3: return .noiseHandshake
case 4: return .noiseEncrypted
case 5: return .fragment
case 6: return .requestSync
case 7: return .fileTransfer
default:
return nil
}
}
static let announce = SyncTypeFlags(messageTypes: [.announce])
static let message = SyncTypeFlags(messageTypes: [.message])
static let fragment = SyncTypeFlags(messageTypes: [.fragment])
static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer])
static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message])
init(messageTypes: [MessageType]) {
var raw: UInt64 = 0
for type in messageTypes {
guard let bit = SyncTypeFlags.bitIndex(for: type) else { continue }
raw |= (1 << UInt64(bit))
}
self.init(rawValue: raw)
}
func contains(_ type: MessageType) -> Bool {
guard let bit = SyncTypeFlags.bitIndex(for: type) else { return false }
return contains(SyncTypeFlags(rawValue: 1 << UInt64(bit)))
}
func union(_ other: SyncTypeFlags) -> SyncTypeFlags {
SyncTypeFlags(rawValue: rawValue | other.rawValue)
}
func intersection(_ other: SyncTypeFlags) -> SyncTypeFlags {
SyncTypeFlags(rawValue: rawValue & other.rawValue)
}
func toMessageTypes() -> [MessageType] {
guard rawValue != 0 else { return [] }
var types: [MessageType] = []
for bit in 0..<64 {
guard (rawValue & (1 << UInt64(bit))) != 0 else { continue }
if let type = SyncTypeFlags.type(forBit: bit) {
types.append(type)
}
}
return types
}
func toData() -> Data? {
guard rawValue != 0 else { return nil }
var value = rawValue
var bytes: [UInt8] = []
while value > 0 && bytes.count < 8 {
bytes.append(UInt8(value & 0xFF))
value >>= 8
}
while let last = bytes.last, last == 0 {
bytes.removeLast()
}
guard !bytes.isEmpty, bytes.count <= 8 else { return nil }
return Data(bytes)
}
static func decode(_ data: Data) -> SyncTypeFlags? {
guard (1...8).contains(data.count) else { return nil }
var raw: UInt64 = 0
for (index, byte) in data.enumerated() {
raw |= UInt64(byte) << UInt64(index * 8)
}
return SyncTypeFlags(rawValue: raw)
}
}
+9 -7
View File
@@ -61,13 +61,15 @@ struct CompressionUtil {
// 1. Data is too small
// 2. Data appears to be already compressed (high entropy)
guard data.count >= compressionThreshold else { return false }
// Quick uniqueness check a high diversity of bytes usually means the
// payload is already compressed. We only need to know how many unique
// values exist rather than keeping full frequency counts.
let uniqueByteCount = Set(data).count
let sampleSize = min(data.count, 256)
let uniqueByteRatio = Double(uniqueByteCount) / Double(sampleSize)
// Simple entropy check - count unique bytes
var byteFrequency = [UInt8: Int]()
for byte in data {
byteFrequency[byte, default: 0] += 1
}
// If we have very high byte diversity, data is likely already compressed
let uniqueByteRatio = Double(byteFrequency.count) / Double(min(data.count, 256))
return uniqueByteRatio < 0.9 // Compress if less than 90% unique bytes
}
}
+2 -2
View File
@@ -5,9 +5,9 @@ enum FileTransferLimits {
/// Absolute ceiling enforced for any file payload (voice, image, other).
static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
/// Voice notes stay small for low-latency relays.
static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB
static let maxVoiceNoteBytes: Int = 1 * 1024 * 1024 // 1 MiB
/// Compressed images after downscaling should comfortably fit under this budget.
static let maxImageBytes: Int = 512 * 1024 // 512 KiB
static let maxImageBytes: Int = 1 * 1024 * 1024 // 1 MiB
/// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads.
static let maxFramedFileBytes: Int = {
let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs
+17 -17
View File
@@ -1,5 +1,4 @@
import Foundation
import BitLogger
/// Comprehensive input validation for BitChat protocol
/// Prevents injection attacks, buffer overflows, and malformed data
@@ -17,28 +16,29 @@ struct InputValidator {
// MARK: - String Content Validation
/// Validates and sanitizes user-provided strings used in UI
///
/// Rejects strings containing control characters to prevent potential security issues
/// and UI rendering problems. This strict approach ensures data integrity at input time.
static func validateUserString(_ string: String, maxLength: Int) -> String? {
// Check empty
guard !string.isEmpty else { return nil }
// Trim whitespace
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
// Check length
guard trimmed.count <= maxLength else { return nil }
// Reject control characters outright instead of rewriting the string.
// This prevents injection attacks and ensures consistent UI rendering.
// Remove control characters
let controlChars = CharacterSet.controlCharacters
if !trimmed.unicodeScalars.allSatisfy({ !controlChars.contains($0) }) {
// Log rejection for monitoring, without exposing actual content for privacy
let controlCharCount = trimmed.unicodeScalars.filter { controlChars.contains($0) }.count
SecureLogger.debug(
"Input validation rejected string (length: \(trimmed.count), control chars: \(controlCharCount))",
category: .security
)
return nil
}
return trimmed
let cleaned = trimmed.components(separatedBy: controlChars).joined()
// Ensure valid UTF-8 (should already be, but double-check)
guard cleaned.data(using: .utf8) != nil else { return nil }
// Prevent zero-width characters and other invisible unicode
let invisibleChars = CharacterSet(charactersIn: "\u{200B}\u{200C}\u{200D}\u{FEFF}")
let visible = cleaned.components(separatedBy: invisibleChars).joined()
return visible.isEmpty ? nil : visible
}
/// Validates nickname
+126 -173
View File
@@ -171,7 +171,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if spid.isGeoChat || spid.isGeoDM {
let full = (nostrKeyMapping[spid] ?? spid.bare).lowercased()
return "nostr:" + full
} else if spid.id.count == 16, let full = getNoiseKeyForShortID(spid)?.id.lowercased() {
} else if spid.id.count == 16, let full = getNoiseKeyForShortID(spid)?.lowercased() {
return "noise:" + full
} else {
return "mesh:" + spid.id.lowercased()
@@ -248,10 +248,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@Published var currentColorScheme: ColorScheme = .light
private let maxMessages = TransportConfig.meshTimelineCap // Maximum messages before oldest are removed
@Published var isConnected = false
private var hasNotifiedNetworkAvailable = false
private var recentlySeenPeers: Set<PeerID> = []
private var lastNetworkNotificationTime = Date.distantPast
private var networkResetTimer: Timer? = nil
private var networkEmptyTimer: Timer? = nil
private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects
@Published var nickname: String = "" {
didSet {
@@ -261,7 +261,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
nickname = trimmed
}
// Update mesh service nickname if it's initialized
if !meshService.myPeerID.isEmpty {
if meshService.myPeerID != "" {
meshService.setNickname(nickname)
}
}
@@ -328,16 +328,16 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
private var peerIDToPublicKeyFingerprint: [PeerID: String] = [:]
private var selectedPrivateChatFingerprint: String? = nil
// Map stable short peer IDs (16-hex) to full Noise public key hex (64-hex) for session continuity
private var shortIDToNoiseKey: [PeerID: PeerID] = [:]
private var shortIDToNoiseKey: [PeerID: String] = [:]
// Resolve full Noise key for a peer's short ID (used by UI header rendering)
@MainActor
private func getNoiseKeyForShortID(_ shortPeerID: PeerID) -> PeerID? {
private func getNoiseKeyForShortID(_ shortPeerID: PeerID) -> String? {
if let mapped = shortIDToNoiseKey[shortPeerID] { return mapped }
// Fallback: derive from active Noise session if available
if shortPeerID.id.count == 16,
let key = meshService.getNoiseService().getPeerPublicKeyData(shortPeerID) {
let stable = PeerID(hexData: key)
let stable = key.hexEncodedString()
shortIDToNoiseKey[shortPeerID] = stable
return stable
}
@@ -346,17 +346,16 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Resolve short mesh ID (16-hex) from a full Noise public key hex (64-hex)
@MainActor
func getShortIDForNoiseKey(_ fullNoiseKeyHex: PeerID) -> PeerID {
guard fullNoiseKeyHex.id.count == 64 else { return fullNoiseKeyHex }
func getShortIDForNoiseKey(_ fullNoiseKeyHex: String) -> PeerID? {
// Check known peers for a noise key match
if let match = allPeers.first(where: { PeerID(hexData: $0.noisePublicKey) == fullNoiseKeyHex }) {
if let match = allPeers.first(where: { $0.noisePublicKey.hexEncodedString() == fullNoiseKeyHex }) {
return match.peerID
}
// Also search cache mapping
if let pair = shortIDToNoiseKey.first(where: { $0.value == fullNoiseKeyHex }) {
return pair.key
}
return fullNoiseKeyHex
return nil
}
private var peerIndex: [PeerID: BitchatPeer] = [:]
@@ -1155,7 +1154,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
NotificationService.shared.sendPrivateMessageNotification(
from: senderName,
message: pm.content,
peerID: convKey
peerID: convKey.id
)
}
}
@@ -1529,25 +1528,15 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// UI updates automatically via @Published var messages
updateChannelActivityTimeThenSend(content: content,
trimmed: trimmed,
mentions: mentions,
geoContext: geoContext,
messageID: message.id,
timestamp: message.timestamp)
updateChannelActivityTimeThenSend(content: content, trimmed: trimmed, mentions: mentions, geoContext: geoContext)
}
private func updateChannelActivityTimeThenSend(content: String,
trimmed: String,
mentions: [String],
geoContext: GeoOutgoingContext?,
messageID: String,
timestamp: Date) {
private func updateChannelActivityTimeThenSend(content: String, trimmed: String, mentions: [String], geoContext: GeoOutgoingContext?) {
switch activeChannel {
case .mesh:
lastPublicActivityAt["mesh"] = Date()
// Send via mesh with mentions
meshService.sendMessage(content, mentions: mentions, messageID: messageID, timestamp: timestamp)
meshService.sendMessage(content, mentions: mentions)
case .location(let ch):
lastPublicActivityAt["geo:\(ch.geohash)"] = Date()
guard let context = geoContext, context.channel.geohash == ch.geohash else {
@@ -1886,7 +1875,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
NotificationService.shared.sendPrivateMessageNotification(
from: senderName,
message: pm.content,
peerID: convKey
peerID: convKey.id
)
}
@@ -1966,7 +1955,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
func isSelfSender(peerID: PeerID?, displayName: String?) -> Bool {
guard let peerID else { return false }
if peerID == meshService.myPeerID { return true }
guard peerID.isGeoDM || peerID.isGeoChat else { return false }
let lowerPeer = peerID.id.lowercased()
guard lowerPeer.hasPrefix("nostr") else { return false }
if let mapped = nostrKeyMapping[peerID]?.lowercased(),
let gh = currentGeohash,
@@ -1976,7 +1966,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if let gh = currentGeohash,
let myIdentity = try? idBridge.deriveIdentity(forGeohash: gh) {
if peerID == PeerID(nostr: myIdentity.publicKeyHex) { return true }
let myLower = myIdentity.publicKeyHex.lowercased()
let shortLen = TransportConfig.nostrShortKeyDisplayLength
let shortKey = "nostr:" + myLower.prefix(shortLen)
if lowerPeer == shortKey { return true }
let suffix = myIdentity.publicKeyHex.suffix(4)
let expected = (nickname + "#" + suffix).lowercased()
if let display = displayName?.lowercased(), display == expected { return true }
@@ -2459,7 +2452,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
let targetPeer = selectedPrivateChatPeer
let message = enqueueMediaMessage(content: "[voice] \(url.lastPathComponent)", targetPeer: targetPeer)
let message = enqueueMediaMessage(content: "[voice] \(url.lastPathComponent)", targetPeer: targetPeer?.id)
let messageID = message.id
let transferId = makeTransferID(messageID: messageID)
@@ -2538,7 +2531,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
)
guard packet.encode() != nil else { throw MediaSendError.encodingFailed }
await MainActor.run {
let message = self.enqueueMediaMessage(content: "[image] \(outputURL.lastPathComponent)", targetPeer: targetPeer)
let message = self.enqueueMediaMessage(content: "[image] \(outputURL.lastPathComponent)", targetPeer: targetPeer?.id)
let messageID = message.id
let transferId = self.makeTransferID(messageID: messageID)
self.registerTransfer(transferId: transferId, messageID: messageID)
@@ -2580,7 +2573,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
@MainActor
private func enqueueMediaMessage(content: String, targetPeer: PeerID?) -> BitchatMessage {
private func enqueueMediaMessage(content: String, targetPeer: String?) -> BitchatMessage {
let timestamp = Date()
let message: BitchatMessage
@@ -2597,7 +2590,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
deliveryStatus: .sending
)
var chats = privateChats
chats[peerID, default: []].append(message)
chats[PeerID(str: peerID), default: []].append(message)
privateChats = chats
trimMessagesIfNeeded()
} else {
@@ -2610,7 +2603,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: senderPeerID,
senderPeerID: PeerID(str: senderPeerID),
deliveryStatus: .sending
)
messages.append(message)
@@ -2635,28 +2628,29 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
return message
}
private func currentPublicSender() -> (name: String, peerID: PeerID) {
private func currentPublicSender() -> (name: String, peerID: String) {
var displaySender = nickname
var senderPeerID = meshService.myPeerID
if case .location(let ch) = activeChannel,
let identity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
let suffix = String(identity.publicKeyHex.suffix(4))
displaySender = nickname + "#" + suffix
senderPeerID = PeerID(nostr: identity.publicKeyHex)
let shortKey = identity.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength)
senderPeerID = PeerID(str: "nostr:\(shortKey)")
}
return (displaySender, senderPeerID)
return (displaySender, senderPeerID.id)
}
@MainActor
private func nicknameForPeer(_ peerID: PeerID) -> String {
if let name = meshService.peerNickname(peerID: peerID) {
private func nicknameForPeer(_ peerID: String) -> String {
if let name = meshService.peerNickname(peerID: PeerID(str: peerID)) {
return name
}
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: PeerID(str: peerID)),
!favorite.peerNickname.isEmpty {
return favorite.peerNickname
}
if let noiseKey = Data(hexString: peerID.id),
if let noiseKey = Data(hexString: peerID),
let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
!favorite.peerNickname.isEmpty {
return favorite.peerNickname
@@ -3345,10 +3339,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// In public chat - send to active public channel
switch activeChannel {
case .mesh:
meshService.sendMessage(screenshotMessage,
mentions: [],
messageID: UUID().uuidString,
timestamp: Date())
meshService.sendMessage(screenshotMessage, mentions: [])
case .location(let ch):
Task { @MainActor in
do {
@@ -4116,7 +4107,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if let spid = message.senderPeerID {
if case .location(let ch) = activeChannel, spid.id.hasPrefix("nostr:") {
if let myGeo = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
return spid == PeerID(nostr: myGeo.publicKeyHex)
return spid == "nostr:\(myGeo.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength))"
}
}
return spid == meshService.myPeerID
@@ -4269,7 +4260,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
let noiseService = meshService.getNoiseService()
if noiseService.hasEstablishedSession(with: peerID) {
peerEncryptionStatus[peerID] = encryptionStatus(for: peerID)
// Check if fingerprint is verified using our persisted data
if let fingerprint = getFingerprint(for: peerID),
verifiedFingerprints.contains(fingerprint) {
peerEncryptionStatus[peerID] = .noiseVerified
} else {
peerEncryptionStatus[peerID] = .noiseSecured
}
} else if noiseService.hasSession(with: peerID) {
// Session exists but not established - handshaking
peerEncryptionStatus[peerID] = .noiseHandshaking
@@ -4304,12 +4301,27 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Determine status based on session state
switch sessionState {
case .established:
status = encryptionStatus(for: peerID)
// We have encryption, now check if it's verified
if let fingerprint = getFingerprint(for: peerID) {
if verifiedFingerprints.contains(fingerprint) {
status = .noiseVerified
} else {
status = .noiseSecured
}
} else {
// We have a session but no fingerprint yet - still secured
status = .noiseSecured
}
case .handshaking, .handshakeQueued:
// If we've ever established a session, show secured instead of handshaking
if hasEverEstablishedSession {
// Check if it was verified before
status = encryptionStatus(for: peerID)
if let fingerprint = getFingerprint(for: peerID),
verifiedFingerprints.contains(fingerprint) {
status = .noiseVerified
} else {
status = .noiseSecured
}
} else {
// First time establishing - show handshaking
status = .noiseHandshaking
@@ -4318,7 +4330,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// If we've ever established a session, show secured instead of no handshake
if hasEverEstablishedSession {
// Check if it was verified before
status = encryptionStatus(for: peerID)
if let fingerprint = getFingerprint(for: peerID),
verifiedFingerprints.contains(fingerprint) {
status = .noiseVerified
} else {
status = .noiseSecured
}
} else {
// Never established - show no handshake
status = .noHandshake
@@ -4327,7 +4344,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// If we've ever established a session, show secured instead of failed
if hasEverEstablishedSession {
// Check if it was verified before
status = encryptionStatus(for: peerID)
if let fingerprint = getFingerprint(for: peerID),
verifiedFingerprints.contains(fingerprint) {
status = .noiseVerified
} else {
status = .noiseSecured
}
} else {
// Never established - show failed
status = .none
@@ -4401,7 +4423,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
private func meshSeed(for peerID: PeerID) -> String {
if let full = getNoiseKeyForShortID(peerID)?.id.lowercased() {
if let full = getNoiseKeyForShortID(peerID)?.lowercased() {
return "noise:" + full
}
return peerID.id.lowercased()
@@ -4718,7 +4740,16 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
let noiseService = meshService.getNoiseService()
if noiseService.hasEstablishedSession(with: peerID) {
peerEncryptionStatus[peerID] = encryptionStatus(for: peerID)
if let fingerprint = getFingerprint(for: peerID) {
if verifiedFingerprints.contains(fingerprint) {
peerEncryptionStatus[peerID] = .noiseVerified
} else {
peerEncryptionStatus[peerID] = .noiseSecured
}
} else {
// Session established but no fingerprint yet
peerEncryptionStatus[peerID] = .noiseSecured
}
} else if noiseService.hasSession(with: peerID) {
peerEncryptionStatus[peerID] = .noiseHandshaking
} else {
@@ -4748,16 +4779,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
return unifiedPeerService.getFingerprint(for: peerID)
}
/// Check if fingerprint is verified using our persisted data
@MainActor
private func encryptionStatus(for peerID: PeerID) -> EncryptionStatus {
if let fp = getFingerprint(for: peerID), verifiedFingerprints.contains(fp) {
return .noiseVerified
} else {
return .noiseSecured
}
}
/// Helper to resolve nickname for a peer ID through various sources
@MainActor
private func resolveNickname(for peerID: PeerID) -> String {
@@ -4859,6 +4880,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
DispatchQueue.main.async {
guard let self = self else { return }
let peerID = PeerID(str: peerID)
SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
@@ -4877,9 +4899,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Cache shortID -> full Noise key mapping as soon as session authenticates
if self.shortIDToNoiseKey[peerID] == nil,
let keyData = self.meshService.getNoiseService().getPeerPublicKeyData(peerID) {
let stable = PeerID(hexData: keyData)
let stable = keyData.hexEncodedString()
self.shortIDToNoiseKey[peerID] = stable
SecureLogger.debug("🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stable.id.prefix(8))", category: .session)
SecureLogger.debug("🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stable.prefix(8))", category: .session)
}
// If a QR verification is pending but not sent yet, send it now that session is authenticated
@@ -5063,12 +5085,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
}
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
Task { @MainActor in
let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines)
let publicMentions = parseMentions(from: normalized)
let msg = BitchatMessage(
id: messageID,
id: UUID().uuidString,
sender: nickname,
content: normalized,
timestamp: timestamp,
@@ -5144,7 +5166,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Cache mapping to full Noise key for session continuity on disconnect
if let peer = unifiedPeerService.getPeer(by: peerID) {
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
let noiseKeyHex = peer.noisePublicKey.hexEncodedString()
shortIDToNoiseKey[peerID] = noiseKeyHex
}
@@ -5160,14 +5182,15 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
identityManager.removeEphemeralSession(peerID: peerID)
// If the open PM is tied to this short peer ID, switch UI context to the full Noise key (offline favorite)
var derivedStableKeyHex = shortIDToNoiseKey[peerID]
var derivedStableKeyHex: String? = shortIDToNoiseKey[peerID]
if derivedStableKeyHex == nil,
let key = meshService.getNoiseService().getPeerPublicKeyData(peerID) {
derivedStableKeyHex = PeerID(hexData: key)
derivedStableKeyHex = key.hexEncodedString()
shortIDToNoiseKey[peerID] = derivedStableKeyHex
}
if let current = selectedPrivateChatPeer, current == peerID, let stableKeyHex = derivedStableKeyHex {
if let current = selectedPrivateChatPeer, current == peerID,
let stableKeyHex = PeerID(str: derivedStableKeyHex) {
// Migrate messages view context to stable key so header shows favorite + Nostr globe
if let messages = privateChats[peerID] {
if privateChats[stableKeyHex] == nil { privateChats[stableKeyHex] = [] }
@@ -5229,29 +5252,34 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
self.cleanupStaleUnreadPeerIDs()
// Smart notification logic for "bitchatters nearby"
let meshPeers = peers.filter { peerID in
self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID)
}
let meshPeerSet = Set(meshPeers)
if meshPeerSet.isEmpty {
self.scheduleNetworkEmptyTimer()
} else {
self.invalidateNetworkEmptyTimer()
// Trim out peers we no longer observe before comparing for new arrivals
self.recentlySeenPeers.formIntersection(meshPeerSet)
let newPeers = meshPeerSet.subtracting(self.recentlySeenPeers)
if !newPeers.isEmpty {
self.lastNetworkNotificationTime = Date()
self.recentlySeenPeers.formUnion(newPeers)
NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count)
SecureLogger.info(
"👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))",
category: .session
)
self.scheduleNetworkResetTimer()
if !peers.isEmpty {
// Cancel any pending reset if peers are back
self.networkResetTimer?.invalidate()
self.networkResetTimer = nil
// Count mesh peers that are connected OR recently reachable via mesh relays
let meshPeers = peers.filter { peerID in
self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID)
}
// Rising-edge only: previously zero peers, now > 0 peers
let currentPeerSet = Set(meshPeers)
let hadNone = self.recentlySeenPeers.isEmpty
if meshPeers.count > 0 && hadNone && !self.hasNotifiedNetworkAvailable {
self.hasNotifiedNetworkAvailable = true
self.lastNetworkNotificationTime = Date()
self.recentlySeenPeers = currentPeerSet
NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count)
SecureLogger.info("👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers", category: .session)
}
} else {
// No peers immediately reset to allow next rising-edge to notify
self.hasNotifiedNetworkAvailable = false
self.recentlySeenPeers.removeAll()
if self.networkResetTimer != nil {
self.networkResetTimer?.invalidate()
self.networkResetTimer = nil
}
SecureLogger.debug("⏳ Mesh empty — reset network notification state", category: .session)
}
// Register ephemeral sessions for all connected peers
@@ -5321,71 +5349,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Keep only receipts from messages we still have
cleanupOldReadReceipts()
}
@MainActor
private func scheduleNetworkResetTimer() {
networkResetTimer?.invalidate()
networkResetTimer = Timer.scheduledTimer(
timeInterval: networkResetGraceSeconds,
target: self,
selector: #selector(onNetworkResetTimerFired(_:)),
userInfo: nil,
repeats: false
)
}
@MainActor
@objc private func onNetworkResetTimerFired(_ timer: Timer) {
let activeMeshPeers = meshService
.currentPeerSnapshots()
.filter { snapshot in
snapshot.isConnected || meshService.isPeerReachable(snapshot.peerID)
}
if activeMeshPeers.isEmpty {
recentlySeenPeers.removeAll()
SecureLogger.debug("⏱️ Network notification window reset after quiet period", category: .session)
} else {
SecureLogger.debug("⏱️ Skipped network notification reset; still seeing \(activeMeshPeers.count) mesh peers", category: .session)
}
networkResetTimer = nil
}
@MainActor
private func scheduleNetworkEmptyTimer() {
guard networkEmptyTimer == nil else { return }
networkEmptyTimer = Timer.scheduledTimer(
timeInterval: TransportConfig.uiMeshEmptyConfirmationSeconds,
target: self,
selector: #selector(onNetworkEmptyTimerFired(_:)),
userInfo: nil,
repeats: false
)
SecureLogger.debug("⏳ Mesh empty — waiting before resetting notification state", category: .session)
}
@MainActor
private func invalidateNetworkEmptyTimer() {
if networkEmptyTimer != nil {
networkEmptyTimer?.invalidate()
networkEmptyTimer = nil
}
}
@MainActor
@objc private func onNetworkEmptyTimerFired(_ timer: Timer) {
let activeMeshPeers = meshService
.currentPeerSnapshots()
.filter { snapshot in
snapshot.isConnected || meshService.isPeerReachable(snapshot.peerID)
}
if activeMeshPeers.isEmpty {
recentlySeenPeers.removeAll()
SecureLogger.debug("⏳ Mesh empty — notification state reset after confirmation", category: .session)
} else {
SecureLogger.debug("⏳ Mesh empty timer cancelled; \(activeMeshPeers.count) mesh peers detected again", category: .session)
}
networkEmptyTimer = nil
}
private func cleanupOldReadReceipts() {
// Skip cleanup during startup phase or if privateChats is empty
@@ -5542,9 +5505,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
)
// Append to current visible messages
messages.append(systemMessage)
// Track the content key so relayed copies of the same system-style message are ignored
let contentKey = normalizedContentKey(systemMessage.content)
recordContentKey(contentKey, timestamp: systemMessage.timestamp)
// Persist into the backing store for the active channel to survive rebinds
switch activeChannel {
case .mesh:
@@ -5595,10 +5555,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
return
}
// Default: send over mesh
meshService.sendMessage(content,
mentions: [],
messageID: UUID().uuidString,
timestamp: Date())
meshService.sendMessage(content, mentions: [])
}
// MARK: - Simplified Nostr Integration (Inlined from MessageRouter)
@@ -5672,7 +5629,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
// Validate recipient
if PeerID(hexData: packet.recipientID) != meshService.myPeerID {
if let rid = packet.recipientID, rid.hexEncodedString() != meshService.myPeerID {
return
}
@@ -5917,7 +5874,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
NotificationService.shared.sendPrivateMessageNotification(
from: senderNickname,
message: messageContent,
peerID: targetPeerID
peerID: targetPeerID.id
)
}
}
@@ -6310,7 +6267,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
NotificationService.shared.sendPrivateMessageNotification(
from: message.sender,
message: message.content,
peerID: peerID
peerID: peerID.id
)
}
} else {
@@ -6364,9 +6321,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
let isGeo = finalMessage.senderPeerID?.isGeoChat == true
// Apply per-sender and per-content rate limits (drop if exceeded)
// Treat action-style system messages (which carry a senderPeerID) the same as regular user messages
let shouldRateLimit = finalMessage.sender != "system" || finalMessage.senderPeerID != nil
if shouldRateLimit {
if finalMessage.sender != "system" {
let senderKey = normalizedSenderKey(for: finalMessage)
let contentKey = normalizedContentKey(finalMessage.content)
let now = Date()
@@ -6384,10 +6339,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Persist mesh messages to mesh timeline always
if !isGeo && finalMessage.sender != "system" {
if !meshTimeline.contains(where: { $0.id == finalMessage.id }) {
meshTimeline.append(finalMessage)
trimMeshTimelineIfNeeded()
}
meshTimeline.append(finalMessage)
trimMeshTimelineIfNeeded()
}
// Persist geochat messages to per-geohash timeline
+243 -122
View File
@@ -27,6 +27,37 @@ private struct MessageDisplayItem: Identifiable {
let message: BitchatMessage
}
// MARK: - Sheet Management
private enum SheetType: Identifiable {
case peopleOrChat
case appInfo
case fingerprint(PeerID)
case imagePreview(URL)
case verification
case locationChannels
case locationNotes
var id: String {
switch self {
case .peopleOrChat:
return "peopleOrChat"
case .appInfo:
return "appInfo"
case .fingerprint(let peerID):
return "fingerprint-\(peerID.id)"
case .imagePreview(let url):
return "imagePreview-\(url.path)"
case .verification:
return "verification"
case .locationChannels:
return "locationChannels"
case .locationNotes:
return "locationNotes"
}
}
}
// MARK: - Main Content View
struct ContentView: View {
@@ -47,7 +78,7 @@ struct ContentView: View {
@State private var commandSuggestions: [String] = []
@State private var showMessageActions = false
@State private var selectedMessageSender: String?
@State private var selectedMessageSenderID: PeerID?
@State private var selectedMessageSenderID: String?
@FocusState private var isNicknameFieldFocused: Bool
@State private var isAtBottomPublic: Bool = true
@State private var isAtBottomPrivate: Bool = true
@@ -121,8 +152,96 @@ struct ContentView: View {
return viewModel.visibleGeohashPeople().count
}
}
// MARK: - Sheet Management
/// Determines which sheet should currently be active based on all state variables.
/// Priority is determined by the order of checks (first match wins).
private var currentSheet: SheetType? {
// People or private chat sheet (highest priority - primary navigation)
if showSidebar || viewModel.selectedPrivateChatPeer != nil {
return .peopleOrChat
}
// Verification sheet
if showVerifySheet {
return .verification
}
// Location channels sheet
if showLocationChannelsSheet {
return .locationChannels
}
// Location notes sheet
if showLocationNotes {
return .locationNotes
}
// App info sheet
if showAppInfo {
return .appInfo
}
// Fingerprint sheet
if let peerID = viewModel.showingFingerprintFor {
return .fingerprint(peerID)
}
// Image preview sheet
if let url = imagePreviewURL {
return .imagePreview(url)
}
return nil
}
/// Binding that maps between the SheetType enum and the individual state variables.
/// When a sheet is presented, it reads from currentSheet.
/// When a sheet is dismissed, it clears all related state variables.
private var sheetBinding: Binding<SheetType?> {
Binding(
get: {
return currentSheet
},
set: { newSheet in
// First, clear all sheet-related state to ensure clean transitions
showSidebar = false
if viewModel.selectedPrivateChatPeer != nil {
viewModel.endPrivateChat()
}
showAppInfo = false
viewModel.showingFingerprintFor = nil
imagePreviewURL = nil
showVerifySheet = false
showLocationChannelsSheet = false
showLocationNotes = false
notesGeohash = nil
// Now set the new sheet state if one was requested
if let sheet = newSheet {
switch sheet {
case .peopleOrChat:
showSidebar = true
case .appInfo:
showAppInfo = true
case .fingerprint(let peerID):
viewModel.showingFingerprintFor = peerID
case .imagePreview(let url):
imagePreviewURL = url
case .verification:
showVerifySheet = true
case .locationChannels:
showLocationChannelsSheet = true
case .locationNotes:
showLocationNotes = true
}
}
}
)
}
private struct PrivateHeaderContext {
let headerPeerID: PeerID
let peer: BitchatPeer?
@@ -132,7 +251,7 @@ struct ContentView: View {
// MARK: - Body
var body: some View {
private var mainContent: some View {
VStack(spacing: 0) {
mainHeaderView
.onAppear {
@@ -176,35 +295,97 @@ struct ContentView: View {
showSidebar = true
}
}
.sheet(
isPresented: Binding(
get: { showSidebar || viewModel.selectedPrivateChatPeer != nil },
set: { isPresented in
if !isPresented {
showSidebar = false
viewModel.endPrivateChat()
}
var body: some View {
mainContent
// MARK: - Consolidated Sheet Presentation
.sheet(item: sheetBinding) { sheet in
switch sheet {
case .peopleOrChat:
peopleSheetView
case .appInfo:
AppInfoView()
.onAppear { viewModel.isAppInfoPresented = true }
.onDisappear { viewModel.isAppInfoPresented = false }
case .fingerprint(let peerID):
FingerprintView(viewModel: viewModel, peerID: peerID)
case .imagePreview(let url):
ImagePreviewView(url: url)
case .verification:
VerificationSheetView(isPresented: $showVerifySheet)
.environmentObject(viewModel)
case .locationChannels:
LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
.onAppear { viewModel.isLocationChannelsSheetPresented = true }
.onDisappear { viewModel.isLocationChannelsSheetPresented = false }
case .locationNotes:
Group {
if let gh = notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {
LocationNotesView(geohash: gh)
.environmentObject(viewModel)
} else {
VStack(spacing: 12) {
HStack {
Text("content.notes.title")
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
Spacer()
Button(action: { showLocationNotes = false }) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons"))
}
.frame(height: headerHeight)
.padding(.horizontal, 12)
.background(backgroundColor.opacity(0.95))
Text("content.notes.location_unavailable")
.font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
Button("content.location.enable") {
LocationChannelManager.shared.enableLocationChannels()
LocationChannelManager.shared.refreshChannels()
}
.buttonStyle(.bordered)
Spacer()
}
.background(backgroundColor)
.foregroundColor(textColor)
}
}
.onAppear {
LocationChannelManager.shared.enableLocationChannels()
LocationChannelManager.shared.beginLiveRefresh()
}
.onDisappear {
LocationChannelManager.shared.endLiveRefresh()
}
.onChange(of: locationManager.availableChannels) { channels in
if let current = channels.first(where: { $0.level == .building })?.geohash,
notesGeohash != current {
notesGeohash = current
#if os(iOS)
let generator = UIImpactFeedbackGenerator(style: .light)
generator.prepare()
generator.impactOccurred()
#endif
}
}
)
) {
peopleSheetView
}
.sheet(isPresented: $showAppInfo) {
AppInfoView()
.onAppear { viewModel.isAppInfoPresented = true }
.onDisappear { viewModel.isAppInfoPresented = false }
}
.sheet(isPresented: Binding(
get: { viewModel.showingFingerprintFor != nil },
set: { _ in viewModel.showingFingerprintFor = nil }
)) {
if let peerID = viewModel.showingFingerprintFor {
FingerprintView(viewModel: viewModel, peerID: peerID)
}
}
#if os(iOS)
// Only present image picker from main view when NOT in a sheet
.fullScreenCover(isPresented: Binding(
// MARK: - Image Picker for Main View
// Only present when NOT in a DM or people sheet (parent-child pattern)
#if os(iOS)
.sheet(isPresented: Binding(
get: { showImagePicker && !showSidebar && viewModel.selectedPrivateChatPeer == nil },
set: { newValue in
if !newValue {
@@ -229,9 +410,8 @@ struct ContentView: View {
}
.ignoresSafeArea()
}
#endif
#if os(macOS)
// Only present Mac image picker from main view when NOT in a sheet
#endif
#if os(macOS)
.sheet(isPresented: Binding(
get: { showMacImagePicker && !showSidebar && viewModel.selectedPrivateChatPeer == nil },
set: { newValue in
@@ -256,15 +436,7 @@ struct ContentView: View {
}
}
}
#endif
.sheet(isPresented: Binding(
get: { imagePreviewURL != nil },
set: { presenting in if !presenting { imagePreviewURL = nil } }
)) {
if let url = imagePreviewURL {
ImagePreviewView(url: url)
}
}
#endif
.alert("Recording Error", isPresented: $showRecordingAlert, actions: {
Button("OK", role: .cancel) {}
}, message: {
@@ -285,12 +457,12 @@ struct ContentView: View {
Button("content.actions.direct_message") {
if let peerID = selectedMessageSenderID {
if peerID.isGeoChat {
if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
if peerID.hasPrefix("nostr:") {
if let full = viewModel.fullNostrHex(forSenderPeerID: PeerID(str: peerID)) {
viewModel.startGeohashDM(withPubkeyHex: full)
}
} else {
viewModel.startPrivateChat(with: peerID)
viewModel.startPrivateChat(with: PeerID(str: peerID))
}
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar = true
@@ -312,8 +484,8 @@ struct ContentView: View {
Button("content.actions.block", role: .destructive) {
// Prefer direct geohash block when we have a Nostr sender ID
if let peerID = selectedMessageSenderID, peerID.isGeoChat,
let full = viewModel.fullNostrHex(forSenderPeerID: peerID),
if let peerID = selectedMessageSenderID, peerID.hasPrefix("nostr:"),
let full = viewModel.fullNostrHex(forSenderPeerID: PeerID(str: peerID)),
let sender = selectedMessageSender {
viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender)
} else if let sender = selectedMessageSender {
@@ -794,7 +966,7 @@ struct ContentView: View {
case "user":
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let peerID = PeerID(str: id.removingPercentEncoding ?? id)
selectedMessageSenderID = peerID
selectedMessageSenderID = peerID.id
if peerID.isGeoDM || peerID.isGeoChat {
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
@@ -922,9 +1094,10 @@ struct ContentView: View {
#if os(macOS)
.frame(minWidth: 420, minHeight: 520)
#endif
// Present image picker from sheet context when IN a sheet (parent-child pattern)
// MARK: - Image Picker for DM Context (Parent-Child Pattern)
// Present image picker when IN a sheet (DM or people sheet)
#if os(iOS)
.fullScreenCover(isPresented: Binding(
.sheet(isPresented: Binding(
get: { showImagePicker && (showSidebar || viewModel.selectedPrivateChatPeer != nil) },
set: { newValue in
if !newValue {
@@ -951,7 +1124,14 @@ struct ContentView: View {
}
#endif
#if os(macOS)
.sheet(isPresented: $showMacImagePicker) {
.sheet(isPresented: Binding(
get: { showMacImagePicker && (showSidebar || viewModel.selectedPrivateChatPeer != nil) },
set: { newValue in
if !newValue {
showMacImagePicker = false
}
}
)) {
MacImagePickerView { url in
showMacImagePicker = false
if let url = url {
@@ -1213,7 +1393,12 @@ struct ContentView: View {
.foregroundColor(textColor)
if !privatePeerID.isGeoDM {
let statusPeerID = viewModel.getShortIDForNoiseKey(privatePeerID)
let statusPeerID: PeerID = {
if privatePeerID.id.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID.id) {
return short
}
return context.headerPeerID
}()
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
if let icon = encryptionStatus.icon {
Image(systemName: icon)
@@ -1247,7 +1432,13 @@ struct ContentView: View {
}
private func makePrivateHeaderContext(for privatePeerID: PeerID) -> PrivateHeaderContext {
let headerPeerID = viewModel.getShortIDForNoiseKey(privatePeerID)
let headerPeerID: PeerID = {
if privatePeerID.id.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID.id) {
return short
}
return privatePeerID
}()
let peer = viewModel.getPeer(byID: headerPeerID)
let displayName: String = {
@@ -1495,79 +1686,9 @@ struct ContentView: View {
showSidebar.toggle()
}
}
.sheet(isPresented: $showVerifySheet) {
VerificationSheetView(isPresented: $showVerifySheet)
.environmentObject(viewModel)
}
}
.frame(height: headerHeight)
.padding(.horizontal, 12)
.sheet(isPresented: $showLocationChannelsSheet) {
LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
.onAppear { viewModel.isLocationChannelsSheetPresented = true }
.onDisappear { viewModel.isLocationChannelsSheetPresented = false }
}
.sheet(isPresented: $showLocationNotes, onDismiss: {
notesGeohash = nil
}) {
Group {
if let gh = notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {
LocationNotesView(geohash: gh)
.environmentObject(viewModel)
} else {
VStack(spacing: 12) {
HStack {
Text("content.notes.title")
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
Spacer()
Button(action: { showLocationNotes = false }) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons"))
}
.frame(height: headerHeight)
.padding(.horizontal, 12)
.background(backgroundColor.opacity(0.95))
Text("content.notes.location_unavailable")
.font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
Button("content.location.enable") {
LocationChannelManager.shared.enableLocationChannels()
LocationChannelManager.shared.refreshChannels()
}
.buttonStyle(.bordered)
Spacer()
}
.background(backgroundColor)
.foregroundColor(textColor)
// per-sheet global onChange added below
}
}
.onAppear {
// Ensure we are authorized and start live location updates (distance-filtered)
LocationChannelManager.shared.enableLocationChannels()
LocationChannelManager.shared.beginLiveRefresh()
}
.onDisappear {
LocationChannelManager.shared.endLiveRefresh()
}
.onChange(of: locationManager.availableChannels) { channels in
if let current = channels.first(where: { $0.level == .building })?.geohash,
notesGeohash != current {
notesGeohash = current
#if os(iOS)
// Light taptic when geohash changes while the sheet is open
let generator = UIImpactFeedbackGenerator(style: .light)
generator.prepare()
generator.impactOccurred()
#endif
}
}
}
.onAppear {
if case .mesh = locationManager.selectedChannel,
locationManager.permissionState == .authorized,
+4 -1
View File
@@ -65,7 +65,10 @@ struct FingerprintView: View {
VStack(alignment: .leading, spacing: 16) {
// Prefer short mesh ID for session/encryption status
let statusPeerID = viewModel.getShortIDForNoiseKey(peerID)
let statusPeerID: PeerID = {
if peerID.id.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID.id) { return short }
return peerID
}()
// Resolve a friendly name
let peerNickname: String = {
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
+1 -1
View File
@@ -298,5 +298,5 @@ private final class MockBitchatDelegate: BitchatDelegate {
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
func didUpdateBluetoothState(_ state: CBManagerState) {}
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {}
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {}
}
@@ -186,11 +186,11 @@ struct PrivateChatE2ETests {
// Bob relays private messages for Charlie
bob.packetDeliveryHandler = { packet in
if let recipientID = packet.recipientID,
PeerID(data: recipientID) == charlie.peerID {
String(data: recipientID, encoding: .utf8) == charlie.peerID {
// Relay to Charlie
var relayPacket = packet
relayPacket.ttl = packet.ttl - 1
charlie.simulateIncomingPacket(relayPacket)
self.charlie.simulateIncomingPacket(relayPacket)
}
}
@@ -388,7 +388,7 @@ struct PublicChatE2ETests {
if let message = BitchatMessage(packet.payload) {
// Don't relay own messages
guard message.senderPeerID != node.peerID else { return }
guard message.senderPeerID?.id != node.peerID else { return }
// Create relay message
let relayMessage = BitchatMessage(
@@ -209,7 +209,7 @@ extension FragmentationTests {
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
func didUpdateBluetoothState(_ state: CBManagerState) {}
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
publicMessages.append((peerID, nickname, content))
}
func didReceiveRegionalPublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {}
-122
View File
@@ -125,138 +125,16 @@ struct GossipSyncManagerTests {
#expect(manager._hasAnnouncement(for: PeerID(str: peerHex)) == false)
#expect(manager._messageCount(for: PeerID(str: peerHex)) == 0)
}
@Test func maintenanceEmitsTypedSyncRequests() throws {
var config = GossipSyncManager.Config()
config.seenCapacity = 10
config.fragmentCapacity = 5
config.fileTransferCapacity = 4
config.messageSyncIntervalSeconds = 1
config.fragmentSyncIntervalSeconds = 1
config.fileTransferSyncIntervalSeconds = 1
config.maintenanceIntervalSeconds = 0
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
let delegate = RecordingDelegate()
manager.delegate = delegate
let sender = try #require(Data(hexString: "1122334455667788"))
let now = UInt64(Date().timeIntervalSince1970 * 1000)
let announcePacket = BitchatPacket(
type: MessageType.announce.rawValue,
senderID: sender,
recipientID: nil,
timestamp: now,
payload: Data(),
signature: nil,
ttl: 1
)
let messagePacket = BitchatPacket(
type: MessageType.message.rawValue,
senderID: sender,
recipientID: nil,
timestamp: now,
payload: Data([0x01]),
signature: nil,
ttl: 1
)
let fragmentPacket = BitchatPacket(
type: MessageType.fragment.rawValue,
senderID: sender,
recipientID: nil,
timestamp: now,
payload: Data([0xAA]),
signature: nil,
ttl: 1
)
let filePacket = BitchatPacket(
type: MessageType.fileTransfer.rawValue,
senderID: sender,
recipientID: nil,
timestamp: now,
payload: Data([0xBB]),
signature: nil,
ttl: 1,
version: 2
)
manager.onPublicPacketSeen(announcePacket)
manager.onPublicPacketSeen(messagePacket)
manager.onPublicPacketSeen(fragmentPacket)
manager.onPublicPacketSeen(filePacket)
manager._performMaintenanceSynchronously(now: Date())
let sentPackets = delegate.packets
#expect(sentPackets.count == 3)
let decoded = sentPackets.compactMap { RequestSyncPacket.decode(from: $0.payload) }
#expect(decoded.count == 3)
#expect(decoded[0].types == .publicMessages)
#expect(decoded[1].types == .fragment)
#expect(decoded[2].types == .fileTransfer)
}
@Test func handleRequestSyncHonorsTypeFilter() async throws {
var config = GossipSyncManager.Config()
config.seenCapacity = 5
config.fragmentCapacity = 5
config.fileTransferCapacity = 0
config.messageSyncIntervalSeconds = 0
config.fragmentSyncIntervalSeconds = 0
config.fileTransferSyncIntervalSeconds = 0
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
let delegate = RecordingDelegate()
manager.delegate = delegate
let sender = try #require(Data(hexString: "aabbccddeeff0011"))
let now = UInt64(Date().timeIntervalSince1970 * 1000)
let messagePacket = BitchatPacket(
type: MessageType.message.rawValue,
senderID: sender,
recipientID: nil,
timestamp: now,
payload: Data([0x10]),
signature: nil,
ttl: 1
)
let fragmentPacket = BitchatPacket(
type: MessageType.fragment.rawValue,
senderID: sender,
recipientID: nil,
timestamp: now,
payload: Data([0x20]),
signature: nil,
ttl: 1
)
manager.onPublicPacketSeen(messagePacket)
manager.onPublicPacketSeen(fragmentPacket)
let peer = PeerID(str: "FFFFFFFFFFFFFFFF")
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .fragment)
manager.handleRequestSync(from: peer, request: request)
try await sleep(0.01)
let sentPackets = delegate.packets
#expect(sentPackets.count == 1)
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
}
}
private final class RecordingDelegate: GossipSyncManager.Delegate {
var onSend: (() -> Void)?
private(set) var lastPacket: BitchatPacket?
private(set) var packets: [BitchatPacket] = []
private let lock = NSLock()
func sendPacket(_ packet: BitchatPacket) {
lock.lock()
lastPacket = packet
packets.append(packet)
lock.unlock()
onSend?()
}
-192
View File
@@ -1,192 +0,0 @@
//
// InputValidatorTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
@testable import bitchat
struct InputValidatorTests {
// MARK: - Basic Validation Tests
@Test func validStringPassesValidation() throws {
let result = InputValidator.validateUserString("Hello World", maxLength: 100)
#expect(result == "Hello World")
}
@Test func emptyStringReturnsNil() throws {
let result = InputValidator.validateUserString("", maxLength: 100)
#expect(result == nil)
}
@Test func whitespaceOnlyStringReturnsNil() throws {
let result = InputValidator.validateUserString(" \n\t ", maxLength: 100)
#expect(result == nil)
}
@Test func stringExceedingMaxLengthReturnsNil() throws {
let longString = String(repeating: "a", count: 101)
let result = InputValidator.validateUserString(longString, maxLength: 100)
#expect(result == nil)
}
@Test func stringAtMaxLengthIsAccepted() throws {
let exactString = String(repeating: "a", count: 100)
let result = InputValidator.validateUserString(exactString, maxLength: 100)
#expect(result == exactString)
}
@Test func whitespaceIsTrimmed() throws {
let result = InputValidator.validateUserString(" Hello ", maxLength: 100)
#expect(result == "Hello")
}
// MARK: - Control Character Tests
@Test func nullCharacterIsRejected() throws {
let stringWithNull = "Hello\u{0000}World"
let result = InputValidator.validateUserString(stringWithNull, maxLength: 100)
#expect(result == nil)
}
@Test func bellCharacterIsRejected() throws {
let stringWithBell = "Hello\u{0007}World"
let result = InputValidator.validateUserString(stringWithBell, maxLength: 100)
#expect(result == nil)
}
@Test func backspaceCharacterIsRejected() throws {
let stringWithBackspace = "Hello\u{0008}World"
let result = InputValidator.validateUserString(stringWithBackspace, maxLength: 100)
#expect(result == nil)
}
@Test func escapeCharacterIsRejected() throws {
let stringWithEscape = "Hello\u{001B}World"
let result = InputValidator.validateUserString(stringWithEscape, maxLength: 100)
#expect(result == nil)
}
@Test func deleteCharacterIsRejected() throws {
let stringWithDelete = "Hello\u{007F}World"
let result = InputValidator.validateUserString(stringWithDelete, maxLength: 100)
#expect(result == nil)
}
@Test func multipleControlCharactersAreRejected() throws {
let stringWithMultiple = "Hello\u{0000}\u{0007}\u{001B}World"
let result = InputValidator.validateUserString(stringWithMultiple, maxLength: 100)
#expect(result == nil)
}
// MARK: - Unicode and Special Character Tests
@Test func emojiIsAccepted() throws {
let result = InputValidator.validateUserString("Hello 👋 World", maxLength: 100)
#expect(result == "Hello 👋 World")
}
@Test func unicodeCharactersAreAccepted() throws {
let result = InputValidator.validateUserString("Hello 世界 مرحبا", maxLength: 100)
#expect(result == "Hello 世界 مرحبا")
}
@Test func specialCharactersAreAccepted() throws {
let result = InputValidator.validateUserString("Hello!@#$%^&*()_+-=[]{}|;':\",./<>?", maxLength: 100)
#expect(result == "Hello!@#$%^&*()_+-=[]{}|;':\",./<>?")
}
// MARK: - Nickname Validation Tests
@Test func validNicknameIsAccepted() throws {
let result = InputValidator.validateNickname("Alice")
#expect(result == "Alice")
}
@Test func nicknameWithEmojiIsAccepted() throws {
let result = InputValidator.validateNickname("Alice 🚀")
#expect(result == "Alice 🚀")
}
@Test func nicknameTooLongIsRejected() throws {
let longNickname = String(repeating: "a", count: 51)
let result = InputValidator.validateNickname(longNickname)
#expect(result == nil)
}
@Test func nicknameAtMaxLengthIsAccepted() throws {
let exactNickname = String(repeating: "a", count: 50)
let result = InputValidator.validateNickname(exactNickname)
#expect(result == exactNickname)
}
@Test func nicknameWithControlCharacterIsRejected() throws {
let result = InputValidator.validateNickname("Alice\u{0000}")
#expect(result == nil)
}
// MARK: - Timestamp Validation Tests
@Test func currentTimestampIsValid() throws {
let now = Date()
let result = InputValidator.validateTimestamp(now)
#expect(result == true)
}
@Test func timestampWithinOneHourIsValid() throws {
let thirtyMinutesAgo = Date().addingTimeInterval(-30 * 60)
let result = InputValidator.validateTimestamp(thirtyMinutesAgo)
#expect(result == true)
}
@Test func timestampTwoHoursAgoIsInvalid() throws {
let twoHoursAgo = Date().addingTimeInterval(-2 * 3600)
let result = InputValidator.validateTimestamp(twoHoursAgo)
#expect(result == false)
}
@Test func timestampTwoHoursInFutureIsInvalid() throws {
let twoHoursFromNow = Date().addingTimeInterval(2 * 3600)
let result = InputValidator.validateTimestamp(twoHoursFromNow)
#expect(result == false)
}
@Test func timestampAtOneHourBoundaryIsValid() throws {
// Just slightly within the one-hour window
let almostOneHourAgo = Date().addingTimeInterval(-3599)
let result = InputValidator.validateTimestamp(almostOneHourAgo)
#expect(result == true)
}
// MARK: - Edge Cases
@Test func singleCharacterStringIsAccepted() throws {
let result = InputValidator.validateUserString("a", maxLength: 100)
#expect(result == "a")
}
@Test func stringWithOnlyNewlinesIsRejected() throws {
let result = InputValidator.validateUserString("\n\n\n", maxLength: 100)
#expect(result == nil)
}
@Test func stringWithMixedWhitespaceIsTrimmed() throws {
let result = InputValidator.validateUserString(" \t\nHello\n\t ", maxLength: 100)
#expect(result == "Hello")
}
@Test func stringWithLeadingControlCharacterIsRejected() throws {
let result = InputValidator.validateUserString("\u{0000}Hello", maxLength: 100)
#expect(result == nil)
}
@Test func stringWithTrailingControlCharacterIsRejected() throws {
let result = InputValidator.validateUserString("Hello\u{0000}", maxLength: 100)
#expect(result == nil)
}
}
-90
View File
@@ -1,90 +0,0 @@
//
// MimeTypeTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
@testable import bitchat
// MARK: - MimeType Mapping and Signature Tests
struct MimeTypeTests {
// MARK: MIME Enum Parsing + Default Extension
@Test(arguments: [
("image/jpeg", MimeType.jpeg, "jpg"),
("image/jpg", MimeType.jpeg, "jpg"),
("image/png", MimeType.png, "png"),
("image/gif", MimeType.gif, "gif"),
("image/webp", MimeType.webp, "webp"),
("audio/mp4", MimeType.mp4Audio, "m4a"),
("audio/m4a", MimeType.m4a, "m4a"),
("audio/aac", MimeType.aac, "m4a"),
("audio/mpeg", MimeType.mpeg, "mp3"),
("audio/mp3", MimeType.mp3, "mp3"),
("audio/wav", MimeType.wav, "wav"),
("audio/x-wav", MimeType.xWav, "wav"),
("audio/ogg", MimeType.ogg, "ogg"),
("application/pdf", MimeType.pdf, "pdf"),
("application/octet-stream", MimeType.octetStream, "bin")
])
func mimeTypeParsingAndExtensions(
mimeString: String,
expectedType: MimeType,
expectedExt: String
) throws {
guard let mime = MimeType(mimeString) else {
Issue.record("Failed to parse \(mimeString)")
return
}
#expect(mime == expectedType, "Expected \(expectedType) for \(mimeString)")
#expect(mime.mimeString == expectedType.mimeString)
#expect(mime.defaultExtension == expectedExt)
#expect(mime.isAllowed)
}
// MARK: - File Signature Validation
@Test(arguments: [
// === Image types ===
(MimeType.jpeg, [0xFF, 0xD8, 0xFF]),
(MimeType.png, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
(MimeType.gif, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // "GIF89a"
(MimeType.webp, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
0x57, 0x45, 0x42, 0x50]), // "RIFF....WEBP"
// === Audio types ===
(MimeType.mp3, [0x49, 0x44, 0x33]), // "ID3"
(MimeType.wav, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
0x57, 0x41, 0x56, 0x45]), // "RIFF....WAVE"
(MimeType.ogg, [0x4F, 0x67, 0x67, 0x53]), // "OggS"
// === Application types ===
(MimeType.pdf, [0x25, 0x50, 0x44, 0x46]) // "%PDF"
])
func validSignatures(mime: MimeType, bytes: [UInt8]) throws {
let data = Data(bytes)
#expect(mime.matches(data: data),
"Expected \(mime.mimeString) to match its signature")
}
// MARK: - Negative Tests
@Test func invalidDataDoesNotMatch() throws {
let badData = Data(repeating: 0x00, count: 16)
for mime in MimeType.allCases where mime != .octetStream {
#expect(!mime.matches(data: badData),
"Unexpectedly matched \(mime.mimeString) with zeroed data")
}
}
// MARK: - Octet-stream (generic binary)
@Test func octetStreamAlwaysMatches() throws {
let randomData = Data([0x00, 0x11, 0x22, 0x33])
#expect(MimeType.octetStream.matches(data: randomData),
"application/octet-stream should always be considered valid")
}
}
+4 -1
View File
@@ -203,7 +203,10 @@ final class MockBLEService: NSObject {
let target = bus.service(for: recipientPeerID) {
target.simulateIncomingPacket(packet)
} else {
// Not directly connected: deliver to neighbors for relay
// Not directly connected: deliver to neighbors for relay; also deliver directly if target is known
if let target = bus.service(for: recipientPeerID) {
target.simulateIncomingPacket(packet)
}
for neighbor in neighbors() where neighbor.peerID != recipientPeerID {
neighbor.simulateIncomingPacket(packet)
}
+36 -10
View File
@@ -273,7 +273,7 @@ struct PeerIDTests {
@Test func comparable_sorting_and_equality() {
let p1 = PeerID(str: "aaa")
let p2 = PeerID(str: "bbb")
let p3 = PeerID(str: "BBB")
let p3 = PeerID(str: "bbb")
#expect(p1 < p2)
#expect(p2 >= p1)
@@ -284,18 +284,44 @@ struct PeerIDTests {
}
@Test func equality() {
let peerID = PeerID(str: "aaa")
let string = "aaa"
let peerID = PeerID(str: string)
let badString = "bbb"
// PeerID == String
#expect(peerID == string)
#expect(peerID == Optional(string))
#expect(Optional(peerID) == string)
#expect(Optional(peerID) == Optional(string))
// PeerID != String
#expect(peerID != badString)
#expect(peerID != Optional(badString))
#expect(Optional(peerID) != badString)
#expect(Optional(peerID) != Optional(badString))
// String == PeerID
#expect(string == peerID)
#expect(Optional(string) == peerID)
#expect(string == Optional(peerID))
#expect(Optional(string) == Optional(peerID))
// String != PeerID
#expect(badString != peerID)
#expect(Optional(badString) != peerID)
#expect(badString != Optional(peerID))
#expect(Optional(badString) != Optional(peerID))
// Regular PeerID <> PeerID
#expect(peerID == PeerID(str: "AAA"))
#expect(peerID == Optional(PeerID(str: "AAA")))
#expect(PeerID(str: "AAA") == peerID)
#expect(Optional(PeerID(str: "AAA")) == Optional(peerID))
#expect(peerID == PeerID(str: "aaa"))
#expect(peerID == Optional(PeerID(str: "aaa")))
#expect(PeerID(str: "aaa") == peerID)
#expect(Optional(PeerID(str: "aaa")) == Optional(peerID))
#expect(peerID != PeerID(str: "BBB"))
#expect(peerID != Optional(PeerID(str: "BBB")))
#expect(PeerID(str: "BBB") != peerID)
#expect(Optional(PeerID(str: "BBB")) != Optional(peerID))
#expect(peerID != PeerID(str: "bbb"))
#expect(peerID != Optional(PeerID(str: "bbb")))
#expect(PeerID(str: "bbb") != peerID)
#expect(Optional(PeerID(str: "bbb")) != Optional(peerID))
}
// MARK: - Computed properties