Move additional files/tests to BitFoundation (#1102)

This commit is contained in:
Islam
2026-04-15 12:26:48 -05:00
committed by GitHub
parent 4cfcefcda6
commit c60eff2c11
46 changed files with 741 additions and 293 deletions
@@ -6,6 +6,7 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import BitFoundation
import Foundation import Foundation
extension BitchatMessage { extension BitchatMessage {
+1
View File
@@ -78,6 +78,7 @@
/// ///
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import CryptoKit import CryptoKit
+1
View File
@@ -1,3 +1,4 @@
import BitFoundation
import Foundation import Foundation
import CryptoKit import CryptoKit
@@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import BitFoundation
import BitLogger import BitLogger
/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files). /// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files).
-63
View File
@@ -62,40 +62,6 @@ import Foundation
import CoreBluetooth import CoreBluetooth
import BitFoundation import BitFoundation
// MARK: - Message Types
/// Simplified BitChat protocol message types.
/// Reduced from 24 types to just 6 essential ones.
/// All private communication metadata (receipts, status) is embedded in noiseEncrypted payloads.
enum MessageType: UInt8 {
// Public messages (unencrypted)
case announce = 0x01 // "I'm here" with nickname
case message = 0x02 // Public chat message
case leave = 0x03 // "I'm leaving"
case requestSync = 0x21 // GCS filter-based sync request (local-only)
// Noise encryption
case noiseHandshake = 0x10 // Handshake (init or response determined by payload)
case noiseEncrypted = 0x11 // All encrypted payloads (messages, receipts, etc.)
// Fragmentation (simplified)
case fragment = 0x20 // Single fragment type for large messages
case fileTransfer = 0x22 // Binary file/audio/image payloads
var description: String {
switch self {
case .announce: return "announce"
case .message: return "message"
case .leave: return "leave"
case .requestSync: return "requestSync"
case .noiseHandshake: return "noiseHandshake"
case .noiseEncrypted: return "noiseEncrypted"
case .fragment: return "fragment"
case .fileTransfer: return "fileTransfer"
}
}
}
// MARK: - Noise Payload Types // MARK: - Noise Payload Types
/// Types of payloads embedded within noiseEncrypted messages. /// Types of payloads embedded within noiseEncrypted messages.
@@ -132,35 +98,6 @@ enum LazyHandshakeState {
case failed(Error) // Handshake failed case failed(Error) // Handshake failed
} }
// MARK: - Delivery Status
// Delivery status for messages
enum DeliveryStatus: Codable, Equatable, Hashable {
case sending
case sent // Left our device
case delivered(to: String, at: Date) // Confirmed by recipient
case read(by: String, at: Date) // Seen by recipient
case failed(reason: String)
case partiallyDelivered(reached: Int, total: Int) // For rooms
var displayText: String {
switch self {
case .sending:
return "Sending..."
case .sent:
return "Sent"
case .delivered(let nickname, _):
return "Delivered to \(nickname)"
case .read(let nickname, _):
return "Read by \(nickname)"
case .failed(let reason):
return "Failed: \(reason)"
case .partiallyDelivered(let reached, let total):
return "Delivered to \(reached)/\(total)"
}
}
}
// MARK: - Delegate Protocol // MARK: - Delegate Protocol
protocol BitchatDelegate: AnyObject { protocol BitchatDelegate: AnyObject {
+1 -67
View File
@@ -7,76 +7,10 @@
// //
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import Security import Security
// MARK: - Keychain Error Types
// BCH-01-009: Proper error classification to distinguish expected states from critical failures
/// Result of a keychain read operation with proper error classification
enum KeychainReadResult {
case success(Data)
case itemNotFound // Expected: key doesn't exist yet
case accessDenied // Critical: app lacks keychain access
case deviceLocked // Recoverable: device is locked
case authenticationFailed // Recoverable: biometric/passcode failed
case otherError(OSStatus) // Unexpected error
var isRecoverableError: Bool {
switch self {
case .deviceLocked, .authenticationFailed:
return true
default:
return false
}
}
}
/// Result of a keychain save operation with proper error classification
enum KeychainSaveResult {
case success
case duplicateItem // Can retry with update
case accessDenied // Critical: app lacks keychain access
case deviceLocked // Recoverable: device is locked
case storageFull // Critical: no space available
case otherError(OSStatus)
var isRecoverableError: Bool {
switch self {
case .duplicateItem, .deviceLocked:
return true
default:
return false
}
}
}
protocol KeychainManagerProtocol {
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
func getIdentityKey(forKey key: String) -> Data?
func deleteIdentityKey(forKey key: String) -> Bool
func deleteAllKeychainData() -> Bool
func secureClear(_ data: inout Data)
func secureClear(_ string: inout String)
func verifyIdentityKeyExists() -> Bool
// BCH-01-009: Methods with proper error classification
/// Get identity key with detailed result for error handling
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult
/// Save identity key with detailed result for error handling
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
/// Save data with a custom service name
func save(key: String, data: Data, service: String, accessible: CFString?)
/// Load data from a custom service
func load(key: String, service: String) -> Data?
/// Delete data from a custom service
func delete(key: String, service: String)
}
final class KeychainManager: KeychainManagerProtocol { final class KeychainManager: KeychainManagerProtocol {
// Use consistent service name for all keychain items // Use consistent service name for all keychain items
private let service = BitchatApp.bundleID private let service = BitchatApp.bundleID
@@ -7,6 +7,7 @@
// //
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
struct NotificationStreamAssembler { struct NotificationStreamAssembler {
-3
View File
@@ -133,9 +133,6 @@ enum TransportConfig {
static let nostrShortKeyDisplayLength: Int = 8 static let nostrShortKeyDisplayLength: Int = 8
static let nostrConvKeyPrefixLength: Int = 16 static let nostrConvKeyPrefixLength: Int = 16
// Compression
static let compressionThresholdBytes: Int = 100
// Message deduplication // Message deduplication
static let messageDedupMaxAgeSeconds: TimeInterval = 300 static let messageDedupMaxAgeSeconds: TimeInterval = 300
static let messageDedupMaxCount: Int = 1000 static let messageDedupMaxCount: Int = 1000
+1
View File
@@ -1,3 +1,4 @@
import struct BitFoundation.BitchatPacket
import Foundation import Foundation
import CryptoKit import CryptoKit
+1
View File
@@ -1,3 +1,4 @@
import BitFoundation
import Foundation import Foundation
/// Bitfield describing which message types are covered by a REQUEST_SYNC round. /// Bitfield describing which message types are covered by a REQUEST_SYNC round.
@@ -5,6 +5,7 @@
// Handles batching and deduplication of public chat messages before surfacing them to the UI. // Handles batching and deduplication of public chat messages before surfacing them to the UI.
// //
import BitFoundation
import Foundation import Foundation
@MainActor @MainActor
@@ -5,6 +5,7 @@
// Maintains mesh and geohash public timelines with simple caps and helpers. // Maintains mesh and geohash public timelines with simple caps and helpers.
// //
import BitFoundation
import Foundation import Foundation
struct PublicTimelineStore { struct PublicTimelineStore {
@@ -7,6 +7,7 @@
// //
import SwiftUI import SwiftUI
import BitFoundation
struct DeliveryStatusView: View { struct DeliveryStatusView: View {
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@@ -7,6 +7,7 @@
// //
import SwiftUI import SwiftUI
import BitFoundation
struct TextMessageView: View { struct TextMessageView: View {
@Environment(\.colorScheme) private var colorScheme: ColorScheme @Environment(\.colorScheme) private var colorScheme: ColorScheme
@@ -6,6 +6,7 @@
// //
import SwiftUI import SwiftUI
import BitFoundation
struct MediaMessageView: View { struct MediaMessageView: View {
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@@ -6,6 +6,7 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import BitFoundation
import Foundation import Foundation
extension BitchatMessage { extension BitchatMessage {
@@ -6,6 +6,7 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import BitFoundation
import Foundation import Foundation
final class PreviewKeychainManager: KeychainManagerProtocol { final class PreviewKeychainManager: KeychainManagerProtocol {
+1 -1
View File
@@ -8,7 +8,7 @@
import Testing import Testing
import CoreBluetooth import CoreBluetooth
import BitFoundation @testable import BitFoundation // to avoid unnecessary public's
@testable import bitchat @testable import bitchat
struct BLEServiceTests { struct BLEServiceTests {
@@ -9,7 +9,7 @@
import Testing import Testing
import CryptoKit import CryptoKit
import struct Foundation.UUID import struct Foundation.UUID
import BitFoundation @testable import BitFoundation // to avoid unnecessary public's
@testable import bitchat @testable import bitchat
struct PrivateChatE2ETests { struct PrivateChatE2ETests {
@@ -8,7 +8,7 @@
import Testing import Testing
import struct Foundation.UUID import struct Foundation.UUID
import BitFoundation @testable import BitFoundation // to avoid unnecessary public's
@testable import bitchat @testable import bitchat
struct PublicChatE2ETests { struct PublicChatE2ETests {
@@ -9,7 +9,7 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
import Testing import Testing
import BitFoundation @testable import BitFoundation // to avoid unnecessary public's
@testable import bitchat @testable import bitchat
struct IntegrationTests { struct IntegrationTests {
@@ -8,7 +8,7 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
import BitFoundation @testable import BitFoundation // to avoid unnecessary public's
@testable import bitchat @testable import bitchat
final class TestNetworkHelper { final class TestNetworkHelper {
+1 -1
View File
@@ -8,7 +8,7 @@
import Foundation import Foundation
import CoreBluetooth import CoreBluetooth
import BitFoundation @testable import BitFoundation // to avoid unnecessary public's
@testable import bitchat @testable import bitchat
/// In-memory BLE test harness used by E2E/Integration tests. /// In-memory BLE test harness used by E2E/Integration tests.
+1
View File
@@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import BitFoundation
@testable import bitchat @testable import bitchat
final class MockKeychain: KeychainManagerProtocol { final class MockKeychain: KeychainManagerProtocol {
+78
View File
@@ -0,0 +1,78 @@
//
// NoiseEncryptionTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
@testable import bitchat
struct NoiseEncryptionTests {
@Test func generatesNewIdentityWhenMissing() throws {
let keychain = MockKeychain()
// Create service with empty keychain - should generate new identity
let service = NoiseEncryptionService(keychain: keychain)
// Should have generated and saved keys
#expect(service.getStaticPublicKeyData().count == 32)
#expect(service.getSigningPublicKeyData().count == 32)
// Keys should be persisted
let noiseKeyResult = keychain.getIdentityKeyWithResult(forKey: "noiseStaticKey")
switch noiseKeyResult {
case .success:
// Expected - key was saved
break
default:
throw KeychainTestError("Expected noise key to be saved")
}
}
@Test func loadsExistingIdentity() throws {
let keychain = MockKeychain()
// Create first service to generate identity
let service1 = NoiseEncryptionService(keychain: keychain)
let originalPublicKey = service1.getStaticPublicKeyData()
let originalSigningKey = service1.getSigningPublicKeyData()
// Create second service - should load same identity
let service2 = NoiseEncryptionService(keychain: keychain)
#expect(service2.getStaticPublicKeyData() == originalPublicKey)
#expect(service2.getSigningPublicKeyData() == originalSigningKey)
}
@Test func handlesAccessDeniedGracefully() throws {
let keychain = MockKeychain()
keychain.simulatedReadError = .accessDenied
// Service should still initialize with ephemeral key
let service = NoiseEncryptionService(keychain: keychain)
// Should have an identity (ephemeral)
#expect(service.getStaticPublicKeyData().count == 32)
#expect(service.getSigningPublicKeyData().count == 32)
}
@Test func handlesDeviceLockedGracefully() throws {
let keychain = MockKeychain()
keychain.simulatedReadError = .deviceLocked
// Service should still initialize with ephemeral key
let service = NoiseEncryptionService(keychain: keychain)
// Should have an identity (ephemeral)
#expect(service.getStaticPublicKeyData().count == 32)
}
}
// TODO: Reuse
private struct KeychainTestError: Error, CustomStringConvertible {
let message: String
init(_ message: String) { self.message = message }
var description: String { message }
}
@@ -1,5 +1,6 @@
import Testing import Testing
import Foundation import Foundation
import BitFoundation
@testable import bitchat @testable import bitchat
struct NotificationStreamAssemblerTests { struct NotificationStreamAssemblerTests {
@@ -1,5 +1,6 @@
import Foundation import Foundation
import XCTest import XCTest
@testable import BitFoundation // to avoid unnecessary public's
@testable import bitchat @testable import bitchat
final class BinaryEncodingUtilsTests: XCTestCase { final class BinaryEncodingUtilsTests: XCTestCase {
@@ -7,6 +7,7 @@
import Testing import Testing
import Foundation import Foundation
import BitFoundation
@testable import bitchat @testable import bitchat
@MainActor @MainActor
@@ -14,9 +14,15 @@ let package = Package(
targets: ["BitFoundation"] targets: ["BitFoundation"]
) )
], ],
dependencies: [
.package(path: "../BitLogger")
],
targets: [ targets: [
.target( .target(
name: "BitFoundation", name: "BitFoundation",
dependencies: [
.product(name: "BitLogger", package: "BitLogger"),
],
path: "Sources" path: "Sources"
), ),
.testTarget( .testTarget(
@@ -5,15 +5,15 @@
// Binary encoding utilities for efficient protocol messages // Binary encoding utilities for efficient protocol messages
// //
import Foundation import struct Foundation.Data
import BitFoundation import struct Foundation.Date
// MARK: - Binary Encoding Utilities // MARK: - Binary Encoding Utilities
extension Data { extension Data {
// MARK: Writing // MARK: Writing
@inlinable mutating func appendUInt8(_ value: UInt8) { @inlinable public mutating func appendUInt8(_ value: UInt8) {
self.append(value) self.append(value)
} }
@@ -35,7 +35,7 @@ extension Data {
} }
} }
mutating func appendString(_ string: String, maxLength: Int = 255) { public mutating func appendString(_ string: String, maxLength: Int = 255) {
guard let data = string.data(using: .utf8) else { return } guard let data = string.data(using: .utf8) else { return }
let length = Swift.min(data.count, maxLength) let length = Swift.min(data.count, maxLength)
@@ -48,7 +48,7 @@ extension Data {
self.append(data.prefix(length)) self.append(data.prefix(length))
} }
mutating func appendData(_ data: Data, maxLength: Int = 65535) { public mutating func appendData(_ data: Data, maxLength: Int = 65535) {
let length = Swift.min(data.count, maxLength) let length = Swift.min(data.count, maxLength)
if maxLength <= 255 { if maxLength <= 255 {
@@ -60,12 +60,12 @@ extension Data {
self.append(data.prefix(length)) self.append(data.prefix(length))
} }
mutating func appendDate(_ date: Date) { public mutating func appendDate(_ date: Date) {
let timestamp = UInt64(date.timeIntervalSince1970 * 1000) // milliseconds let timestamp = UInt64(date.timeIntervalSince1970 * 1000) // milliseconds
self.appendUInt64(timestamp) self.appendUInt64(timestamp)
} }
mutating func appendUUID(_ uuid: String) { public mutating func appendUUID(_ uuid: String) {
// Convert UUID string to 16 bytes // Convert UUID string to 16 bytes
var uuidData = Data(count: 16) var uuidData = Data(count: 16)
@@ -86,7 +86,7 @@ extension Data {
// MARK: Reading // MARK: Reading
@inlinable func readUInt8(at offset: inout Int) -> UInt8? { @inlinable public func readUInt8(at offset: inout Int) -> UInt8? {
guard offset >= 0 && offset < self.count else { return nil } guard offset >= 0 && offset < self.count else { return nil }
let value = self[offset] let value = self[offset]
offset += 1 offset += 1
@@ -120,7 +120,7 @@ extension Data {
return value return value
} }
func readString(at offset: inout Int, maxLength: Int = 255) -> String? { public func readString(at offset: inout Int, maxLength: Int = 255) -> String? {
let length: Int let length: Int
if maxLength <= 255 { if maxLength <= 255 {
@@ -139,7 +139,7 @@ extension Data {
return String(data: stringData, encoding: .utf8) return String(data: stringData, encoding: .utf8)
} }
func readData(at offset: inout Int, maxLength: Int = 65535) -> Data? { public func readData(at offset: inout Int, maxLength: Int = 65535) -> Data? {
let length: Int let length: Int
if maxLength <= 255 { if maxLength <= 255 {
@@ -158,12 +158,12 @@ extension Data {
return data return data
} }
func readDate(at offset: inout Int) -> Date? { public func readDate(at offset: inout Int) -> Date? {
guard let timestamp = readUInt64(at: &offset) else { return nil } guard let timestamp = readUInt64(at: &offset) else { return nil }
return Date(timeIntervalSince1970: Double(timestamp) / 1000.0) return Date(timeIntervalSince1970: Double(timestamp) / 1000.0)
} }
func readUUID(at offset: inout Int) -> String? { public func readUUID(at offset: inout Int) -> String? {
guard offset + 16 <= self.count else { return nil } guard offset + 16 <= self.count else { return nil }
let uuidData = self[offset..<offset + 16] let uuidData = self[offset..<offset + 16]
@@ -184,7 +184,7 @@ extension Data {
return result.uppercased() return result.uppercased()
} }
func readFixedBytes(at offset: inout Int, count: Int) -> Data? { public func readFixedBytes(at offset: inout Int, count: Int) -> Data? {
guard offset + count <= self.count else { return nil } guard offset + count <= self.count else { return nil }
let data = self[offset..<offset + count] let data = self[offset..<offset + count]
@@ -88,40 +88,31 @@
/// - Platform-optimized byte swapping /// - Platform-optimized byte swapping
/// ///
import Foundation import struct Foundation.Data
import BitLogger import class Foundation.NSData
private import BitLogger
extension Data {
func trimmingNullBytes() -> Data {
// Find the first null byte
if let nullIndex = self.firstIndex(of: 0) {
return self.prefix(nullIndex)
}
return self
}
}
/// Implements binary encoding and decoding for BitChat protocol messages. /// Implements binary encoding and decoding for BitChat protocol messages.
/// Provides static methods for converting between BitchatPacket objects and /// Provides static methods for converting between BitchatPacket objects and
/// their binary wire format representation. /// their binary wire format representation.
/// - Note: All multi-byte values use network byte order (big-endian) /// - Note: All multi-byte values use network byte order (big-endian)
struct BinaryProtocol { public struct BinaryProtocol {
static let v1HeaderSize = 14 public static let v1HeaderSize = 14
static let v2HeaderSize = 16 static let v2HeaderSize = 16
static let senderIDSize = 8 public static let senderIDSize = 8
static let recipientIDSize = 8 public static let recipientIDSize = 8
static let signatureSize = 64 public static let signatureSize = 64
// Field offsets within packet header // Field offsets within packet header
struct Offsets { public struct Offsets {
static let version = 0 static let version = 0
static let type = 1 static let type = 1
static let ttl = 2 static let ttl = 2
static let timestamp = 3 static let timestamp = 3
static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8) public static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8)
} }
static func headerSize(for version: UInt8) -> Int? { public static func headerSize(for version: UInt8) -> Int? {
switch version { switch version {
case 1: return v1HeaderSize case 1: return v1HeaderSize
case 2: return v2HeaderSize case 2: return v2HeaderSize
@@ -133,11 +124,11 @@ struct BinaryProtocol {
return version == 2 ? 4 : 2 return version == 2 ? 4 : 2
} }
struct Flags { public struct Flags {
static let hasRecipient: UInt8 = 0x01 public static let hasRecipient: UInt8 = 0x01
static let hasSignature: UInt8 = 0x02 public static let hasSignature: UInt8 = 0x02
static let isCompressed: UInt8 = 0x04 public static let isCompressed: UInt8 = 0x04
static let hasRoute: UInt8 = 0x08 public static let hasRoute: UInt8 = 0x08
static let isRSR: UInt8 = 0x10 static let isRSR: UInt8 = 0x10
} }
@@ -266,7 +257,7 @@ struct BinaryProtocol {
} }
// Decode binary data to BitchatPacket // Decode binary data to BitchatPacket
static func decode(_ data: Data) -> BitchatPacket? { public static func decode(_ data: Data) -> BitchatPacket? {
// Try decode as-is first (robust when padding wasn't applied) // Try decode as-is first (robust when padding wasn't applied)
if let pkt = decodeCore(data) { return pkt } if let pkt = decodeCore(data) { return pkt }
// If that fails, try after removing padding // If that fails, try after removing padding
@@ -6,34 +6,39 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import Foundation import class Foundation.DateFormatter
import BitFoundation
import struct Foundation.AttributedString
import struct Foundation.Data
import struct Foundation.Date
import struct Foundation.TimeInterval
import struct Foundation.UUID
/// Represents a user-visible message in the BitChat system. /// Represents a user-visible message in the BitChat system.
/// Handles both broadcast messages and private encrypted messages, /// Handles both broadcast messages and private encrypted messages,
/// with support for mentions, replies, and delivery tracking. /// with support for mentions, replies, and delivery tracking.
/// - Note: This is the primary data model for chat messages /// - Note: This is the primary data model for chat messages
final class BitchatMessage: Codable { public final class BitchatMessage: Codable {
let id: String public let id: String
let sender: String public let sender: String
let content: String public let content: String
let timestamp: Date public let timestamp: Date
let isRelay: Bool public let isRelay: Bool
let originalSender: String? public let originalSender: String?
let isPrivate: Bool public let isPrivate: Bool
let recipientNickname: String? public let recipientNickname: String?
let senderPeerID: PeerID? public let senderPeerID: PeerID?
let mentions: [String]? // Array of mentioned nicknames public let mentions: [String]? // Array of mentioned nicknames
var deliveryStatus: DeliveryStatus? // Delivery tracking public var deliveryStatus: DeliveryStatus? // Delivery tracking
// Cached formatted text (not included in Codable) // Cached formatted text (not included in Codable)
private var _cachedFormattedText: [String: AttributedString] = [:] private var _cachedFormattedText: [String: AttributedString] = [:]
func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? { public func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
return _cachedFormattedText["\(isDark)-\(isSelf)"] return _cachedFormattedText["\(isDark)-\(isSelf)"]
} }
func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) { public func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
_cachedFormattedText["\(isDark)-\(isSelf)"] = text _cachedFormattedText["\(isDark)-\(isSelf)"] = text
} }
@@ -43,7 +48,7 @@ final class BitchatMessage: Codable {
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
} }
init( public init(
id: String? = nil, id: String? = nil,
sender: String, sender: String,
content: String, content: String,
@@ -73,7 +78,7 @@ final class BitchatMessage: Codable {
// MARK: - Equatable Conformance // MARK: - Equatable Conformance
extension BitchatMessage: Equatable { extension BitchatMessage: Equatable {
static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool { public static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
return lhs.id == rhs.id && return lhs.id == rhs.id &&
lhs.sender == rhs.sender && lhs.sender == rhs.sender &&
lhs.content == rhs.content && lhs.content == rhs.content &&
@@ -331,14 +336,14 @@ extension BitchatMessage {
return formatter return formatter
}() }()
var formattedTimestamp: String { public var formattedTimestamp: String {
Self.timestampFormatter.string(from: timestamp) Self.timestampFormatter.string(from: timestamp)
} }
} }
extension Array where Element == BitchatMessage { extension Array where Element == BitchatMessage {
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest) /// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
func cleanedAndDeduped() -> [Element] { public func cleanedAndDeduped() -> [Element] {
let arr = filter { $0.content.trimmed.isEmpty == false } let arr = filter { $0.content.trimmed.isEmpty == false }
guard arr.count > 1 else { guard arr.count > 1 else {
return arr return arr
@@ -6,26 +6,26 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import Foundation import struct Foundation.Data
import BitFoundation import struct Foundation.Date
/// The core packet structure for all BitChat protocol messages. /// The core packet structure for all BitChat protocol messages.
/// Encapsulates all data needed for routing through the mesh network, /// Encapsulates all data needed for routing through the mesh network,
/// including TTL for hop limiting and optional encryption. /// including TTL for hop limiting and optional encryption.
/// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented /// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented
struct BitchatPacket: Codable { public struct BitchatPacket: Codable {
let version: UInt8 let version: UInt8
let type: UInt8 public let type: UInt8
let senderID: Data public let senderID: Data
let recipientID: Data? public let recipientID: Data?
let timestamp: UInt64 public let timestamp: UInt64
let payload: Data public let payload: Data
var signature: Data? public var signature: Data?
var ttl: UInt8 public var ttl: UInt8
var route: [Data]? public var route: [Data]?
var isRSR: Bool public var isRSR: Bool
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil, isRSR: Bool = false) { public init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil, isRSR: Bool = false) {
self.version = version self.version = version
self.type = type self.type = type
self.senderID = senderID self.senderID = senderID
@@ -66,18 +66,18 @@ struct BitchatPacket: Codable {
BinaryProtocol.encode(self) BinaryProtocol.encode(self)
} }
func toBinaryData(padding: Bool = true) -> Data? { public func toBinaryData(padding: Bool = true) -> Data? {
BinaryProtocol.encode(self, padding: padding) BinaryProtocol.encode(self, padding: padding)
} }
// Backward-compatible helper (defaults to padded encoding) // Backward-compatible helper (defaults to padded encoding)
func toBinaryData() -> Data? { public func toBinaryData() -> Data? {
toBinaryData(padding: true) toBinaryData(padding: true)
} }
/// Create binary representation for signing (without signature and TTL fields) /// Create binary representation for signing (without signature and TTL fields)
/// TTL is excluded because it changes during packet relay operations /// TTL is excluded because it changes during packet relay operations
func toBinaryDataForSigning() -> Data? { public func toBinaryDataForSigning() -> Data? {
// Create a copy without signature and with fixed TTL for signing // Create a copy without signature and with fixed TTL for signing
// TTL must be excluded because it changes during relay // TTL must be excluded because it changes during relay
let unsignedPacket = BitchatPacket( let unsignedPacket = BitchatPacket(
@@ -95,7 +95,7 @@ struct BitchatPacket: Codable {
return BinaryProtocol.encode(unsignedPacket) return BinaryProtocol.encode(unsignedPacket)
} }
static func from(_ data: Data) -> BitchatPacket? { public static func from(_ data: Data) -> BitchatPacket? {
BinaryProtocol.decode(data) BinaryProtocol.decode(data)
} }
} }
@@ -6,12 +6,12 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import Foundation import struct Foundation.Data
import Compression private import Compression
struct CompressionUtil { struct CompressionUtil {
// Compression threshold - don't compress if data is smaller than this // Compression threshold - don't compress if data is smaller than this
static let compressionThreshold = TransportConfig.compressionThresholdBytes // bytes static let compressionThreshold = Constants.compressionThresholdBytes // bytes
// Compress data using zlib algorithm (most compatible) // Compress data using zlib algorithm (most compatible)
static func compress(_ data: Data) -> Data? { static func compress(_ data: Data) -> Data? {
@@ -0,0 +1,12 @@
//
// Constants.swift
// BitFoundation
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
enum Constants {
// Compression
static let compressionThresholdBytes: Int = 100
}
@@ -0,0 +1,35 @@
//
// DeliveryStatus.swift
// BitFoundation
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import struct Foundation.Date
public enum DeliveryStatus: Codable, Equatable, Hashable {
case sending
case sent // Left our device
case delivered(to: String, at: Date) // Confirmed by recipient
case read(by: String, at: Date) // Seen by recipient
case failed(reason: String)
case partiallyDelivered(reached: Int, total: Int) // For rooms
public var displayText: String {
switch self {
case .sending:
return "Sending..."
case .sent:
return "Sent"
case .delivered(let nickname, _):
return "Delivered to \(nickname)"
case .read(let nickname, _):
return "Read by \(nickname)"
case .failed(let reason):
return "Failed: \(reason)"
case .partiallyDelivered(let reached, let total):
return "Delivered to \(reached)/\(total)"
}
}
}
@@ -1,15 +1,13 @@
import Foundation
/// Centralized thresholds for Bluetooth file transfers to keep payload sizes sane on constrained radios. /// Centralized thresholds for Bluetooth file transfers to keep payload sizes sane on constrained radios.
enum FileTransferLimits { public 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 public 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 = 512 * 1024 // 512 KiB public 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 = 512 * 1024 // 512 KiB public 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 = { public static let maxFramedFileBytes: Int = {
let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs
let tlvEnvelopeOverhead = 18 + maxMetadataBytes // TLV tags + lengths + metadata bytes let tlvEnvelopeOverhead = 18 + maxMetadataBytes // TLV tags + lengths + metadata bytes
let binaryEnvelopeOverhead = BinaryProtocol.v2HeaderSize let binaryEnvelopeOverhead = BinaryProtocol.v2HeaderSize
@@ -19,7 +17,7 @@ enum FileTransferLimits {
return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead
}() }()
static func isValidPayload(_ size: Int) -> Bool { public static func isValidPayload(_ size: Int) -> Bool {
size <= maxPayloadBytes size <= maxPayloadBytes
} }
} }
@@ -0,0 +1,78 @@
//
// KeychainManagerProtocol.swift
// BitFoundation
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import struct Foundation.Data
import class CoreFoundation.CFString
import typealias Darwin.OSStatus
public protocol KeychainManagerProtocol {
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
func getIdentityKey(forKey key: String) -> Data?
func deleteIdentityKey(forKey key: String) -> Bool
func deleteAllKeychainData() -> Bool
func secureClear(_ data: inout Data)
func secureClear(_ string: inout String)
func verifyIdentityKeyExists() -> Bool
// BCH-01-009: Methods with proper error classification
/// Get identity key with detailed result for error handling
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult
/// Save identity key with detailed result for error handling
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
/// Save data with a custom service name
func save(key: String, data: Data, service: String, accessible: CFString?)
/// Load data from a custom service
func load(key: String, service: String) -> Data?
/// Delete data from a custom service
func delete(key: String, service: String)
}
// MARK: - Keychain Error Types
// BCH-01-009: Proper error classification to distinguish expected states from critical failures
/// Result of a keychain read operation with proper error classification
public enum KeychainReadResult {
case success(Data)
case itemNotFound // Expected: key doesn't exist yet
case accessDenied // Critical: app lacks keychain access
case deviceLocked // Recoverable: device is locked
case authenticationFailed // Recoverable: biometric/passcode failed
case otherError(OSStatus) // Unexpected error
public var isRecoverableError: Bool {
switch self {
case .deviceLocked, .authenticationFailed:
return true
default:
return false
}
}
}
/// Result of a keychain save operation with proper error classification
public enum KeychainSaveResult {
case success
case duplicateItem // Can retry with update
case accessDenied // Critical: app lacks keychain access
case deviceLocked // Recoverable: device is locked
case storageFull // Critical: no space available
case otherError(OSStatus)
public var isRecoverableError: Bool {
switch self {
case .duplicateItem, .deviceLocked:
return true
default:
return false
}
}
}
@@ -6,7 +6,7 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import Foundation import struct Foundation.Data
/// Provides privacy-preserving message padding to obscure actual content length. /// Provides privacy-preserving message padding to obscure actual content length.
/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis. /// Uses PKCS#7-style padding with random bytes to prevent traffic analysis.
@@ -0,0 +1,39 @@
//
// MessageType.swift
// BitFoundation
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
/// Simplified BitChat protocol message types.
/// Reduced from 24 types to just 6 essential ones.
/// All private communication metadata (receipts, status) is embedded in noiseEncrypted payloads.
public enum MessageType: UInt8 {
// Public messages (unencrypted)
case announce = 0x01 // "I'm here" with nickname
case message = 0x02 // Public chat message
case leave = 0x03 // "I'm leaving"
case requestSync = 0x21 // GCS filter-based sync request (local-only)
// Noise encryption
case noiseHandshake = 0x10 // Handshake (init or response determined by payload)
case noiseEncrypted = 0x11 // All encrypted payloads (messages, receipts, etc.)
// Fragmentation (simplified)
case fragment = 0x20 // Single fragment type for large messages
case fileTransfer = 0x22 // Binary file/audio/image payloads
public var description: String {
switch self {
case .announce: return "announce"
case .message: return "message"
case .leave: return "leave"
case .requestSync: return "requestSync"
case .noiseHandshake: return "noiseHandshake"
case .noiseEncrypted: return "noiseEncrypted"
case .fragment: return "fragment"
case .fileTransfer: return "fileTransfer"
}
}
}
@@ -6,7 +6,7 @@
// //
import Testing import Testing
@testable import bitchat @testable import BitFoundation
struct BinaryProtocolPaddingTests { struct BinaryProtocolPaddingTests {
@Test func padded_vs_unpadded_length() throws { @Test func padded_vs_unpadded_length() throws {
@@ -8,8 +8,7 @@
import Testing import Testing
import Foundation import Foundation
import BitFoundation @testable import BitFoundation
@testable import bitchat
struct BinaryProtocolTests { struct BinaryProtocolTests {
@@ -806,3 +805,13 @@ struct BinaryProtocolTests {
// We don't assert the result, just that no crash happens // We don't assert the result, just that no crash happens
} }
} }
private extension Data {
func trimmingNullBytes() -> Data {
// Find the first null byte
if let nullIndex = self.firstIndex(of: 0) {
return self.prefix(nullIndex)
}
return self
}
}
@@ -9,7 +9,7 @@
import Testing import Testing
import Foundation import Foundation
@testable import bitchat import BitFoundation
struct KeychainErrorHandlingTests { struct KeychainErrorHandlingTests {
@@ -145,67 +145,6 @@ struct KeychainErrorHandlingTests {
throw KeychainTestError("Expected save success, got \(saveResult)") throw KeychainTestError("Expected save success, got \(saveResult)")
} }
} }
// MARK: - NoiseEncryptionService Integration Tests
@Test func noiseEncryptionService_generatesNewIdentityWhenMissing() throws {
let keychain = MockKeychain()
// Create service with empty keychain - should generate new identity
let service = NoiseEncryptionService(keychain: keychain)
// Should have generated and saved keys
#expect(service.getStaticPublicKeyData().count == 32)
#expect(service.getSigningPublicKeyData().count == 32)
// Keys should be persisted
let noiseKeyResult = keychain.getIdentityKeyWithResult(forKey: "noiseStaticKey")
switch noiseKeyResult {
case .success:
// Expected - key was saved
break
default:
throw KeychainTestError("Expected noise key to be saved")
}
}
@Test func noiseEncryptionService_loadsExistingIdentity() throws {
let keychain = MockKeychain()
// Create first service to generate identity
let service1 = NoiseEncryptionService(keychain: keychain)
let originalPublicKey = service1.getStaticPublicKeyData()
let originalSigningKey = service1.getSigningPublicKeyData()
// Create second service - should load same identity
let service2 = NoiseEncryptionService(keychain: keychain)
#expect(service2.getStaticPublicKeyData() == originalPublicKey)
#expect(service2.getSigningPublicKeyData() == originalSigningKey)
}
@Test func noiseEncryptionService_handlesAccessDeniedGracefully() throws {
let keychain = MockKeychain()
keychain.simulatedReadError = .accessDenied
// Service should still initialize with ephemeral key
let service = NoiseEncryptionService(keychain: keychain)
// Should have an identity (ephemeral)
#expect(service.getStaticPublicKeyData().count == 32)
#expect(service.getSigningPublicKeyData().count == 32)
}
@Test func noiseEncryptionService_handlesDeviceLockedGracefully() throws {
let keychain = MockKeychain()
keychain.simulatedReadError = .deviceLocked
// Service should still initialize with ephemeral key
let service = NoiseEncryptionService(keychain: keychain)
// Should have an identity (ephemeral)
#expect(service.getStaticPublicKeyData().count == 32)
}
} }
// Helper error type for tests // Helper error type for tests
@@ -0,0 +1,201 @@
//
// MockKeychain.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import BitFoundation
// TODO: Create a module for test helpers
final class MockKeychain: KeychainManagerProtocol {
private var storage: [String: Data] = [:]
private var serviceStorage: [String: [String: Data]] = [:]
// BCH-01-009: Configurable error simulation for testing
var simulatedReadError: KeychainReadResult?
var simulatedSaveError: KeychainSaveResult?
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
storage[key] = keyData
return true
}
func getIdentityKey(forKey key: String) -> Data? {
storage[key]
}
func deleteIdentityKey(forKey key: String) -> Bool {
storage.removeValue(forKey: key)
return true
}
func deleteAllKeychainData() -> Bool {
storage.removeAll()
serviceStorage.removeAll()
return true
}
func secureClear(_ data: inout Data) {
data = Data()
}
func secureClear(_ string: inout String) {
string = ""
}
func verifyIdentityKeyExists() -> Bool {
storage["identity_noiseStaticKey"] != nil
}
// BCH-01-009: New methods with proper error classification
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
if let simulated = simulatedReadError {
return simulated
}
if let data = storage[key] {
return .success(data)
}
return .itemNotFound
}
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
if let simulated = simulatedSaveError {
return simulated
}
storage[key] = keyData
return .success
}
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
func save(key: String, data: Data, service: String, accessible: CFString?) {
if serviceStorage[service] == nil {
serviceStorage[service] = [:]
}
serviceStorage[service]?[key] = data
}
func load(key: String, service: String) -> Data? {
serviceStorage[service]?[key]
}
func delete(key: String, service: String) {
serviceStorage[service]?.removeValue(forKey: key)
}
}
/// Typealias for backwards compatibility with tests using MockKeychainHelper
typealias MockKeychainHelper = MockKeychain
/// Mock keychain that tracks secureClear calls for testing DH secret clearing
final class TrackingMockKeychain: KeychainManagerProtocol {
private var storage: [String: Data] = [:]
private var serviceStorage: [String: [String: Data]] = [:]
/// Thread-safe counter for secureClear calls
private let lock = NSLock()
private var _secureClearDataCallCount = 0
private var _secureClearStringCallCount = 0
// BCH-01-009: Configurable error simulation for testing
var simulatedReadError: KeychainReadResult?
var simulatedSaveError: KeychainSaveResult?
var secureClearDataCallCount: Int {
lock.lock()
defer { lock.unlock() }
return _secureClearDataCallCount
}
var secureClearStringCallCount: Int {
lock.lock()
defer { lock.unlock() }
return _secureClearStringCallCount
}
var totalSecureClearCallCount: Int {
return secureClearDataCallCount + secureClearStringCallCount
}
func resetCounts() {
lock.lock()
defer { lock.unlock() }
_secureClearDataCallCount = 0
_secureClearStringCallCount = 0
}
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
storage[key] = keyData
return true
}
func getIdentityKey(forKey key: String) -> Data? {
storage[key]
}
func deleteIdentityKey(forKey key: String) -> Bool {
storage.removeValue(forKey: key)
return true
}
func deleteAllKeychainData() -> Bool {
storage.removeAll()
serviceStorage.removeAll()
return true
}
func secureClear(_ data: inout Data) {
lock.lock()
_secureClearDataCallCount += 1
lock.unlock()
data = Data()
}
func secureClear(_ string: inout String) {
lock.lock()
_secureClearStringCallCount += 1
lock.unlock()
string = ""
}
func verifyIdentityKeyExists() -> Bool {
storage["identity_noiseStaticKey"] != nil
}
// BCH-01-009: New methods with proper error classification
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
if let simulated = simulatedReadError {
return simulated
}
if let data = storage[key] {
return .success(data)
}
return .itemNotFound
}
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
if let simulated = simulatedSaveError {
return simulated
}
storage[key] = keyData
return .success
}
func save(key: String, data: Data, service: String, accessible: CFString?) {
if serviceStorage[service] == nil {
serviceStorage[service] = [:]
}
serviceStorage[service]?[key] = data
}
func load(key: String, service: String) -> Data? {
serviceStorage[service]?[key]
}
func delete(key: String, service: String) {
serviceStorage[service]?.removeValue(forKey: key)
}
}
@@ -0,0 +1,28 @@
//
// TestConstants.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
// TODO: Create a module for test helpers
struct TestConstants {
static let defaultTimeout: TimeInterval = 5.0
static let shortTimeout: TimeInterval = 1.0
static let longTimeout: TimeInterval = 10.0
static let testNickname1 = "Alice"
static let testNickname2 = "Bob"
static let testNickname3 = "Charlie"
static let testNickname4 = "David"
static let testMessage1 = "Hello, World!"
static let testMessage2 = "How are you?"
static let testMessage3 = "This is a test message"
static let testLongMessage = String(repeating: "This is a long message. ", count: 100)
static let testSignature = Data(repeating: 0xAB, count: 64)
}
@@ -0,0 +1,143 @@
//
// TestHelpers.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import CryptoKit
@testable import BitFoundation
// TODO: Create a module for test helpers
final class TestHelpers {
// MARK: - Key Generation
static func generateTestKeyPair() -> (privateKey: Curve25519.KeyAgreement.PrivateKey, publicKey: Curve25519.KeyAgreement.PublicKey) {
let privateKey = Curve25519.KeyAgreement.PrivateKey()
let publicKey = privateKey.publicKey
return (privateKey, publicKey)
}
static func generateTestIdentity(peerID: String, nickname: String) -> (peerID: String, nickname: String, privateKey: Curve25519.KeyAgreement.PrivateKey, publicKey: Curve25519.KeyAgreement.PublicKey) {
let (privateKey, publicKey) = generateTestKeyPair()
return (peerID: peerID, nickname: nickname, privateKey: privateKey, publicKey: publicKey)
}
// MARK: - Message Creation
static func createTestMessage(
content: String = TestConstants.testMessage1,
sender: String = TestConstants.testNickname1,
senderPeerID: PeerID = PeerID(str: UUID().uuidString),
isPrivate: Bool = false,
recipientNickname: String? = nil,
mentions: [String]? = nil
) -> BitchatMessage {
return BitchatMessage(
id: UUID().uuidString,
sender: sender,
content: content,
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: isPrivate,
recipientNickname: recipientNickname,
senderPeerID: senderPeerID,
mentions: mentions
)
}
static func createTestPacket(
type: UInt8 = 0x01,
senderID: PeerID = PeerID(str: UUID().uuidString),
recipientID: PeerID? = nil,
payload: Data = "test payload".data(using: .utf8)!,
signature: Data? = nil,
ttl: UInt8 = 3
) -> BitchatPacket {
return BitchatPacket(
type: type,
senderID: senderID.id.data(using: .utf8)!,
recipientID: recipientID?.id.data(using: .utf8),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: signature,
ttl: ttl
)
}
// MARK: - Data Generation
static func generateRandomData(length: Int) -> Data {
var data = Data(count: length)
_ = data.withUnsafeMutableBytes { bytes in
SecRandomCopyBytes(kSecRandomDefault, length, bytes.baseAddress!)
}
return data
}
static func generateTestPeerID() -> String {
return "PEER" + UUID().uuidString.prefix(8)
}
// MARK: - Async Helpers
static func waitFor(_ condition: @escaping () -> Bool, timeout: TimeInterval = TestConstants.defaultTimeout) async throws {
let start = Date()
while !condition() {
if Date().timeIntervalSince(start) > timeout {
throw TestError.timeout
}
try await sleep(0.01)
}
}
@MainActor
static func waitUntil(
_ condition: @escaping () -> Bool,
timeout: TimeInterval = TestConstants.defaultTimeout,
pollInterval: TimeInterval = 0.01
) async -> Bool {
let start = Date()
while !condition() {
if Date().timeIntervalSince(start) > timeout {
return condition()
}
try? await sleep(pollInterval)
}
return true
}
static func expectAsync<T>(
timeout: TimeInterval = TestConstants.defaultTimeout,
operation: @escaping () async throws -> T
) async throws -> T {
return try await withThrowingTaskGroup(of: T.self) { group in
group.addTask {
return try await operation()
}
group.addTask {
try await sleep(1)
throw TestError.timeout
}
let result = try await group.next()!
group.cancelAll()
return result
}
}
}
enum TestError: Error {
case timeout
case unexpectedValue
case testFailure(String)
}
func sleep(_ seconds: TimeInterval) async throws {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
}