mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 06:05:20 +00:00
Move additional files/tests to BitFoundation (#1102)
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -78,6 +78,7 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,3 +1,4 @@
|
|||||||
|
import struct BitFoundation.BitchatPacket
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class MockKeychain: KeychainManagerProtocol {
|
final class MockKeychain: KeychainManagerProtocol {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
+13
-13
@@ -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]
|
||||||
+17
-26
@@ -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
|
||||||
+25
-20
@@ -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
|
||||||
+17
-17
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+3
-3
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-8
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -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 {
|
||||||
+11
-2
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-62
@@ -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))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user