mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
BLEService's per-packet-type orchestration moves into owned, tested components (BLEAnnounceHandler, BLEPublicMessageHandler, BLENoisePacketHandler, BLEFileTransferHandler, BLEFragmentHandler), each taking an environment struct of closures so every queue hop stays in BLEService and the handlers are synchronously testable. Behavior is preserved verbatim, including Noise session recovery on decrypt failure and single-block UI event ordering. handleLeave/handleRequestSync stay in place as already-thin delegations. BLEService drops to 3393 lines. Four coordinators (delivery, private conversation, Nostr, public conversation) drop their unowned/weak ChatViewModel back-references for narrow @MainActor context protocols, with ChatViewModel conformances as single shared witnesses for overlapping members. Their true coupling is now an explicit, reviewable surface, and each gains a mock-context test suite covering flows previously testable only through the full view model. Delivery/read acks now also clear the router's retained-send outbox via the delivery context. New LargeTopologyTests exercise production-shaped meshes with the in-memory harness: an 8-peer relay chain with per-hop TTL decay, a 14-peer cyclic mesh with exactly-once delivery, partition/heal, and topology churn. App-layer runtime/model files updated alongside. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
496 lines
19 KiB
Swift
496 lines
19 KiB
Swift
//
|
|
// ChatPublicConversationCoordinatorContextTests.swift
|
|
// bitchatTests
|
|
//
|
|
// Exercises `ChatPublicConversationCoordinator` against a mock
|
|
// `ChatPublicConversationContext` — proving the coordinator works without a
|
|
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
|
|
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
|
|
//
|
|
// Scope note: flows that hit process-wide singletons are intentionally not
|
|
// exercised here — `checkForMentions` / haptics (NotificationService.shared,
|
|
// UIApplication) and the geohash branch of `sendPublicRaw`
|
|
// (NostrRelayManager.shared, GeoRelayDirectory.shared). The mesh branch of
|
|
// `sendPublicRaw` and all timeline/store/blocking flows are covered.
|
|
//
|
|
|
|
import Testing
|
|
import Foundation
|
|
import BitFoundation
|
|
@testable import bitchat
|
|
|
|
// MARK: - Mock Context
|
|
|
|
/// Lightweight stand-in for `ChatPublicConversationContext` proving that
|
|
/// `ChatPublicConversationCoordinator` is testable without a `ChatViewModel`.
|
|
@MainActor
|
|
private final class MockChatPublicConversationContext: ChatPublicConversationContext {
|
|
// Channel & visible timeline state
|
|
var messages: [BitchatMessage] = []
|
|
var activeChannel: ChannelID = .mesh
|
|
var currentGeohash: String?
|
|
var nickname = "me"
|
|
var myPeerID = PeerID(str: "0011223344556677")
|
|
var isBatchingPublic = false
|
|
private(set) var notifyUIChangedCount = 0
|
|
private(set) var trimMessagesCount = 0
|
|
|
|
func notifyUIChanged() {
|
|
notifyUIChangedCount += 1
|
|
}
|
|
|
|
func trimMessagesIfNeeded() {
|
|
trimMessagesCount += 1
|
|
}
|
|
|
|
// Public timeline store
|
|
var meshTimeline: [BitchatMessage] = []
|
|
var geoTimelines: [String: [BitchatMessage]] = [:]
|
|
private(set) var queuedGeohashSystemMessages: [String] = []
|
|
|
|
func timelineMessages(for channel: ChannelID) -> [BitchatMessage] {
|
|
switch channel {
|
|
case .mesh: return meshTimeline
|
|
case .location(let channel): return geoTimelines[channel.geohash] ?? []
|
|
}
|
|
}
|
|
|
|
func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) {
|
|
switch channel {
|
|
case .mesh: meshTimeline.append(message)
|
|
case .location(let channel): geoTimelines[channel.geohash, default: []].append(message)
|
|
}
|
|
}
|
|
|
|
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {
|
|
if geoTimelines[geohash]?.contains(where: { $0.id == message.id }) == true {
|
|
return false
|
|
}
|
|
geoTimelines[geohash, default: []].append(message)
|
|
return true
|
|
}
|
|
|
|
func removeTimelineMessage(withID id: String) -> BitchatMessage? {
|
|
if let index = meshTimeline.firstIndex(where: { $0.id == id }) {
|
|
return meshTimeline.remove(at: index)
|
|
}
|
|
for (geohash, timeline) in geoTimelines {
|
|
guard let index = timeline.firstIndex(where: { $0.id == id }) else { continue }
|
|
var updated = timeline
|
|
let removed = updated.remove(at: index)
|
|
geoTimelines[geohash] = updated
|
|
return removed
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func removeGeohashTimelineMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) {
|
|
geoTimelines[geohash]?.removeAll(where: predicate)
|
|
}
|
|
|
|
func clearTimeline(for channel: ChannelID) {
|
|
switch channel {
|
|
case .mesh: meshTimeline.removeAll()
|
|
case .location(let channel): geoTimelines[channel.geohash] = []
|
|
}
|
|
}
|
|
|
|
func timelineGeohashKeys() -> [String] {
|
|
Array(geoTimelines.keys)
|
|
}
|
|
|
|
func queueGeohashSystemMessage(_ content: String) {
|
|
queuedGeohashSystemMessages.append(content)
|
|
}
|
|
|
|
// Conversation stores
|
|
private(set) var conversationActiveChannels: [ChannelID] = []
|
|
private(set) var replacedChannelMessages: [(channel: ChannelID, messageIDs: [String])] = []
|
|
private(set) var replacedConversationMessages: [(conversation: ConversationID, messageIDs: [String])] = []
|
|
private(set) var privateStoreSyncCount = 0
|
|
private(set) var selectionStoreSyncCount = 0
|
|
|
|
func setConversationActiveChannel(_ channel: ChannelID) {
|
|
conversationActiveChannels.append(channel)
|
|
}
|
|
|
|
func replaceConversationMessages(_ messages: [BitchatMessage], for channelID: ChannelID) {
|
|
replacedChannelMessages.append((channelID, messages.map(\.id)))
|
|
}
|
|
|
|
func replaceConversationMessages(_ messages: [BitchatMessage], for conversationID: ConversationID) {
|
|
replacedConversationMessages.append((conversationID, messages.map(\.id)))
|
|
}
|
|
|
|
func synchronizePrivateConversationStore() {
|
|
privateStoreSyncCount += 1
|
|
}
|
|
|
|
func synchronizeConversationSelectionStore() {
|
|
selectionStoreSyncCount += 1
|
|
}
|
|
|
|
// Private chats
|
|
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
|
var unreadPrivateMessages: Set<PeerID> = []
|
|
private(set) var cleanedUpFileMessageIDs: [String] = []
|
|
|
|
func cleanupLocalFile(forMessage message: BitchatMessage) {
|
|
cleanedUpFileMessageIDs.append(message.id)
|
|
}
|
|
|
|
// Geohash participants & presence
|
|
var geoNicknames: [String: String] = [:]
|
|
var isTeleported = false
|
|
var nostrKeyMapping: [PeerID: String] = [:]
|
|
var geoPeople: [GeoPerson] = []
|
|
var geoParticipantCounts: [String: Int] = [:]
|
|
private(set) var removedGeoParticipants: [String] = []
|
|
|
|
func visibleGeoPeople() -> [GeoPerson] {
|
|
geoPeople
|
|
}
|
|
|
|
func geoParticipantCount(for geohash: String) -> Int {
|
|
geoParticipantCounts[geohash] ?? 0
|
|
}
|
|
|
|
func removeGeoParticipant(pubkeyHex: String) {
|
|
removedGeoParticipants.append(pubkeyHex)
|
|
}
|
|
|
|
// Nostr identity & blocking
|
|
var blockedNostrPubkeys: Set<String> = []
|
|
|
|
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
|
Self.dummyIdentity
|
|
}
|
|
|
|
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {
|
|
blockedNostrPubkeys.contains(pubkeyHexLowercased)
|
|
}
|
|
|
|
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
|
|
if isBlocked {
|
|
blockedNostrPubkeys.insert(pubkeyHexLowercased)
|
|
} else {
|
|
blockedNostrPubkeys.remove(pubkeyHexLowercased)
|
|
}
|
|
}
|
|
|
|
// Mesh transport
|
|
var meshNicknames: [PeerID: String] = [:]
|
|
private(set) var sentMeshMessages: [(content: String, messageID: String)] = []
|
|
|
|
func meshPeerNicknames() -> [PeerID: String] {
|
|
meshNicknames
|
|
}
|
|
|
|
func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
|
sentMeshMessages.append((content, messageID))
|
|
}
|
|
|
|
// Inbound public message processing
|
|
var blockedMessageIDs: Set<String> = []
|
|
var rateLimitAllowed = true
|
|
private(set) var rateLimitChecks: [(senderKey: String, contentKey: String)] = []
|
|
private(set) var enqueuedMessageIDs: [String] = []
|
|
var stablePeerIDs: [PeerID: PeerID] = [:]
|
|
|
|
func processActionMessage(_ message: BitchatMessage) -> BitchatMessage {
|
|
message
|
|
}
|
|
|
|
func isMessageBlocked(_ message: BitchatMessage) -> Bool {
|
|
blockedMessageIDs.contains(message.id)
|
|
}
|
|
|
|
func allowPublicMessage(senderKey: String, contentKey: String) -> Bool {
|
|
rateLimitChecks.append((senderKey, contentKey))
|
|
return rateLimitAllowed
|
|
}
|
|
|
|
func enqueuePublicMessage(_ message: BitchatMessage) {
|
|
enqueuedMessageIDs.append(message.id)
|
|
}
|
|
|
|
func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? {
|
|
stablePeerIDs[shortPeerID]
|
|
}
|
|
|
|
// Content dedup & formatting
|
|
var contentTimestamps: [String: Date] = [:]
|
|
private(set) var recordedContentKeys: [(key: String, timestamp: Date)] = []
|
|
private(set) var prewarmedMessageIDs: [String] = []
|
|
|
|
func normalizedContentKey(_ content: String) -> String {
|
|
content.lowercased()
|
|
}
|
|
|
|
func contentTimestamp(forKey key: String) -> Date? {
|
|
contentTimestamps[key]
|
|
}
|
|
|
|
func recordContentKey(_ key: String, timestamp: Date) {
|
|
recordedContentKeys.append((key, timestamp))
|
|
}
|
|
|
|
func prewarmMessageFormatting(_ message: BitchatMessage) {
|
|
prewarmedMessageIDs.append(message.id)
|
|
}
|
|
|
|
static let dummyIdentity = NostrIdentity(
|
|
privateKey: Data(repeating: 0x11, count: 32),
|
|
publicKey: Data(repeating: 0x22, count: 32),
|
|
npub: "npub1mock",
|
|
createdAt: Date(timeIntervalSince1970: 0)
|
|
)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
@MainActor
|
|
private func makePublicMessage(
|
|
id: String = UUID().uuidString,
|
|
sender: String = "alice",
|
|
content: String = "hello world",
|
|
senderPeerID: PeerID? = PeerID(str: "aabbccddeeff0011")
|
|
) -> BitchatMessage {
|
|
BitchatMessage(
|
|
id: id,
|
|
sender: sender,
|
|
content: content,
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
isPrivate: false,
|
|
senderPeerID: senderPeerID
|
|
)
|
|
}
|
|
|
|
// MARK: - Coordinator Tests Against Mock Context
|
|
|
|
/// Exercises `ChatPublicConversationCoordinator` against
|
|
/// `MockChatPublicConversationContext` — no `ChatViewModel` involved.
|
|
struct ChatPublicConversationCoordinatorContextTests {
|
|
|
|
@Test @MainActor
|
|
func handlePublicMessage_meshMessage_appendsSyncsStoreAndEnqueues() async {
|
|
let context = MockChatPublicConversationContext()
|
|
let coordinator = ChatPublicConversationCoordinator(context: context)
|
|
let message = makePublicMessage(id: "mesh-msg-1", content: "Hello Mesh")
|
|
|
|
coordinator.handlePublicMessage(message)
|
|
|
|
#expect(context.meshTimeline.map(\.id) == ["mesh-msg-1"])
|
|
#expect(context.replacedChannelMessages.count == 1)
|
|
#expect(context.replacedChannelMessages.first?.channel == .mesh)
|
|
#expect(context.replacedChannelMessages.first?.messageIDs == ["mesh-msg-1"])
|
|
#expect(context.rateLimitChecks.count == 1)
|
|
#expect(context.rateLimitChecks.first?.senderKey == "mesh:aabbccddeeff0011")
|
|
#expect(context.rateLimitChecks.first?.contentKey == "hello mesh")
|
|
#expect(context.enqueuedMessageIDs == ["mesh-msg-1"])
|
|
|
|
// Already visible in the timeline: stored again, but not re-enqueued.
|
|
context.messages = [message]
|
|
coordinator.handlePublicMessage(message)
|
|
#expect(context.enqueuedMessageIDs == ["mesh-msg-1"])
|
|
}
|
|
|
|
@Test @MainActor
|
|
func handlePublicMessage_blockedOrRateLimited_dropsMessage() async {
|
|
let context = MockChatPublicConversationContext()
|
|
let coordinator = ChatPublicConversationCoordinator(context: context)
|
|
|
|
// Blocked sender: dropped before rate limiting and storage.
|
|
context.blockedMessageIDs = ["blocked-msg"]
|
|
coordinator.handlePublicMessage(makePublicMessage(id: "blocked-msg"))
|
|
#expect(context.rateLimitChecks.isEmpty)
|
|
#expect(context.meshTimeline.isEmpty)
|
|
#expect(context.enqueuedMessageIDs.isEmpty)
|
|
|
|
// Rate limited: consulted, then dropped before storage.
|
|
context.rateLimitAllowed = false
|
|
coordinator.handlePublicMessage(makePublicMessage(id: "limited-msg"))
|
|
#expect(context.rateLimitChecks.count == 1)
|
|
#expect(context.meshTimeline.isEmpty)
|
|
#expect(context.enqueuedMessageIDs.isEmpty)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func handlePublicMessage_geoMessage_respectsActiveChannel() async {
|
|
let context = MockChatPublicConversationContext()
|
|
let coordinator = ChatPublicConversationCoordinator(context: context)
|
|
let geohash = "u4pruy"
|
|
context.currentGeohash = geohash
|
|
let senderHex = String(repeating: "ab", count: 32)
|
|
let geoMessage = makePublicMessage(
|
|
id: "geo-msg-1",
|
|
content: "geo hello",
|
|
senderPeerID: PeerID(nostr: senderHex)
|
|
)
|
|
|
|
// On mesh channel: stored in the geohash timeline but not enqueued.
|
|
context.activeChannel = .mesh
|
|
coordinator.handlePublicMessage(geoMessage)
|
|
#expect(context.geoTimelines[geohash]?.map(\.id) == ["geo-msg-1"])
|
|
#expect(context.replacedConversationMessages.count == 1)
|
|
#expect(context.replacedConversationMessages.first?.conversation == .geohash(geohash))
|
|
#expect(context.meshTimeline.isEmpty)
|
|
#expect(context.enqueuedMessageIDs.isEmpty)
|
|
|
|
// On the matching location channel: enqueued for display.
|
|
context.activeChannel = .location(GeohashChannel(level: .city, geohash: geohash))
|
|
let second = makePublicMessage(
|
|
id: "geo-msg-2",
|
|
content: "geo again",
|
|
senderPeerID: PeerID(nostr: senderHex)
|
|
)
|
|
coordinator.handlePublicMessage(second)
|
|
#expect(context.enqueuedMessageIDs == ["geo-msg-2"])
|
|
}
|
|
|
|
@Test @MainActor
|
|
func blockGeohashUser_purgesMessagesMappingsAndPrivateChats() async {
|
|
let context = MockChatPublicConversationContext()
|
|
let coordinator = ChatPublicConversationCoordinator(context: context)
|
|
let geohash = "u4pruy"
|
|
let hex = String(repeating: "cd", count: 32)
|
|
let senderPeerID = PeerID(nostr: hex)
|
|
let convKey = PeerID(nostr_: hex)
|
|
let geoMessage = makePublicMessage(id: "geo-bad-1", sender: "rude", senderPeerID: senderPeerID)
|
|
|
|
context.currentGeohash = geohash
|
|
context.activeChannel = .location(GeohashChannel(level: .city, geohash: geohash))
|
|
context.geoTimelines[geohash] = [geoMessage]
|
|
context.messages = [geoMessage]
|
|
context.nostrKeyMapping = [senderPeerID: hex, convKey: hex]
|
|
context.privateChats[convKey] = [geoMessage]
|
|
context.unreadPrivateMessages = [convKey]
|
|
|
|
coordinator.blockGeohashUser(pubkeyHexLowercased: hex, displayName: "rude#abcd")
|
|
|
|
#expect(context.blockedNostrPubkeys.contains(hex))
|
|
#expect(context.removedGeoParticipants == [hex])
|
|
#expect(context.geoTimelines[geohash]?.isEmpty == true)
|
|
#expect(context.privateChats[convKey] == nil)
|
|
#expect(context.unreadPrivateMessages.isEmpty)
|
|
#expect(context.nostrKeyMapping.isEmpty)
|
|
// The blocked user's visible message is gone; a system notice was added.
|
|
#expect(!context.messages.contains(where: { $0.id == "geo-bad-1" }))
|
|
#expect(context.messages.last?.sender == "system")
|
|
|
|
coordinator.unblockGeohashUser(pubkeyHexLowercased: hex, displayName: "rude#abcd")
|
|
#expect(!context.blockedNostrPubkeys.contains(hex))
|
|
}
|
|
|
|
@Test @MainActor
|
|
func removeMessage_removesEverywhereAndCleansUpFile() async {
|
|
let context = MockChatPublicConversationContext()
|
|
let coordinator = ChatPublicConversationCoordinator(context: context)
|
|
let peerID = PeerID(str: "0102030405060708")
|
|
let message = makePublicMessage(id: "doomed-msg")
|
|
context.messages = [message]
|
|
context.meshTimeline = [message]
|
|
context.privateChats[peerID] = [message]
|
|
|
|
coordinator.removeMessage(withID: "doomed-msg", cleanupFile: true)
|
|
|
|
#expect(context.messages.isEmpty)
|
|
#expect(context.meshTimeline.isEmpty)
|
|
#expect(context.privateChats[peerID] == nil)
|
|
#expect(context.cleanedUpFileMessageIDs == ["doomed-msg"])
|
|
#expect(context.notifyUIChangedCount == 1)
|
|
// Timeline removal triggers a full conversation-store resync.
|
|
#expect(context.replacedChannelMessages.contains(where: { $0.channel == .mesh && $0.messageIDs.isEmpty }))
|
|
}
|
|
|
|
@Test @MainActor
|
|
func addPublicSystemMessage_appendsRefreshesAndRecordsContentKey() async {
|
|
let context = MockChatPublicConversationContext()
|
|
let coordinator = ChatPublicConversationCoordinator(context: context)
|
|
|
|
coordinator.addPublicSystemMessage("Tor Ready")
|
|
|
|
#expect(context.meshTimeline.count == 1)
|
|
#expect(context.meshTimeline.first?.sender == "system")
|
|
// refreshVisibleMessages mirrors the timeline into the visible list.
|
|
#expect(context.messages.map(\.id) == context.meshTimeline.map(\.id))
|
|
#expect(context.recordedContentKeys.map(\.key) == ["tor ready"])
|
|
#expect(context.trimMessagesCount == 1)
|
|
#expect(context.notifyUIChangedCount == 1)
|
|
#expect(context.conversationActiveChannels == [.mesh])
|
|
|
|
// On mesh, geohash-only system messages are queued for the next geo visit.
|
|
coordinator.addGeohashOnlySystemMessage("geo notice")
|
|
#expect(context.queuedGeohashSystemMessages == ["geo notice"])
|
|
}
|
|
|
|
@Test @MainActor
|
|
func sendPublicRaw_onMeshChannel_sendsViaMeshTransport() async {
|
|
let context = MockChatPublicConversationContext()
|
|
let coordinator = ChatPublicConversationCoordinator(context: context)
|
|
|
|
coordinator.sendPublicRaw("raw mesh payload")
|
|
|
|
#expect(context.sentMeshMessages.count == 1)
|
|
#expect(context.sentMeshMessages.first?.content == "raw mesh payload")
|
|
}
|
|
|
|
@Test @MainActor
|
|
func pipelineDelegate_readsAndWritesContextState() async {
|
|
let context = MockChatPublicConversationContext()
|
|
let coordinator = ChatPublicConversationCoordinator(context: context)
|
|
let pipeline = PublicMessagePipeline()
|
|
let message = makePublicMessage(id: "pipeline-msg")
|
|
context.messages = [message]
|
|
context.contentTimestamps["key-1"] = Date(timeIntervalSince1970: 42)
|
|
|
|
#expect(coordinator.pipelineCurrentMessages(pipeline).map(\.id) == ["pipeline-msg"])
|
|
#expect(coordinator.pipeline(pipeline, normalizeContent: "HeLLo") == "hello")
|
|
#expect(coordinator.pipeline(pipeline, contentTimestampForKey: "key-1") == Date(timeIntervalSince1970: 42))
|
|
|
|
coordinator.pipeline(pipeline, setMessages: [])
|
|
#expect(context.messages.isEmpty)
|
|
|
|
coordinator.pipeline(pipeline, recordContentKey: "key-2", timestamp: Date(timeIntervalSince1970: 7))
|
|
#expect(context.recordedContentKeys.map(\.key) == ["key-2"])
|
|
|
|
coordinator.pipelineTrimMessages(pipeline)
|
|
#expect(context.trimMessagesCount == 1)
|
|
|
|
coordinator.pipelinePrewarmMessage(pipeline, message: message)
|
|
#expect(context.prewarmedMessageIDs == ["pipeline-msg"])
|
|
|
|
coordinator.pipelineSetBatchingState(pipeline, isBatching: true)
|
|
#expect(context.isBatchingPublic)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func currentPublicSenderAndDisplayName_deriveGeoSuffixedIdentity() async {
|
|
let context = MockChatPublicConversationContext()
|
|
let coordinator = ChatPublicConversationCoordinator(context: context)
|
|
let identityHex = MockChatPublicConversationContext.dummyIdentity.publicKeyHex
|
|
let suffix = String(identityHex.suffix(4))
|
|
|
|
// Mesh: plain nickname and mesh peer ID.
|
|
let meshSender = coordinator.currentPublicSender()
|
|
#expect(meshSender.name == "me")
|
|
#expect(meshSender.peerID == context.myPeerID)
|
|
|
|
// Location channel: suffixed nickname and nostr peer ID.
|
|
context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruy"))
|
|
let geoSender = coordinator.currentPublicSender()
|
|
#expect(geoSender.name == "me#\(suffix)")
|
|
#expect(geoSender.peerID == PeerID(nostr: identityHex))
|
|
|
|
// Display names: own geo identity, known nickname, and anon fallback.
|
|
context.currentGeohash = "u4pruy"
|
|
#expect(coordinator.displayNameForNostrPubkey(identityHex) == "me#\(suffix)")
|
|
let otherHex = String(repeating: "ef", count: 32)
|
|
context.geoNicknames[otherHex] = "bob"
|
|
#expect(coordinator.displayNameForNostrPubkey(otherHex) == "bob#" + otherHex.suffix(4))
|
|
let unknownHex = String(repeating: "12", count: 32)
|
|
#expect(coordinator.displayNameForNostrPubkey(unknownHex) == "anon#" + unknownHex.suffix(4))
|
|
}
|
|
}
|