mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
[codex] Refactor BLE outbound scheduling and Noise queues (#1306)
* Refactor BLE transport event handling * Make image output paths unique * Keep queued Nostr read receipts alive * Refine BLE ingress fanout * Rediscover BLE service after invalidation * Extract BLE notification retry buffer * Extract BLE inbound write buffer * Extract BLE fragment assembly buffer * Tidy secure log handling from device run * Extract BLE outbound fragment scheduler * Harden app CI media tests * Redact BLE message content from logs * Extract BLE Noise session queues * Fix BLE read receipt UI updates * Allow self-authored RSR ingress replies * Harden read receipt queue test timing * Extract BLE outbound policy and incoming file storage * Avoid duplicate BLE link snapshots during send * Canonicalize Nostr relay URLs --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -16,7 +16,7 @@ enum ImageUtils {
|
|||||||
private static let compressionQuality: CGFloat = 0.82
|
private static let compressionQuality: CGFloat = 0.82
|
||||||
private static let targetImageBytes: Int = 45_000
|
private static let targetImageBytes: Int = 45_000
|
||||||
|
|
||||||
static func processImage(at url: URL, maxDimension: CGFloat = 448) throws -> URL {
|
static func processImage(at url: URL, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) 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 {
|
||||||
@@ -30,15 +30,15 @@ enum ImageUtils {
|
|||||||
let data = try Data(contentsOf: url)
|
let data = try Data(contentsOf: url)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage }
|
guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||||
return try processImage(image, maxDimension: maxDimension)
|
return try processImage(image, maxDimension: maxDimension, outputDirectory: outputDirectory)
|
||||||
#else
|
#else
|
||||||
guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }
|
guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||||
return try processImage(image, maxDimension: maxDimension)
|
return try processImage(image, maxDimension: maxDimension, outputDirectory: outputDirectory)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448) throws -> URL {
|
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) 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)
|
||||||
@@ -64,7 +64,7 @@ enum ImageUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let outputURL = try makeOutputURL()
|
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||||
try jpegData.write(to: outputURL, options: .atomic)
|
try jpegData.write(to: outputURL, options: .atomic)
|
||||||
return outputURL
|
return outputURL
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,7 @@ enum ImageUtils {
|
|||||||
return data as Data
|
return data as Data
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448) throws -> URL {
|
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) 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 {
|
||||||
@@ -142,7 +142,7 @@ enum ImageUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let outputURL = try makeOutputURL()
|
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||||
try jpegData.write(to: outputURL, options: .atomic)
|
try jpegData.write(to: outputURL, options: .atomic)
|
||||||
return outputURL
|
return outputURL
|
||||||
}
|
}
|
||||||
@@ -186,12 +186,17 @@ enum ImageUtils {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private static func makeOutputURL() throws -> URL {
|
private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||||
let fileName = "img_\(formatter.string(from: Date()))_\(UUID().uuidString).jpg"
|
let fileName = "img_\(formatter.string(from: Date()))_\(UUID().uuidString).jpg"
|
||||||
|
|
||||||
let directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
|
let directory: URL
|
||||||
|
if let outputDirectory {
|
||||||
|
directory = outputDirectory
|
||||||
|
} else {
|
||||||
|
directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
|
||||||
|
}
|
||||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||||
return directory.appendingPathComponent(fileName)
|
return directory.appendingPathComponent(fileName)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -381,12 +381,7 @@ final class GeoRelayDirectory {
|
|||||||
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
||||||
let parts = line.split(separator: ",").map { $0.trimmed }
|
let parts = line.split(separator: ",").map { $0.trimmed }
|
||||||
guard parts.count >= 3 else { continue }
|
guard parts.count >= 3 else { continue }
|
||||||
var host = parts[0]
|
guard let host = NostrRelayURL.directoryAddress(parts[0]) else { continue }
|
||||||
host = host.replacingOccurrences(of: "https://", with: "")
|
|
||||||
host = host.replacingOccurrences(of: "http://", with: "")
|
|
||||||
host = host.replacingOccurrences(of: "wss://", with: "")
|
|
||||||
host = host.replacingOccurrences(of: "ws://", with: "")
|
|
||||||
host = host.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
||||||
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
|
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
|
||||||
result.insert(Entry(host: host, lat: lat, lon: lon))
|
result.insert(Entry(host: host, lat: lat, lon: lon))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
"wss://offchain.pub"
|
"wss://offchain.pub"
|
||||||
// For local testing, you can add: "ws://localhost:8080"
|
// For local testing, you can add: "ws://localhost:8080"
|
||||||
]
|
]
|
||||||
private static let defaultRelaySet = Set(defaultRelays)
|
private static let defaultRelaySet = Set(defaultRelays.compactMap { NostrRelayURL.normalized($0) })
|
||||||
|
|
||||||
@Published private(set) var relays: [Relay] = []
|
@Published private(set) var relays: [Relay] = []
|
||||||
@Published private(set) var isConnected = false
|
@Published private(set) var isConnected = false
|
||||||
@@ -390,8 +390,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
// Target specific relays if provided; else default. Filter permanently failed relays.
|
// Target specific relays if provided; else default. Filter permanently failed relays.
|
||||||
let baseUrls = relayUrls ?? Self.defaultRelays
|
let baseUrls = relayUrls ?? Self.defaultRelays
|
||||||
let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) }
|
let urls = allowedRelayList(from: baseUrls).filter { !isPermanentlyFailed($0) }
|
||||||
let urls = allowedRelayList(from: candidateUrls)
|
|
||||||
let requestState = SubscriptionRequestState(messageString: messageString, relayURLs: Set(urls))
|
let requestState = SubscriptionRequestState(messageString: messageString, relayURLs: Set(urls))
|
||||||
if subscriptionRequestState[id] == requestState, subscriptionStateExists(id: id, requestState: requestState) {
|
if subscriptionRequestState[id] == requestState, subscriptionStateExists(id: id, requestState: requestState) {
|
||||||
return
|
return
|
||||||
@@ -474,7 +473,8 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private func allowedRelayList(from urls: [String]) -> [String] {
|
private func allowedRelayList(from urls: [String]) -> [String] {
|
||||||
var seen = Set<String>()
|
var seen = Set<String>()
|
||||||
var result: [String] = []
|
var result: [String] = []
|
||||||
for url in urls {
|
for rawURL in urls {
|
||||||
|
guard let url = NostrRelayURL.normalized(rawURL) else { continue }
|
||||||
if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue }
|
if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue }
|
||||||
if seen.insert(url).inserted {
|
if seen.insert(url).inserted {
|
||||||
result.append(url)
|
result.append(url)
|
||||||
@@ -881,7 +881,8 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
/// Manually retry connection to a specific relay
|
/// Manually retry connection to a specific relay
|
||||||
func retryConnection(to relayUrl: String) {
|
func retryConnection(to relayUrl: String) {
|
||||||
guard let index = relays.firstIndex(where: { $0.url == relayUrl }) else { return }
|
let normalizedRelayUrl = NostrRelayURL.normalized(relayUrl) ?? relayUrl
|
||||||
|
guard let index = relays.firstIndex(where: { $0.url == normalizedRelayUrl }) else { return }
|
||||||
|
|
||||||
// Reset reconnection attempts
|
// Reset reconnection attempts
|
||||||
relays[index].reconnectAttempts = 0
|
relays[index].reconnectAttempts = 0
|
||||||
@@ -889,13 +890,13 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
relays[index].lastError = nil
|
relays[index].lastError = nil
|
||||||
|
|
||||||
// Disconnect if connected
|
// Disconnect if connected
|
||||||
if let connection = connections[relayUrl] {
|
if let connection = connections[normalizedRelayUrl] {
|
||||||
connection.cancel(with: .goingAway, reason: nil)
|
connection.cancel(with: .goingAway, reason: nil)
|
||||||
connections.removeValue(forKey: relayUrl)
|
connections.removeValue(forKey: normalizedRelayUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt immediate reconnection
|
// Attempt immediate reconnection
|
||||||
connectToRelay(relayUrl)
|
connectToRelay(normalizedRelayUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get detailed status for all relays
|
/// Get detailed status for all relays
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum NostrRelayURL {
|
||||||
|
static func normalized(_ rawValue: String, defaultScheme: String? = nil) -> String? {
|
||||||
|
var value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !value.isEmpty else { return nil }
|
||||||
|
|
||||||
|
if !value.contains("://"), let defaultScheme {
|
||||||
|
value = "\(defaultScheme)://\(value)"
|
||||||
|
}
|
||||||
|
|
||||||
|
guard var components = URLComponents(string: value),
|
||||||
|
let rawScheme = components.scheme?.lowercased(),
|
||||||
|
let rawHost = components.host?.lowercased(),
|
||||||
|
!rawHost.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch rawScheme {
|
||||||
|
case "wss", "https":
|
||||||
|
components.scheme = "wss"
|
||||||
|
if components.port == 443 {
|
||||||
|
components.port = nil
|
||||||
|
}
|
||||||
|
case "ws", "http":
|
||||||
|
components.scheme = "ws"
|
||||||
|
if components.port == 80 {
|
||||||
|
components.port = nil
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
components.host = rawHost
|
||||||
|
if components.path == "/" {
|
||||||
|
components.path = ""
|
||||||
|
}
|
||||||
|
components.fragment = nil
|
||||||
|
|
||||||
|
return components.string
|
||||||
|
}
|
||||||
|
|
||||||
|
static func directoryAddress(_ rawValue: String) -> String? {
|
||||||
|
guard var normalized = normalized(rawValue, defaultScheme: "wss") else { return nil }
|
||||||
|
for prefix in ["wss://", "ws://"] where normalized.hasPrefix(prefix) {
|
||||||
|
normalized.removeFirst(prefix.count)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEIncomingFileStore {
|
||||||
|
private static let quotaBytes: Int64 = 100 * 1024 * 1024
|
||||||
|
|
||||||
|
private let fileManager: FileManager
|
||||||
|
private let baseDirectory: URL?
|
||||||
|
private let dateProvider: () -> Date
|
||||||
|
|
||||||
|
init(fileManager: FileManager = .default, baseDirectory: URL? = nil, dateProvider: @escaping () -> Date = Date.init) {
|
||||||
|
self.fileManager = fileManager
|
||||||
|
self.baseDirectory = baseDirectory
|
||||||
|
self.dateProvider = dateProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
func save(
|
||||||
|
data: Data,
|
||||||
|
preferredName: String?,
|
||||||
|
subdirectory: String,
|
||||||
|
fallbackExtension: String?,
|
||||||
|
defaultPrefix: String
|
||||||
|
) -> URL? {
|
||||||
|
do {
|
||||||
|
let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
|
||||||
|
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
let sanitized = sanitizedFileName(
|
||||||
|
preferredName,
|
||||||
|
defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))",
|
||||||
|
fallbackExtension: fallbackExtension
|
||||||
|
)
|
||||||
|
let destination = uniqueFileURL(in: base, fileName: sanitized)
|
||||||
|
try data.write(to: destination, options: .atomic)
|
||||||
|
return destination
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("❌ Failed to persist incoming media: \(error)", category: .session)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func enforceQuota(reservingBytes: Int) {
|
||||||
|
do {
|
||||||
|
let base = try filesDirectory()
|
||||||
|
let incomingDirs = [
|
||||||
|
base.appendingPathComponent("voicenotes/incoming", isDirectory: true),
|
||||||
|
base.appendingPathComponent("images/incoming", isDirectory: true),
|
||||||
|
base.appendingPathComponent("files/incoming", isDirectory: true)
|
||||||
|
]
|
||||||
|
var allFiles: [(url: URL, size: Int64, modified: Date)] = []
|
||||||
|
|
||||||
|
for dir in incomingDirs where fileManager.fileExists(atPath: dir.path) {
|
||||||
|
guard let contents = try? fileManager.contentsOfDirectory(
|
||||||
|
at: dir,
|
||||||
|
includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey],
|
||||||
|
options: [.skipsHiddenFiles]
|
||||||
|
) else { continue }
|
||||||
|
|
||||||
|
for fileURL in contents {
|
||||||
|
guard let attrs = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]),
|
||||||
|
let size = attrs.fileSize,
|
||||||
|
let modified = attrs.contentModificationDate else { continue }
|
||||||
|
allFiles.append((url: fileURL, size: Int64(size), modified: modified))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentUsage = allFiles.reduce(0) { $0 + $1.size }
|
||||||
|
let targetUsage = Self.quotaBytes - Int64(reservingBytes)
|
||||||
|
guard currentUsage > targetUsage else { return }
|
||||||
|
|
||||||
|
let needToFree = currentUsage - targetUsage
|
||||||
|
var freedSpace: Int64 = 0
|
||||||
|
for file in allFiles.sorted(by: { $0.modified < $1.modified }) {
|
||||||
|
guard freedSpace < needToFree else { break }
|
||||||
|
do {
|
||||||
|
try fileManager.removeItem(at: file.url)
|
||||||
|
freedSpace += file.size
|
||||||
|
SecureLogger.debug("🗑️ BCH-01-002: Deleted old incoming file to free space: \(file.url.lastPathComponent)", category: .security)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.warning("⚠️ Failed to delete old file for quota: \(error)", category: .security)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if freedSpace > 0 {
|
||||||
|
SecureLogger.info("📊 BCH-01-002: Freed \(ByteCountFormatter.string(fromByteCount: freedSpace, countStyle: .file)) to stay within incoming files quota", category: .security)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.warning("⚠️ Could not enforce storage quota: \(error)", category: .security)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func filesDirectory() throws -> URL {
|
||||||
|
let root = try baseDirectory ?? fileManager.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
|
let filesDir = root.appendingPathComponent("files", isDirectory: true)
|
||||||
|
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
return filesDir
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
||||||
|
var candidate = (name ?? "")
|
||||||
|
.replacingOccurrences(of: "\0", with: "")
|
||||||
|
.precomposedStringWithCanonicalMapping
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
.replacingOccurrences(of: "\\", with: "_")
|
||||||
|
|
||||||
|
let invalid = CharacterSet(charactersIn: "<>:\"|?*\0").union(.controlCharacters)
|
||||||
|
candidate = candidate.components(separatedBy: invalid).joined(separator: "_").trimmed
|
||||||
|
if candidate.isEmpty { candidate = defaultName }
|
||||||
|
if candidate.hasPrefix(".") { candidate = "_" + candidate }
|
||||||
|
|
||||||
|
if candidate.count > 120 {
|
||||||
|
let ext = (candidate as NSString).pathExtension
|
||||||
|
let base = (candidate as NSString).deletingPathExtension
|
||||||
|
candidate = ext.isEmpty
|
||||||
|
? String(candidate.prefix(120))
|
||||||
|
: String(base.prefix(max(10, 120 - ext.count - 1))) + "." + ext
|
||||||
|
}
|
||||||
|
|
||||||
|
if let fallbackExtension, (candidate as NSString).pathExtension.isEmpty {
|
||||||
|
candidate += ".\(fallbackExtension)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate.isEmpty ? defaultName : candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uniqueFileURL(in directory: URL, fileName: String) -> URL {
|
||||||
|
let directoryPath = directory.standardizedFileURL.path
|
||||||
|
func isInsideDirectory(_ url: URL) -> Bool {
|
||||||
|
url.standardizedFileURL.path.hasPrefix(directoryPath + "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidate = directory.appendingPathComponent(fileName)
|
||||||
|
guard isInsideDirectory(candidate) else {
|
||||||
|
SecureLogger.warning("⚠️ Path traversal blocked: \(fileName)", category: .security)
|
||||||
|
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fileManager.fileExists(atPath: candidate.path) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
let baseName = (fileName as NSString).deletingPathExtension
|
||||||
|
let ext = (fileName as NSString).pathExtension
|
||||||
|
for counter in 1..<100 {
|
||||||
|
let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)"
|
||||||
|
candidate = directory.appendingPathComponent(newName)
|
||||||
|
guard isInsideDirectory(candidate) else {
|
||||||
|
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
||||||
|
}
|
||||||
|
if !fileManager.fileExists(atPath: candidate.path) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return directory.appendingPathComponent("\(baseName)_\(UUID().uuidString).\(ext.isEmpty ? "dat" : ext)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func timestampString(from date: Date) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BLENoisePayloadFactory {
|
||||||
|
static func privateMessage(content: String, messageID: String) -> Data? {
|
||||||
|
guard let payload = PrivateMessagePacket(messageID: messageID, content: content).encode() else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return typedPayload(.privateMessage, payload: payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func readReceipt(originalMessageID: String) -> Data {
|
||||||
|
typedPayload(.readReceipt, payload: Data(originalMessageID.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func delivered(messageID: String) -> Data {
|
||||||
|
typedPayload(.delivered, payload: Data(messageID.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func typedPayload(_ type: NoisePayloadType, payload: Data) -> Data {
|
||||||
|
var typed = Data([type.rawValue])
|
||||||
|
typed.append(payload)
|
||||||
|
return typed
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEPendingPrivateMessage: Equatable {
|
||||||
|
let content: String
|
||||||
|
let messageID: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLENoiseSessionQueues {
|
||||||
|
private var privateMessagesByPeerID: [PeerID: [BLEPendingPrivateMessage]] = [:]
|
||||||
|
private var typedPayloadsByPeerID: [PeerID: [Data]] = [:]
|
||||||
|
|
||||||
|
var isEmpty: Bool {
|
||||||
|
privateMessagesByPeerID.isEmpty && typedPayloadsByPeerID.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll() {
|
||||||
|
privateMessagesByPeerID.removeAll()
|
||||||
|
typedPayloadsByPeerID.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func appendPrivateMessage(content: String, messageID: String, for peerID: PeerID) {
|
||||||
|
privateMessagesByPeerID[peerID, default: []].append(BLEPendingPrivateMessage(content: content, messageID: messageID))
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func takePrivateMessages(for peerID: PeerID) -> [BLEPendingPrivateMessage] {
|
||||||
|
let messages = privateMessagesByPeerID[peerID] ?? []
|
||||||
|
privateMessagesByPeerID.removeValue(forKey: peerID)
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func prependPrivateMessages(_ messages: [BLEPendingPrivateMessage], for peerID: PeerID) {
|
||||||
|
guard !messages.isEmpty else { return }
|
||||||
|
privateMessagesByPeerID[peerID, default: []].insert(contentsOf: messages, at: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func appendTypedPayload(_ payload: Data, for peerID: PeerID) {
|
||||||
|
typedPayloadsByPeerID[peerID, default: []].append(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func takeTypedPayloads(for peerID: PeerID) -> [Data] {
|
||||||
|
let payloads = typedPayloadsByPeerID[peerID] ?? []
|
||||||
|
typedPayloadsByPeerID.removeValue(forKey: peerID)
|
||||||
|
return payloads
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEOutboundFragmentTransferRequest {
|
||||||
|
let packet: BitchatPacket
|
||||||
|
let pad: Bool
|
||||||
|
let maxChunk: Int?
|
||||||
|
let directedPeer: PeerID?
|
||||||
|
let transferId: String?
|
||||||
|
|
||||||
|
var resolvedTransferId: String? {
|
||||||
|
guard packet.type == MessageType.fileTransfer.rawValue else { return nil }
|
||||||
|
return transferId ?? packet.payload.sha256Hex()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEOutboundFragmentTransferScheduler {
|
||||||
|
enum QueuePosition {
|
||||||
|
case front
|
||||||
|
case back
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SubmitResult {
|
||||||
|
case start(request: BLEOutboundFragmentTransferRequest, reservedTransferId: String?)
|
||||||
|
case queued(request: BLEOutboundFragmentTransferRequest, transferId: String?, position: QueuePosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CancelResult {
|
||||||
|
case active(transferId: String, workItems: [DispatchWorkItem])
|
||||||
|
case pending(transferId: String)
|
||||||
|
case missing
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SentResult: Equatable {
|
||||||
|
case progress(sentFragments: Int, totalFragments: Int)
|
||||||
|
case complete(sentFragments: Int, totalFragments: Int)
|
||||||
|
case missing
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ActiveTransferState {
|
||||||
|
let totalFragments: Int
|
||||||
|
var sentFragments: Int
|
||||||
|
var workItems: [DispatchWorkItem]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var activeTransfers: [String: ActiveTransferState] = [:]
|
||||||
|
private var pendingTransfers: [BLEOutboundFragmentTransferRequest] = []
|
||||||
|
|
||||||
|
var activeCount: Int {
|
||||||
|
activeTransfers.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var pendingCount: Int {
|
||||||
|
pendingTransfers.count
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll() -> [(id: String, workItems: [DispatchWorkItem])] {
|
||||||
|
let active = activeTransfers.map { ($0.key, $0.value.workItems) }
|
||||||
|
activeTransfers.removeAll()
|
||||||
|
pendingTransfers.removeAll()
|
||||||
|
return active
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func submit(
|
||||||
|
_ request: BLEOutboundFragmentTransferRequest,
|
||||||
|
maxConcurrentTransfers: Int
|
||||||
|
) -> SubmitResult {
|
||||||
|
guard let transferId = request.resolvedTransferId else {
|
||||||
|
return .start(request: request, reservedTransferId: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard activeTransfers.count < maxConcurrentTransfers else {
|
||||||
|
pendingTransfers.append(request)
|
||||||
|
return .queued(request: request, transferId: transferId, position: .back)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard activeTransfers[transferId] == nil else {
|
||||||
|
pendingTransfers.insert(request, at: 0)
|
||||||
|
return .queued(request: request, transferId: transferId, position: .front)
|
||||||
|
}
|
||||||
|
|
||||||
|
activeTransfers[transferId] = ActiveTransferState(totalFragments: 0, sentFragments: 0, workItems: [])
|
||||||
|
return .start(request: request, reservedTransferId: transferId)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func activateReservedTransfer(
|
||||||
|
id transferId: String,
|
||||||
|
totalFragments: Int,
|
||||||
|
workItems: [DispatchWorkItem]
|
||||||
|
) -> Bool {
|
||||||
|
guard activeTransfers[transferId] != nil else { return false }
|
||||||
|
activeTransfers[transferId] = ActiveTransferState(
|
||||||
|
totalFragments: totalFragments,
|
||||||
|
sentFragments: 0,
|
||||||
|
workItems: workItems
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func updateWorkItems(_ workItems: [DispatchWorkItem], for transferId: String) -> Bool {
|
||||||
|
guard var state = activeTransfers[transferId] else { return false }
|
||||||
|
state.workItems = workItems
|
||||||
|
activeTransfers[transferId] = state
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func releaseReservation(_ transferId: String) -> [DispatchWorkItem]? {
|
||||||
|
activeTransfers.removeValue(forKey: transferId)?.workItems
|
||||||
|
}
|
||||||
|
|
||||||
|
func isActive(_ transferId: String) -> Bool {
|
||||||
|
activeTransfers[transferId] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func cancelTransfer(_ transferId: String) -> CancelResult {
|
||||||
|
if let active = activeTransfers.removeValue(forKey: transferId) {
|
||||||
|
return .active(transferId: transferId, workItems: active.workItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let pendingIndex = pendingTransfers.firstIndex(where: { $0.resolvedTransferId == transferId || $0.transferId == transferId }) {
|
||||||
|
pendingTransfers.remove(at: pendingIndex)
|
||||||
|
return .pending(transferId: transferId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .missing
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func markFragmentSent(transferId: String) -> SentResult {
|
||||||
|
guard var state = activeTransfers[transferId] else { return .missing }
|
||||||
|
|
||||||
|
state.sentFragments = min(state.sentFragments + 1, state.totalFragments)
|
||||||
|
let isComplete = state.sentFragments >= state.totalFragments
|
||||||
|
|
||||||
|
if isComplete {
|
||||||
|
activeTransfers.removeValue(forKey: transferId)
|
||||||
|
return .complete(sentFragments: state.sentFragments, totalFragments: state.totalFragments)
|
||||||
|
}
|
||||||
|
|
||||||
|
activeTransfers[transferId] = state
|
||||||
|
return .progress(sentFragments: state.sentFragments, totalFragments: state.totalFragments)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func reservePendingStarts(maxConcurrentTransfers: Int) -> [SubmitResult] {
|
||||||
|
var availableSlots = max(0, maxConcurrentTransfers - activeTransfers.count)
|
||||||
|
guard availableSlots > 0, !pendingTransfers.isEmpty else { return [] }
|
||||||
|
|
||||||
|
var results: [SubmitResult] = []
|
||||||
|
var blockedFront: [BLEOutboundFragmentTransferRequest] = []
|
||||||
|
|
||||||
|
while availableSlots > 0, !pendingTransfers.isEmpty {
|
||||||
|
let request = pendingTransfers.removeFirst()
|
||||||
|
availableSlots -= 1
|
||||||
|
|
||||||
|
guard let transferId = request.resolvedTransferId else {
|
||||||
|
results.append(.start(request: request, reservedTransferId: nil))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
guard activeTransfers.count < maxConcurrentTransfers else {
|
||||||
|
pendingTransfers.insert(request, at: 0)
|
||||||
|
results.append(.queued(request: request, transferId: transferId, position: .front))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
guard activeTransfers[transferId] == nil else {
|
||||||
|
blockedFront.append(request)
|
||||||
|
results.append(.queued(request: request, transferId: transferId, position: .front))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
activeTransfers[transferId] = ActiveTransferState(totalFragments: 0, sentFragments: 0, workItems: [])
|
||||||
|
results.append(.start(request: request, reservedTransferId: transferId))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !blockedFront.isEmpty {
|
||||||
|
pendingTransfers.insert(contentsOf: blockedFront, at: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BLEOutboundPacketPolicy {
|
||||||
|
private static let fragmentFrameOverhead = 13 + 8 + 8 + 13
|
||||||
|
|
||||||
|
static func messageID(for packet: BitchatPacket) -> String {
|
||||||
|
BLEIngressLinkRegistry.messageID(for: packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func padsBLEFrame(for packetType: UInt8) -> Bool {
|
||||||
|
switch MessageType(rawValue: packetType) {
|
||||||
|
case .noiseEncrypted, .noiseHandshake:
|
||||||
|
return true
|
||||||
|
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func priority(for packet: BitchatPacket, data: Data) -> BLEOutboundWritePriority {
|
||||||
|
guard let messageType = MessageType(rawValue: packet.type) else { return .low }
|
||||||
|
switch messageType {
|
||||||
|
case .fragment:
|
||||||
|
return .fragment(totalFragments: fragmentTotalCount(from: packet.payload))
|
||||||
|
case .fileTransfer:
|
||||||
|
return .fileTransfer
|
||||||
|
default:
|
||||||
|
return .high
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fragmentChunkSize(forLinkLimit limit: Int) -> Int {
|
||||||
|
max(64, limit - fragmentFrameOverhead)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func fragmentTotalCount(from payload: Data) -> Int {
|
||||||
|
guard payload.count >= 12 else { return Int(UInt16.max) }
|
||||||
|
let totalHigh = Int(payload[10])
|
||||||
|
let totalLow = Int(payload[11])
|
||||||
|
let total = (totalHigh << 8) | totalLow
|
||||||
|
return max(total, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -44,7 +44,23 @@ final class ChatDeliveryCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {
|
func deliveryStatus(for messageID: String) -> DeliveryStatus? {
|
||||||
|
if let message = viewModel.messages.first(where: { $0.id == messageID }) {
|
||||||
|
return message.deliveryStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
for messages in viewModel.privateChats.values {
|
||||||
|
if let message = messages.first(where: { $0.id == messageID }) {
|
||||||
|
return message.deliveryStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@discardableResult
|
||||||
|
func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool {
|
||||||
var didUpdateStatus = false
|
var didUpdateStatus = false
|
||||||
|
|
||||||
if let index = viewModel.messages.firstIndex(where: { $0.id == messageID }) {
|
if let index = viewModel.messages.firstIndex(where: { $0.id == messageID }) {
|
||||||
@@ -72,6 +88,8 @@ final class ChatDeliveryCoordinator {
|
|||||||
viewModel.privateChats = privateChats
|
viewModel.privateChats = privateChats
|
||||||
viewModel.objectWillChange.send()
|
viewModel.objectWillChange.send()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return didUpdateStatus
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -209,34 +209,34 @@ private extension ChatTransportEventCoordinator {
|
|||||||
viewModel.meshService.sendDeliveryAck(for: packet.messageID, to: peerID)
|
viewModel.meshService.sendDeliveryAck(for: packet.messageID, to: peerID)
|
||||||
|
|
||||||
case .delivered:
|
case .delivered:
|
||||||
guard let messageID = String(data: payload, encoding: .utf8),
|
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
||||||
let name = viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname,
|
|
||||||
let (foundPeerID, index) = findMessageIndex(
|
|
||||||
for: messageID,
|
|
||||||
peerID: peerID,
|
|
||||||
in: viewModel
|
|
||||||
) else { return }
|
|
||||||
|
|
||||||
if case .read = viewModel.privateChats[foundPeerID]?[index].deliveryStatus { return }
|
let name = deliveryStatusName(for: peerID, in: viewModel)
|
||||||
|
let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus(
|
||||||
|
messageID,
|
||||||
|
status: .delivered(to: name, at: Date())
|
||||||
|
)
|
||||||
|
|
||||||
viewModel.privateChats[foundPeerID]?[index].deliveryStatus = .delivered(to: name, at: Date())
|
if !didUpdate {
|
||||||
viewModel.objectWillChange.send()
|
if case .read? = viewModel.deliveryCoordinator.deliveryStatus(for: messageID) {
|
||||||
|
SecureLogger.debug("📬 Ignored stale delivered ACK for already-read message id=\(messageID.prefix(8))… from \(peerID.id.prefix(8))…", category: .session)
|
||||||
|
} else {
|
||||||
|
SecureLogger.debug("📬 Delivered ACK for unknown message id=\(messageID.prefix(8))… from \(peerID.id.prefix(8))…", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case .readReceipt:
|
case .readReceipt:
|
||||||
guard let messageID = String(data: payload, encoding: .utf8),
|
guard let messageID = String(data: payload, encoding: .utf8) else { return }
|
||||||
let name = viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname,
|
|
||||||
let (foundPeerID, index) = findMessageIndex(
|
|
||||||
for: messageID,
|
|
||||||
peerID: peerID,
|
|
||||||
in: viewModel
|
|
||||||
),
|
|
||||||
let messages = viewModel.privateChats[foundPeerID],
|
|
||||||
index < messages.count else { return }
|
|
||||||
|
|
||||||
messages[index].deliveryStatus = .read(by: name, at: Date())
|
let name = deliveryStatusName(for: peerID, in: viewModel)
|
||||||
viewModel.privateChats[foundPeerID] = messages
|
let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus(
|
||||||
viewModel.privateChatManager.objectWillChange.send()
|
messageID,
|
||||||
viewModel.objectWillChange.send()
|
status: .read(by: name, at: Date())
|
||||||
|
)
|
||||||
|
|
||||||
|
if !didUpdate {
|
||||||
|
SecureLogger.debug("📖 Read receipt for unknown message id=\(messageID.prefix(8))… from \(peerID.id.prefix(8))…", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
case .verifyChallenge:
|
case .verifyChallenge:
|
||||||
viewModel.verificationCoordinator.handleVerifyChallengePayload(from: peerID, payload: payload)
|
viewModel.verificationCoordinator.handleVerifyChallengePayload(from: peerID, payload: payload)
|
||||||
@@ -247,34 +247,7 @@ private extension ChatTransportEventCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func findMessageIndex(
|
func deliveryStatusName(for peerID: PeerID, in viewModel: ChatViewModel) -> String {
|
||||||
for messageID: String,
|
viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname ?? viewModel.resolveNickname(for: peerID)
|
||||||
peerID: PeerID,
|
|
||||||
in viewModel: ChatViewModel
|
|
||||||
) -> (peerID: PeerID, index: Int)? {
|
|
||||||
if let messages = viewModel.privateChats[peerID],
|
|
||||||
let index = messages.firstIndex(where: { $0.id == messageID }) {
|
|
||||||
return (peerID, index)
|
|
||||||
}
|
|
||||||
|
|
||||||
if peerID.bare.count == 16,
|
|
||||||
let peer = viewModel.unifiedPeerService.getPeer(by: peerID),
|
|
||||||
!peer.noisePublicKey.isEmpty {
|
|
||||||
let longID = PeerID(hexData: peer.noisePublicKey)
|
|
||||||
if let messages = viewModel.privateChats[longID],
|
|
||||||
let index = messages.firstIndex(where: { $0.id == messageID }) {
|
|
||||||
return (longID, index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if peerID.bare.count == 64 {
|
|
||||||
let shortID = peerID.toShort()
|
|
||||||
if let messages = viewModel.privateChats[shortID],
|
|
||||||
let index = messages.firstIndex(where: { $0.id == messageID }) {
|
|
||||||
return (shortID, index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -516,6 +516,57 @@ struct ChatViewModelNoisePayloadTests {
|
|||||||
|
|
||||||
#expect(delivered)
|
#expect(delivered)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func didReceiveNoisePayload_readReceiptUpdatesBeforePeerNicknameIsKnown() async {
|
||||||
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
|
let peerID = PeerID(str: "0000000000000005")
|
||||||
|
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: "pm-read-before-name",
|
||||||
|
sender: viewModel.nickname,
|
||||||
|
content: "Waiting on read receipt",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
originalSender: nil,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: "Peer",
|
||||||
|
senderPeerID: viewModel.meshService.myPeerID,
|
||||||
|
mentions: nil,
|
||||||
|
deliveryStatus: .sent
|
||||||
|
)
|
||||||
|
viewModel.privateChats[peerID] = [message]
|
||||||
|
|
||||||
|
viewModel.didReceiveNoisePayload(
|
||||||
|
from: peerID,
|
||||||
|
type: .readReceipt,
|
||||||
|
payload: Data("pm-read-before-name".utf8),
|
||||||
|
timestamp: Date()
|
||||||
|
)
|
||||||
|
|
||||||
|
let privateChatUpdated = await TestHelpers.waitUntil({
|
||||||
|
guard let status = viewModel.privateChats[peerID]?.first?.deliveryStatus else { return false }
|
||||||
|
if case .read = status {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, timeout: TestConstants.defaultTimeout)
|
||||||
|
|
||||||
|
let conversationStoreUpdated = await TestHelpers.waitUntil({
|
||||||
|
let messages = viewModel.conversationStore.directMessages(
|
||||||
|
for: peerID,
|
||||||
|
identityResolver: viewModel.identityResolver
|
||||||
|
)
|
||||||
|
guard let status = messages.first?.deliveryStatus else { return false }
|
||||||
|
if case .read = status {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, timeout: TestConstants.defaultTimeout)
|
||||||
|
|
||||||
|
#expect(privateChatUpdated)
|
||||||
|
#expect(conversationStoreUpdated)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Formatting Tests
|
// MARK: - Formatting Tests
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ private func makeTemporaryFileURL(_ name: String) -> URL {
|
|||||||
FileManager.default.temporaryDirectory.appendingPathComponent(name)
|
FileManager.default.temporaryDirectory.appendingPathComponent(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func makeTemporaryDirectoryURL(_ name: String) -> URL {
|
||||||
|
FileManager.default.temporaryDirectory.appendingPathComponent(name, isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
private func makePlatformImage(size: CGSize) -> UIImage {
|
private func makePlatformImage(size: CGSize) -> UIImage {
|
||||||
UIGraphicsImageRenderer(size: size).image { context in
|
UIGraphicsImageRenderer(size: size).image { context in
|
||||||
@@ -55,11 +59,14 @@ struct ImageUtilsTests {
|
|||||||
@Test
|
@Test
|
||||||
func processImage_writesCompressedJpeg() throws {
|
func processImage_writesCompressedJpeg() throws {
|
||||||
let image = makePlatformImage(size: CGSize(width: 1024, height: 768))
|
let image = makePlatformImage(size: CGSize(width: 1024, height: 768))
|
||||||
let outputURL = try ImageUtils.processImage(image, maxDimension: 256)
|
let outputDirectory = makeTemporaryDirectoryURL("image-output-\(UUID().uuidString)")
|
||||||
defer { try? FileManager.default.removeItem(at: outputURL) }
|
defer { try? FileManager.default.removeItem(at: outputDirectory) }
|
||||||
|
|
||||||
|
let outputURL = try ImageUtils.processImage(image, maxDimension: 256, outputDirectory: outputDirectory)
|
||||||
|
|
||||||
let data = try Data(contentsOf: outputURL)
|
let data = try Data(contentsOf: outputURL)
|
||||||
|
|
||||||
|
#expect(outputURL.deletingLastPathComponent() == outputDirectory)
|
||||||
#expect(outputURL.pathExtension.lowercased() == "jpg")
|
#expect(outputURL.pathExtension.lowercased() == "jpg")
|
||||||
#expect(data.starts(with: Data([0xFF, 0xD8])))
|
#expect(data.starts(with: Data([0xFF, 0xD8])))
|
||||||
#expect(data.count > 0)
|
#expect(data.count > 0)
|
||||||
@@ -68,12 +75,11 @@ struct ImageUtilsTests {
|
|||||||
@Test
|
@Test
|
||||||
func processImage_usesUniqueOutputURLs() throws {
|
func processImage_usesUniqueOutputURLs() throws {
|
||||||
let image = makePlatformImage(size: CGSize(width: 64, height: 64))
|
let image = makePlatformImage(size: CGSize(width: 64, height: 64))
|
||||||
let firstURL = try ImageUtils.processImage(image, maxDimension: 64)
|
let outputDirectory = makeTemporaryDirectoryURL("image-output-\(UUID().uuidString)")
|
||||||
let secondURL = try ImageUtils.processImage(image, maxDimension: 64)
|
defer { try? FileManager.default.removeItem(at: outputDirectory) }
|
||||||
defer {
|
|
||||||
try? FileManager.default.removeItem(at: firstURL)
|
let firstURL = try ImageUtils.processImage(image, maxDimension: 64, outputDirectory: outputDirectory)
|
||||||
try? FileManager.default.removeItem(at: secondURL)
|
let secondURL = try ImageUtils.processImage(image, maxDimension: 64, outputDirectory: outputDirectory)
|
||||||
}
|
|
||||||
|
|
||||||
#expect(firstURL != secondURL)
|
#expect(firstURL != secondURL)
|
||||||
#expect(FileManager.default.fileExists(atPath: firstURL.path))
|
#expect(FileManager.default.fileExists(atPath: firstURL.path))
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
|||||||
relay url,lat,lon
|
relay url,lat,lon
|
||||||
wss://one.example/,10,20
|
wss://one.example/,10,20
|
||||||
https://one.example,10,20
|
https://one.example,10,20
|
||||||
|
wss://one.example:443/,10,20
|
||||||
http://two.example/,11,21
|
http://two.example/,11,21
|
||||||
|
wss://two.example:443,11,21
|
||||||
invalid row
|
invalid row
|
||||||
ws://three.example,not-a-lat,22
|
ws://three.example,not-a-lat,22
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
struct BLENoisePayloadFactoryTests {
|
||||||
|
@Test
|
||||||
|
func privateMessagePayloadPrefixesTLVWithNoiseType() throws {
|
||||||
|
let payload = try #require(BLENoisePayloadFactory.privateMessage(content: "secret", messageID: "pm-1"))
|
||||||
|
|
||||||
|
#expect(payload.first == NoisePayloadType.privateMessage.rawValue)
|
||||||
|
|
||||||
|
let packet = try #require(PrivateMessagePacket.decode(from: Data(payload.dropFirst())))
|
||||||
|
#expect(packet.messageID == "pm-1")
|
||||||
|
#expect(packet.content == "secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func receiptPayloadsUseMessageIDBytes() {
|
||||||
|
let read = BLENoisePayloadFactory.readReceipt(originalMessageID: "read-id")
|
||||||
|
let delivered = BLENoisePayloadFactory.delivered(messageID: "delivered-id")
|
||||||
|
|
||||||
|
#expect(read.first == NoisePayloadType.readReceipt.rawValue)
|
||||||
|
#expect(String(data: Data(read.dropFirst()), encoding: .utf8) == "read-id")
|
||||||
|
#expect(delivered.first == NoisePayloadType.delivered.rawValue)
|
||||||
|
#expect(String(data: Data(delivered.dropFirst()), encoding: .utf8) == "delivered-id")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func typedPayloadKeepsOpaqueDataUnchanged() {
|
||||||
|
let payload = BLENoisePayloadFactory.typedPayload(.verifyChallenge, payload: Data([0xCA, 0xFE]))
|
||||||
|
|
||||||
|
#expect(payload == Data([NoisePayloadType.verifyChallenge.rawValue, 0xCA, 0xFE]))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
struct BLENoiseSessionQueuesTests {
|
||||||
|
@Test
|
||||||
|
func privateMessagesDrainInPeerOrderAndClearOnlyThatPeer() {
|
||||||
|
let firstPeer = PeerID(str: "aaaaaaaaaaaaaaaa")
|
||||||
|
let secondPeer = PeerID(str: "bbbbbbbbbbbbbbbb")
|
||||||
|
var queues = BLENoiseSessionQueues()
|
||||||
|
|
||||||
|
queues.appendPrivateMessage(content: "first", messageID: "m1", for: firstPeer)
|
||||||
|
queues.appendPrivateMessage(content: "second", messageID: "m2", for: firstPeer)
|
||||||
|
queues.appendPrivateMessage(content: "other", messageID: "m3", for: secondPeer)
|
||||||
|
|
||||||
|
let drained = queues.takePrivateMessages(for: firstPeer)
|
||||||
|
|
||||||
|
#expect(drained == [
|
||||||
|
BLEPendingPrivateMessage(content: "first", messageID: "m1"),
|
||||||
|
BLEPendingPrivateMessage(content: "second", messageID: "m2")
|
||||||
|
])
|
||||||
|
#expect(queues.takePrivateMessages(for: firstPeer).isEmpty)
|
||||||
|
#expect(queues.takePrivateMessages(for: secondPeer) == [
|
||||||
|
BLEPendingPrivateMessage(content: "other", messageID: "m3")
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func prependPrivateMessagesRestoresFailedMessagesAheadOfNewerOnes() {
|
||||||
|
let peerID = PeerID(str: "aaaaaaaaaaaaaaaa")
|
||||||
|
var queues = BLENoiseSessionQueues()
|
||||||
|
|
||||||
|
queues.appendPrivateMessage(content: "new", messageID: "m2", for: peerID)
|
||||||
|
queues.prependPrivateMessages([
|
||||||
|
BLEPendingPrivateMessage(content: "retry", messageID: "m1")
|
||||||
|
], for: peerID)
|
||||||
|
|
||||||
|
#expect(queues.takePrivateMessages(for: peerID).map(\.messageID) == ["m1", "m2"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func typedPayloadsDrainIndependentlyFromPrivateMessages() {
|
||||||
|
let peerID = PeerID(str: "aaaaaaaaaaaaaaaa")
|
||||||
|
var queues = BLENoiseSessionQueues()
|
||||||
|
|
||||||
|
queues.appendPrivateMessage(content: "queued", messageID: "m1", for: peerID)
|
||||||
|
queues.appendTypedPayload(Data([0x01]), for: peerID)
|
||||||
|
queues.appendTypedPayload(Data([0x02]), for: peerID)
|
||||||
|
|
||||||
|
#expect(queues.takeTypedPayloads(for: peerID) == [Data([0x01]), Data([0x02])])
|
||||||
|
#expect(queues.takeTypedPayloads(for: peerID).isEmpty)
|
||||||
|
#expect(queues.takePrivateMessages(for: peerID).map(\.messageID) == ["m1"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func removeAllClearsBothQueueTypes() {
|
||||||
|
let peerID = PeerID(str: "aaaaaaaaaaaaaaaa")
|
||||||
|
var queues = BLENoiseSessionQueues()
|
||||||
|
|
||||||
|
queues.appendPrivateMessage(content: "queued", messageID: "m1", for: peerID)
|
||||||
|
queues.appendTypedPayload(Data([0x01]), for: peerID)
|
||||||
|
queues.removeAll()
|
||||||
|
|
||||||
|
#expect(queues.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
struct BLEOutboundFragmentTransferSchedulerTests {
|
||||||
|
@Test
|
||||||
|
func submitStartsPublicMessageWithoutTransferReservation() {
|
||||||
|
var scheduler = BLEOutboundFragmentTransferScheduler()
|
||||||
|
let request = makeRequest(type: MessageType.message.rawValue, transferId: nil)
|
||||||
|
|
||||||
|
let result = scheduler.submit(request, maxConcurrentTransfers: 1)
|
||||||
|
|
||||||
|
if case let .start(_, reservedTransferId) = result {
|
||||||
|
#expect(reservedTransferId == nil)
|
||||||
|
#expect(scheduler.activeCount == 0)
|
||||||
|
#expect(scheduler.pendingCount == 0)
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected non-file fragments to start without reserving a transfer slot")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func submitQueuesFileTransferWhenSlotsAreFull() {
|
||||||
|
var scheduler = BLEOutboundFragmentTransferScheduler()
|
||||||
|
let first = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "first")
|
||||||
|
let second = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "second")
|
||||||
|
|
||||||
|
guard case let .start(_, firstReservation?) = scheduler.submit(first, maxConcurrentTransfers: 1) else {
|
||||||
|
Issue.record("Expected first file transfer to reserve a slot")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(firstReservation == "first")
|
||||||
|
|
||||||
|
let result = scheduler.submit(second, maxConcurrentTransfers: 1)
|
||||||
|
|
||||||
|
if case let .queued(_, transferId, position) = result {
|
||||||
|
#expect(transferId == "second")
|
||||||
|
#expect(position == .back)
|
||||||
|
#expect(scheduler.activeCount == 1)
|
||||||
|
#expect(scheduler.pendingCount == 1)
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected second file transfer to queue while slots are full")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func submitQueuesDuplicateActiveTransferAtFront() {
|
||||||
|
var scheduler = BLEOutboundFragmentTransferScheduler()
|
||||||
|
let request = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "same")
|
||||||
|
|
||||||
|
_ = scheduler.submit(request, maxConcurrentTransfers: 2)
|
||||||
|
let result = scheduler.submit(request, maxConcurrentTransfers: 2)
|
||||||
|
|
||||||
|
if case let .queued(_, transferId, position) = result {
|
||||||
|
#expect(transferId == "same")
|
||||||
|
#expect(position == .front)
|
||||||
|
#expect(scheduler.activeCount == 1)
|
||||||
|
#expect(scheduler.pendingCount == 1)
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected duplicate active transfer to queue at the front")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func cancelActiveTransferReturnsScheduledWorkItems() {
|
||||||
|
var scheduler = BLEOutboundFragmentTransferScheduler()
|
||||||
|
let request = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "active")
|
||||||
|
_ = scheduler.submit(request, maxConcurrentTransfers: 1)
|
||||||
|
let workItem = DispatchWorkItem {}
|
||||||
|
|
||||||
|
let didActivate = scheduler.activateReservedTransfer(id: "active", totalFragments: 2, workItems: [workItem])
|
||||||
|
#expect(didActivate)
|
||||||
|
|
||||||
|
if case let .active(transferId, workItems) = scheduler.cancelTransfer("active") {
|
||||||
|
#expect(transferId == "active")
|
||||||
|
#expect(workItems.count == 1)
|
||||||
|
#expect(scheduler.activeCount == 0)
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected active transfer cancellation to return its work items")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func completedTransferFreesSlotForPendingTransfer() {
|
||||||
|
var scheduler = BLEOutboundFragmentTransferScheduler()
|
||||||
|
let first = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "first")
|
||||||
|
let second = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "second")
|
||||||
|
|
||||||
|
_ = scheduler.submit(first, maxConcurrentTransfers: 1)
|
||||||
|
let didActivate = scheduler.activateReservedTransfer(id: "first", totalFragments: 2, workItems: [])
|
||||||
|
#expect(didActivate)
|
||||||
|
_ = scheduler.submit(second, maxConcurrentTransfers: 1)
|
||||||
|
|
||||||
|
#expect(scheduler.markFragmentSent(transferId: "first") == .progress(sentFragments: 1, totalFragments: 2))
|
||||||
|
#expect(scheduler.markFragmentSent(transferId: "first") == .complete(sentFragments: 2, totalFragments: 2))
|
||||||
|
|
||||||
|
let starts = scheduler.reservePendingStarts(maxConcurrentTransfers: 1)
|
||||||
|
#expect(starts.count == 1)
|
||||||
|
|
||||||
|
if case let .start(_, reservedTransferId?) = starts.first {
|
||||||
|
#expect(reservedTransferId == "second")
|
||||||
|
#expect(scheduler.activeCount == 1)
|
||||||
|
#expect(scheduler.pendingCount == 0)
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected pending transfer to reserve the freed slot")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func removeAllReturnsActiveWorkItemsAndDropsPendingTransfers() {
|
||||||
|
var scheduler = BLEOutboundFragmentTransferScheduler()
|
||||||
|
let active = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "active")
|
||||||
|
let pending = makeRequest(type: MessageType.fileTransfer.rawValue, transferId: "pending")
|
||||||
|
let workItem = DispatchWorkItem {}
|
||||||
|
|
||||||
|
_ = scheduler.submit(active, maxConcurrentTransfers: 1)
|
||||||
|
let didActivate = scheduler.activateReservedTransfer(id: "active", totalFragments: 1, workItems: [workItem])
|
||||||
|
#expect(didActivate)
|
||||||
|
_ = scheduler.submit(pending, maxConcurrentTransfers: 1)
|
||||||
|
|
||||||
|
let removed = scheduler.removeAll()
|
||||||
|
|
||||||
|
#expect(removed.count == 1)
|
||||||
|
#expect(removed.first?.id == "active")
|
||||||
|
#expect(removed.first?.workItems.count == 1)
|
||||||
|
#expect(scheduler.activeCount == 0)
|
||||||
|
#expect(scheduler.pendingCount == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeRequest(type: UInt8, transferId: String?) -> BLEOutboundFragmentTransferRequest {
|
||||||
|
BLEOutboundFragmentTransferRequest(
|
||||||
|
packet: BitchatPacket(
|
||||||
|
type: type,
|
||||||
|
senderID: Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77]),
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: 0x0102030405,
|
||||||
|
payload: Data((transferId ?? "payload").utf8),
|
||||||
|
signature: nil,
|
||||||
|
ttl: 3
|
||||||
|
),
|
||||||
|
pad: false,
|
||||||
|
maxChunk: nil,
|
||||||
|
directedPeer: nil,
|
||||||
|
transferId: transferId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -208,7 +208,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
let relayTwo = "wss://relay-two.example"
|
let relayTwo = "wss://relay-two.example"
|
||||||
let context = makeContext(permission: .denied)
|
let context = makeContext(permission: .denied)
|
||||||
|
|
||||||
context.manager.ensureConnections(to: [relayOne, relayOne, relayTwo])
|
context.manager.ensureConnections(to: [relayOne, "wss://relay-one.example:443/", "WSS://RELAY-TWO.EXAMPLE:443"])
|
||||||
|
|
||||||
let connected = await waitUntil {
|
let connected = await waitUntil {
|
||||||
Set(context.manager.getRelayStatuses().map(\.url)) == Set([relayOne, relayTwo]) &&
|
Set(context.manager.getRelayStatuses().map(\.url)) == Set([relayOne, relayTwo]) &&
|
||||||
|
|||||||
Reference in New Issue
Block a user