mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 06:25:22 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14c4e586fc | ||
|
|
83779240ae | ||
|
|
5034732515 | ||
|
|
b6ce4fae43 | ||
|
|
98fa02cc16 | ||
|
|
0812cafd55 | ||
|
|
450955525c | ||
|
|
475bc70c71 | ||
|
|
af6136c01c | ||
|
|
b839ce5f6c | ||
|
|
0776c9813c | ||
|
|
0dd999af6b | ||
|
|
c3a1af7023 | ||
|
|
880813f256 | ||
|
|
64fb634166 |
@@ -98,8 +98,8 @@
|
|||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
<EnvironmentVariables>
|
<EnvironmentVariables>
|
||||||
<EnvironmentVariable
|
<EnvironmentVariable
|
||||||
key = "-DBITCHAT_DEV_ALLOW_CLEARNET"
|
key = "BITCHAT_LOG_LEVEL"
|
||||||
value = ""
|
value = "debug"
|
||||||
isEnabled = "YES">
|
isEnabled = "YES">
|
||||||
</EnvironmentVariable>
|
</EnvironmentVariable>
|
||||||
</EnvironmentVariables>
|
</EnvironmentVariables>
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
// Get peer ID from userInfo
|
// Get peer ID from userInfo
|
||||||
if let peerID = userInfo["peerID"] as? String {
|
if let peerID = userInfo["peerID"] as? String {
|
||||||
// Don't show notification if the private chat is already open
|
// Don't show notification if the private chat is already open
|
||||||
if chatViewModel?.selectedPrivateChatPeer == peerID {
|
if chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) {
|
||||||
completionHandler([])
|
completionHandler([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ enum ImageUtilsError: Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum ImageUtils {
|
enum ImageUtils {
|
||||||
private static let compressionQuality: CGFloat = 0.85
|
private static let compressionQuality: CGFloat = 0.82
|
||||||
private static let targetImageBytes: Int = 60_000
|
private static let targetImageBytes: Int = 45_000
|
||||||
|
|
||||||
static func processImage(at url: URL, maxDimension: CGFloat = 512) throws -> URL {
|
static func processImage(at url: URL, maxDimension: CGFloat = 448) throws -> URL {
|
||||||
// Security H1: Check file size BEFORE reading into memory
|
// Security H1: Check file size BEFORE reading into memory
|
||||||
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
|
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||||
guard let fileSize = attrs[.size] as? Int else {
|
guard let fileSize = attrs[.size] as? Int else {
|
||||||
@@ -38,7 +38,7 @@ enum ImageUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 512) throws -> URL {
|
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448) throws -> URL {
|
||||||
return try autoreleasepool {
|
return try autoreleasepool {
|
||||||
// Scale the image first
|
// Scale the image first
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
@@ -106,7 +106,7 @@ enum ImageUtils {
|
|||||||
return data as Data
|
return data as Data
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = 512) throws -> URL {
|
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448) throws -> URL {
|
||||||
return try autoreleasepool {
|
return try autoreleasepool {
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
|||||||
|
|
||||||
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
|
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
|
||||||
private let paddingInterval: TimeInterval = 0.5
|
private let paddingInterval: TimeInterval = 0.5
|
||||||
|
private let maxRecordingDuration: TimeInterval = 120
|
||||||
|
|
||||||
private var recorder: AVAudioRecorder?
|
private var recorder: AVAudioRecorder?
|
||||||
private var currentURL: URL?
|
private var currentURL: URL?
|
||||||
@@ -75,14 +76,14 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
|||||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||||
AVSampleRateKey: 16_000,
|
AVSampleRateKey: 16_000,
|
||||||
AVNumberOfChannelsKey: 1,
|
AVNumberOfChannelsKey: 1,
|
||||||
AVEncoderBitRateKey: 20_000
|
AVEncoderBitRateKey: 16_000
|
||||||
]
|
]
|
||||||
|
|
||||||
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
||||||
audioRecorder.delegate = self
|
audioRecorder.delegate = self
|
||||||
audioRecorder.isMeteringEnabled = true
|
audioRecorder.isMeteringEnabled = true
|
||||||
audioRecorder.prepareToRecord()
|
audioRecorder.prepareToRecord()
|
||||||
audioRecorder.record()
|
audioRecorder.record(forDuration: maxRecordingDuration)
|
||||||
|
|
||||||
recorder = audioRecorder
|
recorder = audioRecorder
|
||||||
currentURL = outputURL
|
currentURL = outputURL
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ struct PeerID: Equatable, Hashable {
|
|||||||
// Private so the callers have to go through a convenience init
|
// Private so the callers have to go through a convenience init
|
||||||
private init(prefix: Prefix, bare: any StringProtocol) {
|
private init(prefix: Prefix, bare: any StringProtocol) {
|
||||||
self.prefix = prefix
|
self.prefix = prefix
|
||||||
self.bare = String(bare)
|
self.bare = String(bare).lowercased()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +76,12 @@ extension PeerID {
|
|||||||
init(hexData: Data) {
|
init(hexData: Data) {
|
||||||
self.init(str: hexData.hexEncodedString())
|
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
|
// MARK: - Noise Public Key Helpers
|
||||||
@@ -191,9 +197,7 @@ extension PeerID: Comparable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - String Interop Helpers
|
// MARK: - CustomStringConvertible
|
||||||
|
|
||||||
// MARK: CustomStringConvertible
|
|
||||||
|
|
||||||
extension PeerID: CustomStringConvertible {
|
extension PeerID: CustomStringConvertible {
|
||||||
/// So it returns the actual `id` like before even inside another String
|
/// So it returns the actual `id` like before even inside another String
|
||||||
@@ -201,17 +205,3 @@ extension PeerID: CustomStringConvertible {
|
|||||||
id
|
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 }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ struct RequestSyncPacket {
|
|||||||
let p: Int
|
let p: Int
|
||||||
let m: UInt32
|
let m: UInt32
|
||||||
let data: Data
|
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 {
|
func encode() -> Data {
|
||||||
var out = Data()
|
var out = Data()
|
||||||
@@ -25,6 +33,9 @@ struct RequestSyncPacket {
|
|||||||
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
|
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
|
||||||
// data
|
// data
|
||||||
putTLV(0x03, data)
|
putTLV(0x03, data)
|
||||||
|
if let typesData = types?.toData() {
|
||||||
|
putTLV(0x04, typesData)
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +44,7 @@ struct RequestSyncPacket {
|
|||||||
var p: Int? = nil
|
var p: Int? = nil
|
||||||
var m: UInt32? = nil
|
var m: UInt32? = nil
|
||||||
var payload: Data? = nil
|
var payload: Data? = nil
|
||||||
|
var types: SyncTypeFlags? = nil
|
||||||
|
|
||||||
while off + 3 <= data.count {
|
while off + 3 <= data.count {
|
||||||
let t = Int(data[off]); off += 1
|
let t = Int(data[off]); off += 1
|
||||||
@@ -52,12 +64,16 @@ struct RequestSyncPacket {
|
|||||||
case 0x03:
|
case 0x03:
|
||||||
if v.count > maxAcceptBytes { return nil }
|
if v.count > maxAcceptBytes { return nil }
|
||||||
payload = v
|
payload = v
|
||||||
|
case 0x04:
|
||||||
|
if let decoded = SyncTypeFlags.decode(v) {
|
||||||
|
types = decoded
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
break // forward compatible; ignore unknown TLVs
|
break // forward compatible; ignore unknown TLVs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
|
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)
|
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,7 +284,10 @@ final class GeoRelayDirectory {
|
|||||||
object: nil,
|
object: nil,
|
||||||
queue: .main
|
queue: .main
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
self?.prefetchIfNeeded(force: true)
|
guard let self else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
self.prefetchIfNeeded(force: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
observers.append(torReady)
|
observers.append(torReady)
|
||||||
|
|
||||||
@@ -294,7 +297,10 @@ final class GeoRelayDirectory {
|
|||||||
object: nil,
|
object: nil,
|
||||||
queue: .main
|
queue: .main
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
self?.prefetchIfNeeded()
|
guard let self else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
self.prefetchIfNeeded()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
observers.append(didBecomeActive)
|
observers.append(didBecomeActive)
|
||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
@@ -303,7 +309,10 @@ final class GeoRelayDirectory {
|
|||||||
object: nil,
|
object: nil,
|
||||||
queue: .main
|
queue: .main
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
self?.prefetchIfNeeded()
|
guard let self else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
self.prefetchIfNeeded()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
observers.append(didBecomeActive)
|
observers.append(didBecomeActive)
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ protocol BitchatDelegate: AnyObject {
|
|||||||
|
|
||||||
// Bluetooth state updates for user notifications
|
// Bluetooth state updates for user notifications
|
||||||
func didUpdateBluetoothState(_ state: CBManagerState)
|
func didUpdateBluetoothState(_ state: CBManagerState)
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date)
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provide default implementation to make it effectively optional
|
// Provide default implementation to make it effectively optional
|
||||||
@@ -195,7 +195,7 @@ extension BitchatDelegate {
|
|||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -177,18 +177,18 @@ final class NoiseEncryptionService {
|
|||||||
private let rekeyCheckInterval: TimeInterval = 60.0 // Check every minute
|
private let rekeyCheckInterval: TimeInterval = 60.0 // Check every minute
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication
|
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
|
||||||
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
||||||
|
|
||||||
// Add a handler for peer authentication
|
// Add a handler for peer authentication
|
||||||
func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) {
|
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
|
||||||
serviceQueue.async(flags: .barrier) { [weak self] in
|
serviceQueue.async(flags: .barrier) { [weak self] in
|
||||||
self?.onPeerAuthenticatedHandlers.append(handler)
|
self?.onPeerAuthenticatedHandlers.append(handler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy support - setting this will add to the handlers array
|
// Legacy support - setting this will add to the handlers array
|
||||||
var onPeerAuthenticated: ((String, String) -> Void)? {
|
var onPeerAuthenticated: ((PeerID, String) -> Void)? {
|
||||||
get { nil } // Always return nil for backward compatibility
|
get { nil } // Always return nil for backward compatibility
|
||||||
set {
|
set {
|
||||||
if let handler = newValue {
|
if let handler = newValue {
|
||||||
@@ -546,7 +546,7 @@ final class NoiseEncryptionService {
|
|||||||
// Notify all handlers about authentication
|
// Notify all handlers about authentication
|
||||||
serviceQueue.async { [weak self] in
|
serviceQueue.async { [weak self] in
|
||||||
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
||||||
handler(peerID.id, fingerprint)
|
handler(peerID, fingerprint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,28 +29,30 @@ final class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendLocalNotification(title: String, body: String, identifier: String, userInfo: [String: Any]? = nil) {
|
func sendLocalNotification(
|
||||||
// For now, skip app state check entirely to avoid thread issues
|
title: String,
|
||||||
// The NotificationDelegate will handle foreground presentation
|
body: String,
|
||||||
DispatchQueue.main.async {
|
identifier: String,
|
||||||
let content = UNMutableNotificationContent()
|
userInfo: [String: Any]? = nil,
|
||||||
content.title = title
|
interruptionLevel: UNNotificationInterruptionLevel = .active
|
||||||
content.body = body
|
) {
|
||||||
content.sound = .default
|
let content = UNMutableNotificationContent()
|
||||||
if let userInfo = userInfo {
|
content.title = title
|
||||||
content.userInfo = userInfo
|
content.body = body
|
||||||
}
|
content.sound = .default
|
||||||
|
content.interruptionLevel = interruptionLevel
|
||||||
let request = UNNotificationRequest(
|
|
||||||
identifier: identifier,
|
if let userInfo = userInfo {
|
||||||
content: content,
|
content.userInfo = userInfo
|
||||||
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) {
|
func sendMentionNotification(from sender: String, message: String) {
|
||||||
@@ -61,11 +63,11 @@ final class NotificationService {
|
|||||||
sendLocalNotification(title: title, body: body, identifier: identifier)
|
sendLocalNotification(title: title, body: body, identifier: identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) {
|
func sendPrivateMessageNotification(from sender: String, message: String, peerID: PeerID) {
|
||||||
let title = "🔒 DM from \(sender)"
|
let title = "🔒 DM from \(sender)"
|
||||||
let body = message
|
let body = message
|
||||||
let identifier = "private-\(UUID().uuidString)"
|
let identifier = "private-\(UUID().uuidString)"
|
||||||
let userInfo = ["peerID": peerID, "senderName": sender]
|
let userInfo = ["peerID": peerID.id, "senderName": sender]
|
||||||
|
|
||||||
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
|
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
|
||||||
}
|
}
|
||||||
@@ -83,25 +85,12 @@ final class NotificationService {
|
|||||||
let title = "👥 bitchatters nearby!"
|
let title = "👥 bitchatters nearby!"
|
||||||
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
|
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
|
||||||
let identifier = "network-available-\(Date().timeIntervalSince1970)"
|
let identifier = "network-available-\(Date().timeIntervalSince1970)"
|
||||||
|
|
||||||
// For network notifications, we want to show them even in foreground
|
sendLocalNotification(
|
||||||
// No app state check - let the notification delegate handle presentation
|
title: title,
|
||||||
DispatchQueue.main.async {
|
body: body,
|
||||||
let content = UNMutableNotificationContent()
|
identifier: identifier,
|
||||||
content.title = title
|
interruptionLevel: .timeSensitive
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ struct RelayController {
|
|||||||
senderIsSelf: Bool,
|
senderIsSelf: Bool,
|
||||||
isEncrypted: Bool,
|
isEncrypted: Bool,
|
||||||
isDirectedEncrypted: Bool,
|
isDirectedEncrypted: Bool,
|
||||||
|
isFragment: Bool,
|
||||||
isDirectedFragment: Bool,
|
isDirectedFragment: Bool,
|
||||||
isHandshake: Bool,
|
isHandshake: Bool,
|
||||||
isAnnounce: Bool,
|
isAnnounce: Bool,
|
||||||
@@ -36,6 +37,16 @@ struct RelayController {
|
|||||||
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
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
|
// TTL clamping for broadcast
|
||||||
// - Dense graphs: keep lower but still allow multi-hop bridging
|
// - Dense graphs: keep lower but still allow multi-hop bridging
|
||||||
// - Announces get a bit more headroom
|
// - Announces get a bit more headroom
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ protocol Transport: AnyObject {
|
|||||||
|
|
||||||
// Messaging
|
// Messaging
|
||||||
func sendMessage(_ content: String, mentions: [String])
|
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 sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
||||||
@@ -65,6 +66,10 @@ extension Transport {
|
|||||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
||||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
||||||
func cancelTransfer(_ transferId: String) {}
|
func cancelTransfer(_ transferId: String) {}
|
||||||
|
|
||||||
|
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
||||||
|
sendMessage(content, mentions: mentions)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol TransportPeerEventsDelegate: AnyObject {
|
protocol TransportPeerEventsDelegate: AnyObject {
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ enum TransportConfig {
|
|||||||
static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding
|
static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding
|
||||||
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
|
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
|
||||||
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
|
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
|
// UI / Storage Caps
|
||||||
static let privateChatCap: Int = 1337
|
static let privateChatCap: Int = 1337
|
||||||
@@ -66,6 +70,7 @@ enum TransportConfig {
|
|||||||
static let uiAnimationMediumSeconds: TimeInterval = 0.2
|
static let uiAnimationMediumSeconds: TimeInterval = 0.2
|
||||||
static let uiAnimationSidebarSeconds: TimeInterval = 0.25
|
static let uiAnimationSidebarSeconds: TimeInterval = 0.25
|
||||||
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
|
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
|
||||||
|
static let uiMeshEmptyConfirmationSeconds: TimeInterval = 30.0
|
||||||
|
|
||||||
// BLE maintenance & thresholds
|
// BLE maintenance & thresholds
|
||||||
static let bleMaintenanceInterval: TimeInterval = 5.0
|
static let bleMaintenanceInterval: TimeInterval = 5.0
|
||||||
|
|||||||
@@ -8,6 +8,55 @@ final class GossipSyncManager {
|
|||||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
|
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 {
|
struct Config {
|
||||||
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
|
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
|
||||||
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
||||||
@@ -16,25 +65,43 @@ final class GossipSyncManager {
|
|||||||
var maintenanceIntervalSeconds: TimeInterval = 30.0
|
var maintenanceIntervalSeconds: TimeInterval = 30.0
|
||||||
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
||||||
var stalePeerTimeoutSeconds: 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 myPeerID: PeerID
|
||||||
private let config: Config
|
private let config: Config
|
||||||
weak var delegate: Delegate?
|
weak var delegate: Delegate?
|
||||||
|
|
||||||
// Storage: broadcast messages (ordered by insert), and latest announce per sender
|
// Storage: broadcast packets by type, and latest announce per sender
|
||||||
private var messages: [String: BitchatPacket] = [:] // idHex -> packet
|
private var messages = PacketStore()
|
||||||
private var messageOrder: [String] = []
|
private var fragments = PacketStore()
|
||||||
private var latestAnnouncementByPeer: [String: (id: String, packet: BitchatPacket)] = [:]
|
private var fileTransfers = PacketStore()
|
||||||
|
private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:]
|
||||||
|
|
||||||
// Timer
|
// Timer
|
||||||
private var periodicTimer: DispatchSourceTimer?
|
private var periodicTimer: DispatchSourceTimer?
|
||||||
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
||||||
private var lastStalePeerCleanup: Date = .distantPast
|
private var lastStalePeerCleanup: Date = .distantPast
|
||||||
|
private var syncSchedules: [SyncSchedule] = []
|
||||||
|
|
||||||
init(myPeerID: PeerID, config: Config = Config()) {
|
init(myPeerID: PeerID, config: Config = Config()) {
|
||||||
self.myPeerID = myPeerID
|
self.myPeerID = myPeerID
|
||||||
self.config = config
|
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() {
|
func start() {
|
||||||
@@ -55,7 +122,18 @@ final class GossipSyncManager {
|
|||||||
|
|
||||||
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
|
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
|
||||||
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
||||||
self?.sendRequestSync(to: peerID)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,47 +165,45 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
||||||
let mt = MessageType(rawValue: packet.type)
|
guard let messageType = MessageType(rawValue: packet.type) else { return }
|
||||||
let isBroadcastRecipient: Bool = {
|
let isBroadcastRecipient: Bool = {
|
||||||
guard let r = packet.recipientID else { return true }
|
guard let r = packet.recipientID else { return true }
|
||||||
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
|
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
|
||||||
}()
|
}()
|
||||||
let isBroadcastMessage = (mt == .message && isBroadcastRecipient)
|
|
||||||
let isAnnounce = (mt == .announce)
|
|
||||||
guard isBroadcastMessage || isAnnounce else { return }
|
|
||||||
|
|
||||||
// Reject expired packets to prevent ghost peers and old messages
|
switch messageType {
|
||||||
guard isPacketFresh(packet) else { return }
|
case .announce:
|
||||||
|
guard isPacketFresh(packet) else { return }
|
||||||
if isAnnounce {
|
|
||||||
guard isAnnouncementFresh(packet) else {
|
guard isAnnouncementFresh(packet) else {
|
||||||
let sender = packet.senderID.hexEncodedString().lowercased()
|
let sender = PeerID(hexData: packet.senderID)
|
||||||
removeState(forNormalizedPeerID: sender)
|
removeState(for: sender)
|
||||||
return
|
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)
|
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() {
|
private func sendRequestSync(for types: SyncTypeFlags) {
|
||||||
let payload = buildGcsPayload()
|
let payload = buildGcsPayload(for: types)
|
||||||
let pkt = BitchatPacket(
|
let pkt = BitchatPacket(
|
||||||
type: MessageType.requestSync.rawValue,
|
type: MessageType.requestSync.rawValue,
|
||||||
senderID: Data(hexString: myPeerID.id) ?? Data(),
|
senderID: Data(hexString: myPeerID.id) ?? Data(),
|
||||||
@@ -141,8 +217,8 @@ final class GossipSyncManager {
|
|||||||
delegate?.sendPacket(signed)
|
delegate?.sendPacket(signed)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendRequestSync(to peerID: PeerID) {
|
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) {
|
||||||
let payload = buildGcsPayload()
|
let payload = buildGcsPayload(for: types)
|
||||||
var recipient = Data()
|
var recipient = Data()
|
||||||
var temp = peerID.id
|
var temp = peerID.id
|
||||||
while temp.count >= 2 && recipient.count < 8 {
|
while temp.count >= 2 && recipient.count < 8 {
|
||||||
@@ -170,6 +246,7 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
|
private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
|
||||||
|
let requestedTypes = (request.types ?? .publicMessages)
|
||||||
// Decode GCS into sorted set and prepare membership checker
|
// Decode GCS into sorted set and prepare membership checker
|
||||||
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
||||||
func mightContain(_ id: Data) -> Bool {
|
func mightContain(_ id: Data) -> Bool {
|
||||||
@@ -177,60 +254,100 @@ final class GossipSyncManager {
|
|||||||
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) Announcements: send latest per peer if requester lacks them (and not expired)
|
if requestedTypes.contains(.announce) {
|
||||||
for (_, pair) in latestAnnouncementByPeer {
|
for (_, pair) in latestAnnouncementByPeer {
|
||||||
let (idHex, pkt) = pair
|
let (idHex, pkt) = pair
|
||||||
guard isPacketFresh(pkt) else { continue }
|
guard isPacketFresh(pkt) else { continue }
|
||||||
let idBytes = Data(hexString: idHex) ?? Data()
|
let idBytes = Data(hexString: idHex) ?? Data()
|
||||||
if !mightContain(idBytes) {
|
if !mightContain(idBytes) {
|
||||||
var toSend = pkt
|
var toSend = pkt
|
||||||
toSend.ttl = 0
|
toSend.ttl = 0
|
||||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Broadcast messages: send all missing (and not expired)
|
if requestedTypes.contains(.message) {
|
||||||
let toSendMsgs = messageOrder.compactMap { messages[$0] }
|
let toSendMsgs = messages.allPackets(isFresh: isPacketFresh)
|
||||||
for pkt in toSendMsgs {
|
for pkt in toSendMsgs {
|
||||||
guard isPacketFresh(pkt) else { continue }
|
let idBytes = PacketIdUtil.computeId(pkt)
|
||||||
let idBytes = PacketIdUtil.computeId(pkt)
|
if !mightContain(idBytes) {
|
||||||
if !mightContain(idBytes) {
|
var toSend = pkt
|
||||||
var toSend = pkt
|
toSend.ttl = 0
|
||||||
toSend.ttl = 0
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build REQUEST_SYNC payload using current candidates and GCS params
|
// Build REQUEST_SYNC payload using current candidates and GCS params
|
||||||
private func buildGcsPayload() -> Data {
|
private func buildGcsPayload(for types: SyncTypeFlags) -> Data {
|
||||||
// Collect candidates: latest announce per peer + broadcast messages (only fresh)
|
|
||||||
var candidates: [BitchatPacket] = []
|
var candidates: [BitchatPacket] = []
|
||||||
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count)
|
if types.contains(.announce) {
|
||||||
for (_, pair) in latestAnnouncementByPeer {
|
for (_, pair) in latestAnnouncementByPeer where isPacketFresh(pair.packet) {
|
||||||
if isPacketFresh(pair.packet) {
|
|
||||||
candidates.append(pair.packet)
|
candidates.append(pair.packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for id in messageOrder {
|
if types.contains(.message) {
|
||||||
if let p = messages[id], isPacketFresh(p) {
|
candidates.append(contentsOf: messages.allPackets(isFresh: isPacketFresh))
|
||||||
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
|
// Sort by timestamp desc
|
||||||
candidates.sort { $0.timestamp > $1.timestamp }
|
candidates.sort { $0.timestamp > $1.timestamp }
|
||||||
|
|
||||||
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
|
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
|
||||||
let nMax = GCSFilter.estimateMaxElements(sizeBytes: config.gcsMaxBytes, p: p)
|
let nMax = GCSFilter.estimateMaxElements(sizeBytes: config.gcsMaxBytes, p: p)
|
||||||
let cap = max(1, config.seenCapacity)
|
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 takeN = min(candidates.count, min(nMax, cap))
|
let takeN = min(candidates.count, min(nMax, cap))
|
||||||
if takeN <= 0 {
|
if takeN <= 0 {
|
||||||
let req = RequestSyncPacket(p: p, m: 1, data: Data())
|
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
|
||||||
return req.encode()
|
return req.encode()
|
||||||
}
|
}
|
||||||
let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) }
|
let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) }
|
||||||
let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr)
|
let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr)
|
||||||
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data)
|
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types)
|
||||||
return req.encode()
|
return req.encode()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,20 +358,21 @@ final class GossipSyncManager {
|
|||||||
isPacketFresh(pair.packet)
|
isPacketFresh(pair.packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove expired messages
|
messages.removeExpired(isFresh: isPacketFresh)
|
||||||
let expiredMessageIds = messages.compactMap { id, pkt in
|
fragments.removeExpired(isFresh: isPacketFresh)
|
||||||
isPacketFresh(pkt) ? nil : id
|
fileTransfers.removeExpired(isFresh: isPacketFresh)
|
||||||
}
|
|
||||||
for id in expiredMessageIds {
|
|
||||||
messages.removeValue(forKey: id)
|
|
||||||
messageOrder.removeAll { $0 == id }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func performPeriodicMaintenance(now: Date = Date()) {
|
private func performPeriodicMaintenance(now: Date = Date()) {
|
||||||
cleanupExpiredMessages()
|
cleanupExpiredMessages()
|
||||||
cleanupStaleAnnouncementsIfNeeded(now: now)
|
cleanupStaleAnnouncementsIfNeeded(now: now)
|
||||||
sendRequestSync()
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
||||||
@@ -270,40 +388,27 @@ final class GossipSyncManager {
|
|||||||
let nowMs = UInt64(now.timeIntervalSince1970 * 1000)
|
let nowMs = UInt64(now.timeIntervalSince1970 * 1000)
|
||||||
guard nowMs >= timeoutMs else { return }
|
guard nowMs >= timeoutMs else { return }
|
||||||
let cutoff = nowMs - timeoutMs
|
let cutoff = nowMs - timeoutMs
|
||||||
let stalePeerIDs = latestAnnouncementByPeer.compactMap { (peerHex, pair) -> String? in
|
let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pair in
|
||||||
pair.packet.timestamp < cutoff ? peerHex.lowercased() : nil
|
pair.packet.timestamp < cutoff ? peerID : nil
|
||||||
}
|
}
|
||||||
guard !stalePeerIDs.isEmpty else { return }
|
guard !stalePeerIDs.isEmpty else { return }
|
||||||
for peerKey in stalePeerIDs {
|
for peerKey in stalePeerIDs {
|
||||||
removeState(forNormalizedPeerID: peerKey)
|
removeState(for: peerKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Explicit removal hook for LEAVE/stale peer
|
// Explicit removal hook for LEAVE/stale peer
|
||||||
func removeAnnouncementForPeer(_ peerID: PeerID) {
|
func removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||||
queue.async { [weak self] in
|
queue.async { [weak self] in
|
||||||
self?._removeAnnouncementForPeer(peerID)
|
self?.removeState(for: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func _removeAnnouncementForPeer(_ peerID: PeerID) {
|
private func removeState(for peerID: PeerID) {
|
||||||
let normalizedPeerID = peerID.id.lowercased()
|
_ = latestAnnouncementByPeer.removeValue(forKey: peerID)
|
||||||
removeState(forNormalizedPeerID: normalizedPeerID)
|
messages.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||||
}
|
fragments.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||||
|
fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||||
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 }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,13 +422,13 @@ extension GossipSyncManager {
|
|||||||
|
|
||||||
func _hasAnnouncement(for peerID: PeerID) -> Bool {
|
func _hasAnnouncement(for peerID: PeerID) -> Bool {
|
||||||
queue.sync {
|
queue.sync {
|
||||||
latestAnnouncementByPeer[peerID.id.lowercased()] != nil
|
latestAnnouncementByPeer[peerID] != nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func _messageCount(for peerID: PeerID) -> Int {
|
func _messageCount(for peerID: PeerID) -> Int {
|
||||||
queue.sync {
|
queue.sync {
|
||||||
messages.values.filter { $0.senderID.hexEncodedString().lowercased() == peerID.id.lowercased() }.count
|
messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,15 +61,13 @@ struct CompressionUtil {
|
|||||||
// 1. Data is too small
|
// 1. Data is too small
|
||||||
// 2. Data appears to be already compressed (high entropy)
|
// 2. Data appears to be already compressed (high entropy)
|
||||||
guard data.count >= compressionThreshold else { return false }
|
guard data.count >= compressionThreshold else { return false }
|
||||||
|
|
||||||
// Simple entropy check - count unique bytes
|
// Quick uniqueness check — a high diversity of bytes usually means the
|
||||||
var byteFrequency = [UInt8: Int]()
|
// payload is already compressed. We only need to know how many unique
|
||||||
for byte in data {
|
// values exist rather than keeping full frequency counts.
|
||||||
byteFrequency[byte, default: 0] += 1
|
let uniqueByteCount = Set(data).count
|
||||||
}
|
let sampleSize = min(data.count, 256)
|
||||||
|
let uniqueByteRatio = Double(uniqueByteCount) / Double(sampleSize)
|
||||||
// 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
|
return uniqueByteRatio < 0.9 // Compress if less than 90% unique bytes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ enum FileTransferLimits {
|
|||||||
/// Absolute ceiling enforced for any file payload (voice, image, other).
|
/// Absolute ceiling enforced for any file payload (voice, image, other).
|
||||||
static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||||
/// Voice notes stay small for low-latency relays.
|
/// Voice notes stay small for low-latency relays.
|
||||||
static let maxVoiceNoteBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB
|
||||||
/// Compressed images after downscaling should comfortably fit under this budget.
|
/// Compressed images after downscaling should comfortably fit under this budget.
|
||||||
static let maxImageBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
static let maxImageBytes: Int = 512 * 1024 // 512 KiB
|
||||||
/// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads.
|
/// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads.
|
||||||
static let maxFramedFileBytes: Int = {
|
static let maxFramedFileBytes: Int = {
|
||||||
let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs
|
let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import BitLogger
|
||||||
|
|
||||||
/// Comprehensive input validation for BitChat protocol
|
/// Comprehensive input validation for BitChat protocol
|
||||||
/// Prevents injection attacks, buffer overflows, and malformed data
|
/// Prevents injection attacks, buffer overflows, and malformed data
|
||||||
@@ -16,29 +17,28 @@ struct InputValidator {
|
|||||||
// MARK: - String Content Validation
|
// MARK: - String Content Validation
|
||||||
|
|
||||||
/// Validates and sanitizes user-provided strings used in UI
|
/// 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? {
|
static func validateUserString(_ string: String, maxLength: Int) -> String? {
|
||||||
// Check empty
|
|
||||||
guard !string.isEmpty else { return nil }
|
|
||||||
|
|
||||||
// Trim whitespace
|
|
||||||
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return nil }
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
|
||||||
// Check length
|
|
||||||
guard trimmed.count <= maxLength else { return nil }
|
guard trimmed.count <= maxLength else { return nil }
|
||||||
|
|
||||||
// Remove control characters
|
// Reject control characters outright instead of rewriting the string.
|
||||||
|
// This prevents injection attacks and ensures consistent UI rendering.
|
||||||
let controlChars = CharacterSet.controlCharacters
|
let controlChars = CharacterSet.controlCharacters
|
||||||
let cleaned = trimmed.components(separatedBy: controlChars).joined()
|
if !trimmed.unicodeScalars.allSatisfy({ !controlChars.contains($0) }) {
|
||||||
|
// Log rejection for monitoring, without exposing actual content for privacy
|
||||||
// Ensure valid UTF-8 (should already be, but double-check)
|
let controlCharCount = trimmed.unicodeScalars.filter { controlChars.contains($0) }.count
|
||||||
guard cleaned.data(using: .utf8) != nil else { return nil }
|
SecureLogger.debug(
|
||||||
|
"Input validation rejected string (length: \(trimmed.count), control chars: \(controlCharCount))",
|
||||||
// Prevent zero-width characters and other invisible unicode
|
category: .security
|
||||||
let invisibleChars = CharacterSet(charactersIn: "\u{200B}\u{200C}\u{200D}\u{FEFF}")
|
)
|
||||||
let visible = cleaned.components(separatedBy: invisibleChars).joined()
|
return nil
|
||||||
|
}
|
||||||
return visible.isEmpty ? nil : visible
|
|
||||||
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates nickname
|
/// Validates nickname
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
if spid.isGeoChat || spid.isGeoDM {
|
if spid.isGeoChat || spid.isGeoDM {
|
||||||
let full = (nostrKeyMapping[spid] ?? spid.bare).lowercased()
|
let full = (nostrKeyMapping[spid] ?? spid.bare).lowercased()
|
||||||
return "nostr:" + full
|
return "nostr:" + full
|
||||||
} else if spid.id.count == 16, let full = getNoiseKeyForShortID(spid)?.lowercased() {
|
} else if spid.id.count == 16, let full = getNoiseKeyForShortID(spid)?.id.lowercased() {
|
||||||
return "noise:" + full
|
return "noise:" + full
|
||||||
} else {
|
} else {
|
||||||
return "mesh:" + spid.id.lowercased()
|
return "mesh:" + spid.id.lowercased()
|
||||||
@@ -248,10 +248,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
@Published var currentColorScheme: ColorScheme = .light
|
@Published var currentColorScheme: ColorScheme = .light
|
||||||
private let maxMessages = TransportConfig.meshTimelineCap // Maximum messages before oldest are removed
|
private let maxMessages = TransportConfig.meshTimelineCap // Maximum messages before oldest are removed
|
||||||
@Published var isConnected = false
|
@Published var isConnected = false
|
||||||
private var hasNotifiedNetworkAvailable = false
|
|
||||||
private var recentlySeenPeers: Set<PeerID> = []
|
private var recentlySeenPeers: Set<PeerID> = []
|
||||||
private var lastNetworkNotificationTime = Date.distantPast
|
private var lastNetworkNotificationTime = Date.distantPast
|
||||||
private var networkResetTimer: Timer? = nil
|
private var networkResetTimer: Timer? = nil
|
||||||
|
private var networkEmptyTimer: Timer? = nil
|
||||||
private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects
|
private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects
|
||||||
@Published var nickname: String = "" {
|
@Published var nickname: String = "" {
|
||||||
didSet {
|
didSet {
|
||||||
@@ -261,7 +261,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
nickname = trimmed
|
nickname = trimmed
|
||||||
}
|
}
|
||||||
// Update mesh service nickname if it's initialized
|
// Update mesh service nickname if it's initialized
|
||||||
if meshService.myPeerID != "" {
|
if !meshService.myPeerID.isEmpty {
|
||||||
meshService.setNickname(nickname)
|
meshService.setNickname(nickname)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -328,16 +328,16 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
private var peerIDToPublicKeyFingerprint: [PeerID: String] = [:]
|
private var peerIDToPublicKeyFingerprint: [PeerID: String] = [:]
|
||||||
private var selectedPrivateChatFingerprint: String? = nil
|
private var selectedPrivateChatFingerprint: String? = nil
|
||||||
// Map stable short peer IDs (16-hex) to full Noise public key hex (64-hex) for session continuity
|
// Map stable short peer IDs (16-hex) to full Noise public key hex (64-hex) for session continuity
|
||||||
private var shortIDToNoiseKey: [PeerID: String] = [:]
|
private var shortIDToNoiseKey: [PeerID: PeerID] = [:]
|
||||||
|
|
||||||
// Resolve full Noise key for a peer's short ID (used by UI header rendering)
|
// Resolve full Noise key for a peer's short ID (used by UI header rendering)
|
||||||
@MainActor
|
@MainActor
|
||||||
private func getNoiseKeyForShortID(_ shortPeerID: PeerID) -> String? {
|
private func getNoiseKeyForShortID(_ shortPeerID: PeerID) -> PeerID? {
|
||||||
if let mapped = shortIDToNoiseKey[shortPeerID] { return mapped }
|
if let mapped = shortIDToNoiseKey[shortPeerID] { return mapped }
|
||||||
// Fallback: derive from active Noise session if available
|
// Fallback: derive from active Noise session if available
|
||||||
if shortPeerID.id.count == 16,
|
if shortPeerID.id.count == 16,
|
||||||
let key = meshService.getNoiseService().getPeerPublicKeyData(shortPeerID) {
|
let key = meshService.getNoiseService().getPeerPublicKeyData(shortPeerID) {
|
||||||
let stable = key.hexEncodedString()
|
let stable = PeerID(hexData: key)
|
||||||
shortIDToNoiseKey[shortPeerID] = stable
|
shortIDToNoiseKey[shortPeerID] = stable
|
||||||
return stable
|
return stable
|
||||||
}
|
}
|
||||||
@@ -346,16 +346,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
// Resolve short mesh ID (16-hex) from a full Noise public key hex (64-hex)
|
// Resolve short mesh ID (16-hex) from a full Noise public key hex (64-hex)
|
||||||
@MainActor
|
@MainActor
|
||||||
func getShortIDForNoiseKey(_ fullNoiseKeyHex: String) -> PeerID? {
|
func getShortIDForNoiseKey(_ fullNoiseKeyHex: PeerID) -> PeerID {
|
||||||
|
guard fullNoiseKeyHex.id.count == 64 else { return fullNoiseKeyHex }
|
||||||
// Check known peers for a noise key match
|
// Check known peers for a noise key match
|
||||||
if let match = allPeers.first(where: { $0.noisePublicKey.hexEncodedString() == fullNoiseKeyHex }) {
|
if let match = allPeers.first(where: { PeerID(hexData: $0.noisePublicKey) == fullNoiseKeyHex }) {
|
||||||
return match.peerID
|
return match.peerID
|
||||||
}
|
}
|
||||||
// Also search cache mapping
|
// Also search cache mapping
|
||||||
if let pair = shortIDToNoiseKey.first(where: { $0.value == fullNoiseKeyHex }) {
|
if let pair = shortIDToNoiseKey.first(where: { $0.value == fullNoiseKeyHex }) {
|
||||||
return pair.key
|
return pair.key
|
||||||
}
|
}
|
||||||
return nil
|
return fullNoiseKeyHex
|
||||||
}
|
}
|
||||||
private var peerIndex: [PeerID: BitchatPeer] = [:]
|
private var peerIndex: [PeerID: BitchatPeer] = [:]
|
||||||
|
|
||||||
@@ -1154,7 +1155,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
NotificationService.shared.sendPrivateMessageNotification(
|
NotificationService.shared.sendPrivateMessageNotification(
|
||||||
from: senderName,
|
from: senderName,
|
||||||
message: pm.content,
|
message: pm.content,
|
||||||
peerID: convKey.id
|
peerID: convKey
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1528,15 +1529,25 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
// UI updates automatically via @Published var messages
|
// UI updates automatically via @Published var messages
|
||||||
|
|
||||||
updateChannelActivityTimeThenSend(content: content, trimmed: trimmed, mentions: mentions, geoContext: geoContext)
|
updateChannelActivityTimeThenSend(content: content,
|
||||||
|
trimmed: trimmed,
|
||||||
|
mentions: mentions,
|
||||||
|
geoContext: geoContext,
|
||||||
|
messageID: message.id,
|
||||||
|
timestamp: message.timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateChannelActivityTimeThenSend(content: String, trimmed: String, mentions: [String], geoContext: GeoOutgoingContext?) {
|
private func updateChannelActivityTimeThenSend(content: String,
|
||||||
|
trimmed: String,
|
||||||
|
mentions: [String],
|
||||||
|
geoContext: GeoOutgoingContext?,
|
||||||
|
messageID: String,
|
||||||
|
timestamp: Date) {
|
||||||
switch activeChannel {
|
switch activeChannel {
|
||||||
case .mesh:
|
case .mesh:
|
||||||
lastPublicActivityAt["mesh"] = Date()
|
lastPublicActivityAt["mesh"] = Date()
|
||||||
// Send via mesh with mentions
|
// Send via mesh with mentions
|
||||||
meshService.sendMessage(content, mentions: mentions)
|
meshService.sendMessage(content, mentions: mentions, messageID: messageID, timestamp: timestamp)
|
||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
lastPublicActivityAt["geo:\(ch.geohash)"] = Date()
|
lastPublicActivityAt["geo:\(ch.geohash)"] = Date()
|
||||||
guard let context = geoContext, context.channel.geohash == ch.geohash else {
|
guard let context = geoContext, context.channel.geohash == ch.geohash else {
|
||||||
@@ -1875,7 +1886,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
NotificationService.shared.sendPrivateMessageNotification(
|
NotificationService.shared.sendPrivateMessageNotification(
|
||||||
from: senderName,
|
from: senderName,
|
||||||
message: pm.content,
|
message: pm.content,
|
||||||
peerID: convKey.id
|
peerID: convKey
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1955,8 +1966,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
func isSelfSender(peerID: PeerID?, displayName: String?) -> Bool {
|
func isSelfSender(peerID: PeerID?, displayName: String?) -> Bool {
|
||||||
guard let peerID else { return false }
|
guard let peerID else { return false }
|
||||||
if peerID == meshService.myPeerID { return true }
|
if peerID == meshService.myPeerID { return true }
|
||||||
let lowerPeer = peerID.id.lowercased()
|
guard peerID.isGeoDM || peerID.isGeoChat else { return false }
|
||||||
guard lowerPeer.hasPrefix("nostr") else { return false }
|
|
||||||
|
|
||||||
if let mapped = nostrKeyMapping[peerID]?.lowercased(),
|
if let mapped = nostrKeyMapping[peerID]?.lowercased(),
|
||||||
let gh = currentGeohash,
|
let gh = currentGeohash,
|
||||||
@@ -1966,10 +1976,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
if let gh = currentGeohash,
|
if let gh = currentGeohash,
|
||||||
let myIdentity = try? idBridge.deriveIdentity(forGeohash: gh) {
|
let myIdentity = try? idBridge.deriveIdentity(forGeohash: gh) {
|
||||||
let myLower = myIdentity.publicKeyHex.lowercased()
|
if peerID == PeerID(nostr: myIdentity.publicKeyHex) { return true }
|
||||||
let shortLen = TransportConfig.nostrShortKeyDisplayLength
|
|
||||||
let shortKey = "nostr:" + myLower.prefix(shortLen)
|
|
||||||
if lowerPeer == shortKey { return true }
|
|
||||||
let suffix = myIdentity.publicKeyHex.suffix(4)
|
let suffix = myIdentity.publicKeyHex.suffix(4)
|
||||||
let expected = (nickname + "#" + suffix).lowercased()
|
let expected = (nickname + "#" + suffix).lowercased()
|
||||||
if let display = displayName?.lowercased(), display == expected { return true }
|
if let display = displayName?.lowercased(), display == expected { return true }
|
||||||
@@ -2452,7 +2459,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let targetPeer = selectedPrivateChatPeer
|
let targetPeer = selectedPrivateChatPeer
|
||||||
let message = enqueueMediaMessage(content: "[voice] \(url.lastPathComponent)", targetPeer: targetPeer?.id)
|
let message = enqueueMediaMessage(content: "[voice] \(url.lastPathComponent)", targetPeer: targetPeer)
|
||||||
let messageID = message.id
|
let messageID = message.id
|
||||||
let transferId = makeTransferID(messageID: messageID)
|
let transferId = makeTransferID(messageID: messageID)
|
||||||
|
|
||||||
@@ -2531,7 +2538,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
)
|
)
|
||||||
guard packet.encode() != nil else { throw MediaSendError.encodingFailed }
|
guard packet.encode() != nil else { throw MediaSendError.encodingFailed }
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
let message = self.enqueueMediaMessage(content: "[image] \(outputURL.lastPathComponent)", targetPeer: targetPeer?.id)
|
let message = self.enqueueMediaMessage(content: "[image] \(outputURL.lastPathComponent)", targetPeer: targetPeer)
|
||||||
let messageID = message.id
|
let messageID = message.id
|
||||||
let transferId = self.makeTransferID(messageID: messageID)
|
let transferId = self.makeTransferID(messageID: messageID)
|
||||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||||
@@ -2573,7 +2580,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func enqueueMediaMessage(content: String, targetPeer: String?) -> BitchatMessage {
|
private func enqueueMediaMessage(content: String, targetPeer: PeerID?) -> BitchatMessage {
|
||||||
let timestamp = Date()
|
let timestamp = Date()
|
||||||
let message: BitchatMessage
|
let message: BitchatMessage
|
||||||
|
|
||||||
@@ -2590,7 +2597,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
deliveryStatus: .sending
|
deliveryStatus: .sending
|
||||||
)
|
)
|
||||||
var chats = privateChats
|
var chats = privateChats
|
||||||
chats[PeerID(str: peerID), default: []].append(message)
|
chats[peerID, default: []].append(message)
|
||||||
privateChats = chats
|
privateChats = chats
|
||||||
trimMessagesIfNeeded()
|
trimMessagesIfNeeded()
|
||||||
} else {
|
} else {
|
||||||
@@ -2603,7 +2610,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: false,
|
isPrivate: false,
|
||||||
recipientNickname: nil,
|
recipientNickname: nil,
|
||||||
senderPeerID: PeerID(str: senderPeerID),
|
senderPeerID: senderPeerID,
|
||||||
deliveryStatus: .sending
|
deliveryStatus: .sending
|
||||||
)
|
)
|
||||||
messages.append(message)
|
messages.append(message)
|
||||||
@@ -2628,29 +2635,28 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
return message
|
return message
|
||||||
}
|
}
|
||||||
|
|
||||||
private func currentPublicSender() -> (name: String, peerID: String) {
|
private func currentPublicSender() -> (name: String, peerID: PeerID) {
|
||||||
var displaySender = nickname
|
var displaySender = nickname
|
||||||
var senderPeerID = meshService.myPeerID
|
var senderPeerID = meshService.myPeerID
|
||||||
if case .location(let ch) = activeChannel,
|
if case .location(let ch) = activeChannel,
|
||||||
let identity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
let identity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||||
let suffix = String(identity.publicKeyHex.suffix(4))
|
let suffix = String(identity.publicKeyHex.suffix(4))
|
||||||
displaySender = nickname + "#" + suffix
|
displaySender = nickname + "#" + suffix
|
||||||
let shortKey = identity.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength)
|
senderPeerID = PeerID(nostr: identity.publicKeyHex)
|
||||||
senderPeerID = PeerID(str: "nostr:\(shortKey)")
|
|
||||||
}
|
}
|
||||||
return (displaySender, senderPeerID.id)
|
return (displaySender, senderPeerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func nicknameForPeer(_ peerID: String) -> String {
|
private func nicknameForPeer(_ peerID: PeerID) -> String {
|
||||||
if let name = meshService.peerNickname(peerID: PeerID(str: peerID)) {
|
if let name = meshService.peerNickname(peerID: peerID) {
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: PeerID(str: peerID)),
|
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||||
!favorite.peerNickname.isEmpty {
|
!favorite.peerNickname.isEmpty {
|
||||||
return favorite.peerNickname
|
return favorite.peerNickname
|
||||||
}
|
}
|
||||||
if let noiseKey = Data(hexString: peerID),
|
if let noiseKey = Data(hexString: peerID.id),
|
||||||
let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||||
!favorite.peerNickname.isEmpty {
|
!favorite.peerNickname.isEmpty {
|
||||||
return favorite.peerNickname
|
return favorite.peerNickname
|
||||||
@@ -3339,7 +3345,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// In public chat - send to active public channel
|
// In public chat - send to active public channel
|
||||||
switch activeChannel {
|
switch activeChannel {
|
||||||
case .mesh:
|
case .mesh:
|
||||||
meshService.sendMessage(screenshotMessage, mentions: [])
|
meshService.sendMessage(screenshotMessage,
|
||||||
|
mentions: [],
|
||||||
|
messageID: UUID().uuidString,
|
||||||
|
timestamp: Date())
|
||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
@@ -4107,7 +4116,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
if let spid = message.senderPeerID {
|
if let spid = message.senderPeerID {
|
||||||
if case .location(let ch) = activeChannel, spid.id.hasPrefix("nostr:") {
|
if case .location(let ch) = activeChannel, spid.id.hasPrefix("nostr:") {
|
||||||
if let myGeo = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
if let myGeo = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||||
return spid == "nostr:\(myGeo.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength))"
|
return spid == PeerID(nostr: myGeo.publicKeyHex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return spid == meshService.myPeerID
|
return spid == meshService.myPeerID
|
||||||
@@ -4260,13 +4269,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
let noiseService = meshService.getNoiseService()
|
let noiseService = meshService.getNoiseService()
|
||||||
|
|
||||||
if noiseService.hasEstablishedSession(with: peerID) {
|
if noiseService.hasEstablishedSession(with: peerID) {
|
||||||
// Check if fingerprint is verified using our persisted data
|
peerEncryptionStatus[peerID] = encryptionStatus(for: peerID)
|
||||||
if let fingerprint = getFingerprint(for: peerID),
|
|
||||||
verifiedFingerprints.contains(fingerprint) {
|
|
||||||
peerEncryptionStatus[peerID] = .noiseVerified
|
|
||||||
} else {
|
|
||||||
peerEncryptionStatus[peerID] = .noiseSecured
|
|
||||||
}
|
|
||||||
} else if noiseService.hasSession(with: peerID) {
|
} else if noiseService.hasSession(with: peerID) {
|
||||||
// Session exists but not established - handshaking
|
// Session exists but not established - handshaking
|
||||||
peerEncryptionStatus[peerID] = .noiseHandshaking
|
peerEncryptionStatus[peerID] = .noiseHandshaking
|
||||||
@@ -4301,27 +4304,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Determine status based on session state
|
// Determine status based on session state
|
||||||
switch sessionState {
|
switch sessionState {
|
||||||
case .established:
|
case .established:
|
||||||
// We have encryption, now check if it's verified
|
status = encryptionStatus(for: peerID)
|
||||||
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:
|
case .handshaking, .handshakeQueued:
|
||||||
// If we've ever established a session, show secured instead of handshaking
|
// If we've ever established a session, show secured instead of handshaking
|
||||||
if hasEverEstablishedSession {
|
if hasEverEstablishedSession {
|
||||||
// Check if it was verified before
|
// Check if it was verified before
|
||||||
if let fingerprint = getFingerprint(for: peerID),
|
status = encryptionStatus(for: peerID)
|
||||||
verifiedFingerprints.contains(fingerprint) {
|
|
||||||
status = .noiseVerified
|
|
||||||
} else {
|
|
||||||
status = .noiseSecured
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// First time establishing - show handshaking
|
// First time establishing - show handshaking
|
||||||
status = .noiseHandshaking
|
status = .noiseHandshaking
|
||||||
@@ -4330,12 +4318,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// If we've ever established a session, show secured instead of no handshake
|
// If we've ever established a session, show secured instead of no handshake
|
||||||
if hasEverEstablishedSession {
|
if hasEverEstablishedSession {
|
||||||
// Check if it was verified before
|
// Check if it was verified before
|
||||||
if let fingerprint = getFingerprint(for: peerID),
|
status = encryptionStatus(for: peerID)
|
||||||
verifiedFingerprints.contains(fingerprint) {
|
|
||||||
status = .noiseVerified
|
|
||||||
} else {
|
|
||||||
status = .noiseSecured
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Never established - show no handshake
|
// Never established - show no handshake
|
||||||
status = .noHandshake
|
status = .noHandshake
|
||||||
@@ -4344,12 +4327,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// If we've ever established a session, show secured instead of failed
|
// If we've ever established a session, show secured instead of failed
|
||||||
if hasEverEstablishedSession {
|
if hasEverEstablishedSession {
|
||||||
// Check if it was verified before
|
// Check if it was verified before
|
||||||
if let fingerprint = getFingerprint(for: peerID),
|
status = encryptionStatus(for: peerID)
|
||||||
verifiedFingerprints.contains(fingerprint) {
|
|
||||||
status = .noiseVerified
|
|
||||||
} else {
|
|
||||||
status = .noiseSecured
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Never established - show failed
|
// Never established - show failed
|
||||||
status = .none
|
status = .none
|
||||||
@@ -4423,7 +4401,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func meshSeed(for peerID: PeerID) -> String {
|
private func meshSeed(for peerID: PeerID) -> String {
|
||||||
if let full = getNoiseKeyForShortID(peerID)?.lowercased() {
|
if let full = getNoiseKeyForShortID(peerID)?.id.lowercased() {
|
||||||
return "noise:" + full
|
return "noise:" + full
|
||||||
}
|
}
|
||||||
return peerID.id.lowercased()
|
return peerID.id.lowercased()
|
||||||
@@ -4740,16 +4718,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
let noiseService = meshService.getNoiseService()
|
let noiseService = meshService.getNoiseService()
|
||||||
|
|
||||||
if noiseService.hasEstablishedSession(with: peerID) {
|
if noiseService.hasEstablishedSession(with: peerID) {
|
||||||
if let fingerprint = getFingerprint(for: peerID) {
|
peerEncryptionStatus[peerID] = encryptionStatus(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) {
|
} else if noiseService.hasSession(with: peerID) {
|
||||||
peerEncryptionStatus[peerID] = .noiseHandshaking
|
peerEncryptionStatus[peerID] = .noiseHandshaking
|
||||||
} else {
|
} else {
|
||||||
@@ -4779,6 +4748,16 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
return unifiedPeerService.getFingerprint(for: peerID)
|
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
|
/// Helper to resolve nickname for a peer ID through various sources
|
||||||
@MainActor
|
@MainActor
|
||||||
private func resolveNickname(for peerID: PeerID) -> String {
|
private func resolveNickname(for peerID: PeerID) -> String {
|
||||||
@@ -4880,7 +4859,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
let peerID = PeerID(str: peerID)
|
|
||||||
|
|
||||||
SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
|
SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
|
||||||
|
|
||||||
@@ -4899,9 +4877,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Cache shortID -> full Noise key mapping as soon as session authenticates
|
// Cache shortID -> full Noise key mapping as soon as session authenticates
|
||||||
if self.shortIDToNoiseKey[peerID] == nil,
|
if self.shortIDToNoiseKey[peerID] == nil,
|
||||||
let keyData = self.meshService.getNoiseService().getPeerPublicKeyData(peerID) {
|
let keyData = self.meshService.getNoiseService().getPeerPublicKeyData(peerID) {
|
||||||
let stable = keyData.hexEncodedString()
|
let stable = PeerID(hexData: keyData)
|
||||||
self.shortIDToNoiseKey[peerID] = stable
|
self.shortIDToNoiseKey[peerID] = stable
|
||||||
SecureLogger.debug("🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stable.prefix(8))…", category: .session)
|
SecureLogger.debug("🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stable.id.prefix(8))…", category: .session)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If a QR verification is pending but not sent yet, send it now that session is authenticated
|
// If a QR verification is pending but not sent yet, send it now that session is authenticated
|
||||||
@@ -5085,12 +5063,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let publicMentions = parseMentions(from: normalized)
|
let publicMentions = parseMentions(from: normalized)
|
||||||
let msg = BitchatMessage(
|
let msg = BitchatMessage(
|
||||||
id: UUID().uuidString,
|
id: messageID,
|
||||||
sender: nickname,
|
sender: nickname,
|
||||||
content: normalized,
|
content: normalized,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
@@ -5166,7 +5144,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
// Cache mapping to full Noise key for session continuity on disconnect
|
// Cache mapping to full Noise key for session continuity on disconnect
|
||||||
if let peer = unifiedPeerService.getPeer(by: peerID) {
|
if let peer = unifiedPeerService.getPeer(by: peerID) {
|
||||||
let noiseKeyHex = peer.noisePublicKey.hexEncodedString()
|
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
|
||||||
shortIDToNoiseKey[peerID] = noiseKeyHex
|
shortIDToNoiseKey[peerID] = noiseKeyHex
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5182,15 +5160,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
identityManager.removeEphemeralSession(peerID: peerID)
|
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)
|
// If the open PM is tied to this short peer ID, switch UI context to the full Noise key (offline favorite)
|
||||||
var derivedStableKeyHex: String? = shortIDToNoiseKey[peerID]
|
var derivedStableKeyHex = shortIDToNoiseKey[peerID]
|
||||||
if derivedStableKeyHex == nil,
|
if derivedStableKeyHex == nil,
|
||||||
let key = meshService.getNoiseService().getPeerPublicKeyData(peerID) {
|
let key = meshService.getNoiseService().getPeerPublicKeyData(peerID) {
|
||||||
derivedStableKeyHex = key.hexEncodedString()
|
derivedStableKeyHex = PeerID(hexData: key)
|
||||||
shortIDToNoiseKey[peerID] = derivedStableKeyHex
|
shortIDToNoiseKey[peerID] = derivedStableKeyHex
|
||||||
}
|
}
|
||||||
|
|
||||||
if let current = selectedPrivateChatPeer, current == peerID,
|
if let current = selectedPrivateChatPeer, current == peerID, let stableKeyHex = derivedStableKeyHex {
|
||||||
let stableKeyHex = PeerID(str: derivedStableKeyHex) {
|
|
||||||
// Migrate messages view context to stable key so header shows favorite + Nostr globe
|
// Migrate messages view context to stable key so header shows favorite + Nostr globe
|
||||||
if let messages = privateChats[peerID] {
|
if let messages = privateChats[peerID] {
|
||||||
if privateChats[stableKeyHex] == nil { privateChats[stableKeyHex] = [] }
|
if privateChats[stableKeyHex] == nil { privateChats[stableKeyHex] = [] }
|
||||||
@@ -5252,34 +5229,29 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
self.cleanupStaleUnreadPeerIDs()
|
self.cleanupStaleUnreadPeerIDs()
|
||||||
|
|
||||||
// Smart notification logic for "bitchatters nearby"
|
// Smart notification logic for "bitchatters nearby"
|
||||||
if !peers.isEmpty {
|
let meshPeers = peers.filter { peerID in
|
||||||
// Cancel any pending reset if peers are back
|
self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID)
|
||||||
self.networkResetTimer?.invalidate()
|
}
|
||||||
self.networkResetTimer = nil
|
let meshPeerSet = Set(meshPeers)
|
||||||
// Count mesh peers that are connected OR recently reachable via mesh relays
|
|
||||||
let meshPeers = peers.filter { peerID in
|
if meshPeerSet.isEmpty {
|
||||||
self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID)
|
self.scheduleNetworkEmptyTimer()
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
} else {
|
||||||
// No peers — immediately reset to allow next rising-edge to notify
|
self.invalidateNetworkEmptyTimer()
|
||||||
self.hasNotifiedNetworkAvailable = false
|
// Trim out peers we no longer observe before comparing for new arrivals
|
||||||
self.recentlySeenPeers.removeAll()
|
self.recentlySeenPeers.formIntersection(meshPeerSet)
|
||||||
if self.networkResetTimer != nil {
|
let newPeers = meshPeerSet.subtracting(self.recentlySeenPeers)
|
||||||
self.networkResetTimer?.invalidate()
|
|
||||||
self.networkResetTimer = nil
|
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()
|
||||||
}
|
}
|
||||||
SecureLogger.debug("⏳ Mesh empty — reset network notification state", category: .session)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register ephemeral sessions for all connected peers
|
// Register ephemeral sessions for all connected peers
|
||||||
@@ -5349,6 +5321,71 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Keep only receipts from messages we still have
|
// Keep only receipts from messages we still have
|
||||||
cleanupOldReadReceipts()
|
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() {
|
private func cleanupOldReadReceipts() {
|
||||||
// Skip cleanup during startup phase or if privateChats is empty
|
// Skip cleanup during startup phase or if privateChats is empty
|
||||||
@@ -5505,6 +5542,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
)
|
)
|
||||||
// Append to current visible messages
|
// Append to current visible messages
|
||||||
messages.append(systemMessage)
|
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
|
// Persist into the backing store for the active channel to survive rebinds
|
||||||
switch activeChannel {
|
switch activeChannel {
|
||||||
case .mesh:
|
case .mesh:
|
||||||
@@ -5555,7 +5595,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Default: send over mesh
|
// Default: send over mesh
|
||||||
meshService.sendMessage(content, mentions: [])
|
meshService.sendMessage(content,
|
||||||
|
mentions: [],
|
||||||
|
messageID: UUID().uuidString,
|
||||||
|
timestamp: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Simplified Nostr Integration (Inlined from MessageRouter)
|
// MARK: - Simplified Nostr Integration (Inlined from MessageRouter)
|
||||||
@@ -5629,7 +5672,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate recipient
|
// Validate recipient
|
||||||
if let rid = packet.recipientID, rid.hexEncodedString() != meshService.myPeerID {
|
if PeerID(hexData: packet.recipientID) != meshService.myPeerID {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5874,7 +5917,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
NotificationService.shared.sendPrivateMessageNotification(
|
NotificationService.shared.sendPrivateMessageNotification(
|
||||||
from: senderNickname,
|
from: senderNickname,
|
||||||
message: messageContent,
|
message: messageContent,
|
||||||
peerID: targetPeerID.id
|
peerID: targetPeerID
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6267,7 +6310,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
NotificationService.shared.sendPrivateMessageNotification(
|
NotificationService.shared.sendPrivateMessageNotification(
|
||||||
from: message.sender,
|
from: message.sender,
|
||||||
message: message.content,
|
message: message.content,
|
||||||
peerID: peerID.id
|
peerID: peerID
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -6321,7 +6364,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
let isGeo = finalMessage.senderPeerID?.isGeoChat == true
|
let isGeo = finalMessage.senderPeerID?.isGeoChat == true
|
||||||
|
|
||||||
// Apply per-sender and per-content rate limits (drop if exceeded)
|
// Apply per-sender and per-content rate limits (drop if exceeded)
|
||||||
if finalMessage.sender != "system" {
|
// 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 {
|
||||||
let senderKey = normalizedSenderKey(for: finalMessage)
|
let senderKey = normalizedSenderKey(for: finalMessage)
|
||||||
let contentKey = normalizedContentKey(finalMessage.content)
|
let contentKey = normalizedContentKey(finalMessage.content)
|
||||||
let now = Date()
|
let now = Date()
|
||||||
@@ -6339,8 +6384,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
// Persist mesh messages to mesh timeline always
|
// Persist mesh messages to mesh timeline always
|
||||||
if !isGeo && finalMessage.sender != "system" {
|
if !isGeo && finalMessage.sender != "system" {
|
||||||
meshTimeline.append(finalMessage)
|
if !meshTimeline.contains(where: { $0.id == finalMessage.id }) {
|
||||||
trimMeshTimelineIfNeeded()
|
meshTimeline.append(finalMessage)
|
||||||
|
trimMeshTimelineIfNeeded()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist geochat messages to per-geohash timeline
|
// Persist geochat messages to per-geohash timeline
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ struct ContentView: View {
|
|||||||
@State private var commandSuggestions: [String] = []
|
@State private var commandSuggestions: [String] = []
|
||||||
@State private var showMessageActions = false
|
@State private var showMessageActions = false
|
||||||
@State private var selectedMessageSender: String?
|
@State private var selectedMessageSender: String?
|
||||||
@State private var selectedMessageSenderID: String?
|
@State private var selectedMessageSenderID: PeerID?
|
||||||
@FocusState private var isNicknameFieldFocused: Bool
|
@FocusState private var isNicknameFieldFocused: Bool
|
||||||
@State private var isAtBottomPublic: Bool = true
|
@State private var isAtBottomPublic: Bool = true
|
||||||
@State private var isAtBottomPrivate: Bool = true
|
@State private var isAtBottomPrivate: Bool = true
|
||||||
@@ -203,7 +203,15 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.sheet(isPresented: $showImagePicker) {
|
// Only present image picker from main view when NOT in a sheet
|
||||||
|
.fullScreenCover(isPresented: Binding(
|
||||||
|
get: { showImagePicker && !showSidebar && viewModel.selectedPrivateChatPeer == nil },
|
||||||
|
set: { newValue in
|
||||||
|
if !newValue {
|
||||||
|
showImagePicker = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)) {
|
||||||
ImagePickerView(sourceType: imagePickerSourceType) { image in
|
ImagePickerView(sourceType: imagePickerSourceType) { image in
|
||||||
showImagePicker = false
|
showImagePicker = false
|
||||||
if let image = image {
|
if let image = image {
|
||||||
@@ -223,7 +231,15 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.sheet(isPresented: $showMacImagePicker) {
|
// Only present Mac image picker from main view when NOT in a sheet
|
||||||
|
.sheet(isPresented: Binding(
|
||||||
|
get: { showMacImagePicker && !showSidebar && viewModel.selectedPrivateChatPeer == nil },
|
||||||
|
set: { newValue in
|
||||||
|
if !newValue {
|
||||||
|
showMacImagePicker = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)) {
|
||||||
MacImagePickerView { url in
|
MacImagePickerView { url in
|
||||||
showMacImagePicker = false
|
showMacImagePicker = false
|
||||||
if let url = url {
|
if let url = url {
|
||||||
@@ -269,12 +285,12 @@ struct ContentView: View {
|
|||||||
|
|
||||||
Button("content.actions.direct_message") {
|
Button("content.actions.direct_message") {
|
||||||
if let peerID = selectedMessageSenderID {
|
if let peerID = selectedMessageSenderID {
|
||||||
if peerID.hasPrefix("nostr:") {
|
if peerID.isGeoChat {
|
||||||
if let full = viewModel.fullNostrHex(forSenderPeerID: PeerID(str: peerID)) {
|
if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
|
||||||
viewModel.startGeohashDM(withPubkeyHex: full)
|
viewModel.startGeohashDM(withPubkeyHex: full)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
viewModel.startPrivateChat(with: PeerID(str: peerID))
|
viewModel.startPrivateChat(with: peerID)
|
||||||
}
|
}
|
||||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||||
showSidebar = true
|
showSidebar = true
|
||||||
@@ -296,8 +312,8 @@ struct ContentView: View {
|
|||||||
|
|
||||||
Button("content.actions.block", role: .destructive) {
|
Button("content.actions.block", role: .destructive) {
|
||||||
// Prefer direct geohash block when we have a Nostr sender ID
|
// Prefer direct geohash block when we have a Nostr sender ID
|
||||||
if let peerID = selectedMessageSenderID, peerID.hasPrefix("nostr:"),
|
if let peerID = selectedMessageSenderID, peerID.isGeoChat,
|
||||||
let full = viewModel.fullNostrHex(forSenderPeerID: PeerID(str: peerID)),
|
let full = viewModel.fullNostrHex(forSenderPeerID: peerID),
|
||||||
let sender = selectedMessageSender {
|
let sender = selectedMessageSender {
|
||||||
viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender)
|
viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender)
|
||||||
} else if let sender = selectedMessageSender {
|
} else if let sender = selectedMessageSender {
|
||||||
@@ -778,7 +794,7 @@ struct ContentView: View {
|
|||||||
case "user":
|
case "user":
|
||||||
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
let peerID = PeerID(str: id.removingPercentEncoding ?? id)
|
let peerID = PeerID(str: id.removingPercentEncoding ?? id)
|
||||||
selectedMessageSenderID = peerID.id
|
selectedMessageSenderID = peerID
|
||||||
|
|
||||||
if peerID.isGeoDM || peerID.isGeoChat {
|
if peerID.isGeoDM || peerID.isGeoChat {
|
||||||
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
|
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
|
||||||
@@ -906,6 +922,53 @@ struct ContentView: View {
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.frame(minWidth: 420, minHeight: 520)
|
.frame(minWidth: 420, minHeight: 520)
|
||||||
#endif
|
#endif
|
||||||
|
// Present image picker from sheet context when IN a sheet (parent-child pattern)
|
||||||
|
#if os(iOS)
|
||||||
|
.fullScreenCover(isPresented: Binding(
|
||||||
|
get: { showImagePicker && (showSidebar || viewModel.selectedPrivateChatPeer != nil) },
|
||||||
|
set: { newValue in
|
||||||
|
if !newValue {
|
||||||
|
showImagePicker = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
ImagePickerView(sourceType: imagePickerSourceType) { image in
|
||||||
|
showImagePicker = false
|
||||||
|
if let image = image {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let processedURL = try ImageUtils.processImage(image)
|
||||||
|
await MainActor.run {
|
||||||
|
viewModel.sendImage(from: processedURL)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
#if os(macOS)
|
||||||
|
.sheet(isPresented: $showMacImagePicker) {
|
||||||
|
MacImagePickerView { url in
|
||||||
|
showMacImagePicker = false
|
||||||
|
if let url = url {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let processedURL = try ImageUtils.processImage(at: url)
|
||||||
|
await MainActor.run {
|
||||||
|
viewModel.sendImage(from: processedURL)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - People Sheet Views
|
// MARK: - People Sheet Views
|
||||||
@@ -1150,12 +1213,7 @@ struct ContentView: View {
|
|||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
if !privatePeerID.isGeoDM {
|
if !privatePeerID.isGeoDM {
|
||||||
let statusPeerID: PeerID = {
|
let statusPeerID = viewModel.getShortIDForNoiseKey(privatePeerID)
|
||||||
if privatePeerID.id.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID.id) {
|
|
||||||
return short
|
|
||||||
}
|
|
||||||
return context.headerPeerID
|
|
||||||
}()
|
|
||||||
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
||||||
if let icon = encryptionStatus.icon {
|
if let icon = encryptionStatus.icon {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
@@ -1189,13 +1247,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func makePrivateHeaderContext(for privatePeerID: PeerID) -> PrivateHeaderContext {
|
private func makePrivateHeaderContext(for privatePeerID: PeerID) -> PrivateHeaderContext {
|
||||||
let headerPeerID: PeerID = {
|
let headerPeerID = viewModel.getShortIDForNoiseKey(privatePeerID)
|
||||||
if privatePeerID.id.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID.id) {
|
|
||||||
return short
|
|
||||||
}
|
|
||||||
return privatePeerID
|
|
||||||
}()
|
|
||||||
|
|
||||||
let peer = viewModel.getPeer(byID: headerPeerID)
|
let peer = viewModel.getPeer(byID: headerPeerID)
|
||||||
|
|
||||||
let displayName: String = {
|
let displayName: String = {
|
||||||
|
|||||||
@@ -65,10 +65,7 @@ struct FingerprintView: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
// Prefer short mesh ID for session/encryption status
|
// Prefer short mesh ID for session/encryption status
|
||||||
let statusPeerID: PeerID = {
|
let statusPeerID = viewModel.getShortIDForNoiseKey(peerID)
|
||||||
if peerID.id.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID.id) { return short }
|
|
||||||
return peerID
|
|
||||||
}()
|
|
||||||
// Resolve a friendly name
|
// Resolve a friendly name
|
||||||
let peerNickname: String = {
|
let peerNickname: String = {
|
||||||
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
|
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
|
||||||
|
|||||||
@@ -298,5 +298,5 @@ private final class MockBitchatDelegate: BitchatDelegate {
|
|||||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
||||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
||||||
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {}
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,11 +186,11 @@ struct PrivateChatE2ETests {
|
|||||||
// Bob relays private messages for Charlie
|
// Bob relays private messages for Charlie
|
||||||
bob.packetDeliveryHandler = { packet in
|
bob.packetDeliveryHandler = { packet in
|
||||||
if let recipientID = packet.recipientID,
|
if let recipientID = packet.recipientID,
|
||||||
String(data: recipientID, encoding: .utf8) == charlie.peerID {
|
PeerID(data: recipientID) == charlie.peerID {
|
||||||
// Relay to Charlie
|
// Relay to Charlie
|
||||||
var relayPacket = packet
|
var relayPacket = packet
|
||||||
relayPacket.ttl = packet.ttl - 1
|
relayPacket.ttl = packet.ttl - 1
|
||||||
self.charlie.simulateIncomingPacket(relayPacket)
|
charlie.simulateIncomingPacket(relayPacket)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -388,7 +388,7 @@ struct PublicChatE2ETests {
|
|||||||
|
|
||||||
if let message = BitchatMessage(packet.payload) {
|
if let message = BitchatMessage(packet.payload) {
|
||||||
// Don't relay own messages
|
// Don't relay own messages
|
||||||
guard message.senderPeerID?.id != node.peerID else { return }
|
guard message.senderPeerID != node.peerID else { return }
|
||||||
|
|
||||||
// Create relay message
|
// Create relay message
|
||||||
let relayMessage = BitchatMessage(
|
let relayMessage = BitchatMessage(
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ extension FragmentationTests {
|
|||||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
||||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
||||||
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||||
publicMessages.append((peerID, nickname, content))
|
publicMessages.append((peerID, nickname, content))
|
||||||
}
|
}
|
||||||
func didReceiveRegionalPublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {}
|
func didReceiveRegionalPublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {}
|
||||||
|
|||||||
@@ -125,16 +125,138 @@ struct GossipSyncManagerTests {
|
|||||||
#expect(manager._hasAnnouncement(for: PeerID(str: peerHex)) == false)
|
#expect(manager._hasAnnouncement(for: PeerID(str: peerHex)) == false)
|
||||||
#expect(manager._messageCount(for: PeerID(str: peerHex)) == 0)
|
#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 {
|
private final class RecordingDelegate: GossipSyncManager.Delegate {
|
||||||
var onSend: (() -> Void)?
|
var onSend: (() -> Void)?
|
||||||
private(set) var lastPacket: BitchatPacket?
|
private(set) var lastPacket: BitchatPacket?
|
||||||
|
private(set) var packets: [BitchatPacket] = []
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
|
|
||||||
func sendPacket(_ packet: BitchatPacket) {
|
func sendPacket(_ packet: BitchatPacket) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
lastPacket = packet
|
lastPacket = packet
|
||||||
|
packets.append(packet)
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
onSend?()
|
onSend?()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
//
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -203,10 +203,7 @@ final class MockBLEService: NSObject {
|
|||||||
let target = bus.service(for: recipientPeerID) {
|
let target = bus.service(for: recipientPeerID) {
|
||||||
target.simulateIncomingPacket(packet)
|
target.simulateIncomingPacket(packet)
|
||||||
} else {
|
} else {
|
||||||
// Not directly connected: deliver to neighbors for relay; also deliver directly if target is known
|
// Not directly connected: deliver to neighbors for relay
|
||||||
if let target = bus.service(for: recipientPeerID) {
|
|
||||||
target.simulateIncomingPacket(packet)
|
|
||||||
}
|
|
||||||
for neighbor in neighbors() where neighbor.peerID != recipientPeerID {
|
for neighbor in neighbors() where neighbor.peerID != recipientPeerID {
|
||||||
neighbor.simulateIncomingPacket(packet)
|
neighbor.simulateIncomingPacket(packet)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ struct PeerIDTests {
|
|||||||
@Test func comparable_sorting_and_equality() {
|
@Test func comparable_sorting_and_equality() {
|
||||||
let p1 = PeerID(str: "aaa")
|
let p1 = PeerID(str: "aaa")
|
||||||
let p2 = PeerID(str: "bbb")
|
let p2 = PeerID(str: "bbb")
|
||||||
let p3 = PeerID(str: "bbb")
|
let p3 = PeerID(str: "BBB")
|
||||||
|
|
||||||
#expect(p1 < p2)
|
#expect(p1 < p2)
|
||||||
#expect(p2 >= p1)
|
#expect(p2 >= p1)
|
||||||
@@ -284,44 +284,18 @@ struct PeerIDTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func equality() {
|
@Test func equality() {
|
||||||
let string = "aaa"
|
let peerID = PeerID(str: "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
|
// Regular PeerID <> PeerID
|
||||||
#expect(peerID == PeerID(str: "aaa"))
|
#expect(peerID == PeerID(str: "AAA"))
|
||||||
#expect(peerID == Optional(PeerID(str: "aaa")))
|
#expect(peerID == Optional(PeerID(str: "AAA")))
|
||||||
#expect(PeerID(str: "aaa") == peerID)
|
#expect(PeerID(str: "AAA") == peerID)
|
||||||
#expect(Optional(PeerID(str: "aaa")) == Optional(peerID))
|
#expect(Optional(PeerID(str: "AAA")) == Optional(peerID))
|
||||||
|
|
||||||
#expect(peerID != PeerID(str: "bbb"))
|
#expect(peerID != PeerID(str: "BBB"))
|
||||||
#expect(peerID != Optional(PeerID(str: "bbb")))
|
#expect(peerID != Optional(PeerID(str: "BBB")))
|
||||||
#expect(PeerID(str: "bbb") != peerID)
|
#expect(PeerID(str: "BBB") != peerID)
|
||||||
#expect(Optional(PeerID(str: "bbb")) != Optional(peerID))
|
#expect(Optional(PeerID(str: "BBB")) != Optional(peerID))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Computed properties
|
// MARK: - Computed properties
|
||||||
|
|||||||
Reference in New Issue
Block a user