Fix warnings and improve autocomplete positioning, add unit tests

- Remove unused variables (beforeCount, beforeFavCount)
- Remove Tx Power Level from advertising (not allowed)
- Fix autocomplete popup to appear near cursor position
- Calculate position based on nickname width and @ location
- Add comprehensive unit tests for:
  - Binary protocol encoding/decoding
  - Message padding for privacy
  - Bloom filter duplicate detection
  - BitchatMessage serialization
- Add test target to project.yml
This commit is contained in:
jack
2025-07-04 15:40:47 +02:00
parent e3cc469bf8
commit 986106fea4
7 changed files with 456 additions and 34 deletions
+1 -8
View File
@@ -407,9 +407,7 @@ class BluetoothMeshService: NSObject {
advertisementData = [
CBAdvertisementDataServiceUUIDsKey: [BluetoothMeshService.serviceUUID],
// Use only peer ID without any identifying prefix
CBAdvertisementDataLocalNameKey: myPeerID,
// Request max TX power for better range (system may adjust)
CBAdvertisementDataTxPowerLevelKey: NSNumber(value: 1)
CBAdvertisementDataLocalNameKey: myPeerID
]
isAdvertising = true
@@ -960,22 +958,17 @@ class BluetoothMeshService: NSObject {
if !messageIDsToRemove.isEmpty {
self.messageQueue.async(flags: .barrier) {
// Remove only the messages we sent to this specific peer
let beforeCount = self.messageCache.count
self.messageCache.removeAll { message in
messageIDsToRemove.contains(message.messageID)
}
let afterCount = self.messageCache.count
// Also remove from favorite queue if any
if var favoriteQueue = self.favoriteMessageQueue[peerID] {
let beforeFavCount = favoriteQueue.count
favoriteQueue.removeAll { message in
messageIDsToRemove.contains(message.messageID)
}
self.favoriteMessageQueue[peerID] = favoriteQueue.isEmpty ? nil : favoriteQueue
// Removed from favorite queue
}
// Removed from cache
}
}
}
+37 -25
View File
@@ -99,35 +99,47 @@ struct ContentView: View {
// Autocomplete overlay
if viewModel.showAutocomplete && !viewModel.autocompleteSuggestions.isEmpty {
VStack {
Spacer()
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(viewModel.autocompleteSuggestions.enumerated()), id: \.element) { index, suggestion in
Button(action: {
_ = viewModel.completeNickname(suggestion, in: &messageText)
}) {
HStack {
Text("@\(suggestion)")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(index == viewModel.selectedAutocompleteIndex ? backgroundColor : textColor)
Spacer()
GeometryReader { geometry in
VStack {
Spacer()
HStack {
// Calculate approximate position based on nickname length and @ position
let nicknameWidth: CGFloat = viewModel.selectedPrivateChatPeer != nil ? 90 : 80
let charWidth: CGFloat = 8.5 // Approximate width of monospace character
let atPosition = viewModel.autocompleteRange?.location ?? 0
let offsetX = nicknameWidth + (CGFloat(atPosition) * charWidth)
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(viewModel.autocompleteSuggestions.enumerated()), id: \.element) { index, suggestion in
Button(action: {
_ = viewModel.completeNickname(suggestion, in: &messageText)
}) {
HStack {
Text("@\(suggestion)")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(index == viewModel.selectedAutocompleteIndex ? backgroundColor : textColor)
Spacer()
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(index == viewModel.selectedAutocompleteIndex ? textColor : Color.clear)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(index == viewModel.selectedAutocompleteIndex ? textColor : Color.clear)
}
.buttonStyle(.plain)
.background(backgroundColor)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(secondaryTextColor.opacity(0.5), lineWidth: 1)
)
.frame(width: 150, alignment: .leading)
.offset(x: min(offsetX, geometry.size.width - 180)) // Prevent going off-screen
.padding(.bottom, 45) // Position just above input
Spacer()
}
.padding(.horizontal, 12)
}
.background(backgroundColor)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(secondaryTextColor.opacity(0.5), lineWidth: 1)
)
.frame(maxWidth: 200, alignment: .leading)
.offset(x: 100) // Align with input field
.padding(.bottom, 45) // Position just above input
.padding(.horizontal, 12)
}
}
}
+103
View File
@@ -0,0 +1,103 @@
import XCTest
@testable import bitchat
class BinaryProtocolTests: XCTestCase {
func testPacketEncodingDecoding() {
// Test basic packet
let packet = BitchatPacket(
version: 1,
type: MessageType.message.rawValue,
senderID: Data("testuser".utf8),
recipientID: Data("recipient".utf8),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data("Hello, World!".utf8),
signature: nil,
ttl: 5
)
// Encode
guard let encoded = packet.toBinaryData() else {
XCTFail("Failed to encode packet")
return
}
// Decode
guard let decoded = BitchatPacket.from(encoded) else {
XCTFail("Failed to decode packet")
return
}
// Verify
XCTAssertEqual(decoded.version, packet.version)
XCTAssertEqual(decoded.type, packet.type)
XCTAssertEqual(decoded.ttl, packet.ttl)
XCTAssertEqual(decoded.timestamp, packet.timestamp)
XCTAssertEqual(decoded.payload, packet.payload)
}
func testBroadcastPacket() {
let packet = BitchatPacket(
type: MessageType.message.rawValue,
senderID: Data("sender".utf8),
recipientID: SpecialRecipients.broadcast,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data("Broadcast message".utf8),
signature: nil,
ttl: 3
)
guard let encoded = packet.toBinaryData() else {
XCTFail("Failed to encode broadcast packet")
return
}
guard let decoded = BitchatPacket.from(encoded) else {
XCTFail("Failed to decode broadcast packet")
return
}
// Verify broadcast recipient
XCTAssertEqual(decoded.recipientID, SpecialRecipients.broadcast)
}
func testPacketWithSignature() {
let signature = Data(repeating: 0xAB, count: 64)
let packet = BitchatPacket(
type: MessageType.message.rawValue,
senderID: Data("sender".utf8),
recipientID: Data("recipient".utf8),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data("Signed message".utf8),
signature: signature,
ttl: 5
)
guard let encoded = packet.toBinaryData() else {
XCTFail("Failed to encode signed packet")
return
}
guard let decoded = BitchatPacket.from(encoded) else {
XCTFail("Failed to decode signed packet")
return
}
XCTAssertNotNil(decoded.signature)
XCTAssertEqual(decoded.signature, signature)
}
func testInvalidPacketHandling() {
// Test empty data
XCTAssertNil(BitchatPacket.from(Data()))
// Test truncated data
let truncated = Data(repeating: 0, count: 10)
XCTAssertNil(BitchatPacket.from(truncated))
// Test invalid version
var invalidVersion = Data(repeating: 0, count: 100)
invalidVersion[0] = 99 // Invalid version
XCTAssertNil(BitchatPacket.from(invalidVersion))
}
}
+131
View File
@@ -0,0 +1,131 @@
import XCTest
@testable import bitchat
class BitchatMessageTests: XCTestCase {
func testMessageEncodingDecoding() {
let message = BitchatMessage(
sender: "testuser",
content: "Hello, World!",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: "peer123",
mentions: ["alice", "bob"]
)
guard let encoded = message.toBinaryPayload() else {
XCTFail("Failed to encode message")
return
}
guard let decoded = BitchatMessage.fromBinaryPayload(encoded) else {
XCTFail("Failed to decode message")
return
}
XCTAssertEqual(decoded.sender, message.sender)
XCTAssertEqual(decoded.content, message.content)
XCTAssertEqual(decoded.isPrivate, message.isPrivate)
XCTAssertEqual(decoded.mentions?.count, 2)
XCTAssertTrue(decoded.mentions?.contains("alice") ?? false)
XCTAssertTrue(decoded.mentions?.contains("bob") ?? false)
}
func testPrivateMessage() {
let privateMessage = BitchatMessage(
sender: "alice",
content: "This is private",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: "bob",
senderPeerID: "alicePeer"
)
guard let encoded = privateMessage.toBinaryPayload() else {
XCTFail("Failed to encode private message")
return
}
guard let decoded = BitchatMessage.fromBinaryPayload(encoded) else {
XCTFail("Failed to decode private message")
return
}
XCTAssertTrue(decoded.isPrivate)
XCTAssertEqual(decoded.recipientNickname, "bob")
}
func testRelayMessage() {
let relayMessage = BitchatMessage(
sender: "charlie",
content: "Relayed message",
timestamp: Date(),
isRelay: true,
originalSender: "alice",
isPrivate: false
)
guard let encoded = relayMessage.toBinaryPayload() else {
XCTFail("Failed to encode relay message")
return
}
guard let decoded = BitchatMessage.fromBinaryPayload(encoded) else {
XCTFail("Failed to decode relay message")
return
}
XCTAssertTrue(decoded.isRelay)
XCTAssertEqual(decoded.originalSender, "alice")
}
func testEmptyContent() {
let emptyMessage = BitchatMessage(
sender: "user",
content: "",
timestamp: Date(),
isRelay: false,
originalSender: nil
)
guard let encoded = emptyMessage.toBinaryPayload() else {
XCTFail("Failed to encode empty message")
return
}
guard let decoded = BitchatMessage.fromBinaryPayload(encoded) else {
XCTFail("Failed to decode empty message")
return
}
XCTAssertEqual(decoded.content, "")
}
func testLongContent() {
let longContent = String(repeating: "A", count: 1000)
let longMessage = BitchatMessage(
sender: "user",
content: longContent,
timestamp: Date(),
isRelay: false,
originalSender: nil
)
guard let encoded = longMessage.toBinaryPayload() else {
XCTFail("Failed to encode long message")
return
}
guard let decoded = BitchatMessage.fromBinaryPayload(encoded) else {
XCTFail("Failed to decode long message")
return
}
XCTAssertEqual(decoded.content, longContent)
}
}
+89
View File
@@ -0,0 +1,89 @@
import XCTest
@testable import bitchat
class BloomFilterTests: XCTestCase {
func testBasicBloomFilter() {
let filter = BloomFilter(size: 1024, hashCount: 3)
// Test insertion and lookup
let testStrings = ["message1", "message2", "message3", "test123"]
for str in testStrings {
XCTAssertFalse(filter.contains(str))
filter.insert(str)
XCTAssertTrue(filter.contains(str))
}
}
func testFalsePositiveRate() {
let filter = BloomFilter(size: 4096, hashCount: 3)
let itemCount = 100
// Insert items
for i in 0..<itemCount {
filter.insert("item\(i)")
}
// Check false positive rate
var falsePositives = 0
let testCount = 1000
for i in itemCount..<(itemCount + testCount) {
if filter.contains("item\(i)") {
falsePositives += 1
}
}
let falsePositiveRate = Double(falsePositives) / Double(testCount)
// With 4096 bits and 3 hash functions, for 100 items,
// false positive rate should be around 0.05% (very low)
XCTAssertLessThan(falsePositiveRate, 0.05)
}
func testReset() {
let filter = BloomFilter(size: 1024, hashCount: 3)
// Insert some items
filter.insert("test1")
filter.insert("test2")
filter.insert("test3")
XCTAssertTrue(filter.contains("test1"))
XCTAssertTrue(filter.contains("test2"))
XCTAssertTrue(filter.contains("test3"))
// Reset
filter.reset()
// Should no longer contain items
XCTAssertFalse(filter.contains("test1"))
XCTAssertFalse(filter.contains("test2"))
XCTAssertFalse(filter.contains("test3"))
}
func testHashDistribution() {
let filter = BloomFilter(size: 4096, hashCount: 3)
// Insert many items and check bit distribution
for i in 0..<500 {
filter.insert("message-\(i)")
}
// Count set bits
var setBits = 0
for i in 0..<filter.bitArray.count {
setBits += filter.bitArray[i].nonzeroBitCount
}
// Should have reasonable distribution (not all bits set)
let totalBits = filter.bitArray.count * 64
let utilization = Double(setBits) / Double(totalBits)
// With 500 items, 3 hashes each, we expect around 1500 bits set
// In a 4096 bit filter, that's about 37% utilization
XCTAssertGreaterThan(utilization, 0.2)
XCTAssertLessThan(utilization, 0.6)
}
}
+83
View File
@@ -0,0 +1,83 @@
import XCTest
@testable import bitchat
class MessagePaddingTests: XCTestCase {
func testBasicPadding() {
let originalData = Data("Hello".utf8)
let targetSize = 256
let padded = MessagePadding.pad(originalData, toSize: targetSize)
XCTAssertEqual(padded.count, targetSize)
let unpadded = MessagePadding.unpad(padded)
XCTAssertEqual(unpadded, originalData)
}
func testMultipleBlockSizes() {
let testMessages = [
"Hi",
"This is a longer message",
"This is an even longer message that should require a larger block size",
String(repeating: "A", count: 500)
]
for message in testMessages {
let data = Data(message.utf8)
let blockSize = MessagePadding.optimalBlockSize(for: data.count)
// Block size should be reasonable
XCTAssertGreaterThan(blockSize, data.count)
XCTAssertTrue(MessagePadding.blockSizes.contains(blockSize) || blockSize == data.count)
let padded = MessagePadding.pad(data, toSize: blockSize)
let unpadded = MessagePadding.unpad(padded)
XCTAssertEqual(unpadded, data)
}
}
func testPaddingWithLargeData() {
let largeData = Data(repeating: 0xFF, count: 1500)
let blockSize = MessagePadding.optimalBlockSize(for: largeData.count)
// Should use 2048 block
XCTAssertEqual(blockSize, 2048)
let padded = MessagePadding.pad(largeData, toSize: blockSize)
XCTAssertEqual(padded.count, blockSize)
let unpadded = MessagePadding.unpad(padded)
XCTAssertEqual(unpadded, largeData)
}
func testInvalidPadding() {
// Test empty data
let empty = Data()
let unpaddedEmpty = MessagePadding.unpad(empty)
XCTAssertEqual(unpaddedEmpty, empty)
// Test data with invalid padding length
var invalidPadding = Data(repeating: 0x00, count: 100)
invalidPadding[99] = 255 // Invalid padding length
let result = MessagePadding.unpad(invalidPadding)
XCTAssertEqual(result, invalidPadding) // Should return original if invalid
}
func testPaddingRandomness() {
// Ensure padding bytes are random (not predictable)
let data = Data("Test".utf8)
let padded1 = MessagePadding.pad(data, toSize: 256)
let padded2 = MessagePadding.pad(data, toSize: 256)
// Same size
XCTAssertEqual(padded1.count, padded2.count)
// But different padding bytes (with very high probability)
XCTAssertNotEqual(padded1, padded2)
// Both should unpad to same data
XCTAssertEqual(MessagePadding.unpad(padded1), data)
XCTAssertEqual(MessagePadding.unpad(padded2), data)
}
}
+12 -1
View File
@@ -47,4 +47,15 @@ targets:
CODE_SIGNING_REQUIRED: YES
CODE_SIGNING_ALLOWED: YES
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: YES
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: YES
bitchatTests:
type: bundle.unit-test
platform: [iOS, macOS]
sources:
- bitchatTests
dependencies:
- target: bitchat
settings:
PRODUCT_BUNDLE_IDENTIFIER: com.bitchat.tests
SWIFT_VERSION: 5.0