diff --git a/bitchat/Info.plist b/bitchat/Info.plist index fd87f8b57..545045a04 100644 --- a/bitchat/Info.plist +++ b/bitchat/Info.plist @@ -46,6 +46,8 @@ UISupportedInterfaceOrientations UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight UISupportedInterfaceOrientations~ipad @@ -55,4 +57,4 @@ UIInterfaceOrientationPortraitUpsideDown - + \ No newline at end of file diff --git a/bitchat/Utils/String+DJB2.swift b/bitchat/Utils/String+DJB2.swift new file mode 100644 index 000000000..327d7ae5c --- /dev/null +++ b/bitchat/Utils/String+DJB2.swift @@ -0,0 +1,30 @@ +// +// String+DJB2.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +extension String { + /// Computes the DJB2 hash of this string. + /// + /// DJB2 is a simple, fast non-cryptographic hash function created by Dan Bernstein. + /// It uses the magic number 5381 as the initial seed and the formula: `hash = hash * 33 + byte`. + /// + /// In bitchat, this hash is used to: + /// - Generate stable, deterministic peer color assignments from nicknames or public keys + /// - Provide consistent hue values for UI elements that need reproducible colors + /// + /// - Note: This is NOT suitable for cryptographic purposes or security-sensitive operations. + /// For cryptographic hashing, use SHA-256 instead. + /// + /// - Returns: A 64-bit hash value that is deterministic for the same input string + func djb2() -> UInt64 { + var hash: UInt64 = 5381 + for b in utf8 { hash = ((hash << 5) &+ hash) &+ UInt64(b) } + return hash + } +} diff --git a/bitchat/Utils/String+Nickname.swift b/bitchat/Utils/String+Nickname.swift new file mode 100644 index 000000000..08d114d0f --- /dev/null +++ b/bitchat/Utils/String+Nickname.swift @@ -0,0 +1,43 @@ +// +// String+Nickname.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +extension String { + /// Splits a nickname into base and a '#abcd' suffix if present. + /// + /// In bitchat, peer nicknames can have a 4-character hexadecimal suffix (e.g., "alice#1a2b") + /// to differentiate users with the same base nickname. This function parses such nicknames. + /// + /// The function: + /// - Removes any leading '@' character (for mentions) + /// - Checks if the string ends with a valid '#' followed by 4 hex digits + /// - Returns a tuple of (base, suffix) where suffix includes the '#' character + /// + /// Examples: + /// ``` + /// "alice#1a2b".splitSuffix() // returns ("alice", "#1a2b") + /// "bob".splitSuffix() // returns ("bob", "") + /// "@charlie#ffff".splitSuffix() // returns ("charlie", "#ffff") + /// "eve#xyz".splitSuffix() // returns ("eve#xyz", "") - invalid hex + /// ``` + /// + /// - Returns: A tuple containing the base nickname and the suffix (or empty string if no valid suffix) + func splitSuffix() -> (String, String) { + let name = self.replacingOccurrences(of: "@", with: "") + guard name.count >= 5 else { return (name, "") } + let suffix = String(name.suffix(5)) + if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in + ("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c)) + }) { + let base = String(name.dropLast(5)) + return (base, suffix) + } + return (name, "") + } +} diff --git a/bitchatTests/Utils/ColorPeerTests.swift b/bitchatTests/Utils/ColorPeerTests.swift new file mode 100644 index 000000000..c265f772a --- /dev/null +++ b/bitchatTests/Utils/ColorPeerTests.swift @@ -0,0 +1,196 @@ +// +// ColorPeerTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import SwiftUI +@testable import bitchat + +struct ColorPeerTests { + + // MARK: - Consistency Tests + + @Test func peerColor_sameSeedProducesSameColor() { + let color1 = Color(peerSeed: "alice", isDark: false) + let color2 = Color(peerSeed: "alice", isDark: false) + + // Since the cache is static, both should produce the same color + // We can't directly compare Color objects, but we can verify they produce consistent hashes + #expect(color1.description == color2.description, "Same seed should produce same color") + } + + @Test func peerColor_differentSeedsProduceDifferentColors() { + let color1 = Color(peerSeed: "alice", isDark: false) + let color2 = Color(peerSeed: "bob", isDark: false) + + // Different seeds should produce different colors + #expect(color1.description != color2.description, "Different seeds should produce different colors") + } + + @Test func peerColor_darkModeDifferent() { + let lightColor = Color(peerSeed: "alice", isDark: false) + let darkColor = Color(peerSeed: "alice", isDark: true) + + // Same seed but different dark mode should produce different colors + #expect(lightColor.description != darkColor.description, "Light and dark mode should produce different colors") + } + + // MARK: - Caching Tests + + @Test func peerColor_cacheWorks() { + // Generate color twice with same parameters + let color1 = Color(peerSeed: "testuser", isDark: false) + let color2 = Color(peerSeed: "testuser", isDark: false) + + // Both should be identical (from cache) + #expect(color1.description == color2.description, "Cache should return same color object") + } + + @Test func peerColor_cacheDistinguishesDarkMode() { + // Generate colors for same seed but different dark mode + let lightColor1 = Color(peerSeed: "user", isDark: false) + let darkColor1 = Color(peerSeed: "user", isDark: true) + let lightColor2 = Color(peerSeed: "user", isDark: false) + let darkColor2 = Color(peerSeed: "user", isDark: true) + + // Cache should distinguish between light and dark + #expect(lightColor1.description == lightColor2.description, "Light colors should match from cache") + #expect(darkColor1.description == darkColor2.description, "Dark colors should match from cache") + #expect(lightColor1.description != darkColor1.description, "Light and dark should differ") + } + + // MARK: - Orange Avoidance Tests + + @Test func peerColor_avoidsOrangeHue() { + // Test seeds that might hash to orange-ish hues + // Orange is at 30/360 = 0.0833... + // Avoidance delta is 0.05, so range is roughly 0.033 to 0.133 + + let testSeeds = [ + "orangish1", + "orangish2", + "orangish3", + "test30", + "test35" + ] + + for seed in testSeeds { + _ = Color(peerSeed: seed, isDark: false) + // If the hue was too close to orange, it should have been offset + // We can't easily extract the hue from SwiftUI Color, but we can verify it doesn't crash + } + } + + // MARK: - Hash Distribution Tests + + @Test func peerColor_differentHashBits() { + // Test that different parts of the hash affect the color + let color1 = Color(peerSeed: "aaa", isDark: false) + let color2 = Color(peerSeed: "bbb", isDark: false) + let color3 = Color(peerSeed: "ccc", isDark: false) + + // All three should be different + #expect(color1.description != color2.description) + #expect(color2.description != color3.description) + #expect(color1.description != color3.description) + } + + // MARK: - Real World Scenarios + + @Test func peerColor_nostrPublicKeys() { + // Test with realistic Nostr-like public key hashes + let pubkey1 = "npub1abc123def456..." + let pubkey2 = "npub1xyz789ghi012..." + + let color1 = Color(peerSeed: pubkey1, isDark: false) + let color2 = Color(peerSeed: pubkey2, isDark: false) + + #expect(color1.description != color2.description, "Different pubkeys should produce different colors") + } + + @Test func peerColor_shortSeeds() { + // Test with short seeds + let color1 = Color(peerSeed: "a", isDark: false) + let color2 = Color(peerSeed: "b", isDark: false) + + #expect(color1.description != color2.description, "Even short seeds should produce different colors") + } + + @Test func peerColor_longSeeds() { + // Test with very long seeds + let longSeed1 = String(repeating: "a", count: 100) + let longSeed2 = String(repeating: "b", count: 100) + + let color1 = Color(peerSeed: longSeed1, isDark: false) + let color2 = Color(peerSeed: longSeed2, isDark: false) + + #expect(color1.description != color2.description, "Long seeds should produce different colors") + } + + @Test func peerColor_emptyStringSeed() { + // Edge case: empty string + let color = Color(peerSeed: "", isDark: false) + + // Should not crash and should produce a valid color + #expect(color.description.isEmpty == false, "Empty seed should produce valid color") + } + + @Test func peerColor_unicodeSeeds() { + let color1 = Color(peerSeed: "alice", isDark: false) + let color2 = Color(peerSeed: "alicé", isDark: false) + + #expect(color1.description != color2.description, "Unicode differences should affect color") + } + + // MARK: - Determinism Tests + + @Test func peerColor_isDeterministic() { + // Run multiple times to ensure determinism + let seeds = ["alice", "bob", "charlie", "dave", "eve"] + + for seed in seeds { + let colors = (0..<10).map { _ in + Color(peerSeed: seed, isDark: false).description + } + + // All 10 should be identical + let uniqueColors = Set(colors) + #expect(uniqueColors.count == 1, "Color generation for '\(seed)' should be deterministic") + } + } + + @Test func peerColor_caseMatters() { + let color1 = Color(peerSeed: "Alice", isDark: false) + let color2 = Color(peerSeed: "alice", isDark: false) + let color3 = Color(peerSeed: "ALICE", isDark: false) + + // All three should be different (case-sensitive) + #expect(color1.description != color2.description, "Alice != alice") + #expect(color2.description != color3.description, "alice != ALICE") + #expect(color1.description != color3.description, "Alice != ALICE") + } + + // MARK: - Boundary Value Tests + + @Test func peerColor_numberSeeds() { + let color1 = Color(peerSeed: "1", isDark: false) + let color2 = Color(peerSeed: "2", isDark: false) + let color3 = Color(peerSeed: "99999", isDark: false) + + #expect(color1.description != color2.description) + #expect(color2.description != color3.description) + } + + @Test func peerColor_specialCharacterSeeds() { + let color1 = Color(peerSeed: "user@host", isDark: false) + let color2 = Color(peerSeed: "user#1234", isDark: false) + let color3 = Color(peerSeed: "user!special", isDark: false) + + #expect(color1.description != color2.description) + #expect(color2.description != color3.description) + } +} diff --git a/bitchatTests/Utils/StringUtilsTests.swift b/bitchatTests/Utils/StringUtilsTests.swift new file mode 100644 index 000000000..da201dbf1 --- /dev/null +++ b/bitchatTests/Utils/StringUtilsTests.swift @@ -0,0 +1,185 @@ +// +// StringUtilsTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +@testable import bitchat + +struct StringUtilsTests { + + // MARK: - DJB2 Hash Tests + + @Test func djb2_producesConsistentHash() { + let testString = "test" + let hash1 = testString.djb2() + let hash2 = testString.djb2() + + #expect(hash1 == hash2, "DJB2 should produce consistent hash for same input") + } + + @Test func djb2_producesDifferentHashForDifferentStrings() { + let hash1 = "alice".djb2() + let hash2 = "bob".djb2() + + #expect(hash1 != hash2, "DJB2 should produce different hashes for different inputs") + } + + @Test func djb2_emptyStringProducesSeedValue() { + let hash = "".djb2() + + // Empty string should return the seed value 5381 + #expect(hash == 5381, "DJB2 of empty string should be the seed value") + } + + @Test func djb2_singleCharacter() { + let hash = "a".djb2() + + // hash = ((5381 << 5) + 5381) + 97 (ASCII 'a') + // hash = (5381 * 33) + 97 = 177573 + 97 = 177670 + #expect(hash == 177670, "DJB2 should calculate correctly for single character") + } + + @Test func djb2_caseMatters() { + let hash1 = "Alice".djb2() + let hash2 = "alice".djb2() + + #expect(hash1 != hash2, "DJB2 should be case-sensitive") + } + + @Test func djb2_unicodeCharacters() { + let hash1 = "hello".djb2() + let hash2 = "héllo".djb2() + + #expect(hash1 != hash2, "DJB2 should handle unicode characters") + } + + @Test func djb2_longString() { + let longString = String(repeating: "a", count: 1000) + let hash = longString.djb2() + + #expect(hash > 0, "DJB2 should handle long strings without overflow issues") + } + + // MARK: - Nickname Suffix Tests + + @Test func splitSuffix_withValidSuffix() { + let (base, suffix) = "alice#1a2b".splitSuffix() + + #expect(base == "alice", "Base should be 'alice'") + #expect(suffix == "#1a2b", "Suffix should be '#1a2b'") + } + + @Test func splitSuffix_withoutSuffix() { + let (base, suffix) = "bob".splitSuffix() + + #expect(base == "bob", "Base should be 'bob'") + #expect(suffix == "", "Suffix should be empty") + } + + @Test func splitSuffix_withMentionSymbol() { + let (base, suffix) = "@charlie#ffff".splitSuffix() + + #expect(base == "charlie", "Base should be 'charlie' with @ removed") + #expect(suffix == "#ffff", "Suffix should be '#ffff'") + } + + @Test func splitSuffix_invalidHexInSuffix() { + let (base, suffix) = "eve#xyz1".splitSuffix() + + #expect(base == "eve#xyz1", "Should return full string as base when suffix is invalid") + #expect(suffix == "", "Suffix should be empty for invalid hex") + } + + @Test func splitSuffix_uppercaseHex() { + let (base, suffix) = "frank#ABCD".splitSuffix() + + #expect(base == "frank", "Base should be 'frank'") + #expect(suffix == "#ABCD", "Suffix should handle uppercase hex") + } + + @Test func splitSuffix_mixedCaseHex() { + let (base, suffix) = "grace#1A2b".splitSuffix() + + #expect(base == "grace", "Base should be 'grace'") + #expect(suffix == "#1A2b", "Suffix should handle mixed case hex") + } + + @Test func splitSuffix_tooShortForSuffix() { + let (base, suffix) = "dan".splitSuffix() + + #expect(base == "dan", "Base should be 'dan'") + #expect(suffix == "", "Suffix should be empty for strings too short") + } + + @Test func splitSuffix_exactlyFiveChars() { + let (base, suffix) = "#1234".splitSuffix() + + #expect(base == "", "Base should be empty") + #expect(suffix == "#1234", "Suffix should be '#1234'") + } + + @Test func splitSuffix_hashButNoSuffix() { + let (base, suffix) = "user#".splitSuffix() + + #expect(base == "user#", "Base should include the # when suffix is incomplete") + #expect(suffix == "", "Suffix should be empty") + } + + @Test func splitSuffix_multipleMentionSymbols() { + let (base, suffix) = "@@user#1a2b".splitSuffix() + + // All @ symbols should be removed + #expect(base == "user", "All @ symbols should be removed") + #expect(suffix == "#1a2b", "Suffix should be '#1a2b'") + } + + @Test func splitSuffix_zeroesInHex() { + let (base, suffix) = "zero#0000".splitSuffix() + + #expect(base == "zero", "Base should be 'zero'") + #expect(suffix == "#0000", "Suffix should handle all zeros") + } + + @Test func splitSuffix_onlyThreeHexDigits() { + let (base, suffix) = "short#123".splitSuffix() + + #expect(base == "short#123", "Should not recognize 3-digit suffix") + #expect(suffix == "", "Suffix should be empty for wrong length") + } + + @Test func splitSuffix_fiveHexDigits() { + let (base, suffix) = "long#12345".splitSuffix() + + #expect(base == "long#12345", "Should not recognize 5-digit suffix") + #expect(suffix == "", "Suffix should be empty for wrong length") + } + + @Test func splitSuffix_multipleHashSymbols() { + let (base, suffix) = "test##1234".splitSuffix() + + // Should only look at the last 5 characters + #expect(base == "test##1234", "Should not recognize suffix with double hash") + #expect(suffix == "", "Suffix should be empty") + } + + @Test func splitSuffix_emptyString() { + let (base, suffix) = "".splitSuffix() + + #expect(base == "", "Base should be empty") + #expect(suffix == "", "Suffix should be empty") + } + + @Test func splitSuffix_realWorldExample() { + let (base1, suffix1) = "alice#a1b2".splitSuffix() + let (base2, suffix2) = "alice#c3d4".splitSuffix() + + #expect(base1 == base2, "Base nicknames should match") + #expect(suffix1 != suffix2, "Suffixes should differ") + #expect(base1 == "alice", "Base should be 'alice'") + } +}