From 295f855b6f35023309a0a604f5a3161b72d5f4c1 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:17:26 +0200 Subject: [PATCH 01/18] Harden REQUEST_SYNC and stop gossip-sync re-send loops (#1371) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Harden REQUEST_SYNC and stop gossip-sync re-send loops Two fixes from an end-to-end review of the sync path: Efficiency: the GCS filter (400B, p=7) covers ~355 packet IDs, but stores hold up to 1000 messages + 600 fragments + 200 files. Once a mesh accumulates more than the filter can cover, responders re-sent the entire older tail to every requester every round — ~120KB per pair per 30s during file transfers, dropped by dedup after the airtime was already burned. Requesters now stamp the dormant sinceTimestamp TLV with the oldest timestamp their filter covers, and responders skip older packets (announces exempt: they carry the signing keys needed to verify everything else). Periodic sync also sends one request per type schedule instead of a union filter, so fragment floods can't crowd messages out of the filter budget. Security: a ~40-byte unsigned REQUEST_SYNC with an empty filter could elicit a full store replay (~900KB) — an unauthenticated >10,000x amplification vector, repeatable in a tight loop and relayable with crafted TTL to fan the drain out of every reachable node. Requests now require ttl == 0, a valid signature from the claimed sender's announced signing key, and a matching link binding; REQUEST_SYNC is never relayed regardless of TTL; and responses are rate-limited per peer (8 per 30s sliding window, ~3x the legitimate cadence). Cross-platform: verified against bitchat-android — it signs REQUEST_SYNC and sends SYNC_TTL_HOPS = 0, so both gates hold; it neither sends nor honors sinceTimestamp yet, so mixed pairs keep today's behavior with no regression. Co-Authored-By: Claude Opus 4.8 * Address Codex review: enforce no-relay on route path, exact since-cursor Two P2 findings from Codex on the REQUEST_SYNC hardening: - Route-forwarding bypass: handleRequestSync's early return for a rejected (nonzero-TTL / unsigned) request still fell through to forwardAlongRouteIfNeeded, which relays any routed packet with ttl > 1 regardless of type. The no-relay invariant was only enforced on the flood path. BLERouteForwardingPolicy now suppresses REQUEST_SYNC outright, so a crafted request with a route and TTL headroom can't be forwarded to the next hop either. - Inexact since-cursor: GCSFilter.buildFilter trimmed by hash order when the encoding overflowed the byte budget, so the cursor (computed from the untrimmed prefix) could claim coverage of timestamps whose packets were dropped from the filter — re-sending exactly those every round. buildFilter now trims from the input tail (oldest, since candidates are newest-first) and reports includedCount; the cursor is derived from that, so the covered set is always a contiguous newest-prefix and the cursor is exact. Adds GCSFilter includedCount coverage (full vs trimmed), a route-forwarding test for REQUEST_SYNC, and makes the truncated-cursor test robust to trim variance. Full suite: 1029 tests pass. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: jack Co-authored-by: Claude Opus 4.8 --- .../Services/BLE/BLEIngressLinkRegistry.swift | 6 +- bitchat/Services/BLE/BLEReceivePipeline.swift | 1 + .../BLE/BLERouteForwardingPolicy.swift | 8 + bitchat/Services/BLE/BLEService.swift | 24 ++- bitchat/Services/RelayController.swift | 7 + bitchat/Services/TransportConfig.swift | 2 + bitchat/Sync/GCSFilter.swift | 57 ++--- bitchat/Sync/GossipSyncManager.swift | 51 ++++- bitchat/Sync/SyncResponseRateLimiter.swift | 42 ++++ bitchatTests/GCSFilterTests.swift | 18 ++ bitchatTests/GossipSyncManagerTests.swift | 203 +++++++++++++++++- .../BLEIngressLinkRegistryTests.swift | 47 ++++ .../BLERouteForwardingPolicyTests.swift | 30 +++ .../Services/RelayControllerTests.swift | 19 ++ .../Sync/SyncResponseRateLimiterTests.swift | 61 ++++++ 15 files changed, 534 insertions(+), 42 deletions(-) create mode 100644 bitchat/Sync/SyncResponseRateLimiter.swift create mode 100644 bitchatTests/Sync/SyncResponseRateLimiterTests.swift diff --git a/bitchat/Services/BLE/BLEIngressLinkRegistry.swift b/bitchat/Services/BLE/BLEIngressLinkRegistry.swift index ba41648c..442ba0d2 100644 --- a/bitchat/Services/BLE/BLEIngressLinkRegistry.swift +++ b/bitchat/Services/BLE/BLEIngressLinkRegistry.swift @@ -99,7 +99,11 @@ struct BLEIngressLinkRegistry { } private static func requiresDirectSenderBinding(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool { - packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL + // REQUEST_SYNC is never relayed, so on a bound link the claimed sender + // must be the link peer — it elicits a full store replay, and the + // response is addressed to whoever the sender claims to be. + if packet.type == MessageType.requestSync.rawValue { return true } + return packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL } private static func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool { diff --git a/bitchat/Services/BLE/BLEReceivePipeline.swift b/bitchat/Services/BLE/BLEReceivePipeline.swift index 05bf81f4..b6deaf1a 100644 --- a/bitchat/Services/BLE/BLEReceivePipeline.swift +++ b/bitchat/Services/BLE/BLEReceivePipeline.swift @@ -53,6 +53,7 @@ struct BLEReceivePipeline { isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil, isHandshake: packet.type == MessageType.noiseHandshake.rawValue, isAnnounce: packet.type == MessageType.announce.rawValue, + isRequestSync: packet.type == MessageType.requestSync.rawValue, degree: degree, highDegreeThreshold: highDegreeThreshold ) diff --git a/bitchat/Services/BLE/BLERouteForwardingPolicy.swift b/bitchat/Services/BLE/BLERouteForwardingPolicy.swift index 8c43e0c3..aa12f7e0 100644 --- a/bitchat/Services/BLE/BLERouteForwardingPolicy.swift +++ b/bitchat/Services/BLE/BLERouteForwardingPolicy.swift @@ -35,6 +35,14 @@ struct BLERouteForwardingPolicy { routingPeer: (Data) -> PeerID?, isPeerConnected: (PeerID) -> Bool ) -> BLERouteForwardingPlan { + // REQUEST_SYNC is link-local: never forward it, on the flood path or + // the source-routed path. A crafted request with a route and TTL + // headroom must not be able to fan a full-store replay out to the next + // hop. Suppressing here also short-circuits the flood relay. + if packet.type == MessageType.requestSync.rawValue { + return .suppressFloodRelay + } + if PeerID(hexData: packet.recipientID) == localPeerID { return .suppressFloodRelay } diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 531fe9f8..e82d7ff9 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -282,7 +282,9 @@ final class BLEService: NSObject { fileTransferCapacity: TransportConfig.syncFileTransferCapacity, fragmentSyncIntervalSeconds: TransportConfig.syncFragmentIntervalSeconds, fileTransferSyncIntervalSeconds: TransportConfig.syncFileTransferIntervalSeconds, - messageSyncIntervalSeconds: TransportConfig.syncMessageIntervalSeconds + messageSyncIntervalSeconds: TransportConfig.syncMessageIntervalSeconds, + responseRateLimitMaxResponses: TransportConfig.syncResponseRateLimitMaxResponses, + responseRateLimitWindowSeconds: TransportConfig.syncResponseRateLimitWindowSeconds ) let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) @@ -3287,6 +3289,26 @@ extension BLEService { // Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager private func handleRequestSync(_ packet: BitchatPacket, from peerID: PeerID) { + // REQUEST_SYNC is link-local by design (always sent with ttl 0): a + // nonzero TTL means a crafted or relayed request, and answering one + // would let a single small packet fan a full store replay out of + // every node it reaches. + guard packet.ttl == 0 else { + if logRateLimiter.shouldLog(key: "sync-ttl:\(peerID.id)") { + SecureLogger.warning("🚫 Dropping REQUEST_SYNC with nonzero TTL from \(peerID.id.prefix(8))…", category: .security) + } + return + } + // A response can replay the entire gossip store, so require proof the + // requester owns the claimed sender ID: the request must verify + // against the signing key from that peer's announce. + let signingKey = collectionsQueue.sync { peerRegistry.info(for: peerID)?.signingPublicKey } + guard let signingKey, noiseService.verifyPacketSignature(packet, publicKey: signingKey) else { + if logRateLimiter.shouldLog(key: "sync-sig:\(peerID.id)") { + SecureLogger.warning("🚫 Dropping REQUEST_SYNC without verifiable signature from \(peerID.id.prefix(8))…", category: .security) + } + return + } guard let req = RequestSyncPacket.decode(from: packet.payload) else { SecureLogger.warning("⚠️ Malformed REQUEST_SYNC from \(peerID.id.prefix(8))…", category: .session) return diff --git a/bitchat/Services/RelayController.swift b/bitchat/Services/RelayController.swift index 37fa8584..fae850ea 100644 --- a/bitchat/Services/RelayController.swift +++ b/bitchat/Services/RelayController.swift @@ -18,10 +18,17 @@ struct RelayController { isDirectedFragment: Bool, isHandshake: Bool, isAnnounce: Bool, + isRequestSync: Bool = false, degree: Int, highDegreeThreshold: Int) -> RelayDecision { let ttlCap = min(ttl, TransportConfig.messageTTLDefault) + // REQUEST_SYNC is link-local: never relay it, even when a peer crafts + // one with TTL headroom to turn every reachable node into a responder. + if isRequestSync { + return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0) + } + // Suppress obvious non-relays if ttlCap <= 1 || senderIsSelf || recipientIsSelf { return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0) diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 4aae7294..b5cb00e0 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -271,4 +271,6 @@ enum TransportConfig { static let syncFragmentIntervalSeconds: TimeInterval = 30.0 static let syncFileTransferIntervalSeconds: TimeInterval = 60.0 static let syncMessageIntervalSeconds: TimeInterval = 15.0 + static let syncResponseRateLimitMaxResponses: Int = 8 + static let syncResponseRateLimitWindowSeconds: TimeInterval = 30.0 } diff --git a/bitchat/Sync/GCSFilter.swift b/bitchat/Sync/GCSFilter.swift index 7d76c23c..8b36b376 100644 --- a/bitchat/Sync/GCSFilter.swift +++ b/bitchat/Sync/GCSFilter.swift @@ -10,7 +10,13 @@ import CryptoKit // - Golomb-Rice with parameter P: q = (x - 1) >> P encoded as unary (q ones then a zero), then write P-bit remainder r = (x - 1) & ((1< Params { let p = deriveP(targetFpr: targetFpr) guard !ids.isEmpty else { - return Params(p: p, m: 1, data: Data()) + return Params(p: p, m: 1, data: Data(), includedCount: 0) } let cap = estimateMaxElements(sizeBytes: maxBytes, p: p) - let selected = Array(ids.prefix(cap)) - let range = max(1, hashRange(count: selected.count, p: p)) + // Modulus is fixed to the initial candidate count so `m` stays stable + // as the tail is trimmed to fit the byte budget below. + let range = max(1, hashRange(count: min(ids.count, cap), p: p)) let modulo = UInt64(range) - var mapped = selected - .map { h64($0) } - .map { mapHash($0, modulo: modulo) } - .sorted() - mapped = normalizeMappedValues(mapped, modulo: modulo) - - if mapped.isEmpty { - return Params(p: p, m: range, data: Data()) + // Encode the first `count` inputs (input order). The caller passes IDs + // newest-first, so trimming from the tail drops the oldest — which is + // what lets a since-cursor stay exact: the surviving set is always a + // contiguous newest-prefix, never a hash-order-arbitrary subset. + func encodeFirst(_ count: Int) -> Data { + var mapped = ids.prefix(count) + .map { h64($0) } + .map { mapHash($0, modulo: modulo) } + .sorted() + mapped = normalizeMappedValues(mapped, modulo: modulo) + return mapped.isEmpty ? Data() : encode(sorted: mapped, p: p) } - var encoded = encode(sorted: mapped, p: p) - var trimmedCount = mapped.count - - while encoded.count > maxBytes && trimmedCount > 0 { - if trimmedCount == 1 { - mapped.removeAll() - encoded = Data() - break - } - trimmedCount = max(1, (trimmedCount * 9) / 10) - mapped = Array(mapped.prefix(trimmedCount)) - encoded = encode(sorted: mapped, p: p) + var count = min(ids.count, cap) + var encoded = encodeFirst(count) + while encoded.count > maxBytes && count > 1 { + count = max(1, (count * 9) / 10) + encoded = encodeFirst(count) + } + // A single element that still overflows can't be represented. + if encoded.count > maxBytes { + return Params(p: p, m: range, data: Data(), includedCount: 0) } - return Params(p: p, m: range, data: encoded) + return Params(p: p, m: range, data: encoded, includedCount: encoded.isEmpty ? 0 : count) } static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] { diff --git a/bitchat/Sync/GossipSyncManager.swift b/bitchat/Sync/GossipSyncManager.swift index 3c8472d2..ee337baa 100644 --- a/bitchat/Sync/GossipSyncManager.swift +++ b/bitchat/Sync/GossipSyncManager.swift @@ -73,6 +73,8 @@ final class GossipSyncManager { var fragmentSyncIntervalSeconds: TimeInterval = 30.0 var fileTransferSyncIntervalSeconds: TimeInterval = 60.0 var messageSyncIntervalSeconds: TimeInterval = 15.0 + var responseRateLimitMaxResponses: Int = 8 + var responseRateLimitWindowSeconds: TimeInterval = 30.0 } private let myPeerID: PeerID @@ -91,11 +93,16 @@ final class GossipSyncManager { private let queue = DispatchQueue(label: "mesh.sync", qos: .utility) private var lastStalePeerCleanup: Date = .distantPast private var syncSchedules: [SyncSchedule] = [] + private var responseRateLimiter: SyncResponseRateLimiter init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager) { self.myPeerID = myPeerID self.config = config self.requestSyncManager = requestSyncManager + self.responseRateLimiter = SyncResponseRateLimiter( + maxResponses: config.responseRateLimitMaxResponses, + window: config.responseRateLimitWindowSeconds + ) var schedules: [SyncSchedule] = [] if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 { schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast)) @@ -265,7 +272,17 @@ final class GossipSyncManager { } private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) { + // A response can replay the whole store, so bound how often one peer + // can trigger a diff pass regardless of how fast it asks. + guard responseRateLimiter.shouldRespond(to: peerID, now: Date()) else { + SecureLogger.warning("Rate-limited REQUEST_SYNC from \(peerID.id.prefix(8))…", category: .sync) + return + } let requestedTypes = (request.types ?? .publicMessages) + // The requester's filter only covers packets at or after this cursor; + // older packets are outside the filter but not missing, and without + // the cursor they would be re-sent every round. + let since = request.sinceTimestamp // Decode GCS into sorted set and prepare membership checker let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data) func mightContain(_ id: Data) -> Bool { @@ -273,6 +290,9 @@ final class GossipSyncManager { return GCSFilter.contains(sortedValues: sorted, candidate: bucket) } + // Announces are exempt from the since-cursor: they carry the signing + // keys needed to verify everything else, and there is at most one per + // peer, so the resend cost is negligible. if requestedTypes.contains(.announce) { for (_, pair) in latestAnnouncementByPeer { let (idHex, pkt) = pair @@ -290,6 +310,7 @@ final class GossipSyncManager { if requestedTypes.contains(.message) { let toSendMsgs = messages.allPackets(isFresh: isPacketFresh) for pkt in toSendMsgs { + if let since, pkt.timestamp < since { continue } let idBytes = PacketIdUtil.computeId(pkt) if !mightContain(idBytes) { var toSend = pkt @@ -303,6 +324,7 @@ final class GossipSyncManager { if requestedTypes.contains(.fragment) { let frags = fragments.allPackets(isFresh: isPacketFresh) for pkt in frags { + if let since, pkt.timestamp < since { continue } let idBytes = PacketIdUtil.computeId(pkt) if !mightContain(idBytes) { var toSend = pkt @@ -316,6 +338,7 @@ final class GossipSyncManager { if requestedTypes.contains(.fileTransfer) { let files = fileTransfers.allPackets(isFresh: isPacketFresh) for pkt in files { + if let since, pkt.timestamp < since { continue } let idBytes = PacketIdUtil.computeId(pkt) if !mightContain(idBytes) { var toSend = pkt @@ -368,9 +391,22 @@ final class GossipSyncManager { let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types) return req.encode() } - let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) } + let included = Array(candidates.prefix(takeN)) + let ids: [Data] = included.map { PacketIdUtil.computeId($0) } let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr) - let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types) + // When the filter can't cover every candidate — either the store + // exceeds `takeN` or the encoder trimmed the tail to fit the byte + // budget — tell the responder how far back the filter actually + // reaches. `includedCount` counts inputs in newest-first order, so the + // covered set is a contiguous newest-prefix and the oldest included + // timestamp is an exact cursor. Packets older than it are outside the + // filter but not missing; without the cursor the responder would + // re-send that entire tail every round. + let covered = params.includedCount + let sinceTimestamp: UInt64? = (covered < candidates.count && covered > 0) + ? included[covered - 1].timestamp + : nil + let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types, sinceTimestamp: sinceTimestamp) return req.encode() } @@ -390,19 +426,18 @@ final class GossipSyncManager { cleanupExpiredMessages() cleanupStaleAnnouncementsIfNeeded(now: now) requestSyncManager.cleanup() // Cleanup expired sync requests + responseRateLimiter.prune(now: now) - var dueTypes: SyncTypeFlags = [] + // One request per due schedule rather than a union filter: each type + // group gets the full GCS capacity and its own since-cursor, so heavy + // fragment traffic can't crowd messages out of the filter. for index in syncSchedules.indices { guard syncSchedules[index].interval > 0 else { continue } if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval { syncSchedules[index].lastSent = now - dueTypes.formUnion(syncSchedules[index].types) + sendPeriodicSync(for: syncSchedules[index].types) } } - - if !dueTypes.isEmpty { - sendPeriodicSync(for: dueTypes) - } } private func cleanupStaleAnnouncementsIfNeeded(now: Date) { diff --git a/bitchat/Sync/SyncResponseRateLimiter.swift b/bitchat/Sync/SyncResponseRateLimiter.swift new file mode 100644 index 00000000..a6ffc6a1 --- /dev/null +++ b/bitchat/Sync/SyncResponseRateLimiter.swift @@ -0,0 +1,42 @@ +import BitFoundation +import Foundation + +/// Sliding-window limiter for REQUEST_SYNC responses. +/// +/// A single sync response can replay the entire gossip store, so a peer that +/// requests in a tight loop must not be able to drain the airtime and battery +/// of everyone in radio range. Legitimate peers send at most a few requests +/// per maintenance tick (one per type schedule, plus the initial sync). +struct SyncResponseRateLimiter { + private let maxResponses: Int + private let window: TimeInterval + private var history: [PeerID: [Date]] = [:] + + init(maxResponses: Int, window: TimeInterval) { + self.maxResponses = max(1, maxResponses) + self.window = max(0, window) + } + + /// Returns true (and records the response) if the peer is under its + /// response budget for the current window. + mutating func shouldRespond(to peerID: PeerID, now: Date) -> Bool { + let cutoff = now.addingTimeInterval(-window) + var recent = (history[peerID] ?? []).filter { $0 >= cutoff } + guard recent.count < maxResponses else { + history[peerID] = recent + return false + } + recent.append(now) + history[peerID] = recent + return true + } + + /// Drops history outside the window so departed peers don't accumulate. + mutating func prune(now: Date) { + let cutoff = now.addingTimeInterval(-window) + history = history.compactMapValues { dates in + let recent = dates.filter { $0 >= cutoff } + return recent.isEmpty ? nil : recent + } + } +} diff --git a/bitchatTests/GCSFilterTests.swift b/bitchatTests/GCSFilterTests.swift index 464f368e..b544f843 100644 --- a/bitchatTests/GCSFilterTests.swift +++ b/bitchatTests/GCSFilterTests.swift @@ -41,6 +41,24 @@ struct GCSFilterTests { #expect(truncated.allSatisfy { full.contains($0) }) } + @Test func buildFilterReportsFullCoverageWhenBudgetFits() { + let ids = (0..<8).map { i in Data(repeating: UInt8(i), count: 16) } + let params = GCSFilter.buildFilter(ids: ids, maxBytes: 1024, targetFpr: 0.01) + #expect(params.includedCount == ids.count) + } + + @Test func buildFilterTrimsTailWhenBudgetExceeded() { + // A tight byte budget can't hold every ID, so the encoder trims from + // the input tail and reports how many it actually covered. + let ids = (0..<200).map { i in + Data((0..<16).map { UInt8((i &* 31 &+ $0) & 0xFF) }) + } + let params = GCSFilter.buildFilter(ids: ids, maxBytes: 32, targetFpr: 0.01) + #expect(params.includedCount > 0) + #expect(params.includedCount < ids.count) + #expect(params.data.count <= 32) + } + @Test func requestSyncPacketDecodeRejectsOversizedP() { let valid = RequestSyncPacket(p: 8, m: 4096, data: Data([0x01, 0x02])) #expect(RequestSyncPacket.decode(from: valid.encode()) != nil) diff --git a/bitchatTests/GossipSyncManagerTests.swift b/bitchatTests/GossipSyncManagerTests.swift index 54e65afc..b717e2f0 100644 --- a/bitchatTests/GossipSyncManagerTests.swift +++ b/bitchatTests/GossipSyncManagerTests.swift @@ -194,15 +194,204 @@ struct GossipSyncManagerTests { manager._performMaintenanceSynchronously(now: Date()) + // One request per due schedule so each type group gets the full + // filter capacity: publicMessages, fragment, and fileTransfer. let sentPackets = delegate.packets - #expect(sentPackets.count == 1) + #expect(sentPackets.count == 3) let decoded = sentPackets.compactMap { RequestSyncPacket.decode(from: $0.payload) } - #expect(decoded.count == 1) - let types = try #require(decoded.first?.types) - #expect(types.contains(.announce)) - #expect(types.contains(.message)) - #expect(types.contains(.fragment)) - #expect(types.contains(.fileTransfer)) + #expect(decoded.count == 3) + let allTypes = decoded.compactMap(\.types).reduce(SyncTypeFlags(rawValue: 0)) { $0.union($1) } + #expect(allTypes.contains(.announce)) + #expect(allTypes.contains(.message)) + #expect(allTypes.contains(.fragment)) + #expect(allTypes.contains(.fileTransfer)) + #expect(decoded.contains { $0.types == .publicMessages }) + #expect(decoded.contains { $0.types == .fragment }) + #expect(decoded.contains { $0.types == .fileTransfer }) + } + + @Test func truncatedFilterCarriesSinceCursor() throws { + var config = GossipSyncManager.Config() + config.seenCapacity = 100 + config.gcsMaxBytes = 32 // caps the filter at 28 IDs (256 bits / 9 bits per element) + config.messageSyncIntervalSeconds = 1 + config.fragmentSyncIntervalSeconds = 0 + config.fileTransferSyncIntervalSeconds = 0 + config.maintenanceIntervalSeconds = 0 + + let requestSyncManager = RequestSyncManager() + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) + let delegate = RecordingDelegate() + manager.delegate = delegate + + let sender = try #require(Data(hexString: "1122334455667788")) + let baseTimestamp = UInt64(Date().timeIntervalSince1970 * 1000) + let totalMessages = 40 + for i in 0..= baseTimestamp + 12) + #expect(since < baseTimestamp + UInt64(totalMessages)) + } + + @Test func fullCoverageFilterOmitsSinceCursor() throws { + var config = GossipSyncManager.Config() + config.seenCapacity = 100 + config.messageSyncIntervalSeconds = 1 + config.fragmentSyncIntervalSeconds = 0 + config.fileTransferSyncIntervalSeconds = 0 + config.maintenanceIntervalSeconds = 0 + + let requestSyncManager = RequestSyncManager() + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) + let delegate = RecordingDelegate() + manager.delegate = delegate + + let sender = try #require(Data(hexString: "1122334455667788")) + let packet = BitchatPacket( + type: MessageType.message.rawValue, + senderID: sender, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data([0x01]), + signature: nil, + ttl: 1 + ) + manager.onPublicPacketSeen(packet) + + manager._performMaintenanceSynchronously(now: Date()) + + let sent = try #require(delegate.packets.first) + let request = try #require(RequestSyncPacket.decode(from: sent.payload)) + #expect(request.sinceTimestamp == nil) + } + + @Test func handleRequestSyncHonorsSinceCursorButAlwaysSendsAnnounces() async throws { + var config = GossipSyncManager.Config() + config.seenCapacity = 5 + config.messageSyncIntervalSeconds = 0 + config.fragmentSyncIntervalSeconds = 0 + config.fileTransferSyncIntervalSeconds = 0 + + let requestSyncManager = RequestSyncManager() + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) + let delegate = RecordingDelegate() + manager.delegate = delegate + + let sender = try #require(Data(hexString: "aabbccddeeff0011")) + let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) + + // Announce older than the cursor: must still be sent (identity is + // needed to verify everything else). + let announcePacket = BitchatPacket( + type: MessageType.announce.rawValue, + senderID: sender, + recipientID: nil, + timestamp: nowMs - 50_000, + payload: Data(), + signature: nil, + ttl: 1 + ) + let oldMessage = BitchatPacket( + type: MessageType.message.rawValue, + senderID: sender, + recipientID: nil, + timestamp: nowMs - 60_000, + payload: Data([0x01]), + signature: nil, + ttl: 1 + ) + let newMessage = BitchatPacket( + type: MessageType.message.rawValue, + senderID: sender, + recipientID: nil, + timestamp: nowMs, + payload: Data([0x02]), + signature: nil, + ttl: 1 + ) + + manager.onPublicPacketSeen(announcePacket) + manager.onPublicPacketSeen(oldMessage) + manager.onPublicPacketSeen(newMessage) + + let peer = PeerID(str: "FFFFFFFFFFFFFFFF") + let request = RequestSyncPacket( + p: 7, + m: 1, + data: Data(), + types: .publicMessages, + sinceTimestamp: nowMs - 30_000 + ) + manager.handleRequestSync(from: peer, request: request) + + try await TestHelpers.waitFor({ delegate.packets.count == 2 }, timeout: TestConstants.shortTimeout) + // Barrier: flush the sync queue so a late third packet would be visible. + manager._performMaintenanceSynchronously(now: Date()) + let sentPackets = delegate.packets + #expect(sentPackets.count == 2) + #expect(sentPackets.contains { $0.type == MessageType.announce.rawValue }) + let sentMessages = sentPackets.filter { $0.type == MessageType.message.rawValue } + #expect(sentMessages.count == 1) + #expect(sentMessages.first?.payload == Data([0x02])) + #expect(sentPackets.allSatisfy { $0.isRSR }) + } + + @Test func handleRequestSyncIsRateLimitedPerPeer() async throws { + var config = GossipSyncManager.Config() + config.seenCapacity = 5 + config.messageSyncIntervalSeconds = 0 + config.fragmentSyncIntervalSeconds = 0 + config.fileTransferSyncIntervalSeconds = 0 + config.responseRateLimitMaxResponses = 1 + config.responseRateLimitWindowSeconds = 60 + + let requestSyncManager = RequestSyncManager() + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) + let delegate = RecordingDelegate() + manager.delegate = delegate + + let sender = try #require(Data(hexString: "aabbccddeeff0011")) + let messagePacket = BitchatPacket( + type: MessageType.message.rawValue, + senderID: sender, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data([0x10]), + signature: nil, + ttl: 1 + ) + manager.onPublicPacketSeen(messagePacket) + + let peer = PeerID(str: "FFFFFFFFFFFFFFFF") + let request = RequestSyncPacket(p: 7, m: 1, data: Data(), types: .message) + manager.handleRequestSync(from: peer, request: request) + manager.handleRequestSync(from: peer, request: request) + + try await TestHelpers.waitFor({ delegate.packets.count >= 1 }, timeout: TestConstants.shortTimeout) + // Barrier: both requests have been processed once this returns. + manager._performMaintenanceSynchronously(now: Date()) + #expect(delegate.packets.count == 1) } @Test func initialSyncCoalescesEnabledTypes() async throws { diff --git a/bitchatTests/Services/BLEIngressLinkRegistryTests.swift b/bitchatTests/Services/BLEIngressLinkRegistryTests.swift index af8c2777..75550bc5 100644 --- a/bitchatTests/Services/BLEIngressLinkRegistryTests.swift +++ b/bitchatTests/Services/BLEIngressLinkRegistryTests.swift @@ -105,6 +105,41 @@ struct BLEIngressLinkRegistryTests { #expect(result == .failure(.directSenderMismatch(boundPeerID: boundPeer, claimedSenderID: claimedPeer))) } + @Test + func packetContextRejectsRequestSyncSenderMismatchOnBoundLink() { + let localPeer = PeerID(str: "0011223344556677") + let boundPeer = PeerID(str: "1122334455667788") + let claimedPeer = PeerID(str: "8899aabbccddeeff") + let packet = makeRequestSyncPacket(sender: claimedPeer) + + let result = BLEIngressLinkRegistry.packetContext( + for: packet, + claimedSenderID: claimedPeer, + boundPeerID: boundPeer, + localPeerID: localPeer, + directAnnounceTTL: 7 + ) + + #expect(result == .failure(.directSenderMismatch(boundPeerID: boundPeer, claimedSenderID: claimedPeer))) + } + + @Test + func packetContextAllowsRequestSyncFromBoundPeer() throws { + let localPeer = PeerID(str: "0011223344556677") + let boundPeer = PeerID(str: "1122334455667788") + let packet = makeRequestSyncPacket(sender: boundPeer) + + let context = try #require(trySuccess(BLEIngressLinkRegistry.packetContext( + for: packet, + claimedSenderID: boundPeer, + boundPeerID: boundPeer, + localPeerID: localPeer, + directAnnounceTTL: 7 + ))) + + #expect(context.receivedFromPeerID == boundPeer) + } + @Test func packetContextUsesBoundPeerForRSRValidation() throws { let localPeer = PeerID(str: "0011223344556677") @@ -158,6 +193,18 @@ private func makePacket(sender: PeerID, timestamp: UInt64) -> BitchatPacket { ) } +private func makeRequestSyncPacket(sender: PeerID) -> BitchatPacket { + BitchatPacket( + type: MessageType.requestSync.rawValue, + senderID: Data(hexString: sender.id) ?? Data(), + recipientID: nil, + timestamp: 1, + payload: Data(), + signature: nil, + ttl: 0 + ) +} + private func makeAnnouncePacket(sender: PeerID, ttl: UInt8) -> BitchatPacket { BitchatPacket( type: MessageType.announce.rawValue, diff --git a/bitchatTests/Services/BLERouteForwardingPolicyTests.swift b/bitchatTests/Services/BLERouteForwardingPolicyTests.swift index e6bfce05..7e580098 100644 --- a/bitchatTests/Services/BLERouteForwardingPolicyTests.swift +++ b/bitchatTests/Services/BLERouteForwardingPolicyTests.swift @@ -112,6 +112,36 @@ struct BLERouteForwardingPolicyTests { #expect(plan.nextHop == nil) } + @Test("REQUEST_SYNC is never route-forwarded even with a route and TTL headroom") + func requestSyncNeverRouteForwarded() { + let previous = peer("1111111111111111") + let local = peer("2222222222222222") + let nextHop = peer("3333333333333333") + let destination = peer("4444444444444444") + var packet = makePacket( + sender: previous, + recipient: destination, + ttl: 7, + route: [routeData(local), routeData(nextHop)] + ) + packet = BitchatPacket( + type: MessageType.requestSync.rawValue, + senderID: packet.senderID, + recipientID: packet.recipientID, + timestamp: packet.timestamp, + payload: packet.payload, + signature: nil, + ttl: packet.ttl, + route: packet.route + ) + + let plan = forwardingPlan(packet, local: local, connected: [nextHop]) + + #expect(plan.shouldSuppressFloodRelay) + #expect(plan.forwardPacket == nil) + #expect(plan.nextHop == nil) + } + private func forwardingPlan( _ packet: BitchatPacket, local: PeerID, diff --git a/bitchatTests/Services/RelayControllerTests.swift b/bitchatTests/Services/RelayControllerTests.swift index c0c473cf..7c04fab6 100644 --- a/bitchatTests/Services/RelayControllerTests.swift +++ b/bitchatTests/Services/RelayControllerTests.swift @@ -134,6 +134,25 @@ struct RelayControllerTests { #expect(decision.newTTL == TransportConfig.bleFragmentRelayTtlCapDense - 1) } + @Test + func requestSync_neverRelaysEvenWithTTLHeadroom() async { + let decision = RelayController.decide( + ttl: 7, + senderIsSelf: false, + isEncrypted: false, + isDirectedEncrypted: false, + isFragment: false, + isDirectedFragment: false, + isHandshake: false, + isAnnounce: false, + isRequestSync: true, + degree: 3, + highDegreeThreshold: TransportConfig.bleHighDegreeThreshold + ) + + #expect(!decision.shouldRelay) + } + @Test func denseGraph_capsTTL() async { let decision = RelayController.decide( diff --git a/bitchatTests/Sync/SyncResponseRateLimiterTests.swift b/bitchatTests/Sync/SyncResponseRateLimiterTests.swift new file mode 100644 index 00000000..63f5329a --- /dev/null +++ b/bitchatTests/Sync/SyncResponseRateLimiterTests.swift @@ -0,0 +1,61 @@ +import Foundation +import Testing +import BitFoundation +@testable import bitchat + +struct SyncResponseRateLimiterTests { + + private let peer = PeerID(str: "1122334455667788") + private let otherPeer = PeerID(str: "8899aabbccddeeff") + + @Test func allowsResponsesUpToBudgetThenBlocks() { + var limiter = SyncResponseRateLimiter(maxResponses: 2, window: 30) + let now = Date() + + let first = limiter.shouldRespond(to: peer, now: now) + let second = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(1)) + let third = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(2)) + + #expect(first) + #expect(second) + #expect(!third) + } + + @Test func budgetIsPerPeer() { + var limiter = SyncResponseRateLimiter(maxResponses: 1, window: 30) + let now = Date() + + let first = limiter.shouldRespond(to: peer, now: now) + let repeated = limiter.shouldRespond(to: peer, now: now) + let other = limiter.shouldRespond(to: otherPeer, now: now) + + #expect(first) + #expect(!repeated) + #expect(other) + } + + @Test func allowsAgainAfterWindowSlides() { + var limiter = SyncResponseRateLimiter(maxResponses: 1, window: 30) + let now = Date() + + let first = limiter.shouldRespond(to: peer, now: now) + let insideWindow = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(29)) + let afterWindow = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(31)) + + #expect(first) + #expect(!insideWindow) + #expect(afterWindow) + } + + @Test func pruneDropsExpiredHistory() { + var limiter = SyncResponseRateLimiter(maxResponses: 1, window: 30) + let now = Date() + + let first = limiter.shouldRespond(to: peer, now: now) + limiter.prune(now: now.addingTimeInterval(31)) + let afterPrune = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(32)) + + #expect(first) + #expect(afterPrune) + } +} From 73416962808d548470589df36c9b63e2254ce8e6 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:33:16 +0200 Subject: [PATCH 02/18] Expand store-and-forward: open couriers, spray-and-wait, persistent outbox, 6h public history (#1372) Store-and-forward previously delivered to an out-of-range peer only if a mutual favorite happened to be connected at send time and later met the recipient directly, and everything except courier envelopes died with the app process. This closes those gaps end to end: - Persist the MessageRouter outbox to disk, sealed with a ChaChaPoly key held only in the Keychain (no plaintext at rest); queued private messages now survive an app kill and flush on next launch. - Deposit retry: queued messages are re-deposited whenever a new eligible courier connects, tracked per message so the same courier is never double-burned, until 3 distinct couriers carry it or it expires. - Tiered open couriering: signature-verified strangers can now carry mail (2 envelopes/depositor into a 20-slot pool) alongside mutual favorites (5 each); overflow evicts verified-tier mail before favorites'. - Spray-and-wait: envelopes carry a copy budget (4, capped 8, new TLV, wire-compatible with old clients); couriers split half their remaining budget with each newly encountered courier so mail diffuses through a moving crowd. - Remote handover: a verified relayed announce now floods a copy toward the multi-hop recipient (directed-relay treatment, 10-min per-envelope cooldown) while the carried original stays put for a direct encounter. - Public history: gossip-sync window for whole public messages widened from 15 min to 6 h, matched on the receive-acceptance side, and the message store persists to disk so devices bridge partitions and restarts ("town crier"). - Privacy-safe local delivery counters (bare tallies, log-only) so the store-and-forward stack is measurable on-device. - Panic wipe now also clears the sealed outbox, gossip archive, and counters. - Rewrite WHITEPAPER.md to describe the app as implemented (Noise XX/X, actual flood control, courier system, gossip sync, Nostr path); the old document described a bloom filter, three fragment types, and a MessageRetryService that don't exist. 1037 macOS tests pass (17 new); iOS builds. Co-authored-by: jack Co-authored-by: Claude Fable 5 --- WHITEPAPER.md | 332 +++++------------- bitchat/Services/BLE/BLEPeerRegistry.swift | 3 +- .../Services/BLE/BLEPublicMessagePolicy.swift | 9 +- bitchat/Services/BLE/BLEReceivePipeline.swift | 6 +- bitchat/Services/BLE/BLEService.swift | 133 +++++-- bitchat/Services/Courier/CourierStore.swift | 171 ++++++++- .../Services/Courier/MessageOutboxStore.swift | 156 ++++++++ .../Courier/StoreAndForwardMetrics.swift | 74 ++++ bitchat/Services/MessageRouter.swift | 186 ++++++++-- bitchat/Services/Transport.swift | 18 + bitchat/Services/TransportConfig.swift | 16 + bitchat/Sync/GossipMessageArchive.swift | 80 +++++ bitchat/Sync/GossipSyncManager.swift | 71 +++- .../ChatTransportEventCoordinator.swift | 7 + bitchat/ViewModels/ChatViewModel.swift | 23 +- .../ChatViewModelBootstrapper.swift | 10 +- ...ransportEventCoordinatorContextTests.swift | 2 + bitchatTests/CourierStoreTests.swift | 179 +++++++++- .../EndToEnd/CourierEndToEndTests.swift | 112 ++++-- bitchatTests/GossipSyncManagerTests.swift | 73 ++++ bitchatTests/Mocks/MockTransport.swift | 7 + .../BLEPublicMessageHandlerTests.swift | 4 +- .../BLEPublicMessagePolicyTests.swift | 8 +- .../Services/MessageOutboxStoreTests.swift | 92 +++++ .../Services/MessageRouterTests.swift | 191 ++++++++++ .../BitFoundation/CourierEnvelope.swift | 30 +- .../CourierEnvelopeTests.swift | 32 ++ 27 files changed, 1648 insertions(+), 377 deletions(-) create mode 100644 bitchat/Services/Courier/MessageOutboxStore.swift create mode 100644 bitchat/Services/Courier/StoreAndForwardMetrics.swift create mode 100644 bitchat/Sync/GossipMessageArchive.swift create mode 100644 bitchatTests/Services/MessageOutboxStoreTests.swift diff --git a/WHITEPAPER.md b/WHITEPAPER.md index 6ad839d7..7716850c 100644 --- a/WHITEPAPER.md +++ b/WHITEPAPER.md @@ -1,309 +1,141 @@ -# BitChat Protocol Whitepaper +# bitchat Protocol Whitepaper -**Version 1.1** +**Version 2.0** -**Date: July 25, 2025** +**Date: July 6, 2026** --- ## Abstract -BitChat is a decentralized, peer-to-peer messaging application designed for secure, private, and censorship-resistant communication over ephemeral, ad-hoc networks. This whitepaper details the BitChat Protocol Stack, a layered architecture that combines a modern cryptographic foundation with a flexible application protocol. At its core, BitChat leverages the Noise Protocol Framework (specifically, the `XX` pattern) to establish mutually authenticated, end-to-end encrypted sessions between peers. This document provides a technical specification of the identity management, session lifecycle, message framing, and security considerations that underpin the BitChat network. +bitchat is a decentralized, peer-to-peer messaging application for secure, private, censorship-resistant communication that works with or without the internet. Nearby devices form an ad-hoc Bluetooth Low Energy (BLE) mesh; distant peers are reached over the Nostr protocol when a connection exists. A layered store-and-forward stack — a persistent sender outbox, opportunistic couriers with a spray-and-wait copy budget, gossip-synced public history, and Nostr relay mailboxes — delivers messages to peers who are out of range at send time. This document describes the protocol and its delivery guarantees as implemented. --- -## 1. Introduction +## 1. Design Goals -In an era of centralized communication platforms, BitChat offers a resilient alternative by operating without central servers. It is designed for scenarios where internet connectivity is unavailable or untrustworthy, such as protests, natural disasters, or remote areas. Communication occurs directly between devices over transports like Bluetooth Low Energy (BLE). +* **Confidentiality:** all private communication is end-to-end encrypted; intermediate nodes and couriers carry only opaque ciphertext. +* **Authentication:** peers are identified by cryptographic keys; announcements are signed and verified. +* **Resilience:** the network functions in lossy, low-bandwidth, partitioned environments with churning membership. +* **Eventual delivery:** a message to an out-of-range peer should still arrive — relayed by the mesh, carried by a moving person, or resting on an internet relay — within a bounded retention window. +* **Ephemerality by default:** no plaintext message content is ever written to disk. Everything the store-and-forward stack persists is either sealed ciphertext or already-public broadcast traffic, and all of it dies with the panic wipe. -The design goals of the BitChat Protocol are: +## 2. Architecture Overview -* **Confidentiality:** All communication must be unreadable to third parties. -* **Authentication:** Users must be able to verify the identity of their correspondents. -* **Integrity:** Messages cannot be tampered with in transit. -* **Forward Secrecy:** The compromise of long-term identity keys must not compromise past session keys. -* **Deniability:** It should be difficult to cryptographically prove that a specific user sent a particular message. -* **Resilience:** The protocol must function reliably in lossy, low-bandwidth environments. +Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`: -This paper specifies the technical details of the protocol designed to meet these goals. +* **BLE mesh** — every device is simultaneously a GATT central and peripheral, relaying packets in a controlled flood. No infrastructure, pairing, or accounts. +* **Nostr** — private messages to mutual favorites travel as NIP-17 gift-wrapped events over public relays (over Tor where enabled), bridging separate meshes through the internet. ---- +The router prefers a live mesh link, falls back to Nostr, and engages the courier system when neither can deliver promptly. -## 2. Protocol Stack +## 3. Identity -The BitChat Protocol is a four-layer stack. This layered approach separates concerns, allowing for modularity and future extensibility. +Each device holds two long-term key pairs in the Keychain: -```mermaid -graph TD - A[Application Layer] --> B[Session Layer]; - B --> C[Encryption Layer]; - C --> D[Transport Layer]; +* a **Curve25519 static key** for Noise key agreement — its SHA-256 fingerprint is the peer's stable identity, and +* an **Ed25519 signing key** for packet signatures. - subgraph "BitChat Application" - A - end +On the mesh, peers appear under short ephemeral IDs derived per session; favoriting pins the full Noise public key so identity survives across sessions. Mutual favorites also exchange Nostr public keys for the internet path. Optional QR verification binds a nickname to a fingerprint in person. - subgraph "Message Framing & State" - B - end +## 4. BLE Mesh Layer - subgraph "Noise Protocol Framework" - C - end +### 4.1 Packet Format - subgraph "BLE, Wi-Fi Direct, etc." - D - end +A compact binary header (version, type, TTL, timestamp, flags) is followed by an 8-byte sender ID, an optional 8-byte recipient ID, the payload, and an optional Ed25519 signature. Version 2 packets may carry an explicit source route. Signatures exclude the TTL byte so relays can decrement it without invalidating them. Packets other than fragments are padded toward uniform sizes. - style A fill:#cde4ff - style B fill:#b5d8ff - style C fill:#9ac2ff - style D fill:#7eadff -``` +### 4.2 Flood Control -* **Application Layer:** Defines the structure of user-facing messages (`BitchatMessage`), acknowledgments (`DeliveryAck`), and other application-level data. -* **Session Layer:** Manages the overall communication packet (`BitchatPacket`). This includes routing information (TTL), message typing, fragmentation, and serialization into a compact binary format. -* **Encryption Layer:** Establishes and manages secure channels using the Noise Protocol Framework. It is responsible for the cryptographic handshake, session management, and transport message encryption/decryption. -* **Transport Layer:** The underlying physical medium used for data transmission, such as Bluetooth Low Energy (BLE). This layer is abstracted away from the core protocol. +Relaying is a deterministic controlled flood tuned by local connection degree: ---- +* **TTL:** packets originate with TTL 7. Relays clamp: dense graphs (≥ 6 links) cap broadcast TTL at 5; thin chains (≤ 2 links) relay at full incoming depth. +* **Deduplication:** an LRU seen-set (1000 entries, 5-minute expiry) keyed by sender, timestamp, type, and a payload digest drops duplicates. A scheduled relay is cancelled when a duplicate arrives first from another relay. +* **Jitter:** relays wait a random 10–220 ms (wider when dense) so duplicate suppression wins often. +* **Fanout subsetting:** broadcast messages are re-sent to a deterministic, message-ID-seeded subset of links (~log₂ of degree) rather than all of them; announces, fragments, and sync packets use full fanout. The ingress link is always excluded (split horizon). +* **Directed traffic** (handshakes, private messages, courier envelopes) relays deterministically with TTL − 1 and tight jitter, and is never subset. -## 3. Identity and Key Management +### 4.3 Routing -A peer's identity in BitChat is defined by two persistent cryptographic key pairs, which are generated on first launch and stored securely in the device's Keychain. +Announcements carry up to 10 direct-neighbor IDs, giving each node a shallow topology map (60 s freshness). When a bidirectionally-confirmed path exists, packets are source-routed along it; otherwise — and whenever a route fails — delivery falls back to flooding. -1. **Noise Static Key Pair (`Curve25519`):** This is the long-term identity key used for the Noise Protocol handshake. The public part of this key is shared with peers to establish secure sessions. -2. **Signing Key Pair (`Ed25519`):** This key is used to sign announcements and other protocol messages where non-repudiation is required, such as binding a public key to a nickname. +### 4.4 Fragmentation -### 3.1. Fingerprint +Packets exceeding the link MTU split into ~469-byte fragments (8-byte fragment ID, index/total header) that relay independently and reassemble at each receiving node (128 concurrent assemblies, 30 s timeout, 1 MiB cap). -A user's unique, verifiable fingerprint is the **SHA-256 hash** of their **Noise static public key**. This provides a user-friendly and secure way to verify an identity out-of-band (e.g., by reading it aloud or scanning a QR code). +### 4.5 Presence -`Fingerprint = SHA256(StaticPublicKey_Curve25519)` +Signed announcements propagate multi-hop: every 4 s while isolated, backing off to ~15–30 s (jittered) when connected. A verified announce retains a peer as *reachable* for 60 s after last contact. Connection scheduling is RSSI-gated with duty-cycled scanning to bound battery drain. -### 3.2. Identity Management +## 5. Encryption -The `SecureIdentityStateManager` class is responsible for managing all cryptographic identity material and social metadata (petnames, trust levels, etc.). It uses an in-memory cache for performance and persists this cache to the Keychain after encrypting it with a separate AES-GCM key. +### 5.1 Live Sessions: Noise XX ---- +Connected peers establish sessions with the Noise `XX` pattern (Curve25519 / ChaCha20-Poly1305 / SHA-256), providing mutual authentication and forward secrecy. All private payloads — messages, delivery acks, read receipts — ride inside the session as typed ciphertext. Intermediate relays see only opaque `noiseEncrypted` packets. -## 4. The Social Trust Layer +### 5.2 Offline Seals: Noise X -Beyond cryptographic identity, BitChat incorporates a social trust layer, allowing users to manage their relationships with peers. This functionality is handled by the `SecureIdentityStateManager`. +Courier envelopes are sealed to the recipient's *static* key with the one-way Noise `X` pattern; the sender's identity is authenticated inside the ciphertext. **This path has no forward secrecy** — compromise of the recipient's static key exposes sealed-but-undelivered mail. A prekey scheme is future work. -### 4.1. Peer Verification +### 5.3 Nostr Path -While the Noise handshake cryptographically authenticates a peer's key, it doesn't confirm the real-world identity of the person holding the device. To solve this, users can perform out-of-band (OOB) verification by comparing fingerprints. Once a user confirms that a peer's fingerprint matches the one they expect, they can mark that peer as "verified". This status is stored locally and displayed in the UI, providing a strong assurance of identity for future conversations. +Private messages to mutual favorites are wrapped per NIP-17/NIP-59: a rumor (kind 14) sealed (kind 13) and gift-wrapped (kind 1059) under a throwaway ephemeral key, so relays learn neither sender nor content. -### 4.2. Favorites and Blocking +## 6. Store and Forward -To improve the user experience and provide control over interactions, the protocol supports: -* **Favorites:** Users can mark trusted or frequently contacted peers as "favorites". This is a local designation that can be used by the application to prioritize notifications or display peers more prominently. -* **Blocking:** Users can block peers. When a peer is blocked, the application will discard any incoming packets from that peer's fingerprint at the earliest possible stage, effectively silencing them without notifying the blocked peer. +Four mechanisms cover the "recipient is not here right now" problem. All persisted state is wiped by panic mode. ---- +### 6.1 Sender Outbox -## 5. The Noise Protocol Layer +Private messages without a prompt route are retained per peer (100 messages/peer, 24 h TTL) and re-sent on reconnect events until a delivery or read ack clears them, or a resend cap (8 attempts) drops them with visible failure. The outbox persists to disk sealed under a ChaChaPoly key held only in the Keychain, so queued mail survives an app kill without ever storing plaintext. -BitChat implements the Noise Protocol Framework to provide strong, authenticated end-to-end encryption. +### 6.2 Couriers -### 5.1. Protocol Name +When no transport can deliver promptly, the message is sealed (§5.2) into a **courier envelope** and handed to up to 3 connected peers who may physically encounter the recipient: -The specific Noise protocol implemented is: +* **Opaque addressing.** The only routing information is a 16-byte rotating recipient tag — an HMAC of the recipient's static key and the UTC day — computable solely by parties who already know that key. Couriers learn neither sender, recipient, nor content, and tags do not correlate across days. +* **Trust tiers.** Mutual favorites may deposit 5 envelopes each; any peer with a signature-verified announce may deposit 2, into a bounded pool (20 of 40 slots) that can never crowd out favorites' mail. Envelopes are capped at 16 KiB and 24 h; overflow evicts oldest verified-tier mail first. +* **Deposit retry.** Queued messages are re-deposited whenever a new eligible courier connects, until 3 distinct couriers carry the message or it expires. +* **Spray and wait.** Envelopes carry a copy budget (initially 4, capped at 8). A courier meeting another eligible courier hands over half its remaining budget, so mail diffuses through a moving crowd instead of riding one person. Budgets, spray history, and carried mail persist across app restarts (iOS file protection). +* **Handover.** On a verified *direct* announce from the recipient, matching envelopes are delivered over the live link and removed. On a verified *relayed* announce, a copy floods toward the recipient as a directed packet while the carried original stays put, throttled to one attempt per envelope per 10 minutes. +* Receivers dedup by message ID, so redundant copies and the retained outbox original are harmless. Couriered mail from blocked senders is dropped at decryption time. -**`Noise_XX_25519_ChaChaPoly_SHA256`** +### 6.3 Public History (Gossip Sync) -* **`XX` Pattern:** This handshake pattern provides mutual authentication and forward secrecy. It does not require either party to know the other's static public key before the handshake begins. The keys are exchanged and authenticated during the three-part handshake. This is ideal for a decentralized P2P environment. -* **`25519`:** The Diffie-Hellman function used is Curve25519. -* **`ChaChaPoly`:** The AEAD (Authenticated Encryption with Associated Data) cipher is ChaCha20-Poly1305. -* **`SHA256`:** The hash function used for all cryptographic hashing operations is SHA-256. +Public broadcast messages are cached (1000 packets) and reconciled between peers every ~15 s using compact GCS filters: each side advertises what it holds, the other returns what is missing. Messages stay sync-able for **6 hours** and the cache persists to disk, so a device that walks between two partitions — or relaunches later — serves the room's recent history to whoever missed it. Fragments and file transfers keep a short 15-minute window. -### 5.2. The `XX` Handshake +### 6.4 Nostr Mailboxes -The `XX` handshake consists of three messages exchanged between an Initiator and a Responder to establish a shared secret and derive transport encryption keys. +Gift-wrapped messages rest on Nostr relays; clients re-subscribe with a 24-hour lookback on reconnect, covering the both-devices-offline case for mutual favorites whenever either side touches the internet. -```mermaid -sequenceDiagram - participant I as Initiator - participant R as Responder +### 6.5 Delivery Metrics - Note over I, R: Pre-computation: h = SHA256(protocol_name) +Bare local counters (deposits, handovers, sprays, opens, outbox flushes and drops — no identities, message IDs, or timestamps) let delivery behavior be measured on-device. They never leave the device and are cleared by the panic wipe. - I->>R: -> e - Note right of I: I generates ephemeral key `e_i`.
h = SHA256(h + e_i.pub) +## 7. Application Layer - R->>I: <- e, ee, s, es - Note left of R: R generates ephemeral key `e_r`.
h = SHA256(h + e_r.pub)
MixKey(DH(e_i, e_r))
R sends static key `s_r`, encrypted.
h = SHA256(h + ciphertext)
MixKey(DH(e_i, s_r)) - - I->>R: -> s, se - Note right of I: I decrypts and verifies `s_r`.
I sends static key `s_i`, encrypted.
h = SHA256(h + ciphertext)
MixKey(DH(s_i, e_r)) - - Note over I, R: Handshake complete. Transport keys derived. -``` - -**Handshake Flow:** - -1. **Initiator -> Responder:** The initiator generates a new ephemeral key pair (`e_i`) and sends the public part to the responder. -2. **Responder -> Initiator:** The responder receives the initiator's ephemeral public key. It then generates its own ephemeral key pair (`e_r`), performs a DH exchange with the initiator's ephemeral key (`ee`), sends its own static public key (`s_r`) encrypted with the resulting symmetric key, and performs another DH exchange between the initiator's ephemeral key and its own static key (`es`). -3. **Initiator -> Responder:** The initiator receives the responder's message, decrypts the responder's static key, and authenticates it. The initiator then sends its own static key (`s_i`) encrypted and performs a final DH exchange between its static key and the responder's ephemeral key (`se`). - -Upon completion, both parties share a set of symmetric keys for bidirectional transport message encryption. The final handshake hash is used for channel binding. - -### 5.3. Session Management - -The `NoiseSessionManager` class manages all active Noise sessions. It handles: -* Creating sessions for new peers. -* Coordinating the handshake process to prevent race conditions. -* Storing the resulting transport ciphers (`sendCipher`, `receiveCipher`). -* Periodically checking if sessions need to be re-keyed for enhanced security. - ---- - -## 6. The BitChat Session and Application Protocol - -Once a Noise session is established, peers exchange `BitchatPacket` structures, which are encrypted as the payload of Noise transport messages. - -### 6.1. Binary Packet Format (`BitchatPacket`) - -To minimize bandwidth, `BitchatPacket`s are serialized into a compact binary format. The structure is designed to be fixed-size where possible to resist traffic analysis. - -| Field | Size (bytes) | Description | -|-----------------|--------------|---------------------------------------------------------------------------------------------------------| -| **Header** | **13** | **Fixed-size header** | -| Version | 1 | Protocol version (currently `1`). | -| Type | 1 | Message type (e.g., `message`, `deliveryAck`, `noiseHandshakeInit`). See `MessageType` enum. | -| TTL | 1 | Time-To-Live for mesh network routing. Decremented at each hop. | -| Timestamp | 8 | `UInt64` millisecond timestamp of packet creation. | -| Flags | 1 | Bitmask for optional fields (`hasRecipient`, `hasSignature`, `isCompressed`). | -| Payload Length | 2 | `UInt16` length of the payload field. | -| **Variable** | **...** | **Variable-size fields** | -| Sender ID | 8 | 8-byte truncated peer ID of the sender. | -| Recipient ID | 8 (optional) | 8-byte truncated peer ID of the recipient. Present if `hasRecipient` flag is set. Broadcast if `0xFF..FF`. | -| Payload | Variable | The actual content of the packet, as defined by the `Type` field. | -| Signature | 64 (optional)| `Ed25519` signature of the packet. Present if `hasSignature` flag is set. | - -**Padding:** All packets are padded to the next standard block size (256, 512, 1024, or 2048 bytes) using a PKCS#7-style scheme to obscure the true message length from network observers. - -```mermaid ---- -config: - theme: dark ---- ---- -title: "BitchatPacket" ---- -packet -+8: "Version" -+8: "Type" -+8: "TTL" -+64: "Timestamp" -+8: "Flags" -+16: "Payload Length" -+64: "Sender ID" -+64: "Recipient ID (optional)" -+48: "Payload (variable)" -+64: "Signature (optional)" -``` -_A representation of the sizes of the fields in `BitchatPacket`_ - -### 6.2. Application Message Format (`BitchatMessage`) - -For packets of type `message`, the payload is a binary-serialized `BitchatMessage` containing the chat content. - -| Field | Size (bytes) | Description | -|---------------------|--------------|--------------------------------------------------------------------------| -| Flags | 1 | Bitmask for optional fields (`isRelay`, `isPrivate`, `hasOriginalSender`). | -| Timestamp | 8 | `UInt64` millisecond timestamp of message creation. | -| ID | 1 + len | `UUID` string for the message. | -| Sender | 1 + len | Nickname of the sender. | -| Content | 2 + len | The UTF-8 encoded message content. | -| Original Sender | 1 + len (opt)| Nickname of the original sender if the message is a relay. | -| Recipient Nickname | 1 + len (opt)| Nickname of the recipient for private messages. | - -```mermaid ---- -config: - theme: dark ---- ---- -title: "BitchatMessage" ---- -packet -+8: "Flags" -+64: "Timestamp" -+24: "ID (variable)" -+32: "Sender (variable)" -+32: "Content (variable)" -+32: "Original Sender (variable) (optional)" -+32: "Recipient Nickname (variable) (optional)" -``` -_A representation of the sizes of the fields in `BitchatMessage`_ - ---- - -## 7. Message Routing and Propagation - -BitChat operates as a decentralized mesh network, meaning there are no central servers to route messages. Packets are propagated through the network from peer to peer. The protocol supports several modes of message delivery. - -### 7.1. Direct Connection - -This is the simplest case. If Peer A and Peer B are directly connected, they can exchange packets after establishing a mutually authenticated Noise session. All packets are encrypted using the transport ciphers derived from the handshake. - -### 7.2. Efficient Gossip with Bloom Filters - -To send messages to peers that are not directly connected, BitChat employs a "flooding" or "gossip" protocol. When a peer receives a packet that is not destined for it, it acts as a relay. To prevent infinite routing loops and minimize memory usage, the protocol uses an `OptimizedBloomFilter` to track recently seen packet IDs. - -The logic is as follows: - -1. A peer receives a packet. -2. It checks the Bloom filter to see if the packet's ID has likely been seen before. If so, the packet is discarded. Bloom filters can have false positives (though they are rare), but they guarantee no false negatives. This means that while some packets may be incorrectly discarded due to false positives, the gossip protocol's redundancy ensures these packets will eventually be received through subsequent exchanges with other peers. -3. If the packet is new, its ID is added to the Bloom filter. -4. The peer decrements the packet's Time-To-Live (TTL) field. -5. If the TTL is greater than zero, the peer re-broadcasts the packet to all of its connected peers, *except* for the peer from which it received the packet. - -This mechanism allows packets to "flood" through the network efficiently, maximizing the chance of reaching their destination while using minimal resources to prevent loops. - -### 7.3. Time-To-Live (TTL) - -Every `BitchatPacket` contains an 8-bit TTL field. This value is set by the originating peer and is decremented by one at each relay hop. If a peer receives a packet and decrements its TTL to 0, it will process the packet (if it is the recipient) but will not relay it further. This is a crucial mechanism to prevent packets from circulating endlessly in the mesh. - -### 7.4. Private vs. Broadcast Messages - -The routing logic respects the confidentiality of private messages: - -* **Private Messages:** A packet with a specific `recipientID` is a private message. Relay nodes forward the entire, encrypted Noise message without being able to access the inner `BitchatPacket` or its payload. Only the final recipient, who shares the correct Noise session keys with the sender, can decrypt the packet. -* **Broadcast Messages:** A packet with the special broadcast `recipientID` (`0xFFFFFFFFFFFFFFFF`) is intended for all peers. Any peer that receives and decrypts a broadcast message will process its content. It will still be relayed according to the flooding algorithm to ensure it reaches the entire network. - -### 7.5. Message Reliability and Lifecycle - -To function in unreliable, lossy networks, the protocol includes features to track the lifecycle of a message and ensure its delivery. - -* **Delivery Acknowledgments (`DeliveryAck`):** When a private message reaches its final destination, the recipient's device sends a `DeliveryAck` packet back to the original sender. This acknowledgment contains the ID of the original message. -* **Read Receipts (`ReadReceipt`):** After a message is displayed on the recipient's screen, the application can send a `ReadReceipt`, also containing the original message ID, to inform the sender that the message has been seen. -* **Message Retry Service:** Senders maintain a `MessageRetryService` which tracks outgoing messages. If a `DeliveryAck` is not received for a message within a certain time window, the service will automatically re-send the message, creating a more resilient user experience. - -### 7.6. Fragmentation - -Transport layers like BLE have a Maximum Transmission Unit (MTU) that limits the size of a single packet. To handle messages larger than this limit, BitChat implements a fragmentation protocol. - -* **`fragmentStart`:** A packet with this type marks the beginning of a fragmented message. It contains metadata about the total size and number of fragments. -* **`fragmentContinue`:** These packets carry the intermediate chunks of the message data. -* **`fragmentEnd`:** This packet carries the final chunk of the message and signals the receiver to begin reassembly. - -Receiving peers collect all fragments and reassemble them in the correct order before passing the complete message up to the application layer. - ---- +* **Public chat** — signed broadcast messages within the mesh, backed by the gossip-synced history above. +* **Private chat** — end-to-end encrypted messages with delivery and read receipts, over mesh, courier, or Nostr. +* **Location channels** — geohash-scoped public rooms carried over Nostr relays for regional chat beyond radio range. +* **Favorites** — the mutual-trust relationship that unlocks Nostr delivery and the larger courier quota. +* **Media** — files and images fragment over the mesh (1 MiB cap, explicit accept before anything touches disk); couriers carry text only. +* **Panic wipe** — clears identity keys, favorites, carried courier mail, the sealed outbox, archived public history, and metrics. ## 8. Security Considerations -* **Replay Attacks:** The Noise transport messages include a nonce that is incremented for each message. The `NoiseCipherState` implements a sliding window replay protection mechanism to detect and discard replayed or out-of-order messages. -* **Denial of Service:** The `NoiseRateLimiter` is implemented to prevent resource exhaustion from rapid, repeated handshake attempts from a single peer. -* **Key-Compromise Impersonation:** The `XX` pattern authenticates both parties, preventing an attacker from impersonating one party to the other. -* **Identity Binding:** While the Noise handshake authenticates the cryptographic keys, binding those keys to a human-readable nickname is handled at the application layer. Users must verify fingerprints out-of-band to prevent man-in-the-middle attacks. -* **Traffic Analysis:** The use of fixed-size padding for all packets helps to obscure the exact nature and content of the communication, making it harder for a network-level adversary to infer information based on message size. +* **Relay nodes** cannot read private traffic; they forward padded, opaque ciphertext. +* **Couriers** are quota-bounded mailbags. A malicious courier can drop mail (redundant copies and deposit retry mitigate this) but cannot read it, link it across days, or amplify it — copy budgets are capped and every envelope is validated against size and lifetime policy on deposit. +* **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting. +* **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces. +* **Metadata.** BLE proximity is inherently observable; ephemeral IDs and daily-rotating courier tags limit long-term correlation. Nostr traffic can ride Tor. +* **No forward secrecy for sealed mail** (§5.2) is the main cryptographic trade-off of the offline path. + +## 9. Future Work + +* Prekey-based forward secrecy for courier envelopes. +* Couriered media beyond the 16 KiB text cap. +* Probabilistic relay and edge-of-network TTL boosting for very dense and very sparse graphs. +* Multi-hop courier routing informed by encounter history. --- -## 9. Conclusion - -The BitChat Protocol provides a robust and secure foundation for decentralized, peer-to-peer communication. By layering a flexible application protocol on top of the well-regarded Noise Protocol Framework, it achieves strong confidentiality, authentication, and forward secrecy. The use of a compact binary format and thoughtful security considerations like rate limiting and traffic analysis resistance make it suitable for use in challenging network environments. +*This document describes the protocol as implemented in the current release. The implementation is free and unencumbered software released into the public domain.* diff --git a/bitchat/Services/BLE/BLEPeerRegistry.swift b/bitchat/Services/BLE/BLEPeerRegistry.swift index 78e9591d..29776748 100644 --- a/bitchat/Services/BLE/BLEPeerRegistry.swift +++ b/bitchat/Services/BLE/BLEPeerRegistry.swift @@ -125,7 +125,8 @@ struct BLEPeerRegistry { nickname: resolvedNames[info.peerID] ?? info.nickname, isConnected: info.isConnected, noisePublicKey: info.noisePublicKey, - lastSeen: info.lastSeen + lastSeen: info.lastSeen, + isVerified: info.isVerifiedNickname ) } } diff --git a/bitchat/Services/BLE/BLEPublicMessagePolicy.swift b/bitchat/Services/BLE/BLEPublicMessagePolicy.swift index e81ddec4..9a0bc965 100644 --- a/bitchat/Services/BLE/BLEPublicMessagePolicy.swift +++ b/bitchat/Services/BLE/BLEPublicMessagePolicy.swift @@ -27,8 +27,15 @@ enum BLEPublicMessagePolicy { } let isBroadcast = BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID) + // Acceptance window matches the gossip-sync serving window: a peer + // walking between partitions carries hours of public history, so the + // receive side must not drop what sync legitimately serves. if isBroadcast, - BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: packet.timestamp, now: now) { + BLEPacketFreshnessPolicy.isStale( + timestampMilliseconds: packet.timestamp, + now: now, + maxAgeSeconds: TransportConfig.syncPublicMessageMaxAgeSeconds + ) { return .reject(.staleBroadcast(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds( timestampMilliseconds: packet.timestamp, now: now diff --git a/bitchat/Services/BLE/BLEReceivePipeline.swift b/bitchat/Services/BLE/BLEReceivePipeline.swift index b6deaf1a..71aacbaf 100644 --- a/bitchat/Services/BLE/BLEReceivePipeline.swift +++ b/bitchat/Services/BLE/BLEReceivePipeline.swift @@ -48,7 +48,11 @@ struct BLEReceivePipeline { senderIsSelf: senderID == localPeerID, recipientIsSelf: PeerID(hexData: packet.recipientID) == localPeerID, isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue, - isDirectedEncrypted: packet.type == MessageType.noiseEncrypted.rawValue && packet.recipientID != nil, + // Courier envelopes are directed opaque ciphertext like DMs; a + // remote handover toward a relayed announce rides this same + // deterministic relay treatment instead of the broadcast clamp. + isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue + || packet.type == MessageType.courierEnvelope.rawValue) && packet.recipientID != nil, isFragment: packet.type == MessageType.fragment.rawValue, isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil, isHandshake: packet.type == MessageType.noiseHandshake.rawValue, diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index e82d7ff9..865a89be 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -48,12 +48,17 @@ final class BLEService: NSObject { private let messageDeduplicator = MessageDeduplicator() // Courier store-and-forward: envelopes this device carries for offline - // third parties, and the trust gate for accepting deposits. Injectable - // for tests; main-actor policy because favorites live on the main actor. + // third parties, and the trust gate for accepting deposits. The policy + // maps (depositor key, announce-verified?) to a quota tier, or nil to + // reject. Injectable for tests; main-actor policy because favorites live + // on the main actor. var courierStore: CourierStore = .shared - var courierDepositPolicy: @MainActor (Data) -> Bool = { depositorNoiseKey in - FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey) + var courierDepositPolicy: @MainActor (Data, Bool) -> CourierDepositTier? = { depositorNoiseKey, isVerifiedPeer in + if FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey) { return .favorite } + return isVerifiedPeer ? .verified : nil } + // Local-only store-and-forward counters; nil in unit tests. + var sfMetrics: StoreAndForwardMetrics? #if DEBUG // Test-only tap on the outbound pipeline so multi-node tests can ferry @@ -275,6 +280,7 @@ final class BLEService: NSObject { gcsMaxBytes: TransportConfig.syncGCSMaxBytes, gcsTargetFpr: TransportConfig.syncGCSTargetFpr, maxMessageAgeSeconds: TransportConfig.syncMaxMessageAgeSeconds, + publicMessageMaxAgeSeconds: TransportConfig.syncPublicMessageMaxAgeSeconds, maintenanceIntervalSeconds: TransportConfig.syncMaintenanceIntervalSeconds, stalePeerCleanupIntervalSeconds: TransportConfig.syncStalePeerCleanupIntervalSeconds, stalePeerTimeoutSeconds: TransportConfig.syncStalePeerTimeoutSeconds, @@ -286,8 +292,10 @@ final class BLEService: NSObject { responseRateLimitMaxResponses: TransportConfig.syncResponseRateLimitMaxResponses, responseRateLimitWindowSeconds: TransportConfig.syncResponseRateLimitWindowSeconds ) - - let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) + + // Only real Bluetooth sessions archive to disk; unit tests stay hermetic. + let archive = meshBackgroundEnabled ? GossipMessageArchive() : nil + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager, archive: archive) manager.delegate = self // Only start the periodic sync timers when real Bluetooth exists. In unit // tests there is no mesh to sync with, and the periodic sign/broadcast @@ -2515,7 +2523,8 @@ extension BLEService { epochDay: CourierEnvelope.epochDay(for: now) ), expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000), - ciphertext: sealed + ciphertext: sealed, + copies: TransportConfig.courierInitialCopies ) guard let encoded = envelope.encode() else { return false } payload = encoded @@ -2535,7 +2544,7 @@ extension BLEService { } private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket { - BitchatPacket( + let packet = BitchatPacket( type: MessageType.courierEnvelope.rawValue, senderID: myPeerIDData, recipientID: Data(hexString: peerID.id), @@ -2544,6 +2553,10 @@ extension BLEService { signature: nil, ttl: messageTTL ) + // Signed so a courier can authenticate the depositor before carrying + // mail under their quota. Handover to the recipient doesn't need the + // packet signature — the inner Noise X seal authenticates the sender. + return noiseService.signPacket(packet) ?? packet } /// Handles both courier roles for an incoming envelope addressed to us: @@ -2559,7 +2572,7 @@ extension BLEService { if CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) { openCourierEnvelope(envelope) } else { - acceptCourierDeposit(envelope, from: peerID) + acceptCourierDeposit(envelope, from: peerID, packet: packet) } } @@ -2589,6 +2602,7 @@ extension BLEService { let senderPeerID = isKnownOnMesh ? shortID : PeerID(hexData: senderStaticKey) let payload = Data(typedPayload.dropFirst()) SecureLogger.debug("📦 Opened courier envelope from \(senderPeerID.id.prefix(8))…", category: .session) + sfMetrics?.record(.courierOpened) notifyUI { [weak self] in self?.deliverTransportEvent(.noisePayloadReceived( peerID: senderPeerID, @@ -2603,20 +2617,38 @@ extension BLEService { } } - private func acceptCourierDeposit(_ envelope: CourierEnvelope, from peerID: PeerID) { - guard let depositorKey = collectionsQueue.sync(execute: { peerRegistry.info(for: peerID)?.noisePublicKey }) else { + private func acceptCourierDeposit(_ envelope: CourierEnvelope, from peerID: PeerID, packet: BitchatPacket) { + // A deposit must come from its depositor over the direct link: the + // claimed sender has to be the ingress peer, and the packet signature + // has to verify against that peer's announced signing key. Otherwise + // an untrusted sender could route an envelope through any trusted + // neighbor and have us carry it under the neighbor's quota. + guard PeerID(hexData: packet.senderID) == peerID else { + SecureLogger.debug("📦 Courier deposit rejected: relayed envelope claims sender \(PeerID(hexData: packet.senderID).id.prefix(8))… but arrived from \(peerID.id.prefix(8))…", category: .security) + return + } + let depositorInfo = collectionsQueue.sync { peerRegistry.info(for: peerID) } + guard let depositorKey = depositorInfo?.noisePublicKey else { SecureLogger.debug("📦 Courier deposit from unknown peer \(peerID.id.prefix(8))… rejected", category: .session) return } + guard let signingKey = depositorInfo?.signingPublicKey, + noiseService.verifyPacketSignature(packet, publicKey: signingKey) else { + SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (missing/invalid signature)", category: .security) + return + } + let isVerifiedPeer = depositorInfo?.isVerifiedNickname ?? false let store = courierStore let policy = courierDepositPolicy + let metrics = sfMetrics Task { @MainActor in - guard policy(depositorKey) else { - SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (not a mutual favorite)", category: .session) + guard let tier = policy(depositorKey, isVerifiedPeer) else { + SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (neither favorite nor verified)", category: .session) return } - if store.deposit(envelope, from: depositorKey) { - SecureLogger.debug("📦 Carrying courier envelope deposited by \(peerID.id.prefix(8))…", category: .session) + if store.deposit(envelope, from: depositorKey, tier: tier) { + SecureLogger.debug("📦 Carrying courier envelope deposited by \(peerID.id.prefix(8))… (\(tier.rawValue))", category: .session) + metrics?.record(.courierAccepted) } } } @@ -2629,6 +2661,51 @@ extension BLEService { for envelope in envelopes { guard let payload = envelope.encode() else { continue } sendPacketDirected(makeCourierPacket(payload, to: peerID), to: peerID) + sfMetrics?.record(.courierHandedOver) + } + } + + /// Speculative handover toward a recipient heard only via a relayed + /// announce: the envelope floods the mesh as a directed packet (relays + /// treat it like a directed DM). Non-destructive — the carried copy stays + /// until a direct handover or expiry, throttled per envelope so repeated + /// announces don't re-flood. + private func deliverCourierMailRemotely(to peerID: PeerID, noiseKey: Data) { + let envelopes = courierStore.envelopesForRemoteHandover( + recipientNoiseKey: noiseKey, + cooldown: TransportConfig.courierRemoteHandoverCooldownSeconds + ) + guard !envelopes.isEmpty else { return } + SecureLogger.debug("📦 Remote handover: flooding \(envelopes.count) envelope(s) toward \(peerID.id.prefix(8))…", category: .session) + for envelope in envelopes { + guard let payload = envelope.encode() else { continue } + broadcastPacket(makeCourierPacket(payload, to: peerID)) + sfMetrics?.record(.courierRemoteHandover) + } + } + + /// Spray-and-wait: split copy budgets with another courier we just + /// encountered, so carried mail diffuses through a moving crowd instead + /// of riding a single carrier. Only favorites and verified peers qualify, + /// mirroring the deposit policy they would apply to us. + private func sprayCourierMail(to peerID: PeerID, noiseKey: Data, isVerifiedPeer: Bool) { + let store = courierStore + let metrics = sfMetrics + let sendSpray: ([CourierEnvelope]) -> Void = { [weak self] envelopes in + guard let self, !envelopes.isEmpty else { return } + SecureLogger.debug("📦 Spraying \(envelopes.count) envelope copy(ies) to courier \(peerID.id.prefix(8))…", category: .session) + for envelope in envelopes { + guard let payload = envelope.encode() else { continue } + self.sendPacketDirected(self.makeCourierPacket(payload, to: peerID), to: peerID) + metrics?.record(.courierSprayed) + } + } + let policy = courierDepositPolicy + Task { @MainActor in + // Same trust gate as deposits: don't hand mail to a peer who + // would reject it from us. + guard policy(noiseKey, isVerifiedPeer) != nil else { return } + sendSpray(store.takeSprayCopies(for: noiseKey)) } } @@ -2755,6 +2832,9 @@ extension BLEService { centralManager?.stopScan() startScanning() } + // Backgrounding may precede a kill; flush the public-history archive + // outside its 30s maintenance cadence. + gossipSyncManager?.persistNow() logBluetoothStatus("entered-background") scheduleBluetoothStatusSample(after: 15.0, context: "background-15s") // No Local Name; nothing to refresh for advertising policy @@ -3186,16 +3266,23 @@ extension BLEService { private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) { let result = announceHandler.handle(packet, from: peerID) - // Courier handover: an announce is the moment we learn a peer's Noise - // static key, so check whether we're carrying mail addressed to them. - // Direct announces only: envelopes are removed from the store - // optimistically, so handover must ride an established link rather - // than a speculative multi-hop send toward a relayed announce. + // Courier work: an announce is the moment we learn a peer's Noise + // static key, so check whether we're carrying mail addressed to them + // (or spray-able mail they could carry). Verified announces only. guard !courierStore.isEmpty, let result, - result.isVerified, - result.isDirectAnnounce else { return } - deliverCourierMail(to: result.peerID, noiseKey: result.announcement.noisePublicKey) + result.isVerified else { return } + let noiseKey = result.announcement.noisePublicKey + if result.isDirectAnnounce { + // Established link: destructive handover is safe, and the peer is + // close enough to become a courier for other carried mail. + deliverCourierMail(to: result.peerID, noiseKey: noiseKey) + sprayCourierMail(to: result.peerID, noiseKey: noiseKey, isVerifiedPeer: true) + } else { + // Relayed announce: recipient is multi-hop away. Push a copy + // toward them speculatively; the carried copy stays put. + deliverCourierMailRemotely(to: result.peerID, noiseKey: noiseKey) + } } /// Builds the announce handler environment. All queue hops stay here so diff --git a/bitchat/Services/Courier/CourierStore.swift b/bitchat/Services/Courier/CourierStore.swift index e5523257..063c94d5 100644 --- a/bitchat/Services/Courier/CourierStore.swift +++ b/bitchat/Services/Courier/CourierStore.swift @@ -11,13 +11,22 @@ import BitLogger import Combine import Foundation +/// Trust level of a courier deposit, decided by the caller's policy. +/// Favorites get the larger quota and are never evicted to make room for +/// verified-tier mail; verified (signature-verified announce, not a mutual +/// favorite) get a small quota so a crowd of strangers can still carry mail. +enum CourierDepositTier: String, Codable { + case favorite + case verified +} + /// Holds courier envelopes this device is carrying for offline third parties. /// -/// Envelopes are opaque ciphertext deposited by mutual favorites; this store -/// never learns sender, recipient, or content. Strict quotas keep the device -/// from becoming a public mailbag: bounded count, bounded per-depositor -/// count, bounded size, and a 24-hour lifetime aligned with the outbox -/// retention policy. Carried mail is included in the panic wipe. +/// Envelopes are opaque ciphertext; this store never learns sender, +/// recipient, or content. Strict quotas keep the device from becoming a +/// public mailbag: bounded count, bounded per-depositor count by trust tier, +/// bounded size, and a 24-hour lifetime aligned with the outbox retention +/// policy. Carried mail is included in the panic wipe. final class CourierStore { struct StoredEnvelope: Codable, Equatable { let recipientTag: Data @@ -25,15 +34,63 @@ final class CourierStore { let ciphertext: Data let depositorNoiseKey: Data let storedAt: Date + var tier: CourierDepositTier + /// Remaining spray-and-wait budget (1 = carry-only). + var copies: UInt8 + /// Couriers this envelope was already sprayed to, so a repeat announce + /// from the same peer doesn't burn budget on a copy they already hold. + var sprayedTo: Set + /// Last speculative multi-hop handover toward a relayed announce. + var lastRemoteHandoverAt: Date? var envelope: CourierEnvelope { - CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext) + CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies) + } + + init( + recipientTag: Data, + expiry: UInt64, + ciphertext: Data, + depositorNoiseKey: Data, + storedAt: Date, + tier: CourierDepositTier, + copies: UInt8, + sprayedTo: Set = [], + lastRemoteHandoverAt: Date? = nil + ) { + self.recipientTag = recipientTag + self.expiry = expiry + self.ciphertext = ciphertext + self.depositorNoiseKey = depositorNoiseKey + self.storedAt = storedAt + self.tier = tier + self.copies = copies + self.sprayedTo = sprayedTo + self.lastRemoteHandoverAt = lastRemoteHandoverAt + } + + // Files written before tiers/spray lack the newer fields; treat that + // mail as favorite-tier carry-only, which is what it was. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + recipientTag = try container.decode(Data.self, forKey: .recipientTag) + expiry = try container.decode(UInt64.self, forKey: .expiry) + ciphertext = try container.decode(Data.self, forKey: .ciphertext) + depositorNoiseKey = try container.decode(Data.self, forKey: .depositorNoiseKey) + storedAt = try container.decode(Date.self, forKey: .storedAt) + tier = try container.decodeIfPresent(CourierDepositTier.self, forKey: .tier) ?? .favorite + copies = try container.decodeIfPresent(UInt8.self, forKey: .copies) ?? 1 + sprayedTo = try container.decodeIfPresent(Set.self, forKey: .sprayedTo) ?? [] + lastRemoteHandoverAt = try container.decodeIfPresent(Date.self, forKey: .lastRemoteHandoverAt) } } enum Limits { - static let maxEnvelopes = 20 - static let maxPerDepositor = 5 + static let maxEnvelopes = 40 + /// Verified-tier mail can never crowd out favorites' share. + static let maxVerifiedEnvelopes = 20 + static let maxPerFavoriteDepositor = 5 + static let maxPerVerifiedDepositor = 2 /// Slack on top of the 24h lifetime for depositor clock skew. static let maxExpirySlack: TimeInterval = 60 * 60 } @@ -65,10 +122,11 @@ final class CourierStore { // MARK: - Depositing (courier side) /// Accept an envelope from a depositor. Returns false when quotas or - /// validity checks reject it. Trust policy (mutual favorite) is the - /// caller's responsibility; this store only enforces resource bounds. + /// validity checks reject it. Trust policy (which tier a depositor gets, + /// if any) is the caller's responsibility; this store only enforces + /// resource bounds. @discardableResult - func deposit(_ envelope: CourierEnvelope, from depositorNoiseKey: Data) -> Bool { + func deposit(_ envelope: CourierEnvelope, from depositorNoiseKey: Data, tier: CourierDepositTier = .favorite) -> Bool { let date = now() guard envelope.recipientTag.count == CourierEnvelope.tagLength, !envelope.ciphertext.isEmpty, @@ -86,18 +144,39 @@ final class CourierStore { return queue.sync { pruneExpiredLocked(at: date) - // Identical ciphertext is the same envelope; accept idempotently. - if envelopes.contains(where: { $0.ciphertext == envelope.ciphertext }) { + // Identical ciphertext is the same envelope; accept idempotently, + // keeping the larger spray budget (bounded by maxCopies either way). + if let existing = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) { + envelopes[existing].copies = max(envelopes[existing].copies, envelope.copies) + persistLocked() return true } - guard envelopes.filter({ $0.depositorNoiseKey == depositorNoiseKey }).count < Limits.maxPerDepositor else { - SecureLogger.debug("📦 Courier deposit rejected: per-depositor quota reached", category: .session) + + let perDepositorLimit = tier == .favorite ? Limits.maxPerFavoriteDepositor : Limits.maxPerVerifiedDepositor + guard envelopes.filter({ $0.depositorNoiseKey == depositorNoiseKey }).count < perDepositorLimit else { + SecureLogger.debug("📦 Courier deposit rejected: per-depositor quota reached (\(tier.rawValue))", category: .session) + return false + } + if tier == .verified, + envelopes.filter({ $0.tier == .verified }).count >= Limits.maxVerifiedEnvelopes { + SecureLogger.debug("📦 Courier deposit rejected: verified-tier pool full", category: .session) return false } if envelopes.count >= Limits.maxEnvelopes { - // Oldest-first eviction, matching outbox overflow behavior. - let evicted = envelopes.removeFirst() - SecureLogger.debug("📦 Courier store full - evicted envelope stored at \(evicted.storedAt)", category: .session) + // Oldest-first eviction, shedding verified-tier mail before + // favorites' so open couriering can't crowd out trusted mail. + // A verified deposit never displaces a favorite: when only + // favorite mail is stored, it is rejected instead. + if let victim = envelopes.firstIndex(where: { $0.tier == .verified }) { + let evicted = envelopes.remove(at: victim) + SecureLogger.debug("📦 Courier store full - evicted verified envelope stored at \(evicted.storedAt)", category: .session) + } else if tier == .favorite { + let evicted = envelopes.removeFirst() + SecureLogger.debug("📦 Courier store full - evicted favorite envelope stored at \(evicted.storedAt)", category: .session) + } else { + SecureLogger.debug("📦 Courier deposit rejected: store full of favorite-tier mail", category: .session) + return false + } } envelopes.append(StoredEnvelope( @@ -105,7 +184,9 @@ final class CourierStore { expiry: envelope.expiry, ciphertext: envelope.ciphertext, depositorNoiseKey: depositorNoiseKey, - storedAt: date + storedAt: date, + tier: tier, + copies: envelope.copies )) persistLocked() return true @@ -131,6 +212,58 @@ final class CourierStore { } } + /// Envelopes addressed to a recipient we heard from via a *relayed* + /// announce. Non-destructive: a multi-hop send is speculative, so the + /// envelope stays carried until a direct handover or expiry. The per- + /// envelope cooldown keeps repeated announces from re-flooding the mesh. + func envelopesForRemoteHandover(recipientNoiseKey: Data, cooldown: TimeInterval) -> [CourierEnvelope] { + let date = now() + let candidates = CourierEnvelope.candidateTags(noiseStaticKey: recipientNoiseKey, around: date) + return queue.sync { + pruneExpiredLocked(at: date) + var matched: [CourierEnvelope] = [] + for index in envelopes.indices where candidates.contains(envelopes[index].recipientTag) { + if let last = envelopes[index].lastRemoteHandoverAt, + date.timeIntervalSince(last) < cooldown { + continue + } + envelopes[index].lastRemoteHandoverAt = date + // The delivered copy carries no spray budget. + matched.append(envelopes[index].envelope.withCopies(1)) + } + if !matched.isEmpty { persistLocked() } + return matched + } + } + + // MARK: - Spray-and-wait (on encountering another courier) + + /// Envelopes to re-deposit with a courier we just encountered, each with + /// half its remaining budget (binary spray). Skips envelopes the courier + /// deposited, envelopes addressed to them (those ride the handover path), + /// carry-only envelopes, and couriers already sprayed. + func takeSprayCopies(for courierNoiseKey: Data) -> [CourierEnvelope] { + let date = now() + let courierTags = CourierEnvelope.candidateTags(noiseStaticKey: courierNoiseKey, around: date) + return queue.sync { + pruneExpiredLocked(at: date) + var sprayed: [CourierEnvelope] = [] + for index in envelopes.indices { + let stored = envelopes[index] + guard stored.copies > 1, + stored.depositorNoiseKey != courierNoiseKey, + !stored.sprayedTo.contains(courierNoiseKey), + !courierTags.contains(stored.recipientTag) else { continue } + let given = stored.copies / 2 + envelopes[index].copies = stored.copies - given + envelopes[index].sprayedTo.insert(courierNoiseKey) + sprayed.append(stored.envelope.withCopies(given)) + } + if !sprayed.isEmpty { persistLocked() } + return sprayed + } + } + // MARK: - Maintenance func pruneExpired() { diff --git a/bitchat/Services/Courier/MessageOutboxStore.swift b/bitchat/Services/Courier/MessageOutboxStore.swift new file mode 100644 index 00000000..b38616a2 --- /dev/null +++ b/bitchat/Services/Courier/MessageOutboxStore.swift @@ -0,0 +1,156 @@ +// +// MessageOutboxStore.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import BitLogger +import CryptoKit +import Foundation +import Security + +/// Disk persistence for the MessageRouter outbox, so private messages queued +/// for an offline peer survive an app kill instead of silently evaporating. +/// +/// Nothing else in the app persists message plaintext, and this store keeps +/// that property: the outbox is sealed with a ChaChaPoly key that lives only +/// in the Keychain (after-first-unlock, this device only), on top of iOS file +/// protection. Wiped on panic alongside the courier store. +final class MessageOutboxStore { + struct QueuedMessage: Codable, Equatable { + let content: String + let nickname: String + let messageID: String + let timestamp: Date + var sendAttempts: Int + /// Noise keys of couriers already carrying this message, so deposit + /// retries add couriers instead of re-burning the same ones. + var depositedCourierKeys: Set + + init( + content: String, + nickname: String, + messageID: String, + timestamp: Date, + sendAttempts: Int = 0, + depositedCourierKeys: Set = [] + ) { + self.content = content + self.nickname = nickname + self.messageID = messageID + self.timestamp = timestamp + self.sendAttempts = sendAttempts + self.depositedCourierKeys = depositedCourierKeys + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + content = try container.decode(String.self, forKey: .content) + nickname = try container.decode(String.self, forKey: .nickname) + messageID = try container.decode(String.self, forKey: .messageID) + timestamp = try container.decode(Date.self, forKey: .timestamp) + sendAttempts = try container.decodeIfPresent(Int.self, forKey: .sendAttempts) ?? 0 + depositedCourierKeys = try container.decodeIfPresent(Set.self, forKey: .depositedCourierKeys) ?? [] + } + } + + private static let keychainService = "chat.bitchat.outbox" + private static let keychainKey = "outbox-encryption-key" + + private let fileURL: URL? + private let keychain: KeychainManagerProtocol + + init(keychain: KeychainManagerProtocol, fileURL: URL? = nil) { + self.keychain = keychain + self.fileURL = fileURL ?? Self.defaultFileURL() + } + + // MARK: - API (call from the router's actor; IO is small and atomic) + + func load() -> [PeerID: [QueuedMessage]] { + guard let fileURL, + let sealed = try? Data(contentsOf: fileURL), + let key = encryptionKey(createIfMissing: false), + let box = try? ChaChaPoly.SealedBox(combined: sealed), + let plaintext = try? ChaChaPoly.open(box, using: key), + let decoded = try? JSONDecoder().decode([String: [QueuedMessage]].self, from: plaintext) else { + return [:] + } + var outbox: [PeerID: [QueuedMessage]] = [:] + for (peerID, queue) in decoded where !queue.isEmpty { + outbox[PeerID(str: peerID)] = queue + } + return outbox + } + + func save(_ outbox: [PeerID: [QueuedMessage]]) { + guard let fileURL else { return } + let flattened = outbox.filter { !$0.value.isEmpty } + guard !flattened.isEmpty else { + try? FileManager.default.removeItem(at: fileURL) + return + } + guard let key = encryptionKey(createIfMissing: true) else { + SecureLogger.error("Outbox not persisted: no encryption key available", category: .session) + return + } + do { + let keyed = Dictionary(uniqueKeysWithValues: flattened.map { ($0.key.id, $0.value) }) + let plaintext = try JSONEncoder().encode(keyed) + let sealed = try ChaChaPoly.seal(plaintext, using: key).combined + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + var options: Data.WritingOptions = [.atomic] + #if os(iOS) + options.insert(.completeFileProtection) + #endif + try sealed.write(to: fileURL, options: options) + } catch { + SecureLogger.error("Failed to persist outbox: \(error)", category: .session) + } + } + + /// Panic wipe: drop the queued mail and the key that could ever read it. + func wipe() { + if let fileURL { + try? FileManager.default.removeItem(at: fileURL) + } + keychain.delete(key: Self.keychainKey, service: Self.keychainService) + } + + // MARK: - Internals + + private func encryptionKey(createIfMissing: Bool) -> SymmetricKey? { + if let data = keychain.load(key: Self.keychainKey, service: Self.keychainService), data.count == 32 { + return SymmetricKey(data: data) + } + guard createIfMissing else { return nil } + let key = SymmetricKey(size: .bits256) + let data = key.withUnsafeBytes { Data($0) } + // After-first-unlock so queued mail can flush from background BLE wakes. + keychain.save( + key: Self.keychainKey, + data: data, + service: Self.keychainService, + accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + ) + return key + } + + private static func defaultFileURL() -> URL? { + guard let base = try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) else { return nil } + return base + .appendingPathComponent("courier", isDirectory: true) + .appendingPathComponent("outbox.sealed") + } +} diff --git a/bitchat/Services/Courier/StoreAndForwardMetrics.swift b/bitchat/Services/Courier/StoreAndForwardMetrics.swift new file mode 100644 index 00000000..5452f357 --- /dev/null +++ b/bitchat/Services/Courier/StoreAndForwardMetrics.swift @@ -0,0 +1,74 @@ +// +// StoreAndForwardMetrics.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitLogger +import Foundation + +/// Privacy-safe local counters for the store-and-forward stack: bare event +/// tallies with no message IDs, peer identities, or timestamps, so delivery +/// behavior can be measured on-device without recording who talked to whom. +/// Log-only surface — nothing here ever leaves the device. +final class StoreAndForwardMetrics { + enum Event: String, CaseIterable { + /// A private message entered the outbox (no prompt route available). + case outboxQueued = "outbox.queued" + /// A retained message was re-sent on a flush. + case outboxResent = "outbox.resent" + /// A delivery/read ack cleared a retained message. + case outboxDelivered = "outbox.delivered" + /// A retained message was dropped (attempt cap, TTL, or overflow). + case outboxDropped = "outbox.dropped" + /// We handed sealed mail to a courier. + case courierDeposited = "courier.deposited" + /// We accepted sealed mail to carry for a third party. + case courierAccepted = "courier.accepted" + /// We handed carried mail to its recipient over a direct link. + case courierHandedOver = "courier.handedOver" + /// We pushed carried mail toward a recipient heard via relay. + case courierRemoteHandover = "courier.remoteHandover" + /// We split spray copies to another courier. + case courierSprayed = "courier.sprayed" + /// Couriered mail addressed to us was opened and delivered. + case courierOpened = "courier.opened" + } + + static let shared = StoreAndForwardMetrics() + + private let lock = NSLock() + private var counts: [String: Int] + private let defaults: UserDefaults + private static let defaultsKey = "chat.bitchat.storeAndForwardMetrics" + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + self.counts = defaults.dictionary(forKey: Self.defaultsKey) as? [String: Int] ?? [:] + } + + func record(_ event: Event) { + lock.lock() + let total = (counts[event.rawValue] ?? 0) + 1 + counts[event.rawValue] = total + defaults.set(counts, forKey: Self.defaultsKey) + lock.unlock() + SecureLogger.debug("📊 S&F \(event.rawValue) → \(total)", category: .session) + } + + func snapshot() -> [String: Int] { + lock.lock() + defer { lock.unlock() } + return counts + } + + /// Included in the panic wipe alongside the stores it describes. + func reset() { + lock.lock() + counts = [:] + defaults.removeObject(forKey: Self.defaultsKey) + lock.unlock() + } +} diff --git a/bitchat/Services/MessageRouter.swift b/bitchat/Services/MessageRouter.swift index e0417851..976b8eb8 100644 --- a/bitchat/Services/MessageRouter.swift +++ b/bitchat/Services/MessageRouter.swift @@ -7,7 +7,9 @@ import Foundation struct CourierDirectory { /// Noise static key for a peer we can address while they're offline. var noiseKey: (PeerID) -> Data? - /// Whether a peer (by Noise static key) may carry our mail. + /// Whether a peer (by Noise static key) is a mutual favorite — the + /// preferred courier tier. Verified non-favorites are the fallback tier, + /// read off the transport snapshot. var isTrustedCourier: (Data) -> Bool @MainActor @@ -30,9 +32,13 @@ struct CourierDirectory { /// Routes messages using available transports (Mesh, Nostr, etc.) @MainActor final class MessageRouter { + typealias QueuedMessage = MessageOutboxStore.QueuedMessage + private let transports: [Transport] private let now: () -> Date private let courierDirectory: CourierDirectory + private let outboxStore: MessageOutboxStore? + private let metrics: StoreAndForwardMetrics? /// Invoked whenever a retained private message is dropped without a /// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction) @@ -41,20 +47,11 @@ final class MessageRouter { var onMessageDropped: ((_ messageID: String, _ peerID: PeerID) -> Void)? /// Invoked when a message with no reachable transport was handed to at - /// least one courier (a connected mutual favorite who will physically - /// carry the sealed envelope). Delivery stays best-effort: the outbox - /// retains the message until an ack arrives. + /// least one courier (a connected peer who will physically carry the + /// sealed envelope). Delivery stays best-effort: the outbox retains the + /// message until an ack arrives. var onMessageCarried: ((_ messageID: String, _ peerID: PeerID) -> Void)? - // Outbox entry with timestamp for TTL-based eviction - private struct QueuedMessage { - let content: String - let nickname: String - let messageID: String - let timestamp: Date - var sendAttempts: Int = 0 - } - private var outbox: [PeerID: [QueuedMessage]] = [:] // Outbox limits to prevent unbounded memory growth @@ -69,11 +66,16 @@ final class MessageRouter { init( transports: [Transport], now: @escaping () -> Date = Date.init, - courierDirectory: CourierDirectory? = nil + courierDirectory: CourierDirectory? = nil, + outboxStore: MessageOutboxStore? = nil, + metrics: StoreAndForwardMetrics? = nil ) { self.transports = transports self.now = now self.courierDirectory = courierDirectory ?? .favoritesBacked() + self.outboxStore = outboxStore + self.metrics = metrics + self.outbox = outboxStore?.load() ?? [:] // Observe favorites changes to learn Nostr mapping and flush queued messages NotificationCenter.default.addObserver( @@ -134,50 +136,137 @@ final class MessageRouter { // may never come. Double delivery is harmless — receivers dedup // by message ID, and delivered/read acks never downgrade. if !transport.canDeliverPromptly(to: peerID) { - attemptCourierDeposit(content: content, messageID: messageID, for: peerID) + attemptCourierDeposit(messageID: messageID, for: peerID) } } else { var unsent = message unsent.sendAttempts = 0 enqueue(unsent, for: peerID) SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session) - attemptCourierDeposit(content: content, messageID: messageID, for: peerID) + attemptCourierDeposit(messageID: messageID, for: peerID) } } + // MARK: - Couriers + /// Last resort when no transport can deliver promptly — the peer is /// unreachable, or only reachable through a send queue waiting on /// internet: seal the message to their known static key and hand it to - /// connected mutual favorites who may physically encounter them. The - /// queued copy above stays retained, so direct delivery still wins if - /// the peer reappears first (receivers dedup by message ID). - private func attemptCourierDeposit(content: String, messageID: String, for peerID: PeerID) { - guard let recipientKey = courierDirectory.noiseKey(peerID) else { return } + /// connected couriers who may physically encounter them. Mutual favorites + /// are preferred; signature-verified strangers fill remaining slots so a + /// crowd without favorites can still carry mail (envelopes are opaque + /// either way). The queued copy stays retained, so direct delivery still + /// wins if the peer reappears first (receivers dedup by message ID). + private func attemptCourierDeposit(messageID: String, for peerID: PeerID) { + guard let recipientKey = courierDirectory.noiseKey(peerID), + let entry = queuedMessage(messageID, for: peerID) else { return } + let remainingSlots = Self.maxCouriersPerMessage - entry.depositedCourierKeys.count + guard remainingSlots > 0 else { return } + for transport in transports { - let couriers = transport.currentPeerSnapshots() - .filter { snapshot in - guard snapshot.isConnected, - let key = snapshot.noisePublicKey, - key != recipientKey else { return false } - return courierDirectory.isTrustedCourier(key) - } - .prefix(Self.maxCouriersPerMessage) - .map(\.peerID) + let couriers = eligibleCouriers( + on: transport, + recipientKey: recipientKey, + excluding: entry.depositedCourierKeys, + limit: remainingSlots + ) guard !couriers.isEmpty else { continue } - if transport.sendCourierMessage(content, messageID: messageID, recipientNoiseKey: recipientKey, via: Array(couriers)) { + if transport.sendCourierMessage(entry.content, messageID: messageID, recipientNoiseKey: recipientKey, via: couriers.map(\.peerID)) { SecureLogger.debug("📦 PM \(messageID.prefix(8))… handed to \(couriers.count) courier(s) for \(peerID.id.prefix(8))…", category: .session) + recordCourierDeposit(messageID: messageID, for: peerID, courierKeys: couriers.map(\.noiseKey)) onMessageCarried?(messageID, peerID) return } } } + /// A courier candidate just connected: hand them any queued mail they are + /// not already carrying. This is what turns couriering from "a favorite + /// happened to be around at send time" into eventual spread — deposits + /// retry as eligible peers appear, until each message rides with + /// `maxCouriersPerMessage` distinct couriers or expires. + func courierBecameAvailable(_ peerID: PeerID) { + for transport in transports { + guard transport.isPeerConnected(peerID), + let snapshot = transport.currentPeerSnapshots().first(where: { $0.peerID == peerID && $0.isConnected }), + let courierKey = snapshot.noisePublicKey, + courierDirectory.isTrustedCourier(courierKey) || snapshot.isVerified else { continue } + + let currentDate = now() + for (recipient, queue) in outbox { + // Mail *to* this peer flushes directly on connect. + guard recipient != peerID, + let recipientKey = courierDirectory.noiseKey(recipient), + recipientKey != courierKey else { continue } + for message in queue { + guard message.depositedCourierKeys.count < Self.maxCouriersPerMessage, + !message.depositedCourierKeys.contains(courierKey), + currentDate.timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds else { continue } + if transport.sendCourierMessage(message.content, messageID: message.messageID, recipientNoiseKey: recipientKey, via: [peerID]) { + SecureLogger.debug("📦 Deposit retry: PM \(message.messageID.prefix(8))… handed to \(peerID.id.prefix(8))… for \(recipient.id.prefix(8))…", category: .session) + recordCourierDeposit(messageID: message.messageID, for: recipient, courierKeys: [courierKey]) + onMessageCarried?(message.messageID, recipient) + } + } + } + return + } + } + + private struct CourierCandidate { + let peerID: PeerID + let noiseKey: Data + } + + private func eligibleCouriers( + on transport: Transport, + recipientKey: Data, + excluding excludedKeys: Set, + limit: Int + ) -> [CourierCandidate] { + guard limit > 0 else { return [] } + let candidates = transport.currentPeerSnapshots().compactMap { snapshot -> (CourierCandidate, isFavorite: Bool)? in + guard snapshot.isConnected, + let key = snapshot.noisePublicKey, + key != recipientKey, + !excludedKeys.contains(key) else { return nil } + let isFavorite = courierDirectory.isTrustedCourier(key) + guard isFavorite || snapshot.isVerified else { return nil } + return (CourierCandidate(peerID: snapshot.peerID, noiseKey: key), isFavorite) + } + return candidates + .sorted { $0.isFavorite && !$1.isFavorite } + .prefix(limit) + .map(\.0) + } + + private func queuedMessage(_ messageID: String, for peerID: PeerID) -> QueuedMessage? { + outbox[peerID]?.first { $0.messageID == messageID } + } + + private func recordCourierDeposit(messageID: String, for peerID: PeerID, courierKeys: [Data]) { + metrics?.record(.courierDeposited) + guard var queue = outbox[peerID], + let index = queue.firstIndex(where: { $0.messageID == messageID }) else { return } + queue[index].depositedCourierKeys.formUnion(courierKeys) + outbox[peerID] = queue + persistOutbox() + } + + // MARK: - Outbox Management + /// A delivery or read ack confirms receipt; stop retaining the message. func markDelivered(_ messageID: String) { + var cleared = false for (peerID, queue) in outbox { let filtered = queue.filter { $0.messageID != messageID } guard filtered.count != queue.count else { continue } outbox[peerID] = filtered.isEmpty ? nil : filtered + cleared = true + } + if cleared { + metrics?.record(.outboxDelivered) + persistOutbox() } } @@ -191,9 +280,26 @@ final class MessageRouter { if queue.count > Self.maxMessagesPerPeer { let evicted = queue.removeFirst() SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted.messageID.prefix(8))…", category: .session) - onMessageDropped?(evicted.messageID, peerID) + dropMessage(evicted.messageID, for: peerID) } outbox[peerID] = queue + metrics?.record(.outboxQueued) + persistOutbox() + } + + private func dropMessage(_ messageID: String, for peerID: PeerID) { + metrics?.record(.outboxDropped) + onMessageDropped?(messageID, peerID) + } + + private func persistOutbox() { + outboxStore?.save(outbox) + } + + /// Panic wipe: forget queued mail on disk and in memory. + func wipeOutbox() { + outbox.removeAll() + outboxStore?.wipe() } func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { @@ -220,8 +326,6 @@ final class MessageRouter { } } - // MARK: - Outbox Management - func flushOutbox(for peerID: PeerID) { guard let queued = outbox[peerID], !queued.isEmpty else { return } SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session) @@ -233,7 +337,7 @@ final class MessageRouter { // Skip expired messages (TTL exceeded) if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds { SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session) - onMessageDropped?(message.messageID, peerID) + dropMessage(message.messageID, for: peerID) continue } @@ -241,16 +345,18 @@ final class MessageRouter { // Live link: send and stop retaining. SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session) transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID) + metrics?.record(.outboxResent) } else if let transport = reachableTransport(for: peerID) { // Weak signal: send but keep retaining until an ack clears it, // bounded by attempt count for peers that never ack. guard message.sendAttempts < Self.maxSendAttempts else { SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session) - onMessageDropped?(message.messageID, peerID) + dropMessage(message.messageID, for: peerID) continue } SecureLogger.debug("Outbox -> \(type(of: transport)) (reachable) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session) transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID) + metrics?.record(.outboxResent) var retained = message retained.sendAttempts += 1 remaining.append(retained) @@ -264,6 +370,7 @@ final class MessageRouter { } else { outbox[peerID] = remaining } + persistOutbox() } func flushAllOutbox() { @@ -273,6 +380,7 @@ final class MessageRouter { /// Periodically clean up expired messages from all outboxes func cleanupExpiredMessages() { let now = now() + var droppedAny = false for peerID in Array(outbox.keys) { var expiredMessageIDs: [String] = [] outbox[peerID]?.removeAll { message in @@ -285,8 +393,12 @@ final class MessageRouter { } for messageID in expiredMessageIDs { SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) - onMessageDropped?(messageID, peerID) + dropMessage(messageID, for: peerID) + droppedAny = true } } + if droppedAny { + persistOutbox() + } } } diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 5cd095a6..27c38700 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -11,6 +11,24 @@ struct TransportPeerSnapshot: Equatable, Hashable { let isConnected: Bool let noisePublicKey: Data? let lastSeen: Date + /// Whether the peer's announce was signature-verified (courier tier gate). + let isVerified: Bool + + init( + peerID: PeerID, + nickname: String, + isConnected: Bool, + noisePublicKey: Data?, + lastSeen: Date, + isVerified: Bool = false + ) { + self.peerID = peerID + self.nickname = nickname + self.isConnected = isConnected + self.noisePublicKey = noisePublicKey + self.lastSeen = lastSeen + self.isVerified = isVerified + } } enum TransportEvent: @unchecked Sendable { diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index b5cb00e0..59d15c55 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -262,7 +262,14 @@ enum TransportConfig { static let syncSeenCapacity: Int = 1000 static let syncGCSMaxBytes: Int = 400 static let syncGCSTargetFpr: Double = 0.01 + // Fragments and file transfers keep the short window; whole public + // messages get hours so a phone walking between partitions carries the + // room's recent history with it (see syncPublicMessageMaxAgeSeconds). static let syncMaxMessageAgeSeconds: TimeInterval = 900 + // How far back public broadcast messages stay sync-able. Must not exceed + // the receive-side acceptance window (BLEPublicMessagePolicy uses this + // same constant) or served packets would be dropped as stale. + static let syncPublicMessageMaxAgeSeconds: TimeInterval = 6 * 60 * 60 static let syncMaintenanceIntervalSeconds: TimeInterval = 30.0 static let syncStalePeerCleanupIntervalSeconds: TimeInterval = 60.0 static let syncStalePeerTimeoutSeconds: TimeInterval = 60.0 @@ -273,4 +280,13 @@ enum TransportConfig { static let syncMessageIntervalSeconds: TimeInterval = 15.0 static let syncResponseRateLimitMaxResponses: Int = 8 static let syncResponseRateLimitWindowSeconds: TimeInterval = 30.0 + + // Courier store-and-forward + // Initial spray-and-wait budget per deposited envelope: each courier may + // hand half its remaining copies to another courier on encounter, so a + // message diffuses through a moving crowd instead of riding one person. + static let courierInitialCopies: UInt8 = 4 + // Cooldown between speculative multi-hop handovers of the same envelope + // toward a recipient heard only via relayed announces. + static let courierRemoteHandoverCooldownSeconds: TimeInterval = 10 * 60 } diff --git a/bitchat/Sync/GossipMessageArchive.swift b/bitchat/Sync/GossipMessageArchive.swift new file mode 100644 index 00000000..dd81983d --- /dev/null +++ b/bitchat/Sync/GossipMessageArchive.swift @@ -0,0 +1,80 @@ +// +// GossipMessageArchive.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitLogger +import Foundation + +/// Disk persistence for the gossip-sync public message store, so the recent +/// public history a device carries survives app restarts. This is what lets +/// a phone act as a town crier: walk between two mesh partitions (or relaunch +/// hours later) and sync the room's backlog to whoever missed it. +/// +/// Contents are signed public broadcasts — already visible to anyone in radio +/// range — so file protection (no additional sealing) is the right at-rest +/// posture. Wiped on panic. +final class GossipMessageArchive { + private let fileURL: URL? + + init(fileURL: URL? = nil) { + self.fileURL = fileURL ?? Self.defaultFileURL() + } + + /// Raw binary packets, decoded and freshness-filtered by the caller. + func load() -> [Data] { + guard let fileURL, + let data = try? Data(contentsOf: fileURL), + let packets = try? JSONDecoder().decode([Data].self, from: data) else { + return [] + } + return packets + } + + func save(_ packets: [Data]) { + guard let fileURL else { return } + guard !packets.isEmpty else { + try? FileManager.default.removeItem(at: fileURL) + return + } + do { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try JSONEncoder().encode(packets) + var options: Data.WritingOptions = [.atomic] + #if os(iOS) + options.insert(.completeFileProtection) + #endif + try data.write(to: fileURL, options: options) + } catch { + SecureLogger.error("Failed to persist gossip archive: \(error)", category: .sync) + } + } + + func wipe() { + guard let fileURL else { return } + try? FileManager.default.removeItem(at: fileURL) + } + + /// Panic-wipe hook for callers that don't hold the live instance. + static func wipeDefault() { + GossipMessageArchive().wipe() + } + + private static func defaultFileURL() -> URL? { + guard let base = try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) else { return nil } + return base + .appendingPathComponent("sync", isDirectory: true) + .appendingPathComponent("public-messages.json") + } +} diff --git a/bitchat/Sync/GossipSyncManager.swift b/bitchat/Sync/GossipSyncManager.swift index ee337baa..c966a7ca 100644 --- a/bitchat/Sync/GossipSyncManager.swift +++ b/bitchat/Sync/GossipSyncManager.swift @@ -64,7 +64,10 @@ final class GossipSyncManager { var seenCapacity: Int = 1000 // max packets per sync (cap across types) var gcsMaxBytes: Int = 400 // filter size budget (128..1024) var gcsTargetFpr: Double = 0.01 // 1% - var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages + var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - fragments/files/announces + // Whole public messages stay sync-able much longer so devices carry + // the room's recent history between partitions and across restarts. + var publicMessageMaxAgeSeconds: TimeInterval = 900 var maintenanceIntervalSeconds: TimeInterval = 30.0 var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0 var stalePeerTimeoutSeconds: TimeInterval = 60.0 @@ -80,6 +83,7 @@ final class GossipSyncManager { private let myPeerID: PeerID private let config: Config private let requestSyncManager: RequestSyncManager + private let archive: GossipMessageArchive? weak var delegate: Delegate? // Storage: broadcast packets by type, and latest announce per sender @@ -87,6 +91,7 @@ final class GossipSyncManager { private var fragments = PacketStore() private var fileTransfers = PacketStore() private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:] + private var archiveDirty = false // Timer private var periodicTimer: DispatchSourceTimer? @@ -95,10 +100,11 @@ final class GossipSyncManager { private var syncSchedules: [SyncSchedule] = [] private var responseRateLimiter: SyncResponseRateLimiter - init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager) { + init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager, archive: GossipMessageArchive? = nil) { self.myPeerID = myPeerID self.config = config self.requestSyncManager = requestSyncManager + self.archive = archive self.responseRateLimiter = SyncResponseRateLimiter( maxResponses: config.responseRateLimitMaxResponses, window: config.responseRateLimitWindowSeconds @@ -114,6 +120,12 @@ final class GossipSyncManager { schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast)) } syncSchedules = schedules + + if archive != nil { + queue.async { [weak self] in + self?.restoreArchivedMessages() + } + } } func start() { @@ -153,10 +165,15 @@ final class GossipSyncManager { } } - // Helper to check if a packet is within the age threshold + // Helper to check if a packet is within the age threshold. Whole public + // messages get the long town-crier window; fragments, file transfers and + // announces keep the short one. private func isPacketFresh(_ packet: BitchatPacket) -> Bool { + let maxAgeSeconds = packet.type == MessageType.message.rawValue + ? config.publicMessageMaxAgeSeconds + : config.maxMessageAgeSeconds let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) - let ageThresholdMs = UInt64(config.maxMessageAgeSeconds * 1000) + let ageThresholdMs = UInt64(maxAgeSeconds * 1000) // If current time is less than threshold, accept all (handle clock issues gracefully) guard nowMs >= ageThresholdMs else { return true } @@ -197,6 +214,7 @@ final class GossipSyncManager { guard isPacketFresh(packet) else { return } let idHex = PacketIdUtil.computeId(packet).hexEncodedString() messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity)) + archiveDirty = true case .fragment: guard isBroadcastRecipient else { return } guard isPacketFresh(packet) else { return } @@ -417,14 +435,55 @@ final class GossipSyncManager { isPacketFresh(pair.packet) } + let messageCountBefore = messages.packets.count messages.removeExpired(isFresh: isPacketFresh) + if messages.packets.count != messageCountBefore { + archiveDirty = true + } fragments.removeExpired(isFresh: isPacketFresh) fileTransfers.removeExpired(isFresh: isPacketFresh) } + // MARK: - Archive (public message persistence) + + /// Rebuild the public message store from disk on launch, dropping + /// anything that aged out while the app was dead. + private func restoreArchivedMessages() { + guard let archive else { return } + var restored = 0 + for data in archive.load() { + guard let packet = BitchatPacket.from(data), + packet.type == MessageType.message.rawValue, + isPacketFresh(packet) else { continue } + let idHex = PacketIdUtil.computeId(packet).hexEncodedString() + messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity)) + restored += 1 + } + if restored > 0 { + SecureLogger.debug("Restored \(restored) archived public message(s) for gossip sync", category: .sync) + archiveDirty = true + } + } + + private func persistArchiveIfDirty() { + guard archiveDirty, let archive else { return } + archiveDirty = false + let packets = messages.allPackets(isFresh: isPacketFresh) + .compactMap { $0.toBinaryData(padding: false) } + archive.save(packets) + } + + /// Flush the archive outside the maintenance cadence (app backgrounding). + func persistNow() { + queue.async { [weak self] in + self?.persistArchiveIfDirty() + } + } + private func performPeriodicMaintenance(now: Date = Date()) { cleanupExpiredMessages() cleanupStaleAnnouncementsIfNeeded(now: now) + persistArchiveIfDirty() requestSyncManager.cleanup() // Cleanup expired sync requests responseRateLimiter.prune(now: now) @@ -471,7 +530,11 @@ final class GossipSyncManager { private func removeState(for peerID: PeerID) { _ = latestAnnouncementByPeer.removeValue(forKey: peerID) + let messageCountBefore = messages.packets.count messages.remove { PeerID(hexData: $0.senderID) == peerID } + if messages.packets.count != messageCountBefore { + archiveDirty = true + } fragments.remove { PeerID(hexData: $0.senderID) == peerID } fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID } } diff --git a/bitchat/ViewModels/ChatTransportEventCoordinator.swift b/bitchat/ViewModels/ChatTransportEventCoordinator.swift index 31c38f7b..75571b2e 100644 --- a/bitchat/ViewModels/ChatTransportEventCoordinator.swift +++ b/bitchat/ViewModels/ChatTransportEventCoordinator.swift @@ -57,6 +57,8 @@ protocol ChatTransportEventContext: AnyObject { // MARK: Routing & acknowledgements func flushRouterOutbox(for peerID: PeerID) + /// Offer queued mail for *other* peers to this newly connected courier. + func retryCourierDeposits(via peerID: PeerID) func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) // MARK: Delivery status @@ -103,6 +105,10 @@ extension ChatViewModel: ChatTransportEventContext { messageRouter.flushOutbox(for: peerID) } + func retryCourierDeposits(via peerID: PeerID) { + messageRouter.courierBecameAvailable(peerID) + } + func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) { meshService.sendDeliveryAck(for: messageID, to: peerID) } @@ -208,6 +214,7 @@ final class ChatTransportEventCoordinator { } context.flushRouterOutbox(for: peerID) + context.retryCourierDeposits(via: peerID) } } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 803fec7f..578b6509 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -764,15 +764,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele locationPresenceStore: LocationPresenceStore? = nil, locationManager: LocationChannelManager = .shared ) { + let meshService = BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager) + meshService.sfMetrics = .shared self.init( keychain: keychain, idBridge: idBridge, identityManager: identityManager, - transport: BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager), + transport: meshService, conversations: conversations, peerIdentityStore: peerIdentityStore ?? PeerIdentityStore(), locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(), - locationManager: locationManager + locationManager: locationManager, + outboxStore: MessageOutboxStore(keychain: keychain), + sfMetrics: .shared ) } @@ -788,7 +792,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele peerIdentityStore: PeerIdentityStore? = nil, locationPresenceStore: LocationPresenceStore? = nil, locationManager: LocationChannelManager = .shared, - readReceiptsDefaults: UserDefaults? = nil + readReceiptsDefaults: UserDefaults? = nil, + outboxStore: MessageOutboxStore? = nil, + sfMetrics: StoreAndForwardMetrics? = nil ) { let conversations = conversations ?? ConversationStore() let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore() @@ -797,7 +803,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele keychain: keychain, idBridge: idBridge, identityManager: identityManager, - meshService: transport + meshService: transport, + outboxStore: outboxStore, + sfMetrics: sfMetrics ) self.keychain = keychain @@ -1189,8 +1197,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // Clear persistent favorites from keychain FavoritesPersistenceService.shared.clearAllFavorites() - // Drop courier mail carried for third parties (memory and disk) + // Drop courier mail carried for third parties (memory and disk), + // our own queued outbox, the carried public history, and the + // counters describing all of it CourierStore.shared.wipe() + messageRouter.wipeOutbox() + GossipMessageArchive.wipeDefault() + StoreAndForwardMetrics.shared.reset() // Identity manager has cleared persisted identity data above diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index 8897d7c8..35bc0ef4 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -17,7 +17,9 @@ struct ChatViewModelServiceBundle { keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge, identityManager: SecureIdentityStateManagerProtocol, - meshService: Transport + meshService: Transport, + outboxStore: MessageOutboxStore? = nil, + sfMetrics: StoreAndForwardMetrics? = nil ) { let commandProcessor = CommandProcessor(identityManager: identityManager) let privateChatManager = PrivateChatManager(meshService: meshService) @@ -28,7 +30,11 @@ struct ChatViewModelServiceBundle { ) let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge) nostrTransport.senderPeerID = meshService.myPeerID - let messageRouter = MessageRouter(transports: [meshService, nostrTransport]) + let messageRouter = MessageRouter( + transports: [meshService, nostrTransport], + outboxStore: outboxStore, + metrics: sfMetrics + ) self.commandProcessor = commandProcessor self.messageRouter = messageRouter diff --git a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift index 8ffd2933..1c1fc46f 100644 --- a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift +++ b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift @@ -121,9 +121,11 @@ private final class MockChatTransportEventContext: ChatTransportEventContext { // Routing & acknowledgements private(set) var flushedOutboxPeerIDs: [PeerID] = [] + private(set) var courierRetryPeerIDs: [PeerID] = [] private(set) var meshDeliveryAcks: [(messageID: String, peerID: PeerID)] = [] func flushRouterOutbox(for peerID: PeerID) { flushedOutboxPeerIDs.append(peerID) } + func retryCourierDeposits(via peerID: PeerID) { courierRetryPeerIDs.append(peerID) } func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) { meshDeliveryAcks.append((messageID, peerID)) } diff --git a/bitchatTests/CourierStoreTests.swift b/bitchatTests/CourierStoreTests.swift index 9e675649..23829b0e 100644 --- a/bitchatTests/CourierStoreTests.swift +++ b/bitchatTests/CourierStoreTests.swift @@ -103,7 +103,7 @@ struct CourierStoreTests { @Test func perDepositorQuota() { let store = makeStore() - for _ in 0.. give 1, keep 1). + let courierY = Data(repeating: 0xC2, count: 32) + let sprayedToY = store.takeSprayCopies(for: courierY) + #expect(sprayedToY.count == 1) + #expect(sprayedToY.first?.copies == 1) + + // Budget exhausted (carry-only): nothing left to spray. + #expect(store.takeSprayCopies(for: Data(repeating: 0xC3, count: 32)).isEmpty) + // The carried original is still deliverable. + #expect(store.takeEnvelopes(for: recipientKey).count == 1) + } + + @Test func carryOnlyEnvelopesAreNeverSprayed() { + let store = makeStore() + #expect(store.deposit(makeEnvelope(), from: depositorA)) + #expect(store.takeSprayCopies(for: Data(repeating: 0xC1, count: 32)).isEmpty) + } + + @Test func duplicateDepositKeepsLargerSprayBudget() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let ciphertext = Data(repeating: 0x42, count: 96) + let carryOnly = makeEnvelope(recipientKey: recipientKey, ciphertext: ciphertext) + #expect(store.deposit(carryOnly, from: depositorA)) + #expect(store.deposit(carryOnly.withCopies(4), from: depositorB)) + + let sprayed = store.takeSprayCopies(for: Data(repeating: 0xC1, count: 32)) + #expect(sprayed.first?.copies == 2) + } + + // MARK: - Remote handover (relayed announces) + + @Test func remoteHandoverIsNonDestructiveAndCooledDown() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let envelope = makeEnvelope(recipientKey: recipientKey).withCopies(4) + #expect(store.deposit(envelope, from: depositorA)) + + let first = store.envelopesForRemoteHandover(recipientNoiseKey: recipientKey, cooldown: 600) + #expect(first.count == 1) + // The flooded copy carries no spray budget. + #expect(first.first?.copies == 1) + // Non-destructive: the envelope is still carried... + #expect(!store.isEmpty) + // ...and inside the cooldown it is not re-flooded. + #expect(store.envelopesForRemoteHandover(recipientNoiseKey: recipientKey, cooldown: 600).isEmpty) + // A direct encounter still hands it over destructively. + #expect(store.takeEnvelopes(for: recipientKey).count == 1) + #expect(store.isEmpty) + } + + // MARK: - Legacy persistence + + @Test func legacyPersistedFileLoadsAsFavoriteCarryOnly() throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("courier-legacy-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: fileURL) } + + // Envelope persisted by a pre-tier/pre-spray build: no tier, copies, + // or spray bookkeeping fields. + let recipientKey = Data(repeating: 0xB0, count: 32) + let envelope = makeEnvelope(recipientKey: recipientKey) + let legacy: [[String: Any]] = [[ + "recipientTag": envelope.recipientTag.base64EncodedString(), + "expiry": envelope.expiry, + "ciphertext": envelope.ciphertext.base64EncodedString(), + "depositorNoiseKey": depositorA.base64EncodedString(), + "storedAt": Self.baseDate.timeIntervalSinceReferenceDate + ]] + let data = try JSONSerialization.data(withJSONObject: legacy) + try data.write(to: fileURL) + + let store = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate }) + // Carry-only, so never sprayed... + #expect(store.takeSprayCopies(for: Data(repeating: 0xC1, count: 32)).isEmpty) + // ...but still delivered on encounter. + #expect(store.takeEnvelopes(for: recipientKey).count == 1) + } } diff --git a/bitchatTests/EndToEnd/CourierEndToEndTests.swift b/bitchatTests/EndToEnd/CourierEndToEndTests.swift index a8cc3079..22e56c79 100644 --- a/bitchatTests/EndToEnd/CourierEndToEndTests.swift +++ b/bitchatTests/EndToEnd/CourierEndToEndTests.swift @@ -104,7 +104,7 @@ struct CourierEndToEndTests { let bob = makeService() // Alice and Carol are mutual favorites; trust policy is exercised // separately in depositFromUntrustedPeerIsRejected. - carol.courierDepositPolicy = { _ in true } + carol.courierDepositPolicy = { _, _ in .favorite } let bobDelegate = NoiseCaptureDelegate() bob.delegate = bobDelegate @@ -134,7 +134,7 @@ struct CourierEndToEndTests { let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) // 2. Ferry the deposit to Carol; she carries it (opaque to her). - carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID) + carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData()) let carried = await TestHelpers.waitUntil( { !carol.courierStore.isEmpty }, timeout: TestConstants.defaultTimeout @@ -186,7 +186,7 @@ struct CourierEndToEndTests { let carol = makeService() let bobIdentity = MockIdentityManager(MockKeychain()) let bob = makeService(identityManager: bobIdentity) - carol.courierDepositPolicy = { _ in true } + carol.courierDepositPolicy = { _, _ in .favorite } let bobDelegate = NoiseCaptureDelegate() bob.delegate = bobDelegate @@ -215,7 +215,7 @@ struct CourierEndToEndTests { #expect(deposited) let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) - carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID) + carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData()) let carried = await TestHelpers.waitUntil( { !carol.courierStore.isEmpty }, timeout: TestConstants.defaultTimeout @@ -254,7 +254,7 @@ struct CourierEndToEndTests { let alice = makeService() let carol = makeService() let bob = makeService() - carol.courierDepositPolicy = { _ in true } + carol.courierDepositPolicy = { _, _ in .favorite } let aliceOut = PacketTap() alice._test_onOutboundPacket = aliceOut.record @@ -278,7 +278,7 @@ struct CourierEndToEndTests { #expect(deposited) let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) - carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID) + carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData()) let carried = await TestHelpers.waitUntil( { !carol.courierStore.isEmpty }, timeout: TestConstants.defaultTimeout @@ -312,11 +312,11 @@ struct CourierEndToEndTests { #expect(carol.courierStore.isEmpty) } - @Test func relayedAnnounceDoesNotTriggerCourierHandover() async throws { + @Test func relayedAnnounceTriggersNonDestructiveRemoteHandover() async throws { let alice = makeService() let carol = makeService() let bob = makeService() - carol.courierDepositPolicy = { _ in true } + carol.courierDepositPolicy = { _, _ in .favorite } let aliceOut = PacketTap() alice._test_onOutboundPacket = aliceOut.record @@ -340,7 +340,7 @@ struct CourierEndToEndTests { #expect(deposited) let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) - carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID) + carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData()) let carried = await TestHelpers.waitUntil( { !carol.courierStore.isEmpty }, timeout: TestConstants.defaultTimeout @@ -356,23 +356,24 @@ struct CourierEndToEndTests { let directAnnounce = try #require(bobOut.first(ofType: .announce)) // A relayed copy has a decremented TTL but a still-valid signature - // (TTL is excluded from announce signatures). Envelopes are removed - // from the store optimistically, so handover must wait for a direct - // encounter instead of chasing a multi-hop path. + // (TTL is excluded from announce signatures). The recipient is + // multi-hop away, so a copy floods toward them speculatively while + // the carried original stays put for a future direct encounter. var relayedAnnounce = directAnnounce relayedAnnounce.ttl = directAnnounce.ttl - 1 carol._test_handlePacket(relayedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false) - let leakedOnRelayedAnnounce = await TestHelpers.waitUntil( - { carolOut.count(ofType: .courierEnvelope) > 0 }, - timeout: TestConstants.shortTimeout + let remoteHandover = await TestHelpers.waitUntil( + { carolOut.count(ofType: .courierEnvelope) == 1 }, + timeout: TestConstants.defaultTimeout ) - #expect(!leakedOnRelayedAnnounce) + #expect(remoteHandover) #expect(!carol.courierStore.isEmpty) - // The relayed copy consumed the original announce's dedup key - // (sender/timestamp/payload — TTL excluded), so the direct handover - // needs a fresh announce. Wait out the 1s announce throttle first. + // A second relayed announce inside the cooldown must not re-flood + // the same envelope. The original announce's dedup key is consumed + // (sender/timestamp/payload — TTL excluded), so use a fresh announce; + // wait out the 1s announce throttle first. try await Task.sleep(nanoseconds: 1_100_000_000) bob.sendBroadcastAnnounce() let reannounced = await TestHelpers.waitUntil( @@ -383,10 +384,32 @@ struct CourierEndToEndTests { let freshAnnounce = try #require( bobOut.all(ofType: .announce).first { $0.timestamp != directAnnounce.timestamp } ) - carol._test_handlePacket(freshAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false) + var relayedFreshAnnounce = freshAnnounce + relayedFreshAnnounce.ttl = freshAnnounce.ttl - 1 + carol._test_handlePacket(relayedFreshAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false) + + let refloodedInCooldown = await TestHelpers.waitUntil( + { carolOut.count(ofType: .courierEnvelope) > 1 }, + timeout: TestConstants.shortTimeout + ) + #expect(!refloodedInCooldown) + #expect(!carol.courierStore.isEmpty) + + // A later *direct* announce still performs the destructive handover. + try await Task.sleep(nanoseconds: 1_100_000_000) + bob.sendBroadcastAnnounce() + let announcedAgain = await TestHelpers.waitUntil( + { bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp && $0.timestamp != freshAnnounce.timestamp } }, + timeout: TestConstants.defaultTimeout + ) + #expect(announcedAgain) + let directAgain = try #require( + bobOut.all(ofType: .announce).first { $0.timestamp != directAnnounce.timestamp && $0.timestamp != freshAnnounce.timestamp } + ) + carol._test_handlePacket(directAgain, fromPeerID: bob.myPeerID, preseedPeer: false) let handedOver = await TestHelpers.waitUntil( - { carolOut.count(ofType: .courierEnvelope) == 1 }, + { carolOut.count(ofType: .courierEnvelope) == 2 }, timeout: TestConstants.defaultTimeout ) #expect(handedOver) @@ -417,7 +440,7 @@ struct CourierEndToEndTests { @Test func depositFromUntrustedPeerIsRejected() async throws { let carol = makeService() - carol.courierDepositPolicy = { _ in false } // depositor is not a mutual favorite + carol.courierDepositPolicy = { _, _ in nil } // depositor is neither favorite nor verified let alice = NoiseEncryptionService(keychain: MockKeychain()) let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData() @@ -433,6 +456,45 @@ struct CourierEndToEndTests { ciphertext: sealed ) let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let unsigned = BitchatPacket( + type: MessageType.courierEnvelope.rawValue, + senderID: Data(hexString: alicePeerID.id) ?? Data(), + recipientID: Data(hexString: carol.myPeerID.id), + timestamp: UInt64(now.timeIntervalSince1970 * 1000), + payload: try #require(envelope.encode()), + signature: nil, + ttl: 1 + ) + let packet = try #require(alice.signPacket(unsigned)) + + carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData()) + let stored = await TestHelpers.waitUntil( + { !carol.courierStore.isEmpty }, + timeout: TestConstants.shortTimeout + ) + #expect(!stored) + } + + @Test func unsignedDepositIsRejected() async throws { + let carol = makeService() + carol.courierDepositPolicy = { _, _ in .favorite } + + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData() + let typedPayload = try #require(BLENoisePayloadFactory.privateMessage(content: "x", messageID: "m-unsigned")) + let sealed = try alice.sealCourierPayload(typedPayload, recipientStaticKey: bobKey) + let now = Date() + let envelope = CourierEnvelope( + recipientTag: CourierEnvelope.recipientTag( + noiseStaticKey: bobKey, + epochDay: CourierEnvelope.epochDay(for: now) + ), + expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000), + ciphertext: sealed + ) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + // Correct sender, willing policy — but no packet signature: the + // courier cannot authenticate the depositor, so it must not carry. let packet = BitchatPacket( type: MessageType.courierEnvelope.rawValue, senderID: Data(hexString: alicePeerID.id) ?? Data(), @@ -443,7 +505,7 @@ struct CourierEndToEndTests { ttl: 1 ) - carol._test_handlePacket(packet, fromPeerID: alicePeerID) + carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData()) let stored = await TestHelpers.waitUntil( { !carol.courierStore.isEmpty }, timeout: TestConstants.shortTimeout @@ -459,8 +521,8 @@ struct CourierEndToEndTests { preseedConnectedPeer(mallory, in: carol) let trustedAliceKey = Data(hexString: alice.myPeerID.id) ?? Data() - carol.courierDepositPolicy = { depositorKey in - depositorKey == trustedAliceKey + carol.courierDepositPolicy = { depositorKey, _ in + depositorKey == trustedAliceKey ? .favorite : nil } let aliceNoise = NoiseEncryptionService(keychain: MockKeychain()) diff --git a/bitchatTests/GossipSyncManagerTests.swift b/bitchatTests/GossipSyncManagerTests.swift index b717e2f0..c44de8f6 100644 --- a/bitchatTests/GossipSyncManagerTests.swift +++ b/bitchatTests/GossipSyncManagerTests.swift @@ -468,6 +468,79 @@ struct GossipSyncManagerTests { #expect(sentPackets.count == 1) #expect(sentPackets[0].type == MessageType.fragment.rawValue) } + + // MARK: - Archive persistence + + @Test func publicMessagesRestoreFromArchiveAcrossRestart() async throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("gossip-archive-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let senderID = try #require(Data(hexString: "1122334455667788")) + let packet = BitchatPacket( + type: MessageType.message.rawValue, + senderID: senderID, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data([0x01, 0x02]), + signature: nil, + ttl: 1 + ) + + let first = GossipSyncManager( + myPeerID: myPeerID, + requestSyncManager: RequestSyncManager(), + archive: GossipMessageArchive(fileURL: fileURL) + ) + first.onPublicPacketSeen(packet) + // Maintenance persists the dirty store to disk. + first._performMaintenanceSynchronously(now: Date()) + #expect(FileManager.default.fileExists(atPath: fileURL.path)) + + // "App restart": a fresh manager over the same archive re-serves it. + let second = GossipSyncManager( + myPeerID: myPeerID, + requestSyncManager: RequestSyncManager(), + archive: GossipMessageArchive(fileURL: fileURL) + ) + let restored = await TestHelpers.waitUntil( + { second._messageCount(for: PeerID(hexData: senderID)) == 1 }, + timeout: TestConstants.shortTimeout + ) + #expect(restored) + } + + @Test func archiveDropsMessagesOlderThanPublicWindow() throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("gossip-archive-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: fileURL) } + + var config = GossipSyncManager.Config() + config.publicMessageMaxAgeSeconds = 60 + + let senderID = try #require(Data(hexString: "1122334455667788")) + let stale = BitchatPacket( + type: MessageType.message.rawValue, + senderID: senderID, + recipientID: nil, + timestamp: UInt64((Date().timeIntervalSince1970 - 120) * 1000), + payload: Data([0x01]), + signature: nil, + ttl: 1 + ) + let archive = GossipMessageArchive(fileURL: fileURL) + archive.save([stale.toBinaryData(padding: false)!]) + + let manager = GossipSyncManager( + myPeerID: myPeerID, + config: config, + requestSyncManager: RequestSyncManager(), + archive: archive + ) + manager._performMaintenanceSynchronously(now: Date()) + #expect(manager._messageCount(for: PeerID(hexData: senderID)) == 0) + } + } private final class RecordingDelegate: GossipSyncManager.Delegate { diff --git a/bitchatTests/Mocks/MockTransport.swift b/bitchatTests/Mocks/MockTransport.swift index 0cdbe7a0..e4cf08de 100644 --- a/bitchatTests/Mocks/MockTransport.swift +++ b/bitchatTests/Mocks/MockTransport.swift @@ -42,6 +42,7 @@ final class MockTransport: Transport { private(set) var cancelledTransfers: [String] = [] private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] + private(set) var sentCourierMessages: [(content: String, messageID: String, recipientNoiseKey: Data, couriers: [PeerID])] = [] private(set) var startServicesCallCount = 0 private(set) var stopServicesCallCount = 0 private(set) var emergencyDisconnectCallCount = 0 @@ -189,6 +190,12 @@ final class MockTransport: Transport { sentVerifyResponses.append((peerID, noiseKeyHex, nonceA)) } + var courierSendResult = true + func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { + sentCourierMessages.append((content, messageID, recipientNoiseKey, couriers)) + return courierSendResult + } + // MARK: - Test Helpers /// Clears all recorded method calls for fresh assertions diff --git a/bitchatTests/Services/BLEPublicMessageHandlerTests.swift b/bitchatTests/Services/BLEPublicMessageHandlerTests.swift index 056d3ca9..74a4441d 100644 --- a/bitchatTests/Services/BLEPublicMessageHandlerTests.swift +++ b/bitchatTests/Services/BLEPublicMessageHandlerTests.swift @@ -100,11 +100,11 @@ struct BLEPublicMessageHandlerTests { @Test func staleBroadcastIsDropped() { - let now = Date(timeIntervalSince1970: 1_000) + let now = Date(timeIntervalSince1970: 1_000_000) let recorder = Recorder() recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] let handler = makeHandler(recorder: recorder, now: now) - let staleTimestamp = UInt64((now.timeIntervalSince1970 - 901) * 1000) + let staleTimestamp = UInt64((now.timeIntervalSince1970 - TransportConfig.syncPublicMessageMaxAgeSeconds - 1) * 1000) let packet = makeMessagePacket(sender: remotePeerID, content: "old", timestamp: staleTimestamp) handler.handle(packet, from: remotePeerID) diff --git a/bitchatTests/Services/BLEPublicMessagePolicyTests.swift b/bitchatTests/Services/BLEPublicMessagePolicyTests.swift index fc725194..dc6d7271 100644 --- a/bitchatTests/Services/BLEPublicMessagePolicyTests.swift +++ b/bitchatTests/Services/BLEPublicMessagePolicyTests.swift @@ -36,11 +36,13 @@ struct BLEPublicMessagePolicyTests { @Test func staleBroadcastIsRejectedWithAge() { - let now = Date(timeIntervalSince1970: 1_000) + // The acceptance window matches the gossip public-history window. + let staleAge = TransportConfig.syncPublicMessageMaxAgeSeconds + 1 + let now = Date(timeIntervalSince1970: 1_000_000) let sender = PeerID(str: "8877665544332211") let packet = makePacket( sender: sender, - timestamp: UInt64((now.timeIntervalSince1970 - 901) * 1000), + timestamp: UInt64((now.timeIntervalSince1970 - staleAge) * 1000), recipientID: nil ) @@ -51,7 +53,7 @@ struct BLEPublicMessagePolicyTests { now: now ) - #expect(decision == .reject(.staleBroadcast(ageSeconds: 901))) + #expect(decision == .reject(.staleBroadcast(ageSeconds: staleAge))) } @Test diff --git a/bitchatTests/Services/MessageOutboxStoreTests.swift b/bitchatTests/Services/MessageOutboxStoreTests.swift new file mode 100644 index 00000000..218f6cf0 --- /dev/null +++ b/bitchatTests/Services/MessageOutboxStoreTests.swift @@ -0,0 +1,92 @@ +// +// MessageOutboxStoreTests.swift +// bitchatTests +// +// Tests for the encrypted-at-rest outbox persistence. +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +struct MessageOutboxStoreTests { + + private func makeTempURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("outbox-\(UUID().uuidString).sealed") + } + + private func makeMessage(_ id: String, content: String = "hello") -> MessageOutboxStore.QueuedMessage { + MessageOutboxStore.QueuedMessage( + content: content, + nickname: "peer", + messageID: id, + timestamp: Date(timeIntervalSince1970: 1_750_000_000), + sendAttempts: 2, + depositedCourierKeys: [Data(repeating: 0xC1, count: 32)] + ) + } + + @Test func roundTripAcrossInstances() { + let fileURL = makeTempURL() + defer { try? FileManager.default.removeItem(at: fileURL) } + let keychain = MockKeychain() + let peerID = PeerID(str: "0000000000000001") + + let store = MessageOutboxStore(keychain: keychain, fileURL: fileURL) + store.save([peerID: [makeMessage("m1")]]) + + // Same keychain (encryption key) reads it back, fields intact. + let reloaded = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load() + #expect(reloaded[peerID]?.count == 1) + #expect(reloaded[peerID]?.first?.messageID == "m1") + #expect(reloaded[peerID]?.first?.sendAttempts == 2) + #expect(reloaded[peerID]?.first?.depositedCourierKeys.count == 1) + } + + @Test func contentIsNotPlaintextOnDisk() throws { + let fileURL = makeTempURL() + defer { try? FileManager.default.removeItem(at: fileURL) } + let store = MessageOutboxStore(keychain: MockKeychain(), fileURL: fileURL) + store.save([PeerID(str: "0000000000000001"): [makeMessage("m1", content: "very secret message")]]) + + let raw = try Data(contentsOf: fileURL) + #expect(!raw.isEmpty) + // Sealed bytes must not contain the message plaintext. + #expect(raw.range(of: Data("very secret message".utf8)) == nil) + } + + @Test func loadWithoutKeyReturnsEmpty() { + let fileURL = makeTempURL() + defer { try? FileManager.default.removeItem(at: fileURL) } + let store = MessageOutboxStore(keychain: MockKeychain(), fileURL: fileURL) + store.save([PeerID(str: "0000000000000001"): [makeMessage("m1")]]) + + // A different keychain (fresh device / wiped key) cannot read the file. + let other = MessageOutboxStore(keychain: MockKeychain(), fileURL: fileURL) + #expect(other.load().isEmpty) + } + + @Test func wipeRemovesFileAndKey() { + let fileURL = makeTempURL() + let keychain = MockKeychain() + let store = MessageOutboxStore(keychain: keychain, fileURL: fileURL) + store.save([PeerID(str: "0000000000000001"): [makeMessage("m1")]]) + #expect(FileManager.default.fileExists(atPath: fileURL.path)) + + store.wipe() + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + #expect(store.load().isEmpty) + } + + @Test func savingEmptyOutboxRemovesFile() { + let fileURL = makeTempURL() + let keychain = MockKeychain() + let store = MessageOutboxStore(keychain: keychain, fileURL: fileURL) + let peerID = PeerID(str: "0000000000000001") + store.save([peerID: [makeMessage("m1")]]) + store.save([peerID: []]) + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + } +} diff --git a/bitchatTests/Services/MessageRouterTests.swift b/bitchatTests/Services/MessageRouterTests.swift index 13f020a9..78a9caec 100644 --- a/bitchatTests/Services/MessageRouterTests.swift +++ b/bitchatTests/Services/MessageRouterTests.swift @@ -226,6 +226,197 @@ struct MessageRouterTests { #expect(transport.sentFavoriteNotifications.count == 1) } + + // MARK: - Courier deposits + + private static func snapshot(_ peerID: PeerID, key: Data, verified: Bool) -> TransportPeerSnapshot { + TransportPeerSnapshot( + peerID: peerID, + nickname: "peer", + isConnected: true, + noisePublicKey: key, + lastSeen: Date(), + isVerified: verified + ) + } + + /// Directory that resolves one offline recipient and treats a fixed key + /// set as mutual favorites. + private static func directory(recipient: PeerID, recipientKey: Data, favoriteKeys: Set = []) -> CourierDirectory { + CourierDirectory( + noiseKey: { peerID in peerID == recipient ? recipientKey : nil }, + isTrustedCourier: { favoriteKeys.contains($0) } + ) + } + + @Test @MainActor + func sendPrivate_depositsWithVerifiedStrangerWhenNoFavoriteAround() async { + let recipient = PeerID(str: "00000000000000aa") + let recipientKey = Data(repeating: 0xBB, count: 32) + let courier = PeerID(str: "00000000000000cc") + let courierKey = Data(repeating: 0xCC, count: 32) + + let transport = MockTransport() + transport.connectedPeers.insert(courier) + transport.updatePeerSnapshots([Self.snapshot(courier, key: courierKey, verified: true)]) + + let router = MessageRouter( + transports: [transport], + courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey) + ) + router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cv1") + + #expect(transport.sentCourierMessages.count == 1) + #expect(transport.sentCourierMessages.first?.couriers == [courier]) + } + + @Test @MainActor + func sendPrivate_neverDepositsWithUnverifiedStranger() async { + let recipient = PeerID(str: "00000000000000aa") + let courier = PeerID(str: "00000000000000cc") + + let transport = MockTransport() + transport.connectedPeers.insert(courier) + transport.updatePeerSnapshots([Self.snapshot(courier, key: Data(repeating: 0xCC, count: 32), verified: false)]) + + let router = MessageRouter( + transports: [transport], + courierDirectory: Self.directory(recipient: recipient, recipientKey: Data(repeating: 0xBB, count: 32)) + ) + router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cv2") + + #expect(transport.sentCourierMessages.isEmpty) + } + + @Test @MainActor + func sendPrivate_prefersFavoriteCouriersOverVerifiedOnes() async { + let recipient = PeerID(str: "00000000000000aa") + let recipientKey = Data(repeating: 0xBB, count: 32) + let favorite = PeerID(str: "00000000000000f0") + let favoriteKey = Data(repeating: 0xF0, count: 32) + var snapshots = [Self.snapshot(favorite, key: favoriteKey, verified: false)] + let transport = MockTransport() + transport.connectedPeers.insert(favorite) + // Three verified strangers compete for the three courier slots. + for byte: UInt8 in [0xC1, 0xC2, 0xC3] { + let peer = PeerID(str: String(format: "00000000000000%02x", byte)) + transport.connectedPeers.insert(peer) + snapshots.append(Self.snapshot(peer, key: Data(repeating: byte, count: 32), verified: true)) + } + transport.updatePeerSnapshots(snapshots) + + let router = MessageRouter( + transports: [transport], + courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey, favoriteKeys: [favoriteKey]) + ) + router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cv3") + + let couriers = transport.sentCourierMessages.first?.couriers ?? [] + #expect(couriers.count == 3) + #expect(couriers.contains(favorite)) + } + + @Test @MainActor + func courierBecameAvailable_retriesDepositOnceWithoutDoubleBurn() async { + let recipient = PeerID(str: "00000000000000aa") + let recipientKey = Data(repeating: 0xBB, count: 32) + let courier = PeerID(str: "00000000000000cc") + let courierKey = Data(repeating: 0xCC, count: 32) + + let transport = MockTransport() + let router = MessageRouter( + transports: [transport], + courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey) + ) + // Nobody around at send time: the message just queues. + router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cr1") + #expect(transport.sentCourierMessages.isEmpty) + + // A verified courier appears later: the deposit retries. + transport.connectedPeers.insert(courier) + transport.updatePeerSnapshots([Self.snapshot(courier, key: courierKey, verified: true)]) + router.courierBecameAvailable(courier) + #expect(transport.sentCourierMessages.count == 1) + #expect(transport.sentCourierMessages.first?.couriers == [courier]) + + // The same courier reconnecting does not receive the same mail twice. + router.courierBecameAvailable(courier) + #expect(transport.sentCourierMessages.count == 1) + } + + @Test @MainActor + func courierBecameAvailable_ignoresTheRecipientThemselves() async { + let recipient = PeerID(str: "00000000000000aa") + let recipientKey = Data(repeating: 0xBB, count: 32) + + let transport = MockTransport() + let router = MessageRouter( + transports: [transport], + courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey) + ) + router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cr2") + + // The recipient connecting is a flush, not a courier opportunity. + transport.connectedPeers.insert(recipient) + transport.updatePeerSnapshots([Self.snapshot(recipient, key: recipientKey, verified: true)]) + router.courierBecameAvailable(recipient) + #expect(transport.sentCourierMessages.isEmpty) + } + + // MARK: - Outbox persistence + + @Test @MainActor + func queuedMessagesSurviveRouterRestart() async { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("router-outbox-\(UUID().uuidString).sealed") + defer { try? FileManager.default.removeItem(at: fileURL) } + let keychain = MockKeychain() + let peerID = PeerID(str: "00000000000000dd") + + let transport = MockTransport() + let router = MessageRouter( + transports: [transport], + outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL) + ) + router.sendPrivate("Survive", to: peerID, recipientNickname: "Peer", messageID: "p1") + #expect(transport.sentPrivateMessages.isEmpty) + + // "App restart": a fresh router over the same store, peer now around. + let transport2 = MockTransport() + transport2.reachablePeers.insert(peerID) + let router2 = MessageRouter( + transports: [transport2], + outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL) + ) + router2.flushOutbox(for: peerID) + #expect(transport2.sentPrivateMessages.map(\.messageID) == ["p1"]) + } + + @Test @MainActor + func deliveredMessagesDoNotResurrectAfterRestart() async { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("router-outbox-\(UUID().uuidString).sealed") + defer { try? FileManager.default.removeItem(at: fileURL) } + let keychain = MockKeychain() + let peerID = PeerID(str: "00000000000000de") + + let transport = MockTransport() + let router = MessageRouter( + transports: [transport], + outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL) + ) + router.sendPrivate("Once", to: peerID, recipientNickname: "Peer", messageID: "p2") + router.markDelivered("p2") + + let transport2 = MockTransport() + transport2.reachablePeers.insert(peerID) + let router2 = MessageRouter( + transports: [transport2], + outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL) + ) + router2.flushOutbox(for: peerID) + #expect(transport2.sentPrivateMessages.isEmpty) + } } /// Mutable wall clock injected into `MessageRouter` so TTL expiry is testable diff --git a/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift b/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift index 4bb99de3..4dea3873 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift @@ -24,23 +24,37 @@ public struct CourierEnvelope: Equatable { public let expiry: UInt64 /// Opaque one-way Noise X ciphertext (sender identity rides inside). public let ciphertext: Data + /// Spray-and-wait copy budget: how many redundant copies of this envelope + /// the holder may still hand to other couriers (binary split on each + /// spray). 1 means carry-only — deliver to the recipient, never re-spray. + public let copies: UInt8 public static let tagLength = 16 /// Couriered messages are text-sized; media transfers are out of scope. public static let maxCiphertextBytes = 16 * 1024 /// Matches the outbox retention policy in MessageRouter. public static let maxLifetimeSeconds: TimeInterval = 24 * 60 * 60 + /// Cap on the copy budget a depositor can claim, so a malicious envelope + /// cannot turn the courier network into an amplifier. + public static let maxCopies: UInt8 = 8 private enum TLVType: UInt8 { case recipientTag = 0x01 case expiry = 0x02 case ciphertext = 0x03 + case copies = 0x04 } - public init(recipientTag: Data, expiry: UInt64, ciphertext: Data) { + public init(recipientTag: Data, expiry: UInt64, ciphertext: Data, copies: UInt8 = 1) { self.recipientTag = recipientTag self.expiry = expiry self.ciphertext = ciphertext + self.copies = min(max(copies, 1), Self.maxCopies) + } + + /// The same envelope with a different remaining copy budget. + public func withCopies(_ copies: UInt8) -> CourierEnvelope { + CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies) } public var isExpired: Bool { @@ -75,6 +89,14 @@ public struct CourierEnvelope: Equatable { appendBE(UInt16(ciphertext.count), into: &encoded) encoded.append(ciphertext) + // Omitted when 1 so carry-only envelopes stay byte-identical to the + // pre-spray wire format (old clients skip the TLV as unknown anyway). + if copies > 1 { + encoded.append(TLVType.copies.rawValue) + appendBE(UInt16(1), into: &encoded) + encoded.append(copies) + } + return encoded } @@ -85,6 +107,7 @@ public struct CourierEnvelope: Equatable { var recipientTag: Data? var expiry: UInt64? var ciphertext: Data? + var copies: UInt8 = 1 while cursor < end { let typeRaw = data[cursor] @@ -107,6 +130,9 @@ public struct CourierEnvelope: Equatable { case .ciphertext: guard length > 0, length <= maxCiphertextBytes else { return nil } ciphertext = Data(value) + case .copies: + guard length == 1 else { return nil } + copies = value.first ?? 1 case nil: // Unknown TLV: skip for forward compatibility. continue @@ -114,7 +140,7 @@ public struct CourierEnvelope: Equatable { } guard let recipientTag, let expiry, let ciphertext else { return nil } - return CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext) + return CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies) } // MARK: - Recipient Tags diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/CourierEnvelopeTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/CourierEnvelopeTests.swift index a64551ab..9631ee81 100644 --- a/localPackages/BitFoundation/Tests/BitFoundationTests/CourierEnvelopeTests.swift +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/CourierEnvelopeTests.swift @@ -20,6 +20,38 @@ struct CourierEnvelopeTests { CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext) } + // MARK: - Spray copies + + @Test func copiesRoundTrip() throws { + let envelope = makeEnvelope().withCopies(4) + let encoded = try #require(envelope.encode()) + let decoded = try #require(CourierEnvelope.decode(encoded)) + #expect(decoded.copies == 4) + #expect(decoded == envelope) + } + + @Test func carryOnlyEnvelopeEncodesIdenticallyToLegacyFormat() throws { + // copies == 1 must be byte-identical to the pre-spray wire format so + // old and new clients dedup the same envelope the same way. + let envelope = makeEnvelope() + #expect(envelope.copies == 1) + let encoded = try #require(envelope.encode()) + let withExplicitOne = try #require(envelope.withCopies(1).encode()) + #expect(encoded == withExplicitOne) + #expect(!encoded.contains(0x04) || CourierEnvelope.decode(encoded)?.copies == 1) + } + + @Test func decodeWithoutCopiesTLVDefaultsToCarryOnly() throws { + let encoded = try #require(makeEnvelope().encode()) + let decoded = try #require(CourierEnvelope.decode(encoded)) + #expect(decoded.copies == 1) + } + + @Test func copiesAreClampedToPolicyBounds() { + #expect(makeEnvelope().withCopies(0).copies == 1) + #expect(makeEnvelope().withCopies(200).copies == CourierEnvelope.maxCopies) + } + // MARK: - Codec @Test func roundTrip() throws { From c2a56685690efc63b589e497e2adca6a1e251782 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:04:14 +0200 Subject: [PATCH 03/18] Sync cleanups: normalize SyncTypeFlags, single announce-ID path, TODO (#1373) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups deferred from the REQUEST_SYNC review (#1371): - SyncTypeFlags.init(rawValue:) now masks to the union of bits that map to a known message type (derived from the bit↔type table, so it tracks new types automatically). Phantom bits from a truncated/garbled flags field — or a type a newer peer added — no longer live in the set as membership no contains() matches yet toData() re-serializes. - GossipSyncManager stored each latest announce as (hex-id string, packet) and diffed announces against the stored string while every other type recomputed the ID via PacketIdUtil. Collapsed the store to just the packet and recompute the ID everywhere, removing the latent dual-path divergence. - Documented the REQUEST_SYNC TLV table and marked fragmentIdFilter (0x06) with a TODO(v2): it's parsed/re-serialized but never populated or honored (reserved for incremental fragment sync) — finish or drop, not silent dead surface. Adds SyncTypeFlags phantom-bit/round-trip tests and a GossipSyncManager test that an announce already in the requester's filter is suppressed (guards the recompute path). Full suite: 1034 tests pass. Co-authored-by: jack Co-authored-by: Claude Opus 4.8 --- bitchat/Models/RequestSyncPacket.swift | 9 +++++ bitchat/Sync/GossipSyncManager.swift | 22 +++++------ bitchat/Sync/SyncTypeFlags.swift | 18 ++++++++- bitchatTests/GossipSyncManagerTests.swift | 38 +++++++++++++++++++ bitchatTests/Sync/SyncTypeFlagsTests.swift | 43 ++++++++++++++++++++++ 5 files changed, 117 insertions(+), 13 deletions(-) create mode 100644 bitchatTests/Sync/SyncTypeFlagsTests.swift diff --git a/bitchat/Models/RequestSyncPacket.swift b/bitchat/Models/RequestSyncPacket.swift index 0db8e7aa..b34dd925 100644 --- a/bitchat/Models/RequestSyncPacket.swift +++ b/bitchat/Models/RequestSyncPacket.swift @@ -4,6 +4,15 @@ import Foundation // - 0x01: P (uint8) — Golomb-Rice parameter // - 0x02: M (uint32, big-endian) — hash range (N * 2^P) // - 0x03: data (opaque) — GR bitstream bytes (MSB-first) +// - 0x04: types (bitfield) — SyncTypeFlags of covered message types +// - 0x05: sinceTimestamp (uint64, big-endian) — oldest ts the filter covers +// - 0x06: fragmentIdFilter (utf8) — reserved +// +// TODO(v2): fragmentIdFilter (0x06) is parsed and re-serialized but never +// populated or honored — it's the reserved surface for incremental fragment +// sync (request the missing fragments of one file by ID instead of diffing the +// whole fragment set). Either wire it into buildGcsPayload/_handleRequestSync +// or drop the field; don't leave it as silent dead protocol surface. struct RequestSyncPacket { let p: Int let m: UInt32 diff --git a/bitchat/Sync/GossipSyncManager.swift b/bitchat/Sync/GossipSyncManager.swift index c966a7ca..f848be7b 100644 --- a/bitchat/Sync/GossipSyncManager.swift +++ b/bitchat/Sync/GossipSyncManager.swift @@ -90,7 +90,7 @@ final class GossipSyncManager { private var messages = PacketStore() private var fragments = PacketStore() private var fileTransfers = PacketStore() - private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:] + private var latestAnnouncementByPeer: [PeerID: BitchatPacket] = [:] private var archiveDirty = false // Timer @@ -206,9 +206,8 @@ final class GossipSyncManager { removeState(for: sender) return } - let idHex = PacketIdUtil.computeId(packet).hexEncodedString() let sender = PeerID(hexData: packet.senderID) - latestAnnouncementByPeer[sender] = (id: idHex, packet: packet) + latestAnnouncementByPeer[sender] = packet case .message: guard isBroadcastRecipient else { return } guard isPacketFresh(packet) else { return } @@ -312,10 +311,9 @@ final class GossipSyncManager { // keys needed to verify everything else, and there is at most one per // peer, so the resend cost is negligible. if requestedTypes.contains(.announce) { - for (_, pair) in latestAnnouncementByPeer { - let (idHex, pkt) = pair + for (_, pkt) in latestAnnouncementByPeer { guard isPacketFresh(pkt) else { continue } - let idBytes = Data(hexString: idHex) ?? Data() + let idBytes = PacketIdUtil.computeId(pkt) if !mightContain(idBytes) { var toSend = pkt toSend.ttl = 0 @@ -372,8 +370,8 @@ final class GossipSyncManager { private func buildGcsPayload(for types: SyncTypeFlags) -> Data { var candidates: [BitchatPacket] = [] if types.contains(.announce) { - for (_, pair) in latestAnnouncementByPeer where isPacketFresh(pair.packet) { - candidates.append(pair.packet) + for (_, pkt) in latestAnnouncementByPeer where isPacketFresh(pkt) { + candidates.append(pkt) } } if types.contains(.message) { @@ -431,8 +429,8 @@ final class GossipSyncManager { // Periodic cleanup of expired messages and announcements private func cleanupExpiredMessages() { // Remove expired announcements - latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pair in - isPacketFresh(pair.packet) + latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pkt in + isPacketFresh(pkt) } let messageCountBefore = messages.packets.count @@ -512,8 +510,8 @@ final class GossipSyncManager { let nowMs = UInt64(now.timeIntervalSince1970 * 1000) guard nowMs >= timeoutMs else { return } let cutoff = nowMs - timeoutMs - let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pair in - pair.packet.timestamp < cutoff ? peerID : nil + let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pkt in + pkt.timestamp < cutoff ? peerID : nil } guard !stalePeerIDs.isEmpty else { return } for peerKey in stalePeerIDs { diff --git a/bitchat/Sync/SyncTypeFlags.swift b/bitchat/Sync/SyncTypeFlags.swift index 430485a0..fe796809 100644 --- a/bitchat/Sync/SyncTypeFlags.swift +++ b/bitchat/Sync/SyncTypeFlags.swift @@ -7,9 +7,25 @@ struct SyncTypeFlags: OptionSet { let rawValue: UInt64 init(rawValue: UInt64) { - self.rawValue = rawValue & 0x00FF_FFFF_FFFF_FFFF // Trim to max 8 bytes + // Drop any bit that doesn't map to a known message type. Wire data can + // carry up to 8 bytes of flags; without this mask, bits with no type + // (a truncated/garbled field, or a type a newer peer added) would live + // in the set as phantom membership that no `contains` check matches and + // `toData` re-serializes — a meaningless "accepted but does nothing" + // state. Masking here keeps every instance normalized at the source. + self.rawValue = rawValue & SyncTypeFlags.knownTypeMask } + /// Union of every bit that maps to a message type. Derived from the + /// bit↔type table so it tracks automatically when a type is added. + private static let knownTypeMask: UInt64 = { + var mask: UInt64 = 0 + for bit in 0..<64 where SyncTypeFlags.type(forBit: bit) != nil { + mask |= (1 << UInt64(bit)) + } + return mask + }() + private static func bitIndex(for type: MessageType) -> Int? { switch type { case .announce: return 0 diff --git a/bitchatTests/GossipSyncManagerTests.swift b/bitchatTests/GossipSyncManagerTests.swift index c44de8f6..0e7b35eb 100644 --- a/bitchatTests/GossipSyncManagerTests.swift +++ b/bitchatTests/GossipSyncManagerTests.swift @@ -357,6 +357,44 @@ struct GossipSyncManagerTests { #expect(sentPackets.allSatisfy { $0.isRSR }) } + @Test func handleRequestSyncSkipsAnnounceAlreadyInFilter() async throws { + var config = GossipSyncManager.Config() + config.messageSyncIntervalSeconds = 0 + config.fragmentSyncIntervalSeconds = 0 + config.fileTransferSyncIntervalSeconds = 0 + + let requestSyncManager = RequestSyncManager() + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) + let delegate = RecordingDelegate() + manager.delegate = delegate + + let sender = try #require(Data(hexString: "aabbccddeeff0011")) + let announcePacket = BitchatPacket( + type: MessageType.announce.rawValue, + senderID: sender, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data(), + signature: nil, + ttl: 1 + ) + manager.onPublicPacketSeen(announcePacket) + + // A filter that already contains the announce's canonical ID must + // suppress the response — this only holds if the responder recomputes + // the ID the same way the filter was built (the dual-path bug would + // diff a stored hex string instead). + let announceID = PacketIdUtil.computeId(announcePacket) + let params = GCSFilter.buildFilter(ids: [announceID], maxBytes: 256, targetFpr: 0.01) + let request = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: .announce) + + let peer = PeerID(str: "FFFFFFFFFFFFFFFF") + manager.handleRequestSync(from: peer, request: request) + // Barrier: the async handler is enqueued, so this sync flush runs after it. + manager._performMaintenanceSynchronously(now: Date()) + #expect(delegate.packets.isEmpty) + } + @Test func handleRequestSyncIsRateLimitedPerPeer() async throws { var config = GossipSyncManager.Config() config.seenCapacity = 5 diff --git a/bitchatTests/Sync/SyncTypeFlagsTests.swift b/bitchatTests/Sync/SyncTypeFlagsTests.swift new file mode 100644 index 00000000..a3ab3cc4 --- /dev/null +++ b/bitchatTests/Sync/SyncTypeFlagsTests.swift @@ -0,0 +1,43 @@ +import Foundation +import Testing +import BitFoundation +@testable import bitchat + +struct SyncTypeFlagsTests { + + @Test func knownTypesRoundTripThroughData() throws { + let flags: SyncTypeFlags = [.announce, .message, .fragment, .fileTransfer] + let data = try #require(flags.toData()) + let decoded = try #require(SyncTypeFlags.decode(data)) + #expect(decoded == flags) + } + + @Test func decodeDropsPhantomBits() { + // Bits 8+ map to no message type. They must not survive decode as + // phantom membership. + let phantom = Data([0x00, 0xFF]) // bits 8..15 set, no known type + let decoded = SyncTypeFlags.decode(phantom) + #expect(decoded?.rawValue == 0) + #expect(decoded?.toMessageTypes().isEmpty == true) + } + + @Test func phantomBitsAreStrippedButKnownBitsSurvive() { + // Low byte = announce(0) + message(1); high byte = phantom. + let mixed = Data([0b0000_0011, 0xFF]) + let decoded = SyncTypeFlags.decode(mixed) + #expect(decoded?.contains(.announce) == true) + #expect(decoded?.contains(.message) == true) + // Only the two known bits remain; phantom high byte is gone. + #expect(decoded?.rawValue == 0b0000_0011) + } + + @Test func rawValueInitNormalizesPhantomBits() { + let flags = SyncTypeFlags(rawValue: 0xFFFF_FFFF_FFFF_FFFF) + // Every known type bit is set; nothing above them survives, so the + // field serializes to a single byte. + #expect(flags.contains(.announce)) + #expect(flags.contains(.fileTransfer)) + let data = flags.toData() + #expect(data?.count == 1) + } +} From 5fdc15d5af53f9243fc1c6f5036997054e198dae Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:07:52 +0200 Subject: [PATCH 04/18] Add capability bits to announce TLV (#1375) Announces now carry an optional capabilities TLV (0x05): a little-endian bitfield with named bits for upcoming features (prekeys, wifiBulk, gateway, groups, board, vouch, meshDiagnostics). Old clients skip the unknown TLV; peers without it decode as nil so features can distinguish "legacy peer" from "advertises nothing". PeerCapabilities lives in BitFoundation with a minimal-length encoding that preserves unknown bits for forward compatibility. Peer capabilities are stored in the BLE peer registry on verified announce and exposed via BLEService.peerCapabilities(_:). The local advertisement set is empty until each feature ships its bit. Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/Protocols/Packets.swift | 32 ++++++++++++- .../Protocols/PeerCapabilities+Local.swift | 7 +++ bitchat/Services/BLE/BLEPeerRegistry.swift | 11 ++++- bitchat/Services/BLE/BLEService.swift | 12 ++++- bitchatTests/Protocols/PacketsTests.swift | 37 +++++++++++++++ .../BitFoundation/PeerCapabilities.swift | 45 +++++++++++++++++++ .../PeerCapabilitiesTests.swift | 43 ++++++++++++++++++ 7 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 bitchat/Protocols/PeerCapabilities+Local.swift create mode 100644 localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift create mode 100644 localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift diff --git a/bitchat/Protocols/Packets.swift b/bitchat/Protocols/Packets.swift index 8914d4b7..309860aa 100644 --- a/bitchat/Protocols/Packets.swift +++ b/bitchat/Protocols/Packets.swift @@ -1,3 +1,4 @@ +import BitFoundation import Foundation // MARK: - Protocol TLV Packets @@ -7,12 +8,28 @@ struct AnnouncementPacket { let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement) let signingPublicKey: Data // Ed25519 public key for signing let directNeighbors: [Data]? // 8-byte peer IDs + let capabilities: PeerCapabilities? // advertised feature bits; nil when absent (old clients) + + init( + nickname: String, + noisePublicKey: Data, + signingPublicKey: Data, + directNeighbors: [Data]?, + capabilities: PeerCapabilities? = nil + ) { + self.nickname = nickname + self.noisePublicKey = noisePublicKey + self.signingPublicKey = signingPublicKey + self.directNeighbors = directNeighbors + self.capabilities = capabilities + } private enum TLVType: UInt8 { case nickname = 0x01 case noisePublicKey = 0x02 case signingPublicKey = 0x03 case directNeighbors = 0x04 + case capabilities = 0x05 } func encode() -> Data? { @@ -48,6 +65,15 @@ struct AnnouncementPacket { } } + // TLV for capabilities (optional) + if let capabilities = capabilities { + let capabilityBytes = capabilities.encoded() + guard capabilityBytes.count <= 255 else { return nil } + data.append(TLVType.capabilities.rawValue) + data.append(UInt8(capabilityBytes.count)) + data.append(capabilityBytes) + } + return data } @@ -57,6 +83,7 @@ struct AnnouncementPacket { var noisePublicKey: Data? var signingPublicKey: Data? var directNeighbors: [Data]? + var capabilities: PeerCapabilities? while offset + 2 <= data.count { let typeRaw = data[offset] @@ -87,6 +114,8 @@ struct AnnouncementPacket { } directNeighbors = neighbors } + case .capabilities: + capabilities = PeerCapabilities(encoded: Data(value)) } } else { // Unknown TLV; skip (tolerant decoder for forward compatibility) @@ -99,7 +128,8 @@ struct AnnouncementPacket { nickname: nickname, noisePublicKey: noisePublicKey, signingPublicKey: signingPublicKey, - directNeighbors: directNeighbors + directNeighbors: directNeighbors, + capabilities: capabilities ) } } diff --git a/bitchat/Protocols/PeerCapabilities+Local.swift b/bitchat/Protocols/PeerCapabilities+Local.swift new file mode 100644 index 00000000..40f97b9f --- /dev/null +++ b/bitchat/Protocols/PeerCapabilities+Local.swift @@ -0,0 +1,7 @@ +import BitFoundation + +extension PeerCapabilities { + /// Capabilities this build advertises in its announce packets. + /// Each feature adds its bit here when it ships. + static let localSupported: PeerCapabilities = [] +} diff --git a/bitchat/Services/BLE/BLEPeerRegistry.swift b/bitchat/Services/BLE/BLEPeerRegistry.swift index 29776748..4db3e931 100644 --- a/bitchat/Services/BLE/BLEPeerRegistry.swift +++ b/bitchat/Services/BLE/BLEPeerRegistry.swift @@ -9,6 +9,7 @@ struct BLEPeerInfo: Equatable { var signingPublicKey: Data? var isVerifiedNickname: Bool var lastSeen: Date + var capabilities: PeerCapabilities = [] } struct BLEPeerAnnounceUpdate: Equatable { @@ -107,6 +108,10 @@ struct BLEPeerRegistry { peers[peerID]?.noisePublicKey?.sha256Fingerprint() } + func capabilities(for peerID: PeerID) -> PeerCapabilities { + peers[peerID.toShort()]?.capabilities ?? [] + } + func displayNicknames(selfNickname: String) -> [PeerID: String] { let connected = peers.filter { $0.value.isConnected } let tuples = connected.map { ($0.key, $0.value.nickname, true) } @@ -157,7 +162,8 @@ struct BLEPeerRegistry { noisePublicKey: Data, signingPublicKey: Data?, isConnected: Bool, - now: Date + now: Date, + capabilities: PeerCapabilities = [] ) -> BLEPeerAnnounceUpdate { let existing = peers[peerID] let update = BLEPeerAnnounceUpdate( @@ -173,7 +179,8 @@ struct BLEPeerRegistry { noisePublicKey: noisePublicKey, signingPublicKey: signingPublicKey, isVerifiedNickname: true, - lastSeen: now + lastSeen: now, + capabilities: capabilities ) return update diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 865a89be..d1ed84f2 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -619,6 +619,12 @@ final class BLEService: NSObject { } } + /// Capabilities the peer advertised in its last verified announce. + /// Empty for peers that predate the capabilities TLV. + func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { + collectionsQueue.sync { peerRegistry.capabilities(for: peerID) } + } + func getPeerNicknames() -> [PeerID: String] { return collectionsQueue.sync { peerRegistry.displayNicknames(selfNickname: myNickname) @@ -1261,7 +1267,8 @@ final class BLEService: NSObject { nickname: myNickname, noisePublicKey: noisePub, signingPublicKey: signingPub, - directNeighbors: connectedPeerIDs + directNeighbors: connectedPeerIDs, + capabilities: PeerCapabilities.localSupported ) guard let payload = announcement.encode() else { @@ -3315,7 +3322,8 @@ extension BLEService { noisePublicKey: announcement.noisePublicKey, signingPublicKey: announcement.signingPublicKey, isConnected: isConnected, - now: now + now: now, + capabilities: announcement.capabilities ?? [] ) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) }, shouldEmitReconnectLog: { [weak self] peerID, now in diff --git a/bitchatTests/Protocols/PacketsTests.swift b/bitchatTests/Protocols/PacketsTests.swift index 78c244de..2368925a 100644 --- a/bitchatTests/Protocols/PacketsTests.swift +++ b/bitchatTests/Protocols/PacketsTests.swift @@ -1,3 +1,4 @@ +import BitFoundation import Foundation import Testing @@ -108,6 +109,42 @@ struct PacketsTests { #expect(decoded.directNeighbors == nil) } + @Test + func announcementPacketRoundTripsCapabilities() throws { + let capabilities: PeerCapabilities = [.prekeys, .board, .meshDiagnostics] + let packet = AnnouncementPacket( + nickname: "alice", + noisePublicKey: Data(repeating: 0x11, count: 32), + signingPublicKey: Data(repeating: 0x22, count: 32), + directNeighbors: nil, + capabilities: capabilities + ) + + let encoded = try #require(packet.encode()) + let decoded = try #require(AnnouncementPacket.decode(from: encoded)) + #expect(decoded.capabilities == capabilities) + } + + @Test + func announcementPacketWithoutCapabilitiesDecodesNilAndUnknownBitsSurvive() throws { + let legacy = try #require( + AnnouncementPacket( + nickname: "alice", + noisePublicKey: Data(repeating: 0x11, count: 32), + signingPublicKey: Data(repeating: 0x22, count: 32), + directNeighbors: nil + ).encode() + ) + // The TLV is emitted only when capabilities are set, so legacy peers + // (and this packet) decode as nil rather than empty. + #expect(try #require(AnnouncementPacket.decode(from: legacy)).capabilities == nil) + + var withFutureBits = legacy + withFutureBits.append(makeTLV(type: 0x05, value: Data([0x80, 0x01]))) + let decoded = try #require(AnnouncementPacket.decode(from: withFutureBits)) + #expect(decoded.capabilities?.rawValue == 0x0180) + } + @Test func privateMessagePacketRejectsUnknownTypeAndTruncation() { let unknownTLV = Data([0x7F, 0x01, 0x41]) diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift new file mode 100644 index 00000000..94858d9b --- /dev/null +++ b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift @@ -0,0 +1,45 @@ +import Foundation + +/// Feature capabilities a peer advertises in its announce packet. +/// +/// Encoded as a little-endian bitfield with trailing zero bytes dropped, so the +/// wire form grows only when high bits are assigned. Decoders keep the low 64 +/// bits and ignore any longer field, and unknown bits are preserved verbatim — +/// old clients skip the TLV entirely, new clients degrade per-feature. +public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable { + public let rawValue: UInt64 + + public init(rawValue: UInt64) { + self.rawValue = rawValue + } + + public static let prekeys = PeerCapabilities(rawValue: 1 << 0) + public static let wifiBulk = PeerCapabilities(rawValue: 1 << 1) + public static let gateway = PeerCapabilities(rawValue: 1 << 2) + public static let groups = PeerCapabilities(rawValue: 1 << 3) + public static let board = PeerCapabilities(rawValue: 1 << 4) + public static let vouch = PeerCapabilities(rawValue: 1 << 5) + public static let meshDiagnostics = PeerCapabilities(rawValue: 1 << 6) + + /// Minimal little-endian byte encoding; always at least one byte so an + /// empty set is distinguishable from an absent TLV. + public func encoded() -> Data { + var value = rawValue + var bytes = Data() + repeat { + bytes.append(UInt8(truncatingIfNeeded: value)) + value >>= 8 + } while value != 0 + return bytes + } + + /// Accepts any length; bytes beyond the low 64 bits are ignored for + /// forward compatibility. + public init(encoded data: Data) { + var value: UInt64 = 0 + for (index, byte) in data.prefix(8).enumerated() { + value |= UInt64(byte) << (8 * index) + } + self.init(rawValue: value) + } +} diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift new file mode 100644 index 00000000..1c83530f --- /dev/null +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift @@ -0,0 +1,43 @@ +// +// PeerCapabilitiesTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +@testable import BitFoundation + +struct PeerCapabilitiesTests { + @Test + func encodingIsMinimalAndRoundTrips() { + #expect(PeerCapabilities([]).encoded() == Data([0x00])) + #expect(PeerCapabilities.prekeys.encoded() == Data([0x01])) + #expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40])) + + let high = PeerCapabilities(rawValue: 1 << 9) + #expect(high.encoded() == Data([0x00, 0x02])) + + let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics] + #expect(PeerCapabilities(encoded: all.encoded()) == all) + #expect(PeerCapabilities(encoded: high.encoded()) == high) + #expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == []) + } + + @Test + func decodingToleratesUnknownBitsAndOversizedFields() { + // Unknown bits survive a round-trip untouched. + let unknown = PeerCapabilities(encoded: Data([0xFF, 0xFF])) + #expect(unknown.rawValue == 0xFFFF) + #expect(unknown.contains(.gateway)) + + // Fields longer than 8 bytes keep the low 64 bits and ignore the rest. + let oversized = Data([0x01] + [UInt8](repeating: 0x00, count: 7) + [0xAA, 0xBB]) + #expect(PeerCapabilities(encoded: oversized) == .prekeys) + + // Empty value decodes to no capabilities. + #expect(PeerCapabilities(encoded: Data()) == []) + } +} From 201dbac49a1bdce81594165212f42c80a1d37dbb Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:10:58 +0200 Subject: [PATCH 05/18] docs: reconcile protocol docstrings with implementation (#1374) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BitchatProtocol.swift: stop advertising "timing obfuscation prevents traffic analysis" — what exists is randomized relay jitter (RelayController, 10-220 ms) and PKCS#7-style padding to 256/512/1024/2048-byte blocks (MessagePadding); there is no cover traffic or per-message timing obfuscation. Also update the stale Message Types list (Delivery/Read are Noise payloads, no Version negotiation type; add CourierEnvelope/RequestSync/FileTransfer). - MessageType.swift: header said "6 essential" types; the enum has 9 cases. WHITEPAPER.md needed no changes: the #1372 rewrite already replaced the old Bloom-filter and MessageRetryService claims, and its numbers (dedup 1000/5min, jitter, outbox 100/peer 24h 8 attempts, courier 16 KiB/24h/40-20-5-2 quotas, spray 4/8, gossip 1000/15s/6h) all match the code. Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/Protocols/BitchatProtocol.swift | 16 +++++++++------- .../Sources/BitFoundation/MessageType.swift | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index 4ebe6509..267d8cc5 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -18,7 +18,7 @@ /// - Efficient binary message encoding /// - Message fragmentation for large payloads /// - TTL-based routing for mesh networks -/// - Privacy features like padding and timing obfuscation +/// - Privacy features: message padding and randomized relay jitter /// - Integration points for end-to-end encryption /// /// ## Protocol Design @@ -38,18 +38,20 @@ /// 7. **Decoding**: Binary data parsed back to message objects /// /// ## Security Considerations -/// - Message padding obscures actual content length -/// - Timing obfuscation prevents traffic analysis +/// - Message padding (to 256/512/1024/2048-byte blocks) obscures actual content length +/// - Randomized relay jitter reduces the traffic-analysis signal; there is no +/// cover traffic or per-message timing obfuscation /// - Integration with Noise Protocol for E2E encryption /// - No persistent identifiers in protocol headers /// /// ## Message Types /// - **Announce/Leave**: Peer presence notifications -/// - **Message**: User chat messages (broadcast or directed) +/// - **Message**: Public chat messages /// - **Fragment**: Multi-part message handling -/// - **Delivery/Read**: Message acknowledgments -/// - **Noise**: Encrypted channel establishment -/// - **Version**: Protocol version negotiation +/// - **NoiseHandshake/NoiseEncrypted**: Encrypted channel establishment and +/// all private payloads (messages, delivery acks, read receipts) +/// - **CourierEnvelope**: Sealed store-and-forward mail +/// - **RequestSync/FileTransfer**: Gossip history sync and media transfer /// /// ## Future Extensions /// The protocol is designed to be extensible: diff --git a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift index ceac0cd0..23f391b9 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift @@ -7,7 +7,7 @@ // /// Simplified BitChat protocol message types. -/// Reduced from 24 types to just 6 essential ones. +/// Consolidated from the original 24 wire types down to the 9 cases below. /// All private communication metadata (receipts, status) is embedded in noiseEncrypted payloads. public enum MessageType: UInt8 { // Public messages (unencrypted) From 276cde44e780776b11d1c1022505d45f9c4229fa Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:11:49 +0200 Subject: [PATCH 06/18] Gate Tor/relay startup on network reachability (#1389) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a mesh-only/offline device the app used to bootstrap Tor and spin Nostr relay reconnects forever ("connecting to Tor…"), wasting battery even when there was provably no network path at all. Add an NWPathMonitor-backed reachability signal (NetworkReachabilityMonitor) and fold it into NetworkActivationService's activation gate: - Tor bootstrap and relay connect/reconnect are now gated on the network path being usable. When the path is fully unsatisfied (no interface at all) we set autoStart off, shut Tor down, and disconnect relays instead of looping. When a usable path returns we resume. - Conservative policy: only NWPath.Status.unsatisfied counts as offline. A flaky-but-present link stays "reachable" (Tor tolerates intermittent connectivity); we never tear down on the first hiccup. - Transitions are debounced (ReachabilityDebounce, ~2.5s) so path flapping cannot thrash Tor/relay startup. The debounce is a pure value type, unit-tested without the Network framework or real timers. - Starts optimistic (reachable) so nothing is suppressed before the first path evaluation arrives. - BLE mesh never consults this gate and works fully offline. - NWPathMonitor's background callback hops to the main actor before touching any state. Surfaces NetworkActivationService.isNetworkReachable for UI to distinguish "offline" from "connecting to Tor". Tests: pure debounce (satisfied → allowed, unsatisfied → suppressed after interval, flap debounced, recover-after-outage) plus service wiring (unreachable suppresses Tor+relays, recovery resumes, loss disconnects). Co-authored-by: jack Co-authored-by: Claude Fable 5 --- .../Services/NetworkActivationService.swift | 45 +++- .../Services/NetworkReachabilityMonitor.swift | 167 ++++++++++++++ .../NetworkActivationServiceTests.swift | 1 + .../NetworkReachabilityGateTests.swift | 207 ++++++++++++++++++ 4 files changed, 416 insertions(+), 4 deletions(-) create mode 100644 bitchat/Services/NetworkReachabilityMonitor.swift create mode 100644 bitchatTests/Services/NetworkReachabilityGateTests.swift diff --git a/bitchat/Services/NetworkActivationService.swift b/bitchat/Services/NetworkActivationService.swift index 1f68af8d..b8f5a7ba 100644 --- a/bitchat/Services/NetworkActivationService.swift +++ b/bitchat/Services/NetworkActivationService.swift @@ -25,14 +25,20 @@ extension NostrRelayManager: NetworkActivationRelayControlling {} extension TorURLSession: NetworkActivationProxyControlling {} /// Coordinates when the app is allowed to start Tor and connect to Nostr relays. -/// Policy: permit start when either location permissions are authorized OR -/// there exists at least one mutual favorite. Otherwise, do not start. +/// Policy: permit start when (location permissions are authorized OR there +/// exists at least one mutual favorite) AND the device has a usable network +/// path. When there is provably no network at all we do not bootstrap Tor or +/// spin relay reconnects — that only wastes battery on a mesh-only/offline +/// device. BLE mesh is entirely independent of this gate. @MainActor final class NetworkActivationService: ObservableObject { static let shared = NetworkActivationService() @Published private(set) var activationAllowed: Bool = false @Published private(set) var userTorEnabled: Bool = true + /// Coarse, debounced network reachability. `false` only when the OS reports + /// no usable interface at all. Surfaced for UI ("offline" vs "connecting"). + @Published private(set) var isNetworkReachable: Bool = true private var cancellables = Set() private var started = false @@ -43,6 +49,7 @@ final class NetworkActivationService: ObservableObject { private let mutualFavoritesPublisher: AnyPublisher, Never> private let permissionProvider: () -> LocationChannelManager.PermissionState private let mutualFavoritesProvider: () -> Set + private let reachabilityMonitor: NetworkReachabilityMonitoring private let torController: NetworkActivationTorControlling // Resolved lazily: NostrRelayManager.init() reads NetworkActivationService.shared // (via its live dependencies), so capturing NostrRelayManager.shared here would @@ -58,6 +65,7 @@ final class NetworkActivationService: ObservableObject { mutualFavoritesPublisher = FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher() permissionProvider = { LocationChannelManager.shared.permissionState } mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites } + reachabilityMonitor = NWPathReachabilityMonitor() torController = TorManager.shared relayControllerProvider = { NostrRelayManager.shared } proxyController = TorURLSession.shared @@ -70,6 +78,7 @@ final class NetworkActivationService: ObservableObject { mutualFavoritesPublisher: AnyPublisher, Never>, permissionProvider: @escaping () -> LocationChannelManager.PermissionState, mutualFavoritesProvider: @escaping () -> Set, + reachabilityMonitor: NetworkReachabilityMonitoring, torController: NetworkActivationTorControlling, relayController: NetworkActivationRelayControlling, proxyController: NetworkActivationProxyControlling, @@ -80,6 +89,7 @@ final class NetworkActivationService: ObservableObject { self.mutualFavoritesPublisher = mutualFavoritesPublisher self.permissionProvider = permissionProvider self.mutualFavoritesProvider = mutualFavoritesProvider + self.reachabilityMonitor = reachabilityMonitor self.torController = torController self.relayControllerProvider = { relayController } self.proxyController = proxyController @@ -96,8 +106,12 @@ final class NetworkActivationService: ObservableObject { userTorEnabled = true } + // Begin (idempotent) reachability monitoring and seed initial state. + reachabilityMonitor.start() + isNetworkReachable = reachabilityMonitor.isReachable + // Initial compute - let allowed = basePolicyAllowed() + let allowed = effectiveAllowed() activationAllowed = allowed torAutoStartDesired = allowed && userTorEnabled torController.setAutoStartAllowed(torAutoStartDesired) @@ -123,6 +137,21 @@ final class NetworkActivationService: ObservableObject { self?.reevaluate() } .store(in: &cancellables) + + // React to network reachability changes (debounced, unsatisfied-only). + reachabilityMonitor.reachabilityPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] reachable in + guard let self else { return } + guard reachable != self.isNetworkReachable else { return } + self.isNetworkReachable = reachable + SecureLogger.info( + "NetworkActivationService: isNetworkReachable -> \(reachable)", + category: .session + ) + self.reevaluate() + } + .store(in: &cancellables) } func setUserTorEnabled(_ enabled: Bool) { @@ -138,7 +167,7 @@ final class NetworkActivationService: ObservableObject { } private func reevaluate() { - let allowed = basePolicyAllowed() + let allowed = effectiveAllowed() let torDesired = allowed && userTorEnabled let statusChanged = allowed != activationAllowed let torChanged = torDesired != torAutoStartDesired @@ -163,12 +192,20 @@ final class NetworkActivationService: ObservableObject { } } + /// Base policy: who is allowed to use the network at all (permission or a + /// mutual favorite), ignoring current link state. private func basePolicyAllowed() -> Bool { let permOK = permissionProvider() == .authorized let hasMutual = !mutualFavoritesProvider().isEmpty return permOK || hasMutual } + /// Effective gate: base policy AND a usable network path. When there is + /// provably no network, Tor bootstrap and relay reconnects are suppressed. + private func effectiveAllowed() -> Bool { + basePolicyAllowed() && reachabilityMonitor.isReachable + } + private func applyTorState(torDesired: Bool) { proxyController.setProxyMode(useTor: torDesired) if torDesired { diff --git a/bitchat/Services/NetworkReachabilityMonitor.swift b/bitchat/Services/NetworkReachabilityMonitor.swift new file mode 100644 index 00000000..bc2b77cf --- /dev/null +++ b/bitchat/Services/NetworkReachabilityMonitor.swift @@ -0,0 +1,167 @@ +import Foundation +import Combine +import BitLogger +#if canImport(Network) +import Network +#endif + +/// Coarse, conservative network-reachability signal used to gate Tor bootstrap +/// and Nostr relay connections. +/// +/// Policy (deliberately conservative): +/// - Reports `false` only when the OS says there is *no* usable interface at +/// all (`NWPath.Status.unsatisfied`). A flaky-but-present link stays +/// `true` because Tor tolerates intermittent connectivity, and tearing down +/// on the first hiccup would cost more battery/latency than it saves. +/// - Transitions are debounced (see `ReachabilityDebounce`) so path flapping +/// does not thrash Tor/relay startup. +/// - Starts optimistic (`true`) so nothing is ever suppressed before the first +/// path evaluation arrives. +/// +/// BLE mesh must never consult this monitor — the mesh works fully offline. +@MainActor +protocol NetworkReachabilityMonitoring: AnyObject { + /// Current debounced coarse reachability. + var isReachable: Bool { get } + /// Emits the debounced reachability whenever it changes (main-actor). + var reachabilityPublisher: AnyPublisher { get } + /// Begin monitoring. Idempotent. + func start() +} + +/// Pure debounce/decision logic for reachability, split out so it can be +/// unit-tested without the Network framework or real timers. +/// +/// A candidate state only becomes the committed state once it has been stable +/// (uninterrupted) for `interval`. Any observation matching the committed state +/// cancels a pending opposite change, which is what makes flapping a no-op. +struct ReachabilityDebounce { + let interval: TimeInterval + private(set) var committed: Bool + private var pending: (value: Bool, since: Date)? + + init(interval: TimeInterval, initial: Bool) { + self.interval = interval + self.committed = initial + } + + /// Whether a change is currently waiting out the debounce window. + var hasPendingChange: Bool { pending != nil } + + /// Feed a raw observation. Returns the new committed value if it changed, + /// otherwise `nil`. + mutating func observe(reachable: Bool, at now: Date) -> Bool? { + if reachable == committed { + // Already in this state — cancel any pending opposite change. + pending = nil + return nil + } + // Differs from committed: (re)arm the pending change, preserving the + // timestamp if we're already waiting on this same target value. + if pending?.value != reachable { + pending = (reachable, now) + } + return commitIfAged(at: now) + } + + /// Called from a timer to commit a pending change once it has aged past + /// `interval`. Returns the new committed value if it changed, else `nil`. + mutating func flush(at now: Date) -> Bool? { + commitIfAged(at: now) + } + + private mutating func commitIfAged(at now: Date) -> Bool? { + guard let pending else { return nil } + guard now.timeIntervalSince(pending.since) >= interval else { return nil } + committed = pending.value + self.pending = nil + return committed + } +} + +/// Always-reachable stub. Used as the default in tests and as the fallback on +/// platforms without the Network framework, so reachability never suppresses +/// startup by itself. +@MainActor +final class AlwaysReachableMonitor: NetworkReachabilityMonitoring { + var isReachable: Bool { true } + var reachabilityPublisher: AnyPublisher { + Empty(completeImmediately: false).eraseToAnyPublisher() + } + func start() {} +} + +/// `NWPathMonitor`-backed reachability. All state lives on the main actor; the +/// background path callback hops here before touching the debounce. +@MainActor +final class NWPathReachabilityMonitor: NetworkReachabilityMonitoring { + private let subject: CurrentValueSubject + private var debounce: ReachabilityDebounce + private var flushWorkItem: DispatchWorkItem? + private var started = false + private let now: () -> Date + + #if canImport(Network) + private var monitor: NWPathMonitor? + private let monitorQueue = DispatchQueue(label: "chat.bitchat.reachability") + #endif + + init(debounceInterval: TimeInterval = 2.5, now: @escaping () -> Date = Date.init) { + self.now = now + self.debounce = ReachabilityDebounce(interval: debounceInterval, initial: true) + self.subject = CurrentValueSubject(true) + } + + var isReachable: Bool { subject.value } + + var reachabilityPublisher: AnyPublisher { + subject.removeDuplicates().dropFirst().eraseToAnyPublisher() + } + + func start() { + guard !started else { return } + started = true + #if canImport(Network) + let monitor = NWPathMonitor() + self.monitor = monitor + monitor.pathUpdateHandler = { [weak self] path in + // Conservative: only "no interface at all" counts as unreachable. + let reachable = path.status != .unsatisfied + Task { @MainActor in + self?.ingest(reachable: reachable) + } + } + monitor.start(queue: monitorQueue) + #else + // No Network framework: never suppress startup. + #endif + } + + /// Feed an observation into the debounce and publish committed changes. + /// Exposed internally so higher layers/tests could drive it if needed. + func ingest(reachable: Bool) { + flushWorkItem?.cancel() + flushWorkItem = nil + if let committed = debounce.observe(reachable: reachable, at: now()) { + publish(committed) + } else if debounce.hasPendingChange { + scheduleFlush() + } + } + + private func scheduleFlush() { + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + if let committed = self.debounce.flush(at: self.now()) { + self.publish(committed) + } + } + flushWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + debounce.interval, execute: work) + } + + private func publish(_ reachable: Bool) { + SecureLogger.info("NWPathReachabilityMonitor: network reachable -> \(reachable)", category: .session) + subject.send(reachable) + } +} diff --git a/bitchatTests/Services/NetworkActivationServiceTests.swift b/bitchatTests/Services/NetworkActivationServiceTests.swift index 5523ccdc..01d8fdca 100644 --- a/bitchatTests/Services/NetworkActivationServiceTests.swift +++ b/bitchatTests/Services/NetworkActivationServiceTests.swift @@ -111,6 +111,7 @@ final class NetworkActivationServiceTests: XCTestCase { mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(), permissionProvider: { permissionSubject.value }, mutualFavoritesProvider: { favoritesSubject.value }, + reachabilityMonitor: AlwaysReachableMonitor(), torController: torController, relayController: relayController, proxyController: proxyController, diff --git a/bitchatTests/Services/NetworkReachabilityGateTests.swift b/bitchatTests/Services/NetworkReachabilityGateTests.swift new file mode 100644 index 00000000..420a54db --- /dev/null +++ b/bitchatTests/Services/NetworkReachabilityGateTests.swift @@ -0,0 +1,207 @@ +import Combine +import XCTest +@testable import bitchat + +/// Covers the reachability-gate decision logic (pure debounce) and the +/// `NetworkActivationService` wiring that suppresses Tor/relay startup when +/// there is provably no network. +@MainActor +final class NetworkReachabilityGateTests: XCTestCase { + + // MARK: - Pure debounce logic + + func test_debounce_satisfiedStaysReachable() { + var d = ReachabilityDebounce(interval: 2.5, initial: true) + let t0 = Date() + // An interface remains present: no change, no pending. + XCTAssertNil(d.observe(reachable: true, at: t0)) + XCTAssertTrue(d.committed) + XCTAssertFalse(d.hasPendingChange) + } + + func test_debounce_unsatisfiedSuppressesAfterInterval() { + var d = ReachabilityDebounce(interval: 2.5, initial: true) + let t0 = Date() + // Path drops: not committed immediately (within debounce window). + XCTAssertNil(d.observe(reachable: false, at: t0)) + XCTAssertTrue(d.committed) + XCTAssertTrue(d.hasPendingChange) + // Still within window. + XCTAssertNil(d.flush(at: t0.addingTimeInterval(1.0))) + XCTAssertTrue(d.committed) + // Past the window: commit unreachable. + XCTAssertEqual(d.flush(at: t0.addingTimeInterval(2.5)), false) + XCTAssertFalse(d.committed) + XCTAssertFalse(d.hasPendingChange) + } + + func test_debounce_flapIsIgnored() { + var d = ReachabilityDebounce(interval: 2.5, initial: true) + let t0 = Date() + // Drop then recover well within the window — must never commit a change. + XCTAssertNil(d.observe(reachable: false, at: t0)) + XCTAssertTrue(d.hasPendingChange) + XCTAssertNil(d.observe(reachable: true, at: t0.addingTimeInterval(0.5))) + XCTAssertFalse(d.hasPendingChange, "recovery should cancel the pending drop") + // A late flush after the original deadline is a no-op (nothing pending). + XCTAssertNil(d.flush(at: t0.addingTimeInterval(3.0))) + XCTAssertTrue(d.committed) + } + + func test_debounce_recoverAfterOutageCommitsAfterInterval() { + var d = ReachabilityDebounce(interval: 2.5, initial: false) + let t0 = Date() + XCTAssertNil(d.observe(reachable: true, at: t0)) + XCTAssertTrue(d.hasPendingChange) + XCTAssertEqual(d.flush(at: t0.addingTimeInterval(2.5)), true) + XCTAssertTrue(d.committed) + } + + // MARK: - Service gating + + func test_start_whenUnreachable_suppressesTorAndRelays() { + let ctx = makeService(permission: .authorized, reachable: false) + ctx.service.start() + + XCTAssertFalse(ctx.service.activationAllowed) + XCTAssertFalse(ctx.service.isNetworkReachable) + XCTAssertTrue(ctx.reachability.startCalled) + XCTAssertEqual(ctx.torController.startIfNeededCallCount, 0) + XCTAssertEqual(ctx.torController.autoStartAllowedValues, [false]) + XCTAssertEqual(ctx.relayController.connectCallCount, 0) + XCTAssertEqual(ctx.relayController.disconnectCallCount, 1) + } + + func test_start_whenReachable_allowsTorAndRelays() { + let ctx = makeService(permission: .authorized, reachable: true) + ctx.service.start() + + XCTAssertTrue(ctx.service.activationAllowed) + XCTAssertEqual(ctx.torController.startIfNeededCallCount, 1) + XCTAssertEqual(ctx.relayController.connectCallCount, 1) + } + + func test_reachabilityRecovery_resumesTorAndRelays() async { + let ctx = makeService(permission: .authorized, reachable: false) + ctx.service.start() + XCTAssertFalse(ctx.service.activationAllowed) + + ctx.reachability.set(true) + let resumed = await waitUntil { ctx.service.activationAllowed } + XCTAssertTrue(resumed) + XCTAssertTrue(ctx.service.isNetworkReachable) + XCTAssertGreaterThanOrEqual(ctx.torController.startIfNeededCallCount, 1) + XCTAssertGreaterThanOrEqual(ctx.relayController.connectCallCount, 1) + } + + func test_reachabilityLoss_disconnectsRelaysAndStopsTor() async { + let ctx = makeService(permission: .authorized, reachable: true) + ctx.service.start() + XCTAssertTrue(ctx.service.activationAllowed) + let disconnectsBefore = ctx.relayController.disconnectCallCount + + ctx.reachability.set(false) + let suppressed = await waitUntil { !ctx.service.activationAllowed } + XCTAssertTrue(suppressed) + XCTAssertFalse(ctx.service.isNetworkReachable) + XCTAssertGreaterThan(ctx.relayController.disconnectCallCount, disconnectsBefore) + XCTAssertTrue(ctx.torController.autoStartAllowedValues.contains(false)) + XCTAssertGreaterThanOrEqual(ctx.torController.shutdownCompletelyCallCount, 1) + } + + // MARK: - Harness + + private func makeService( + permission: LocationChannelManager.PermissionState, + reachable: Bool + ) -> Context { + let suiteName = "NetworkReachabilityGateTests-\(UUID().uuidString)" + let storage = UserDefaults(suiteName: suiteName)! + storage.removePersistentDomain(forName: suiteName) + + let permissionSubject = CurrentValueSubject(permission) + let favoritesSubject = CurrentValueSubject, Never>([]) + let reachability = ControllableReachabilityMonitor(initial: reachable) + let torController = GateMockTorController() + let relayController = GateMockRelayController() + let proxyController = GateMockProxyController() + let service = NetworkActivationService( + storage: storage, + locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(), + mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(), + permissionProvider: { permissionSubject.value }, + mutualFavoritesProvider: { favoritesSubject.value }, + reachabilityMonitor: reachability, + torController: torController, + relayController: relayController, + proxyController: proxyController, + notificationCenter: NotificationCenter() + ) + return Context( + service: service, + reachability: reachability, + torController: torController, + relayController: relayController + ) + } + + private func waitUntil( + timeout: TimeInterval = 1.0, + condition: @escaping @MainActor () -> Bool + ) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { return true } + try? await Task.sleep(nanoseconds: 10_000_000) + } + return condition() + } +} + +@MainActor +private struct Context { + let service: NetworkActivationService + let reachability: ControllableReachabilityMonitor + let torController: GateMockTorController + let relayController: GateMockRelayController +} + +@MainActor +private final class ControllableReachabilityMonitor: NetworkReachabilityMonitoring { + private let subject: CurrentValueSubject + private(set) var startCalled = false + + init(initial: Bool) { + subject = CurrentValueSubject(initial) + } + + var isReachable: Bool { subject.value } + var reachabilityPublisher: AnyPublisher { + subject.removeDuplicates().dropFirst().eraseToAnyPublisher() + } + func start() { startCalled = true } + func set(_ reachable: Bool) { subject.send(reachable) } +} + +@MainActor +private final class GateMockTorController: NetworkActivationTorControlling { + private(set) var autoStartAllowedValues: [Bool] = [] + private(set) var startIfNeededCallCount = 0 + private(set) var shutdownCompletelyCallCount = 0 + func setAutoStartAllowed(_ allowed: Bool) { autoStartAllowedValues.append(allowed) } + func startIfNeeded() { startIfNeededCallCount += 1 } + func shutdownCompletely() { shutdownCompletelyCallCount += 1 } +} + +@MainActor +private final class GateMockRelayController: NetworkActivationRelayControlling { + private(set) var connectCallCount = 0 + private(set) var disconnectCallCount = 0 + func connect() { connectCallCount += 1 } + func disconnect() { disconnectCallCount += 1 } +} + +private final class GateMockProxyController: NetworkActivationProxyControlling { + private(set) var proxyModes: [Bool] = [] + func setProxyMode(useTor: Bool) { proxyModes.append(useTor) } +} From ede6368296d20e9dbec6266c1c72ef424282c28e Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:12:37 +0200 Subject: [PATCH 07/18] NIP-13 proof-of-work for geohash channels: mine on send, relax rate limits for PoW senders (#1382) * NIP-13 proof-of-work for geohash channels: mine on send, relax rate limits for PoW senders Outgoing kind-20000 geohash messages mine a NIP-13 nonce tag (8 leading zero bits, ~256 hashes, typically <1 ms) off the main actor before signing. Mining is hard-capped at 2 s and cancellable (newer send or channel switch): on cap/cancel the committed target steps down so the message still ships promptly with an honest commitment - sending is never blocked and nothing is dropped. The hot loop serializes the canonical event once and rewrites only the fixed-width nonce bytes. Inbound kind-20000 events are scored per NIP-13 commitment semantics (committed target counts; the ID must actually meet it, extra work earns nothing) and never hard-rejected: validated PoW >= 8 bits skips the per-sender rate-limit bucket while the per-content flood bucket still applies, so old non-mining clients keep working under today's strict limits while bulk spam gets expensive. Presence heartbeats (kind 20001), kind-1 notes, and DMs are unchanged; no UI beyond a pow= field in an existing sampled debug log. Reimplemented from scratch rather than cherry-picking the stale feature/pow-geohash-mining-ui branch (unbounded loop, hard receive filtering, mining UI, XCTest, force unwraps). Co-Authored-By: Claude Fable 5 * Geohash: serialize PoW sends so order matches send order Two location-channel sends back-to-back only cancelled the previous mining task and started a new one. Cancellation merely *expedites* NIP-13 mining (the target is polled and steps down; it never aborts the send), so the cancelled task still appended + relayed once mining returned. Both tasks ran concurrently and the second (shorter to mine) could finish first, reordering messages in the timeline and on relays. Chain the mining tasks: each geohash send captures the previous send's task, cancels it (to expedite, so delays never stack), and awaits its completion before it echoes and relays. Order is now always send order. The >2s mining cap is preserved: cancellation expedites the awaited task, so a send is never blocked beyond NostrPoW.miningTimeCap. Test: two rapid sends where the first mines longer (larger content) still land in send order for both the local echo and the relayed events. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/Nostr/NostrPoW.swift | 215 +++++++++++++++++ bitchat/Nostr/NostrProtocol.swift | 67 +++++- .../ViewModels/ChatLifecycleCoordinator.swift | 2 +- .../ViewModels/ChatOutgoingCoordinator.swift | 197 ++++++++-------- .../ChatPublicConversationCoordinator.swift | 18 +- bitchat/ViewModels/ChatViewModel.swift | 10 + bitchat/ViewModels/MessageRateLimiter.swift | 28 ++- bitchat/ViewModels/NostrInboundPipeline.swift | 14 +- .../ChatNostrCoordinatorContextTests.swift | 1 + .../ChatOutgoingCoordinatorContextTests.swift | 34 +++ ...cConversationCoordinatorContextTests.swift | 6 +- bitchatTests/MessageRateLimiterTests.swift | 119 ++++++++++ bitchatTests/Nostr/NostrPoWTests.swift | 222 ++++++++++++++++++ .../PerformanceBaselineTests.swift | 1 + 14 files changed, 808 insertions(+), 126 deletions(-) create mode 100644 bitchat/Nostr/NostrPoW.swift create mode 100644 bitchatTests/MessageRateLimiterTests.swift create mode 100644 bitchatTests/Nostr/NostrPoWTests.swift diff --git a/bitchat/Nostr/NostrPoW.swift b/bitchat/Nostr/NostrPoW.swift new file mode 100644 index 00000000..006bba6d --- /dev/null +++ b/bitchat/Nostr/NostrPoW.swift @@ -0,0 +1,215 @@ +import BitFoundation +import CryptoKit +import Foundation + +/// NIP-13 proof-of-work for Nostr events. +/// +/// Outgoing kind-20000 geohash messages mine a `["nonce", "", ""]` +/// tag so the event ID carries at least `target` leading zero bits. Inbound +/// events are scored (never hard-rejected — the network has clients that do +/// not mine): validated PoW at or above `rateLimitBypassBits` relaxes the +/// per-sender public rate limit, everything else keeps the strict limits. +enum NostrPoW { + + // MARK: - Tuning + + /// Difficulty (leading zero bits of the event ID) mined onto outgoing + /// geohash messages. 8 bits is ~256 hash attempts — typically well under + /// 100 ms on any supported device. + static let targetBits = 8 + + /// Inbound events whose validated NIP-13 difficulty is at least this many + /// bits skip the per-sender rate-limit bucket (the content-flood bucket + /// still applies). See `MessageRateLimiter.allow`. + static let rateLimitBypassBits = 8 + + /// Hard cap on mining wall-clock time. When it hits, the committed target + /// steps down until a difficulty reachable in a small extra budget is + /// found and the message is sent anyway — mining never blocks sending. + static let miningTimeCap: TimeInterval = 2.0 + + /// Budget for each stepped-down attempt after the main cap (or a task + /// cancellation) hits. + private static let fallbackTimeCap: TimeInterval = 0.15 + + /// The hot loop checks the deadline and task cancellation every this many + /// hash attempts. + private static let checkInterval: UInt64 = 1024 + + /// The nonce value is a fixed-width hex counter so the serialized event + /// template can be mutated in place without reallocation. + private static let nonceLength = 16 + + // MARK: - Scoring + + /// Number of leading zero bits in a byte sequence (NIP-13 difficulty of + /// an event-ID hash). + static func leadingZeroBits>(_ bytes: Bytes) -> Int { + var total = 0 + for byte in bytes { + if byte == 0 { + total += 8 + } else { + total += byte.leadingZeroBitCount + break + } + } + return total + } + + /// Validated NIP-13 difficulty of an inbound event. + /// + /// The committed target in the nonce tag is what counts: the actual + /// leading zero bits of the ID must meet it (otherwise the claim is void + /// and the event scores 0), and work beyond the commitment earns no extra + /// credit — this stops spammers who mine a low target from getting lucky + /// high scores. Events without a well-formed commitment score 0. + static func validatedDifficulty(idHex: String, tags: [[String]]) -> Int { + guard let nonceTag = tags.last(where: { $0.first == "nonce" }), + nonceTag.count >= 3, + let committed = Int(nonceTag[2]), + committed > 0, committed <= 256, + let idData = Data(hexString: idHex) + else { + return 0 + } + return leadingZeroBits(idData) >= committed ? committed : 0 + } + + // MARK: - Mining + + /// Mine a `["nonce", value, target]` tag for the given unsigned-event + /// fields. Nonisolated async: runs off the calling actor. + /// + /// Bounded by `miningTimeCap`: when the cap hits — or the surrounding + /// task is cancelled — the committed target steps down (halving to 0, + /// which any hash satisfies) so the event still ships promptly with an + /// honest commitment at the difficulty actually reached. Returns nil only + /// if canonical serialization fails; the caller then sends unmined. + static func mineNonceTag( + pubkey: String, + createdAt: Int, + kind: Int, + tags: [[String]], + content: String, + targetBits: Int = NostrPoW.targetBits + ) async -> [String]? { + var target = min(max(targetBits, 0), 256) + var budget = miningTimeCap + while true { + if let tag = mineAttempt( + pubkey: pubkey, + createdAt: createdAt, + kind: kind, + baseTags: tags, + content: content, + targetBits: target, + budget: budget + ) { + return tag + } + // Target 0 succeeds on the first hash, so reaching it with nil + // means serialization itself failed — give up on mining. + if target == 0 { return nil } + target /= 2 + budget = fallbackTimeCap + } + } + + /// One bounded mining pass at a fixed committed target. Allocation-light: + /// the canonical serialization is built once and only the fixed-width + /// nonce bytes are rewritten per attempt (the event ID is recomputed for + /// every attempt, per NIP-13). Returns nil on timeout/cancellation or if + /// the template could not be built. + private static func mineAttempt( + pubkey: String, + createdAt: Int, + kind: Int, + baseTags: [[String]], + content: String, + targetBits: Int, + budget: TimeInterval + ) -> [String]? { + let targetString = String(targetBits) + guard let template = serializedTemplate( + pubkey: pubkey, + createdAt: createdAt, + kind: kind, + baseTags: baseTags, + content: content, + targetString: targetString + ) else { + return nil + } + var buffer = template.buffer + let nonceRange = template.nonceRange + + let deadline = DispatchTime.now().uptimeNanoseconds &+ UInt64(budget * 1_000_000_000) + let hexDigits = [UInt8]("0123456789abcdef".utf8) + var nonce = UInt64.random(in: .min ... .max) + var attempts: UInt64 = 0 + + while true { + // Write the nonce as 16 lowercase hex chars, in place. + var value = nonce + var index = nonceRange.upperBound + while index > nonceRange.lowerBound { + index -= 1 + buffer[index] = hexDigits[Int(value & 0xF)] + value >>= 4 + } + + if leadingZeroBits(SHA256.hash(data: buffer)) >= targetBits { + // Identical to the bytes just written into the buffer. + return ["nonce", String(format: "%016llx", nonce), targetString] + } + + nonce &+= 1 + attempts &+= 1 + if attempts % checkInterval == 0, + Task.isCancelled || DispatchTime.now().uptimeNanoseconds >= deadline { + return nil + } + } + } + + /// Canonical NIP-01 serialization of the event with a placeholder nonce, + /// plus the byte range of the nonce value inside it. + /// + /// The range is located by serializing twice with two same-length + /// placeholders and diffing the buffers — the only differing bytes are + /// the nonce value, so this stays correct however `JSONSerialization` + /// escapes the surrounding fields (and even if the content contains the + /// placeholder text itself). + private static func serializedTemplate( + pubkey: String, + createdAt: Int, + kind: Int, + baseTags: [[String]], + content: String, + targetString: String + ) -> (buffer: Data, nonceRange: Range)? { + func serialize(noncePlaceholder: String) -> Data? { + var tags = baseTags + tags.append(["nonce", noncePlaceholder, targetString]) + let serialized: [Any] = [0, pubkey, createdAt, kind, tags, content] + return try? JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes]) + } + + guard let zeros = serialize(noncePlaceholder: String(repeating: "0", count: nonceLength)), + let effs = serialize(noncePlaceholder: String(repeating: "f", count: nonceLength)), + zeros.count == effs.count + else { + return nil + } + + var firstDiff = -1 + var lastDiff = -1 + for index in 0..= 0, lastDiff - firstDiff + 1 == nonceLength else { return nil } + return (zeros, firstDiff..<(firstDiff + nonceLength)) + } +} diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index d59a5200..83578e2b 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -170,6 +170,63 @@ struct NostrProtocol { nickname: String? = nil, teleported: Bool = false ) throws -> NostrEvent { + let event = NostrEvent( + pubkey: senderIdentity.publicKeyHex, + createdAt: Date(), + kind: .ephemeralEvent, + tags: ephemeralGeohashTags(geohash: geohash, nickname: nickname, teleported: teleported), + content: content + ) + let schnorrKey = try senderIdentity.schnorrSigningKey() + return try event.sign(with: schnorrKey) + } + + /// Create a kind-20000 geohash message carrying a NIP-13 proof-of-work + /// nonce tag (see `NostrPoW`). Mining runs off the calling actor and is + /// bounded by `NostrPoW.miningTimeCap`; when the cap hits (or the + /// surrounding task is cancelled) the event ships at the highest + /// committed difficulty still met, and if mining is impossible it ships + /// unmined — sending is never blocked. + static func createMinedEphemeralGeohashEvent( + content: String, + geohash: String, + senderIdentity: NostrIdentity, + nickname: String? = nil, + teleported: Bool = false, + powTargetBits: Int = NostrPoW.targetBits + ) async throws -> NostrEvent { + var tags = ephemeralGeohashTags(geohash: geohash, nickname: nickname, teleported: teleported) + // Fix created_at up front: the mined nonce commits to the full + // serialized event, so the signed event must reuse the exact value. + let createdAt = Int(Date().timeIntervalSince1970) + if let nonceTag = await NostrPoW.mineNonceTag( + pubkey: senderIdentity.publicKeyHex, + createdAt: createdAt, + kind: EventKind.ephemeralEvent.rawValue, + tags: tags, + content: content, + targetBits: powTargetBits + ) { + tags.append(nonceTag) + } + let event = NostrEvent( + pubkey: senderIdentity.publicKeyHex, + createdAt: Date(timeIntervalSince1970: TimeInterval(createdAt)), + kind: .ephemeralEvent, + tags: tags, + content: content + ) + let schnorrKey = try senderIdentity.schnorrSigningKey() + return try event.sign(with: schnorrKey) + } + + /// Tags for a kind-20000 geohash message (shared by the plain and mined + /// variants). + private static func ephemeralGeohashTags( + geohash: String, + nickname: String?, + teleported: Bool + ) -> [[String]] { var tags = [["g", geohash]] if let nickname = nickname?.trimmedOrNilIfEmpty { tags.append(["n", nickname]) @@ -177,15 +234,7 @@ struct NostrProtocol { if teleported { tags.append(["t", "teleport"]) } - let event = NostrEvent( - pubkey: senderIdentity.publicKeyHex, - createdAt: Date(), - kind: .ephemeralEvent, - tags: tags, - content: content - ) - let schnorrKey = try senderIdentity.schnorrSigningKey() - return try event.sign(with: schnorrKey) + return tags } /// Create a geohash presence heartbeat (kind 20001) diff --git a/bitchat/ViewModels/ChatLifecycleCoordinator.swift b/bitchat/ViewModels/ChatLifecycleCoordinator.swift index bea1b5b2..60c199f6 100644 --- a/bitchat/ViewModels/ChatLifecycleCoordinator.swift +++ b/bitchat/ViewModels/ChatLifecycleCoordinator.swift @@ -324,7 +324,7 @@ private extension ChatLifecycleCoordinator { do { let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash) - let event = try NostrProtocol.createEphemeralGeohashEvent( + let event = try await NostrProtocol.createMinedEphemeralGeohashEvent( content: message, geohash: channel.geohash, senderIdentity: identity, diff --git a/bitchat/ViewModels/ChatOutgoingCoordinator.swift b/bitchat/ViewModels/ChatOutgoingCoordinator.swift index 915ea7fa..8d8b6a9e 100644 --- a/bitchat/ViewModels/ChatOutgoingCoordinator.swift +++ b/bitchat/ViewModels/ChatOutgoingCoordinator.swift @@ -68,10 +68,22 @@ extension ChatViewModel: ChatOutgoingContext { final class ChatOutgoingCoordinator { private unowned let context: any ChatOutgoingContext + /// In-flight NIP-13 mining for the most recent geohash send. A newer send + /// (or leaving the channel) cancels it, which only expedites the mining — + /// the message still goes out at the difficulty already reached. + /// (Read access is internal so tests can await the send's completion.) + private(set) var geohashMiningTask: Task? + init(context: any ChatOutgoingContext) { self.context = context } + /// Finish any in-flight geohash PoW mining early (the pending message + /// still sends, at whatever committed difficulty it reached). + func expeditePendingGeohashMining() { + geohashMiningTask?.cancel() + } + func sendMessage(_ content: String) { guard let trimmed = content.trimmedOrNilIfEmpty else { return } @@ -92,120 +104,117 @@ final class ChatOutgoingCoordinator { } let mentions = context.parseMentions(from: content) - let preparedMessage = preparePublicMessage(content: content, trimmed: trimmed, mentions: mentions) - guard let preparedMessage else { return } - appendLocalEcho(preparedMessage.message) - routePublicMessage( - originalContent: content, - mentions: mentions, - geoContext: preparedMessage.geoContext, - messageID: preparedMessage.message.id, - timestamp: preparedMessage.message.timestamp - ) + switch context.activeChannel { + case .mesh: + sendMeshPublicMessage(originalContent: content, trimmed: trimmed, mentions: mentions) + case .location(let channel): + sendGeohashPublicMessage(trimmed, mentions: mentions, channel: channel) + } } } private extension ChatOutgoingCoordinator { - func preparePublicMessage( - content: String, - trimmed: String, - mentions: [String] - ) -> (message: BitchatMessage, geoContext: ChatViewModel.GeoOutgoingContext?)? { - var geoContext: ChatViewModel.GeoOutgoingContext? - var displaySender = context.nickname - var localSenderPeerID = context.myPeerID - var messageID: String? - var messageTimestamp = Date() + func sendMeshPublicMessage(originalContent: String, trimmed: String, mentions: [String]) { + let message = BitchatMessage( + sender: context.nickname, + content: trimmed, + timestamp: Date(), + isRelay: false, + senderPeerID: context.myPeerID, + mentions: mentions.isEmpty ? nil : mentions + ) - switch context.activeChannel { - case .mesh: - break + appendLocalEcho(message, to: .mesh) + context.recordPublicActivity(forChannelKey: "mesh") + context.sendMeshMessage( + originalContent, + mentions: mentions, + messageID: message.id, + timestamp: message.timestamp + ) + } - case .location(let channel): + /// Geohash sends mine a NIP-13 nonce tag first (off the main actor, see + /// `NostrPoW`), so the whole echo-and-send runs in a task once the signed + /// event — whose ID is also the local message ID — exists. Typical mining + /// at the default target is well under 100 ms and hard-capped at + /// `NostrPoW.miningTimeCap`, so sending is never meaningfully delayed. + func sendGeohashPublicMessage(_ trimmed: String, mentions: [String], channel: GeohashChannel) { + let identity: NostrIdentity + do { + identity = try context.deriveNostrIdentity(forGeohash: channel.geohash) + } catch { + SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session) + context.addSystemMessage( + String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") + ) + return + } + + let displaySender = context.nickname + "#" + String(identity.publicKeyHex.suffix(4)) + let senderPeerID = PeerID(nostr: identity.publicKeyHex) + let teleported = context.isTeleported + let nickname = context.nickname + + // Serialize geohash sends: each send awaits the previous send's task + // before it appends + relays, so user-visible order always matches + // send order even when an earlier message mines longer than a later + // one. Cancelling the previous task only *expedites* its mining (the + // NIP-13 target is polled, not aborted), so it still finishes and + // sends — and it finishes fast, so awaiting it never stacks mining + // delays or blocks a send beyond `NostrPoW.miningTimeCap`. + let previousSend = geohashMiningTask + previousSend?.cancel() + geohashMiningTask = Task { @MainActor [weak context = self.context] in + await previousSend?.value + + let event: NostrEvent do { - let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash) - let suffix = String(identity.publicKeyHex.suffix(4)) - displaySender = context.nickname + "#" + suffix - localSenderPeerID = PeerID(nostr: identity.publicKeyHex) - - let teleported = context.isTeleported - let event = try NostrProtocol.createEphemeralGeohashEvent( + event = try await NostrProtocol.createMinedEphemeralGeohashEvent( content: trimmed, geohash: channel.geohash, senderIdentity: identity, - nickname: context.nickname, - teleported: teleported - ) - - messageID = event.id - messageTimestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - geoContext = ( - channel: channel, - event: event, - identity: identity, + nickname: nickname, teleported: teleported ) } catch { SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session) - context.addSystemMessage( - String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") - ) - return nil - } - } - - let message = BitchatMessage( - id: messageID, - sender: displaySender, - content: trimmed, - timestamp: messageTimestamp, - isRelay: false, - senderPeerID: localSenderPeerID, - mentions: mentions.isEmpty ? nil : mentions - ) - - return (message, geoContext) - } - - func appendLocalEcho(_ message: BitchatMessage) { - context.appendPublicMessage(message, to: ConversationID(channelID: context.activeChannel)) - - let contentKey = context.normalizedContentKey(message.content) - context.recordContentKey(contentKey, timestamp: message.timestamp) - } - - func routePublicMessage( - originalContent: String, - mentions: [String], - geoContext: ChatViewModel.GeoOutgoingContext?, - messageID: String, - timestamp: Date - ) { - switch context.activeChannel { - case .mesh: - context.recordPublicActivity(forChannelKey: "mesh") - context.sendMeshMessage( - originalContent, - mentions: mentions, - messageID: messageID, - timestamp: timestamp - ) - - case .location(let channel): - context.recordPublicActivity(forChannelKey: "geo:\(channel.geohash)") - - guard let geoContext, geoContext.channel.geohash == channel.geohash else { - SecureLogger.error("Geo: missing send context for \(channel.geohash)", category: .session) - context.addSystemMessage( + context?.addSystemMessage( String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") ) return } + guard let context else { return } - Task { @MainActor [weak context = self.context] in - context?.sendGeohash(context: geoContext) - } + let message = BitchatMessage( + id: event.id, + sender: displaySender, + content: trimmed, + timestamp: Date(timeIntervalSince1970: TimeInterval(event.created_at)), + isRelay: false, + senderPeerID: senderPeerID, + mentions: mentions.isEmpty ? nil : mentions + ) + + context.appendPublicMessage(message, to: ConversationID(channelID: .location(channel))) + let contentKey = context.normalizedContentKey(message.content) + context.recordContentKey(contentKey, timestamp: message.timestamp) + + context.recordPublicActivity(forChannelKey: "geo:\(channel.geohash)") + context.sendGeohash(context: ( + channel: channel, + event: event, + identity: identity, + teleported: teleported + )) } } + + func appendLocalEcho(_ message: BitchatMessage, to conversationID: ConversationID) { + context.appendPublicMessage(message, to: conversationID) + + let contentKey = context.normalizedContentKey(message.content) + context.recordContentKey(contentKey, timestamp: message.timestamp) + } } diff --git a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift index d6777a71..5a9ad6db 100644 --- a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift @@ -82,7 +82,9 @@ protocol ChatPublicConversationContext: AnyObject { // MARK: Inbound public message processing func processActionMessage(_ message: BitchatMessage) -> BitchatMessage func isMessageBlocked(_ message: BitchatMessage) -> Bool - func allowPublicMessage(senderKey: String, contentKey: String) -> Bool + /// `powBits` is the validated NIP-13 difficulty of the source Nostr event + /// (0 for mesh messages); sufficient PoW relaxes the per-sender bucket. + func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool /// Buffers a visible-channel message for the batched (~80 ms) pipeline /// flush, which commits it to `conversationID` in the store. func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) @@ -137,8 +139,8 @@ extension ChatViewModel: ChatPublicConversationContext { meshService.sendMessage(content, mentions: mentions, messageID: messageID, timestamp: timestamp) } - func allowPublicMessage(senderKey: String, contentKey: String) -> Bool { - publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey) + func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool { + publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey, powBits: powBits) } func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) { @@ -367,7 +369,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { guard let context else { return } do { let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash) - let event = try NostrProtocol.createEphemeralGeohashEvent( + let event = try await NostrProtocol.createMinedEphemeralGeohashEvent( content: content, geohash: channel.geohash, senderIdentity: identity, @@ -395,7 +397,11 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { ) } - func handlePublicMessage(_ message: BitchatMessage) { + /// - Parameter powBits: validated NIP-13 difficulty of the source Nostr + /// event (0 for mesh messages). Sufficient PoW relaxes the per-sender + /// rate limit; low/no-PoW events keep the strict limits so old clients + /// still get through at normal rates. + func handlePublicMessage(_ message: BitchatMessage, powBits: Int = 0) { let finalMessage = context.processActionMessage(message) if context.isMessageBlocked(finalMessage) { return } @@ -405,7 +411,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { if shouldRateLimit { let senderKey = normalizedSenderKey(for: finalMessage) let contentKey = context.normalizedContentKey(finalMessage.content) - if !context.allowPublicMessage(senderKey: senderKey, contentKey: contentKey) { + if !context.allowPublicMessage(senderKey: senderKey, contentKey: contentKey, powBits: powBits) { return } } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 578b6509..d995ecff 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -311,6 +311,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele get { conversations.activeChannel } set { guard conversations.activeChannel != newValue else { return } + // Leaving a channel expedites any in-flight NIP-13 mining: the + // pending message still sends, at the difficulty already reached. + outgoingCoordinator.expeditePendingGeohashMining() conversations.setActiveChannel(newValue) visibleMessagesCache = nil objectWillChange.send() @@ -1683,6 +1686,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele publicConversationCoordinator.handlePublicMessage(message) } + /// Handle an incoming public Nostr message with its validated NIP-13 + /// difficulty; sufficient PoW relaxes the per-sender rate limit. + @MainActor + func handlePublicMessage(_ message: BitchatMessage, powBits: Int) { + publicConversationCoordinator.handlePublicMessage(message, powBits: powBits) + } + /// Check for mentions and send notifications func checkForMentions(_ message: BitchatMessage) { publicConversationCoordinator.checkForMentions(message) diff --git a/bitchat/ViewModels/MessageRateLimiter.swift b/bitchat/ViewModels/MessageRateLimiter.swift index 5bca9a6d..a0309447 100644 --- a/bitchat/ViewModels/MessageRateLimiter.swift +++ b/bitchat/ViewModels/MessageRateLimiter.swift @@ -48,15 +48,25 @@ struct MessageRateLimiter { self.contentRefill = contentRefillPerSec } - mutating func allow(senderKey: String, contentKey: String, now: Date = Date()) -> Bool { - var senderBucket = senderBuckets[senderKey] ?? TokenBucket( - capacity: senderCapacity, - tokens: senderCapacity, - refillPerSec: senderRefill, - lastRefill: now - ) - let senderAllowed = senderBucket.allow(now: now) - senderBuckets[senderKey] = senderBucket + /// - Parameter powBits: validated NIP-13 difficulty of the event + /// (`NostrPoW.validatedDifficulty`; 0 for mesh or no-PoW events). + /// At or above `NostrPoW.rateLimitBypassBits` the per-sender bucket is + /// skipped entirely — each such message paid for itself with work — but + /// the per-content flood bucket still applies. + mutating func allow(senderKey: String, contentKey: String, powBits: Int = 0, now: Date = Date()) -> Bool { + let senderAllowed: Bool + if powBits >= NostrPoW.rateLimitBypassBits { + senderAllowed = true + } else { + var senderBucket = senderBuckets[senderKey] ?? TokenBucket( + capacity: senderCapacity, + tokens: senderCapacity, + refillPerSec: senderRefill, + lastRefill: now + ) + senderAllowed = senderBucket.allow(now: now) + senderBuckets[senderKey] = senderBucket + } var contentBucket = contentBuckets[contentKey] ?? TokenBucket( capacity: contentCapacity, diff --git a/bitchat/ViewModels/NostrInboundPipeline.swift b/bitchat/ViewModels/NostrInboundPipeline.swift index 337fa474..2d2e6825 100644 --- a/bitchat/ViewModels/NostrInboundPipeline.swift +++ b/bitchat/ViewModels/NostrInboundPipeline.swift @@ -32,7 +32,10 @@ protocol NostrInboundPipelineContext: AnyObject { func recordGeoParticipant(pubkeyHex: String) // MARK: Inbound public messages - func handlePublicMessage(_ message: BitchatMessage) + /// `powBits` is the validated NIP-13 difficulty of the source event + /// (`NostrPoW.validatedDifficulty`); it relaxes the per-sender rate limit + /// downstream. + func handlePublicMessage(_ message: BitchatMessage, powBits: Int) func checkForMentions(_ message: BitchatMessage) func sendHapticFeedback(for message: BitchatMessage) func parseMentions(from content: String) -> [String] @@ -152,6 +155,7 @@ final class NostrInboundPipeline { let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) let timestamp = min(rawTs, Date()) let mentions = context.parseMentions(from: content) + let powBits = NostrPoW.validatedDifficulty(idHex: event.id, tags: event.tags) let message = BitchatMessage( id: event.id, sender: senderName, @@ -165,7 +169,7 @@ final class NostrInboundPipeline { Task { @MainActor [weak context] in guard let context else { return } let isBlocked = context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) - context.handlePublicMessage(message) + context.handlePublicMessage(message, powBits: powBits) if !isBlocked { context.checkForMentions(message) context.sendHapticFeedback(for: message) @@ -187,10 +191,12 @@ final class NostrInboundPipeline { guard event.isValidSignature() else { return } context.recordProcessedNostrEvent(event.id) + let powBits = NostrPoW.validatedDifficulty(idHex: event.id, tags: event.tags) + // Sampled: fires for every geo event and floods dev logs in busy geohashes. geoEventLogCount += 1 if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) { - SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session) + SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… pow=\(powBits) tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session) } if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) { @@ -255,7 +261,7 @@ final class NostrInboundPipeline { Task { @MainActor [weak context] in guard let context else { return } - context.handlePublicMessage(message) + context.handlePublicMessage(message, powBits: powBits) context.checkForMentions(message) context.sendHapticFeedback(for: message) } diff --git a/bitchatTests/ChatNostrCoordinatorContextTests.swift b/bitchatTests/ChatNostrCoordinatorContextTests.swift index 7777310e..5cbd0c7d 100644 --- a/bitchatTests/ChatNostrCoordinatorContextTests.swift +++ b/bitchatTests/ChatNostrCoordinatorContextTests.swift @@ -71,6 +71,7 @@ private final class MockChatNostrContext: ChatNostrContext { private(set) var hapticMessageIDs: [String] = [] func handlePublicMessage(_ message: BitchatMessage) { handledPublicMessages.append(message) } + func handlePublicMessage(_ message: BitchatMessage, powBits: Int) { handledPublicMessages.append(message) } func checkForMentions(_ message: BitchatMessage) { mentionCheckedMessageIDs.append(message.id) } func sendHapticFeedback(for message: BitchatMessage) { hapticMessageIDs.append(message.id) } func parseMentions(from content: String) -> [String] { [] } diff --git a/bitchatTests/ChatOutgoingCoordinatorContextTests.swift b/bitchatTests/ChatOutgoingCoordinatorContextTests.swift index 6ed7c6da..25aa8300 100644 --- a/bitchatTests/ChatOutgoingCoordinatorContextTests.swift +++ b/bitchatTests/ChatOutgoingCoordinatorContextTests.swift @@ -192,6 +192,9 @@ struct ChatOutgoingCoordinatorContextTests { context.isTeleported = true coordinator.sendMessage("hello geo") + // Geohash sends mine a NIP-13 nonce tag off-main before echoing and + // sending; await the send task, then drain the main queue. + await coordinator.geohashMiningTask?.value await drainMainActorTasks() // Local echo carries the geohash sender suffix (#last-4-of-pubkey) and @@ -215,4 +218,35 @@ struct ChatOutgoingCoordinatorContextTests { #expect(context.appendedPublicMessages.count == 1) #expect(context.sentGeohashContexts.count == 1) } + + @Test @MainActor + func sendMessage_onLocationChannel_serializesRapidSendsInSendOrder() async { + let context = MockChatOutgoingContext() + let coordinator = ChatOutgoingCoordinator(context: context) + let channel = GeohashChannel(level: .city, geohash: "u4pruydq") + context.activeChannel = .location(channel) + + // Two back-to-back sends. The first carries much larger content, so + // its NIP-13 mining hashes a bigger event per attempt and runs longer + // than the second's. Without serialization the second (faster) task + // could finish first and reorder both the local timeline and the + // relayed events. The coordinator chains the mining tasks — each send + // awaits the previous send's task before it echoes and relays — so the + // visible order must always match the send order. + let first = "first " + String(repeating: "x", count: 4000) + let second = "second" + coordinator.sendMessage(first) + coordinator.sendMessage(second) + + // The stored task is the second send, which awaits the first. + await coordinator.geohashMiningTask?.value + await drainMainActorTasks() + + // Local echoes land in send order… + #expect(context.appendedPublicMessages.map(\.message.content) == [first, second]) + // …and so do the relayed events (IDs match the echoes 1:1, in order). + #expect(context.sentGeohashContexts.count == 2) + #expect(context.sentGeohashContexts.map(\.event.id) + == context.appendedPublicMessages.map(\.message.id)) + } } diff --git a/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift b/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift index 9bcd2b93..be76fe82 100644 --- a/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift +++ b/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift @@ -186,7 +186,7 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon // Inbound public message processing var blockedMessageIDs: Set = [] var rateLimitAllowed = true - private(set) var rateLimitChecks: [(senderKey: String, contentKey: String)] = [] + private(set) var rateLimitChecks: [(senderKey: String, contentKey: String, powBits: Int)] = [] private(set) var enqueuedMessages: [(messageID: String, conversationID: ConversationID)] = [] var enqueuedMessageIDs: [String] { enqueuedMessages.map(\.messageID) } var stablePeerIDs: [PeerID: PeerID] = [:] @@ -199,8 +199,8 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon blockedMessageIDs.contains(message.id) } - func allowPublicMessage(senderKey: String, contentKey: String) -> Bool { - rateLimitChecks.append((senderKey, contentKey)) + func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool { + rateLimitChecks.append((senderKey, contentKey, powBits)) return rateLimitAllowed } diff --git a/bitchatTests/MessageRateLimiterTests.swift b/bitchatTests/MessageRateLimiterTests.swift new file mode 100644 index 00000000..c9959761 --- /dev/null +++ b/bitchatTests/MessageRateLimiterTests.swift @@ -0,0 +1,119 @@ +// +// MessageRateLimiterTests.swift +// bitchatTests +// +// Tests for the public-intake token buckets, including the NIP-13 +// proof-of-work relaxation of the per-sender bucket. +// + +import Foundation +import Testing +@testable import bitchat + +struct MessageRateLimiterTests { + + private func makeLimiter( + senderCapacity: Double = 2, + contentCapacity: Double = 100 + ) -> MessageRateLimiter { + MessageRateLimiter( + senderCapacity: senderCapacity, + senderRefillPerSec: 0.0001, + contentCapacity: contentCapacity, + contentRefillPerSec: 0.0001 + ) + } + + @Test func senderBucketBlocksAfterCapacity() { + var limiter = makeLimiter() + let now = Date() + + let first = limiter.allow(senderKey: "s", contentKey: "c1", now: now) + let second = limiter.allow(senderKey: "s", contentKey: "c2", now: now) + let third = limiter.allow(senderKey: "s", contentKey: "c3", now: now) + let otherSender = limiter.allow(senderKey: "other", contentKey: "c4", now: now) + + #expect(first) + #expect(second) + #expect(!third) + #expect(otherSender) + } + + @Test func validPoWBypassesExhaustedSenderBucket() { + var limiter = makeLimiter() + let now = Date() + + // Exhaust the sender bucket with plain (no-PoW) messages. + let first = limiter.allow(senderKey: "s", contentKey: "c1", now: now) + let second = limiter.allow(senderKey: "s", contentKey: "c2", now: now) + let exhausted = limiter.allow(senderKey: "s", contentKey: "c3", now: now) + + // A message carrying sufficient validated PoW still passes, and so + // does more-than-sufficient PoW; plain messages stay blocked. + let powExact = limiter.allow( + senderKey: "s", + contentKey: "c4", + powBits: NostrPoW.rateLimitBypassBits, + now: now + ) + let powHigh = limiter.allow(senderKey: "s", contentKey: "c5", powBits: 20, now: now) + let plainAgain = limiter.allow(senderKey: "s", contentKey: "c6", now: now) + + #expect(first) + #expect(second) + #expect(!exhausted) + #expect(powExact) + #expect(powHigh) + #expect(!plainAgain) + } + + @Test func lowPoWDoesNotBypassSenderBucket() { + var limiter = makeLimiter(senderCapacity: 1) + let now = Date() + + let first = limiter.allow(senderKey: "s", contentKey: "c1", now: now) + let lowPow = limiter.allow( + senderKey: "s", + contentKey: "c2", + powBits: NostrPoW.rateLimitBypassBits - 1, + now: now + ) + let zeroPow = limiter.allow(senderKey: "s", contentKey: "c3", powBits: 0, now: now) + + #expect(first) + #expect(!lowPow) + #expect(!zeroPow) + } + + @Test func powDoesNotBypassContentFloodBucket() { + var limiter = makeLimiter(senderCapacity: 100, contentCapacity: 1) + let now = Date() + + let first = limiter.allow(senderKey: "a", contentKey: "same", now: now) + // Identical content spammed with PoW is still throttled by the + // content bucket: PoW only relaxes the per-sender limit. + let powSameContent = limiter.allow(senderKey: "b", contentKey: "same", powBits: 20, now: now) + let powNewContent = limiter.allow(senderKey: "b", contentKey: "different", powBits: 20, now: now) + + #expect(first) + #expect(!powSameContent) + #expect(powNewContent) + } + + @Test func powBypassDoesNotDrainSenderBucket() { + var limiter = makeLimiter(senderCapacity: 1) + let now = Date() + + // PoW messages don't consume sender tokens, so a subsequent plain + // message still has its full budget. + let powFirst = limiter.allow(senderKey: "s", contentKey: "c1", powBits: 20, now: now) + let powSecond = limiter.allow(senderKey: "s", contentKey: "c2", powBits: 20, now: now) + let plain = limiter.allow(senderKey: "s", contentKey: "c3", now: now) + let plainExhausted = limiter.allow(senderKey: "s", contentKey: "c4", now: now) + + #expect(powFirst) + #expect(powSecond) + #expect(plain) + #expect(!plainExhausted) + } +} diff --git a/bitchatTests/Nostr/NostrPoWTests.swift b/bitchatTests/Nostr/NostrPoWTests.swift new file mode 100644 index 00000000..1905e0f0 --- /dev/null +++ b/bitchatTests/Nostr/NostrPoWTests.swift @@ -0,0 +1,222 @@ +// +// NostrPoWTests.swift +// bitchatTests +// +// Tests for NIP-13 proof-of-work: leading-zero-bit counting, commitment +// semantics, and nonce-tag mining for geohash (kind 20000) events. +// + +import CryptoKit +import Foundation +import Testing +import BitFoundation +@testable import bitchat + +struct NostrPoWTests { + + // MARK: - Leading zero bits + + @Test func leadingZeroBitsVectors() { + #expect(NostrPoW.leadingZeroBits(Data()) == 0) + #expect(NostrPoW.leadingZeroBits(Data([0x80])) == 0) + #expect(NostrPoW.leadingZeroBits(Data([0xFF, 0x00])) == 0) + #expect(NostrPoW.leadingZeroBits(Data([0x40])) == 1) + #expect(NostrPoW.leadingZeroBits(Data([0x01])) == 7) + #expect(NostrPoW.leadingZeroBits(Data([0x00, 0x00, 0xF0])) == 16) + #expect(NostrPoW.leadingZeroBits(Data(repeating: 0x00, count: 32)) == 256) + } + + @Test func leadingZeroBitsExactByteBoundaries() { + // Zero byte contributes exactly 8, then the next byte decides. + #expect(NostrPoW.leadingZeroBits(Data([0x00, 0xFF])) == 8) + #expect(NostrPoW.leadingZeroBits(Data([0x00, 0x80])) == 8) + #expect(NostrPoW.leadingZeroBits(Data([0x00, 0x7F])) == 9) + #expect(NostrPoW.leadingZeroBits(Data([0x00, 0x01])) == 15) + #expect(NostrPoW.leadingZeroBits(Data([0x00, 0x00, 0x01])) == 23) + } + + @Test func leadingZeroBitsMatchesNIP13ExampleVector() throws { + // Worked example from the NIP-13 spec: this event ID has 36 leading + // zero bits. + let idHex = "000000000e9d97a1ab09fc381030b346cdd7a142ad57e6df0b46dc9bef6c7e2d" + let idData = try #require(Data(hexString: idHex)) + #expect(NostrPoW.leadingZeroBits(idData) == 36) + } + + // MARK: - Commitment semantics + + /// An ID with exactly 16 leading zero bits. + private let id16 = "0000f000" + String(repeating: "ab", count: 28) + + @Test func committedTargetCountsNotActualDifficulty() { + // Claimed < actual: only the committed target is credited, so lucky + // extra zeroes earn nothing beyond the commitment. + let tags = [["g", "u4pruy"], ["nonce", "12345", "8"]] + #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: tags) == 8) + } + + @Test func unmetCommitmentScoresZero() { + // Actual < claimed: the commitment is not met, so the claim is void. + let tags = [["nonce", "12345", "24"]] + #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: tags) == 0) + } + + @Test func exactCommitmentIsCredited() { + let tags = [["nonce", "12345", "16"]] + #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: tags) == 16) + } + + @Test func missingOrMalformedNonceTagScoresZero() { + // No nonce tag at all: leading zeroes without a commitment earn no + // credit (old clients simply keep the strict rate limits). + #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["g", "u4pruy"]]) == 0) + // Nonce tag without a committed target. + #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "12345"]]) == 0) + // Non-numeric or nonsensical targets. + #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "1", "high"]]) == 0) + #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "1", "0"]]) == 0) + #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "1", "-4"]]) == 0) + #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "1", "400"]]) == 0) + // Malformed event ID. + #expect(NostrPoW.validatedDifficulty(idHex: "not-hex", tags: [["nonce", "1", "8"]]) == 0) + } + + // MARK: - Mining + + @Test func minedNonceTagMeetsCommittedDifficulty() async throws { + let pubkey = String(repeating: "a", count: 64) + let createdAt = 1_700_000_000 + let baseTags = [["g", "u4pruydq"], ["n", "tester"]] + let content = "hello pow" + + let nonceTag = try #require(await NostrPoW.mineNonceTag( + pubkey: pubkey, + createdAt: createdAt, + kind: 20000, + tags: baseTags, + content: content, + targetBits: 8 + )) + + #expect(nonceTag.count == 3) + #expect(nonceTag.first == "nonce") + #expect(nonceTag[2] == "8") + + // Recompute the canonical NIP-01 event ID with the mined tag appended + // and verify the committed difficulty is genuinely met. + let idData = try Self.eventIDHash( + pubkey: pubkey, + createdAt: createdAt, + kind: 20000, + tags: baseTags + [nonceTag], + content: content + ) + #expect(NostrPoW.leadingZeroBits(idData) >= 8) + let idHex = idData.map { String(format: "%02x", $0) }.joined() + #expect(NostrPoW.validatedDifficulty(idHex: idHex, tags: baseTags + [nonceTag]) == 8) + } + + @Test func miningSurvivesContentThatNeedsEscaping() async throws { + // The in-place template mutation must stay correct when the content + // gets JSON-escaped — including content that contains hex runs that + // look exactly like the internal nonce placeholder. + let pubkey = String(repeating: "b", count: 64) + let createdAt = 1_700_000_123 + let content = "she said \"hi\"\n0000000000000000 / ffffffffffffffff 😀\\" + let baseTags = [["g", "9q8yy"]] + + let nonceTag = try #require(await NostrPoW.mineNonceTag( + pubkey: pubkey, + createdAt: createdAt, + kind: 20000, + tags: baseTags, + content: content, + targetBits: 4 + )) + + let idData = try Self.eventIDHash( + pubkey: pubkey, + createdAt: createdAt, + kind: 20000, + tags: baseTags + [nonceTag], + content: content + ) + #expect(NostrPoW.leadingZeroBits(idData) >= 4) + } + + @Test func minedGeohashEventValidatesEndToEnd() async throws { + let identity = try NostrIdentity.generate() + let event = try await NostrProtocol.createMinedEphemeralGeohashEvent( + content: "hello from a mined event", + geohash: "u4pruydq", + senderIdentity: identity, + nickname: "miner", + teleported: false + ) + + // The signed event's own ID (recomputed by sign()) carries the work. + #expect(event.isValidSignature()) + let idData = try #require(Data(hexString: event.id)) + #expect(NostrPoW.leadingZeroBits(idData) >= NostrPoW.targetBits) + #expect(NostrPoW.validatedDifficulty(idHex: event.id, tags: event.tags) == NostrPoW.targetBits) + + // Mining must not disturb the regular geohash tags. + #expect(event.tags.contains(["g", "u4pruydq"])) + #expect(event.tags.contains(["n", "miner"])) + #expect(event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue) + } + + @Test func cancelledMiningStillProducesHonestCommitment() async throws { + // Cancelling the surrounding task expedites mining: it steps the + // committed target down and still returns a tag whose commitment the + // hash actually meets — the message is never dropped or dishonest. + let pubkey = String(repeating: "c", count: 64) + let createdAt = 1_700_000_456 + let baseTags = [["g", "gbsuv"]] + let content = "expedited" + + let miningTask = Task { + await NostrPoW.mineNonceTag( + pubkey: pubkey, + createdAt: createdAt, + kind: 20000, + tags: baseTags, + content: content, + targetBits: 240 // unreachable: forces the cap/cancel path + ) + } + miningTask.cancel() + + let nonceTag = try #require(await miningTask.value) + let committed = try #require(Int(nonceTag[2])) + #expect(committed >= 0) + #expect(committed < 240) + + if committed > 0 { + let idData = try Self.eventIDHash( + pubkey: pubkey, + createdAt: createdAt, + kind: 20000, + tags: baseTags + [nonceTag], + content: content + ) + #expect(NostrPoW.leadingZeroBits(idData) >= committed) + } + } + + // MARK: - Helpers + + /// Canonical NIP-01 event ID hash, computed independently of the + /// production code path. + private static func eventIDHash( + pubkey: String, + createdAt: Int, + kind: Int, + tags: [[String]], + content: String + ) throws -> Data { + let serialized: [Any] = [0, pubkey, createdAt, kind, tags, content] + let json = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes]) + return Data(SHA256.hash(data: json)) + } +} diff --git a/bitchatTests/Performance/PerformanceBaselineTests.swift b/bitchatTests/Performance/PerformanceBaselineTests.swift index 39ed95bf..d496529c 100644 --- a/bitchatTests/Performance/PerformanceBaselineTests.swift +++ b/bitchatTests/Performance/PerformanceBaselineTests.swift @@ -611,6 +611,7 @@ private final class PerfNostrContext: ChatNostrContext { private(set) var handledPublicMessageCount = 0 func handlePublicMessage(_ message: BitchatMessage) { handledPublicMessageCount += 1 } + func handlePublicMessage(_ message: BitchatMessage, powBits: Int) { handledPublicMessageCount += 1 } func checkForMentions(_ message: BitchatMessage) {} func sendHapticFeedback(for message: BitchatMessage) {} func parseMentions(from content: String) -> [String] { From 60be88a4f5e530855c572754eb32ca185afd554c Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:13:25 +0200 Subject: [PATCH 08/18] Geohash bulletin board: persistent signed notices over mesh sync (#1379) * Add geohash bulletin board: persistent signed notices over mesh sync New MessageType 0x23 carries TLV-encoded board posts and tombstones, self-signed with the author's Ed25519 key ("bitchat-board-v1" / "bitchat-board-del-v1" domains) so notices verify without the author present. BoardStore persists raw signed packets under Application Support/board/ (200 posts, 5 per author, oldest evicted; expiry sweep; tombstones retained until the deleted post's original expiry) and is wiped on panic. Board packets join gossip sync as bit 8 of the existing variable-length types bitfield (a second byte old decoders already accept and ignore), with a 60s round and its own capacity, served straight from the board store so retention has one owner. Posts relay like broadcasts; urgent posts get the announce-class TTL cap. UI: a pin button in the header opens the board for the current channel (geohash board, or mesh-local board), with urgent-pinned newest-first listing, compose with urgent toggle and 1/3/7-day expiry, and swipe-delete on own posts. Geohash posts also publish one-way as Nostr kind-1 location notes when relays are reachable. Co-Authored-By: Claude Fable 5 * Board: bound orphan tombstones and reject future-dated posts at ingest Two hardening fixes from Codex review of the geohash bulletin board: - Orphan tombstones (P1): retention was derived solely from the sender-chosen deletedAt, so self-signed tombstones for unseen post IDs with far-future deletedAt persisted and re-entered sync unboundedly. Retention is now also clamped to receive time (now + 7d + 1h skew -- no post can outlive that), and orphans are capped at 100 globally and 5 per author key with oldest-received evicted first. Matched tombstones and disk restores keep their existing behavior. - Future-dated posts (P2): ingest only checked expiresAt > now, letting posts dated years ahead sort above honest posts and squat the 200 global slots without ever pruning. The single ingest chokepoint (radio, sync, and disk restore all funnel through it) now rejects createdAt > now + 1h skew and expiresAt > now + 7d + 1h skew; the decoder's span rule is unchanged. Adds tests for the skew boundary, far-future expiry, receive-time tombstone clamping, orphan caps/eviction, and matched-tombstone exemption. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/App/AppChromeModel.swift | 3 + bitchat/Protocols/BoardPackets.swift | 348 ++++++++++++++++ .../BLE/BLEOutboundPacketPolicy.swift | 2 +- bitchat/Services/BLE/BLEReceivePipeline.swift | 4 + bitchat/Services/BLE/BLEService.swift | 71 ++++ bitchat/Services/Board/BoardManager.swift | 165 ++++++++ bitchat/Services/Board/BoardStore.swift | 358 ++++++++++++++++ bitchat/Services/RelayController.swift | 5 +- bitchat/Services/Transport.swift | 5 + bitchat/Sync/GossipSyncManager.swift | 41 ++ bitchat/Sync/SyncTypeFlags.swift | 6 + bitchat/ViewModels/ChatViewModel.swift | 4 + bitchat/Views/BoardView.swift | 270 ++++++++++++ bitchat/Views/ContentHeaderView.swift | 33 ++ .../Protocols/BoardPacketsTests.swift | 211 ++++++++++ bitchatTests/Services/BoardStoreTests.swift | 385 ++++++++++++++++++ bitchatTests/Sync/GossipSyncBoardTests.swift | 130 ++++++ .../Sync/SyncTypeFlagsBoardTests.swift | 78 ++++ .../Sources/BitFoundation/MessageType.swift | 4 +- 19 files changed, 2119 insertions(+), 4 deletions(-) create mode 100644 bitchat/Protocols/BoardPackets.swift create mode 100644 bitchat/Services/Board/BoardManager.swift create mode 100644 bitchat/Services/Board/BoardStore.swift create mode 100644 bitchat/Views/BoardView.swift create mode 100644 bitchatTests/Protocols/BoardPacketsTests.swift create mode 100644 bitchatTests/Services/BoardStoreTests.swift create mode 100644 bitchatTests/Sync/GossipSyncBoardTests.swift create mode 100644 bitchatTests/Sync/SyncTypeFlagsBoardTests.swift diff --git a/bitchat/App/AppChromeModel.swift b/bitchat/App/AppChromeModel.swift index 3f9cacd7..d90df814 100644 --- a/bitchat/App/AppChromeModel.swift +++ b/bitchat/App/AppChromeModel.swift @@ -18,6 +18,9 @@ final class AppChromeModel: ObservableObject { private let chatViewModel: ChatViewModel private var cancellables = Set() + /// Bulletin-board coordinator, created on first use of the board sheet. + private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService) + init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) { self.chatViewModel = chatViewModel self.nickname = chatViewModel.nickname diff --git a/bitchat/Protocols/BoardPackets.swift b/bitchat/Protocols/BoardPackets.swift new file mode 100644 index 00000000..d265a1c1 --- /dev/null +++ b/bitchat/Protocols/BoardPackets.swift @@ -0,0 +1,348 @@ +// +// BoardPackets.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import CryptoKit +import Foundation + +// MARK: - Board wire format (MessageType.boardPost payloads) +// +// TLV layout (type u8, length u16 big-endian, value), matching REQUEST_SYNC: +// - 0x01: kind (u8) — 0x01 post, 0x02 tombstone +// - 0x02: postID (16B random) +// - 0x03: geohash (UTF-8, empty = mesh-local board, max 12 chars) +// - 0x04: content (UTF-8, 1...512 bytes) [post] +// - 0x05: authorSigningKey (32B Ed25519 public key) +// - 0x06: authorNickname (UTF-8, max 64 bytes) +// - 0x07: createdAt (u64 big-endian, ms) [post] +// - 0x08: expiresAt (u64 big-endian, ms, max 7 days after createdAt) [post] +// - 0x09: flags (u8, bit0 = urgent) [post] +// - 0x0A: signature (64B Ed25519) +// - 0x0B: deletedAt (u64 big-endian, ms) [tombstone] +// Unknown TLVs are skipped for forward compatibility. + +enum BoardWireConstants { + static let postIDLength = 16 + static let signingKeyLength = 32 + static let signatureLength = 64 + static let contentMaxBytes = 512 + static let nicknameMaxBytes = 64 + static let geohashMaxLength = 12 + /// Posts may live at most 7 days past their creation timestamp. + static let maxLifetimeMs: UInt64 = 7 * 24 * 60 * 60 * 1000 + static let postSigningContext = "bitchat-board-v1" + static let tombstoneSigningContext = "bitchat-board-del-v1" + static let geohashAlphabet = Set("0123456789bcdefghjkmnpqrstuvwxyz") +} + +private enum BoardTLVType: UInt8 { + case kind = 0x01 + case postID = 0x02 + case geohash = 0x03 + case content = 0x04 + case authorSigningKey = 0x05 + case authorNickname = 0x06 + case createdAt = 0x07 + case expiresAt = 0x08 + case flags = 0x09 + case signature = 0x0A + case deletedAt = 0x0B +} + +private enum BoardWireKind: UInt8 { + case post = 0x01 + case tombstone = 0x02 +} + +/// A signed, persistent bulletin-board notice. +struct BoardPostPacket: Equatable { + let postID: Data + /// Empty string scopes the post to the mesh-local board. + let geohash: String + let content: String + let authorSigningKey: Data + let authorNickname: String + let createdAt: UInt64 + let expiresAt: UInt64 + let flags: UInt8 + let signature: Data + + static let urgentFlag: UInt8 = 0x01 + + var isUrgent: Bool { flags & Self.urgentFlag != 0 } + + /// Canonical bytes covered by the Ed25519 signature. Variable-length + /// fields are length-prefixed so no two field combinations can collide. + static func signingBytes( + postID: Data, + geohash: String, + content: String, + authorSigningKey: Data, + authorNickname: String, + createdAt: UInt64, + expiresAt: UInt64, + flags: UInt8 + ) -> Data { + var out = Data() + BoardWireEncoding.appendContext(BoardWireConstants.postSigningContext, to: &out) + out.append(postID) + BoardWireEncoding.appendLengthPrefixed(Data(geohash.utf8), to: &out) + BoardWireEncoding.appendLengthPrefixed(Data(content.utf8), to: &out) + out.append(authorSigningKey) + BoardWireEncoding.appendLengthPrefixed(Data(authorNickname.utf8), to: &out) + BoardWireEncoding.appendUInt64(createdAt, to: &out) + BoardWireEncoding.appendUInt64(expiresAt, to: &out) + out.append(flags) + return out + } + + var signingBytes: Data { + Self.signingBytes( + postID: postID, + geohash: geohash, + content: content, + authorSigningKey: authorSigningKey, + authorNickname: authorNickname, + createdAt: createdAt, + expiresAt: expiresAt, + flags: flags + ) + } + + func verifySignature() -> Bool { + BoardWireEncoding.verify(signature: signature, over: signingBytes, publicKey: authorSigningKey) + } +} + +/// A signed deletion marker. Only the author's key can produce one; receivers +/// keep it until the post's original expiry so the delete outruns the post. +struct BoardTombstonePacket: Equatable { + let postID: Data + let authorSigningKey: Data + let deletedAt: UInt64 + let signature: Data + + static func signingBytes(postID: Data, deletedAt: UInt64) -> Data { + var out = Data() + BoardWireEncoding.appendContext(BoardWireConstants.tombstoneSigningContext, to: &out) + out.append(postID) + BoardWireEncoding.appendUInt64(deletedAt, to: &out) + return out + } + + var signingBytes: Data { + Self.signingBytes(postID: postID, deletedAt: deletedAt) + } + + func verifySignature() -> Bool { + BoardWireEncoding.verify(signature: signature, over: signingBytes, publicKey: authorSigningKey) + } +} + +/// Decoded board payload: either a live post or a tombstone. +enum BoardWire: Equatable { + case post(BoardPostPacket) + case tombstone(BoardTombstonePacket) + + func encode() -> Data { + var out = Data() + func putTLV(_ t: BoardTLVType, _ v: Data) { + out.append(t.rawValue) + let len = UInt16(v.count) + out.append(UInt8((len >> 8) & 0xFF)) + out.append(UInt8(len & 0xFF)) + out.append(v) + } + switch self { + case .post(let post): + putTLV(.kind, Data([BoardWireKind.post.rawValue])) + putTLV(.postID, post.postID) + putTLV(.geohash, Data(post.geohash.utf8)) + putTLV(.content, Data(post.content.utf8)) + putTLV(.authorSigningKey, post.authorSigningKey) + putTLV(.authorNickname, Data(post.authorNickname.utf8)) + putTLV(.createdAt, BoardWireEncoding.uint64Data(post.createdAt)) + putTLV(.expiresAt, BoardWireEncoding.uint64Data(post.expiresAt)) + putTLV(.flags, Data([post.flags])) + putTLV(.signature, post.signature) + case .tombstone(let tombstone): + putTLV(.kind, Data([BoardWireKind.tombstone.rawValue])) + putTLV(.postID, tombstone.postID) + putTLV(.authorSigningKey, tombstone.authorSigningKey) + putTLV(.deletedAt, BoardWireEncoding.uint64Data(tombstone.deletedAt)) + putTLV(.signature, tombstone.signature) + } + return out + } + + /// Structural decode; the caller must still verify the signature before + /// ingesting (`verifySignature()`). + static func decode(from data: Data) -> BoardWire? { + var off = data.startIndex + var kind: BoardWireKind? + var postID: Data? + var geohash: String? + var content: String? + var contentBytes = 0 + var authorSigningKey: Data? + var authorNickname: String? + var nicknameBytes = 0 + var createdAt: UInt64? + var expiresAt: UInt64? + var flags: UInt8? + var signature: Data? + var deletedAt: UInt64? + + while off + 3 <= data.endIndex { + let t = data[off]; off += 1 + let len = (Int(data[off]) << 8) | Int(data[off + 1]); off += 2 + guard off + len <= data.endIndex else { return nil } + let v = data.subdata(in: off..<(off + len)); off += len + switch BoardTLVType(rawValue: t) { + case .kind: + guard v.count == 1 else { return nil } + kind = BoardWireKind(rawValue: v[v.startIndex]) + case .postID: + guard v.count == BoardWireConstants.postIDLength else { return nil } + postID = v + case .geohash: + guard v.count <= BoardWireConstants.geohashMaxLength else { return nil } + geohash = String(data: v, encoding: .utf8) + case .content: + guard v.count <= BoardWireConstants.contentMaxBytes else { return nil } + contentBytes = v.count + content = String(data: v, encoding: .utf8) + case .authorSigningKey: + guard v.count == BoardWireConstants.signingKeyLength else { return nil } + authorSigningKey = v + case .authorNickname: + guard v.count <= BoardWireConstants.nicknameMaxBytes else { return nil } + nicknameBytes = v.count + authorNickname = String(data: v, encoding: .utf8) + case .createdAt: + createdAt = BoardWireEncoding.uint64(from: v) + case .expiresAt: + expiresAt = BoardWireEncoding.uint64(from: v) + case .flags: + guard v.count == 1 else { return nil } + flags = v[v.startIndex] + case .signature: + guard v.count == BoardWireConstants.signatureLength else { return nil } + signature = v + case .deletedAt: + deletedAt = BoardWireEncoding.uint64(from: v) + case nil: + continue // forward compatible; ignore unknown TLVs + } + } + + guard let postID, let authorSigningKey, let signature else { return nil } + + switch kind { + case .post: + guard let geohash, let content, let authorNickname, + let createdAt, let expiresAt, let flags, + contentBytes >= 1, + nicknameBytes <= BoardWireConstants.nicknameMaxBytes, + isValidGeohashField(geohash), + expiresAt > createdAt, + expiresAt - createdAt <= BoardWireConstants.maxLifetimeMs else { + return nil + } + return .post(BoardPostPacket( + postID: postID, + geohash: geohash, + content: content, + authorSigningKey: authorSigningKey, + authorNickname: authorNickname, + createdAt: createdAt, + expiresAt: expiresAt, + flags: flags, + signature: signature + )) + case .tombstone: + guard let deletedAt else { return nil } + return .tombstone(BoardTombstonePacket( + postID: postID, + authorSigningKey: authorSigningKey, + deletedAt: deletedAt, + signature: signature + )) + case nil: + return nil + } + } + + func verifySignature() -> Bool { + switch self { + case .post(let post): return post.verifySignature() + case .tombstone(let tombstone): return tombstone.verifySignature() + } + } + + /// Cheap TLV peek for relay policy: is this payload an urgent post? + /// Avoids a full decode on the hot relay path. + static func urgentFlag(in data: Data) -> Bool { + var off = data.startIndex + while off + 3 <= data.endIndex { + let t = data[off]; off += 1 + let len = (Int(data[off]) << 8) | Int(data[off + 1]); off += 2 + guard off + len <= data.endIndex else { return false } + if t == BoardTLVType.flags.rawValue, len == 1 { + return data[off] & BoardPostPacket.urgentFlag != 0 + } + off += len + } + return false + } + + /// Empty geohash = mesh-local board; otherwise 1-12 chars of the geohash + /// base32 alphabet. + private static func isValidGeohashField(_ geohash: String) -> Bool { + geohash.isEmpty || geohash.allSatisfy { BoardWireConstants.geohashAlphabet.contains($0) } + } +} + +enum BoardWireEncoding { + static func appendContext(_ context: String, to out: inout Data) { + let bytes = Data(context.utf8) + out.append(UInt8(min(bytes.count, 255))) + out.append(bytes.prefix(255)) + } + + static func appendLengthPrefixed(_ value: Data, to out: inout Data) { + let len = UInt16(min(value.count, Int(UInt16.max))) + out.append(UInt8((len >> 8) & 0xFF)) + out.append(UInt8(len & 0xFF)) + out.append(value.prefix(Int(UInt16.max))) + } + + static func appendUInt64(_ value: UInt64, to out: inout Data) { + var be = value.bigEndian + withUnsafeBytes(of: &be) { out.append(contentsOf: $0) } + } + + static func uint64Data(_ value: UInt64) -> Data { + var out = Data() + appendUInt64(value, to: &out) + return out + } + + static func uint64(from data: Data) -> UInt64? { + guard data.count == 8 else { return nil } + var value: UInt64 = 0 + for byte in data { value = (value << 8) | UInt64(byte) } + return value + } + + static func verify(signature: Data, over message: Data, publicKey: Data) -> Bool { + guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKey) else { + return false + } + return key.isValidSignature(signature, for: message) + } +} diff --git a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift index 18b144d2..7c23725f 100644 --- a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift +++ b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift @@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy { switch MessageType(rawValue: packetType) { case .noiseEncrypted, .noiseHandshake: return true - case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope: + case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost: return false } } diff --git a/bitchat/Services/BLE/BLEReceivePipeline.swift b/bitchat/Services/BLE/BLEReceivePipeline.swift index 71aacbaf..02fe152a 100644 --- a/bitchat/Services/BLE/BLEReceivePipeline.swift +++ b/bitchat/Services/BLE/BLEReceivePipeline.swift @@ -58,6 +58,10 @@ struct BLEReceivePipeline { isHandshake: packet.type == MessageType.noiseHandshake.rawValue, isAnnounce: packet.type == MessageType.announce.rawValue, isRequestSync: packet.type == MessageType.requestSync.rawValue, + // Board posts relay like broadcast messages; urgent ones get the + // announce-class TTL headroom so alerts travel the extra hop. + isUrgentBoardPost: packet.type == MessageType.boardPost.rawValue + && BoardWire.urgentFlag(in: packet.payload), degree: degree, highDegreeThreshold: highDegreeThreshold ) diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index d1ed84f2..598f9821 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -53,6 +53,8 @@ final class BLEService: NSObject { // reject. Injectable for tests; main-actor policy because favorites live // on the main actor. var courierStore: CourierStore = .shared + // Bulletin-board posts this device carries; injectable for tests. + var boardStore: BoardStore = .shared var courierDepositPolicy: @MainActor (Data, Bool) -> CourierDepositTier? = { depositorNoiseKey, isVerifiedPeer in if FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey) { return .favorite } return isVerifiedPeer ? .verified : nil @@ -297,6 +299,14 @@ final class BLEService: NSObject { let archive = meshBackgroundEnabled ? GossipMessageArchive() : nil let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager, archive: archive) manager.delegate = self + // Board posts sync from the board store (their retention owner) so + // deleted/expired posts drop out of rounds immediately. Real sessions + // only, matching the archive: unit tests stay hermetic. + if meshBackgroundEnabled { + manager.boardPacketsProvider = { [weak self] in + self?.boardStore.syncCandidates() ?? [] + } + } // Only start the periodic sync timers when real Bluetooth exists. In unit // tests there is no mesh to sync with, and the periodic sign/broadcast // churn just keeps the process busy and aggravates flaky exit hangs. @@ -3210,6 +3220,10 @@ extension BLEService { case .courierEnvelope: handleCourierEnvelope(packet, from: peerID) + case .boardPost: + // Invalid or deleted posts must not spread; skip the relay step. + guard handleBoardPost(packet, from: senderID) else { return } + case .leave: handleLeave(packet, from: senderID) @@ -3382,6 +3396,63 @@ extension BLEService { ) } + // MARK: - Board (geohash bulletin board) + + /// Validates and stores an incoming board post or tombstone. Returns + /// whether the packet is worth relaying onward. + private func handleBoardPost(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { + guard let wire = BoardWire.decode(from: packet.payload) else { + SecureLogger.warning("⚠️ Malformed board packet from \(peerID.id.prefix(8))…", category: .session) + return false + } + // Posts are self-authenticating: the payload embeds the author's + // Ed25519 key and signature, so verification does not depend on the + // author still being around to announce. + guard wire.verifySignature() else { + if logRateLimiter.shouldLog(key: "board-sig:\(peerID.id)") { + SecureLogger.warning("🚫 Dropping board packet with invalid signature from \(peerID.id.prefix(8))…", category: .security) + } + return false + } + switch boardStore.ingest(wire, packet: packet) { + case .accepted, .duplicate: + return true + case .rejected: + return false + } + } + + /// Broadcasts a pre-signed board payload (post or tombstone) built by the + /// board manager, and ingests it locally so it shows up on our own board + /// and joins gossip sync immediately. + func sendBoardPayload(_ payload: Data) { + guard let wire = BoardWire.decode(from: payload), wire.verifySignature() else { + SecureLogger.error("❌ Refusing to send invalid board payload", category: .session) + return + } + messageQueue.async { [weak self] in + guard let self = self else { return } + let basePacket = BitchatPacket( + type: MessageType.boardPost.rawValue, + senderID: Data(hexString: self.myPeerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: self.messageTTL + ) + guard let signedPacket = self.noiseService.signPacket(basePacket) else { + SecureLogger.error("❌ Failed to sign board packet", category: .security) + return + } + // Pre-mark our own broadcast as processed to avoid handling a relayed self copy + let dedupID = BLESelfBroadcastTracker.dedupID(for: signedPacket) + self.messageDeduplicator.markProcessed(dedupID) + self.boardStore.ingest(wire, packet: signedPacket) + self.broadcastPacket(signedPacket) + } + } + // Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager private func handleRequestSync(_ packet: BitchatPacket, from peerID: PeerID) { // REQUEST_SYNC is link-local by design (always sent with ttl 0): a diff --git a/bitchat/Services/Board/BoardManager.swift b/bitchat/Services/Board/BoardManager.swift new file mode 100644 index 00000000..989fda34 --- /dev/null +++ b/bitchat/Services/Board/BoardManager.swift @@ -0,0 +1,165 @@ +// +// BoardManager.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitLogger +import Combine +import Foundation + +/// UI-facing coordinator for the bulletin board: builds and signs posts and +/// tombstones with the device's Noise signing key, hands them to the mesh +/// transport, and mirrors the store's live posts for SwiftUI. +@MainActor +final class BoardManager: ObservableObject { + /// Live posts across all boards, newest state from the store. + @Published private(set) var posts: [BoardPostPacket] = [] + + private let transport: Transport + private let store: BoardStore + private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String) -> Void + private var cancellable: AnyCancellable? + + init( + transport: Transport, + store: BoardStore = .shared, + publishToNostr: ((String, String, String) -> Void)? = nil + ) { + self.transport = transport + self.store = store + self.publishToNostr = publishToNostr ?? Self.livePublishToNostr + cancellable = store.$postsSnapshot + .receive(on: DispatchQueue.main) + .sink { [weak self] snapshot in + self?.posts = snapshot + } + } + + /// Posts for one board context, urgent first, then newest first. + func posts(forGeohash geohash: String) -> [BoardPostPacket] { + posts + .filter { $0.geohash == geohash } + .sorted { + if $0.isUrgent != $1.isUrgent { return $0.isUrgent } + return $0.createdAt > $1.createdAt + } + } + + func isOwnPost(_ post: BoardPostPacket) -> Bool { + let key = transport.noiseSigningPublicKeyData() + return !key.isEmpty && key == post.authorSigningKey + } + + /// Creates, signs, and broadcasts a board post. Returns false when the + /// content is empty/oversized or signing fails. + @discardableResult + func createPost( + content: String, + geohash: String, + urgent: Bool, + expiryDays: Int, + nickname: String + ) -> Bool { + guard let trimmed = content.trimmedOrNilIfEmpty, + trimmed.utf8.count <= BoardWireConstants.contentMaxBytes else { + return false + } + let signingKey = transport.noiseSigningPublicKeyData() + guard signingKey.count == BoardWireConstants.signingKeyLength else { return false } + + var cleanNickname = nickname + while cleanNickname.utf8.count > BoardWireConstants.nicknameMaxBytes { + cleanNickname.removeLast() + } + let createdAt = UInt64(Date().timeIntervalSince1970 * 1000) + let lifetimeMs = min( + UInt64(max(1, expiryDays)) * 24 * 60 * 60 * 1000, + BoardWireConstants.maxLifetimeMs + ) + let expiresAt = createdAt + lifetimeMs + let flags: UInt8 = urgent ? BoardPostPacket.urgentFlag : 0 + var postID = Data(count: BoardWireConstants.postIDLength) + let status = postID.withUnsafeMutableBytes { buffer -> Int32 in + guard let base = buffer.baseAddress else { return -1 } + return SecRandomCopyBytes(kSecRandomDefault, buffer.count, base) + } + guard status == errSecSuccess else { return false } + + let signingBytes = BoardPostPacket.signingBytes( + postID: postID, + geohash: geohash, + content: trimmed, + authorSigningKey: signingKey, + authorNickname: cleanNickname, + createdAt: createdAt, + expiresAt: expiresAt, + flags: flags + ) + guard let signature = transport.noiseSignData(signingBytes) else { + SecureLogger.error("Board: failed to sign post", category: .session) + return false + } + let post = BoardPostPacket( + postID: postID, + geohash: geohash, + content: trimmed, + authorSigningKey: signingKey, + authorNickname: cleanNickname, + createdAt: createdAt, + expiresAt: expiresAt, + flags: flags, + signature: signature + ) + transport.sendBoardPayload(BoardWire.post(post).encode()) + + // One-way Nostr bridge (v1): geohash posts also go out as kind-1 + // location notes so online users see them. No inbound merge yet. + if !geohash.isEmpty { + publishToNostr(trimmed, geohash, cleanNickname) + } + return true + } + + /// Signs and broadcasts a tombstone for one of our own posts. + @discardableResult + func deletePost(_ post: BoardPostPacket) -> Bool { + guard isOwnPost(post) else { return false } + let deletedAt = UInt64(Date().timeIntervalSince1970 * 1000) + let signingBytes = BoardTombstonePacket.signingBytes(postID: post.postID, deletedAt: deletedAt) + guard let signature = transport.noiseSignData(signingBytes) else { + SecureLogger.error("Board: failed to sign tombstone", category: .session) + return false + } + let tombstone = BoardTombstonePacket( + postID: post.postID, + authorSigningKey: post.authorSigningKey, + deletedAt: deletedAt, + signature: signature + ) + transport.sendBoardPayload(BoardWire.tombstone(tombstone).encode()) + return true + } + + private static func livePublishToNostr(content: String, geohash: String, nickname: String) { + let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount) + guard !relays.isEmpty else { + SecureLogger.debug("Board: no geo relays for \(geohash); skipping Nostr bridge", category: .session) + return + } + do { + let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash) + let event = try NostrProtocol.createGeohashTextNote( + content: content, + geohash: geohash, + senderIdentity: identity, + nickname: nickname + ) + NostrRelayManager.shared.sendEvent(event, to: relays) + } catch { + SecureLogger.error("Board: failed to bridge post to Nostr: \(error)", category: .session) + } + } +} diff --git a/bitchat/Services/Board/BoardStore.swift b/bitchat/Services/Board/BoardStore.swift new file mode 100644 index 00000000..8ffdf7f3 --- /dev/null +++ b/bitchat/Services/Board/BoardStore.swift @@ -0,0 +1,358 @@ +// +// BoardStore.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import BitLogger +import Combine +import Foundation + +/// Outcome of feeding a board packet into the store, so the transport can +/// decide whether the packet is still worth relaying. +enum BoardIngestResult { + /// New post or tombstone accepted (or a quota rejected it locally while + /// it remains valid for other devices). + case accepted + /// Already known; nothing changed. + case duplicate + /// Invalid, expired, or deleted; do not relay. + case rejected +} + +/// Persistent storage for bulletin-board posts and their tombstones. +/// +/// Posts are signed public notices designed to outlive chat: they stay on +/// disk until their author-chosen expiry (max 7 days) and re-enter gossip +/// sync after a restart. Tombstones are retained until the deleted post's +/// original expiry so the delete keeps outrunning stale copies of the post. +/// +/// The on-disk format is the raw signed packets themselves (like +/// `GossipMessageArchive`); state is rebuilt by re-verifying and re-ingesting +/// them on launch. Wiped on panic. +final class BoardStore { + enum Limits { + static let maxPosts = 200 + static let maxPostsPerAuthor = 5 + /// Retention for a tombstone whose post we never saw: we cannot know + /// the original expiry, so cap at the max post lifetime. + static let orphanTombstoneLifetimeMs = BoardWireConstants.maxLifetimeMs + /// Orphan tombstones name posts nobody here has seen, so their volume + /// is entirely sender-controlled; cap them like posts. + static let maxOrphanTombstones = 100 + static let maxOrphanTombstonesPerAuthor = 5 + /// Allowance for clock skew between peers when judging received + /// timestamps against local time. + static let clockSkewMs: UInt64 = 60 * 60 * 1000 + } + + private struct StoredPost { + let post: BoardPostPacket + let packet: BitchatPacket + let rawPacket: Data + } + + private struct StoredTombstone { + let tombstone: BoardTombstonePacket + let packet: BitchatPacket + let rawPacket: Data + let retainUntil: UInt64 + /// True when no matching post was known at ingest time; only these + /// count against the orphan caps. + let isOrphan: Bool + } + + /// On-disk entry: the raw signed packet, plus the retention deadline for + /// tombstones (derived from the deleted post's original expiry, which is + /// no longer recoverable once the post is gone). + private struct PersistedEntry: Codable { + let packet: Data + let retainUntil: UInt64? + } + + static let shared = BoardStore() + + /// Live posts, published on the main thread for the board UI. + @Published private(set) var postsSnapshot: [BoardPostPacket] = [] + + private var posts: [StoredPost] = [] + private var tombstones: [StoredTombstone] = [] + private let queue = DispatchQueue(label: "chat.bitchat.board.store") + private let fileURL: URL? + private let now: () -> Date + + /// - Parameter fileURL: Overrides the on-disk location (tests). Ignored + /// when `persistsToDisk` is false. + init(persistsToDisk: Bool = true, fileURL: URL? = nil, now: @escaping () -> Date = Date.init) { + self.now = now + self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil + loadFromDisk() + } + + // MARK: - Ingest + + /// Ingest a board packet whose payload decodes to `wire`. The caller must + /// have verified the wire signature already (`BoardWire.verifySignature`). + @discardableResult + func ingest(_ wire: BoardWire, packet: BitchatPacket) -> BoardIngestResult { + guard let rawPacket = packet.toBinaryData(padding: false) else { return .rejected } + let nowMs = currentMs() + return queue.sync { + let result = ingestLocked(wire, packet: packet, rawPacket: rawPacket, nowMs: nowMs) + if result == .accepted { + persistLocked() + } + return result + } + } + + // MARK: - Reads + + /// Live posts scoped to one board (geohash, or "" for the mesh board). + func posts(forGeohash geohash: String) -> [BoardPostPacket] { + let nowMs = currentMs() + return queue.sync { + pruneExpiredLocked(nowMs: nowMs) + return posts.map(\.post).filter { $0.geohash == geohash } + } + } + + /// Raw signed packets (posts and live tombstones) for gossip sync rounds. + func syncCandidates() -> [BitchatPacket] { + let nowMs = currentMs() + return queue.sync { + pruneExpiredLocked(nowMs: nowMs) + return posts.map(\.packet) + tombstones.map(\.packet) + } + } + + // MARK: - Maintenance + + func pruneExpired() { + let nowMs = currentMs() + queue.sync { + pruneExpiredLocked(nowMs: nowMs) + persistLocked() + } + } + + /// Panic wipe: drop all board data from memory and disk. + func wipe() { + queue.sync { + posts.removeAll() + tombstones.removeAll() + if let fileURL { + try? FileManager.default.removeItem(at: fileURL) + } + publishSnapshotLocked() + } + } + + // MARK: - Internals (call only on `queue`) + + private func ingestLocked( + _ wire: BoardWire, + packet: BitchatPacket, + rawPacket: Data, + nowMs: UInt64, + retainUntilOverride: UInt64? = nil + ) -> BoardIngestResult { + pruneExpiredLocked(nowMs: nowMs) + switch wire { + case .post(let post): + return ingestPostLocked(post, packet: packet, rawPacket: rawPacket, nowMs: nowMs) + case .tombstone(let tombstone): + return ingestTombstoneLocked(tombstone, packet: packet, rawPacket: rawPacket, nowMs: nowMs, retainUntilOverride: retainUntilOverride) + } + } + + private func ingestPostLocked(_ post: BoardPostPacket, packet: BitchatPacket, rawPacket: Data, nowMs: UInt64) -> BoardIngestResult { + guard post.expiresAt > nowMs else { return .rejected } + // Receive-time sanity (this is the single chokepoint for radio, sync, + // and disk restores): the decoder only enforces the createdAt to + // expiresAt span, so a forged future createdAt would sort ahead of + // honest posts and hold a store slot without ever pruning. + guard post.createdAt <= nowMs &+ Limits.clockSkewMs, + post.expiresAt <= nowMs &+ BoardWireConstants.maxLifetimeMs &+ Limits.clockSkewMs else { + return .rejected + } + if tombstones.contains(where: { $0.tombstone.postID == post.postID && $0.tombstone.authorSigningKey == post.authorSigningKey }) { + return .rejected + } + guard !posts.contains(where: { $0.post.postID == post.postID }) else { return .duplicate } + + posts.append(StoredPost(post: post, packet: packet, rawPacket: rawPacket)) + + // Per-author cap, then global cap; oldest posts are evicted first. + let authorPosts = posts.filter { $0.post.authorSigningKey == post.authorSigningKey } + if authorPosts.count > Limits.maxPostsPerAuthor { + evictOldestLocked(from: authorPosts, keep: Limits.maxPostsPerAuthor) + } + if posts.count > Limits.maxPosts { + evictOldestLocked(from: posts, keep: Limits.maxPosts) + } + publishSnapshotLocked() + // Even when the new post itself was the eviction victim it stays + // valid mesh-wide; peers with room should still receive it. + return .accepted + } + + private func ingestTombstoneLocked( + _ tombstone: BoardTombstonePacket, + packet: BitchatPacket, + rawPacket: Data, + nowMs: UInt64, + retainUntilOverride: UInt64? = nil + ) -> BoardIngestResult { + guard !tombstones.contains(where: { $0.tombstone.postID == tombstone.postID }) else { return .duplicate } + + // Cap retention by both the claimed deletion time (so a doctored file + // cannot pin a tombstone past any legal expiry) and the receive time: + // deletedAt is sender-chosen, so a far-future value must not retain + // the tombstone longer than any post still able to arrive could live. + let maxRetain = min( + tombstone.deletedAt &+ Limits.orphanTombstoneLifetimeMs, + nowMs &+ Limits.orphanTombstoneLifetimeMs &+ Limits.clockSkewMs + ) + let retainUntil: UInt64 + let isOrphan: Bool + if let index = posts.firstIndex(where: { $0.post.postID == tombstone.postID }) { + let target = posts[index].post + // Only the author's key can delete: the tombstone signature was + // already verified against its embedded key, so it suffices to + // require that key to be the post's author key. + guard target.authorSigningKey == tombstone.authorSigningKey else { return .rejected } + retainUntil = target.expiresAt + isOrphan = false + posts.remove(at: index) + publishSnapshotLocked() + } else if let retainUntilOverride { + // Restored from disk: the post is long gone, so trust the + // retention deadline recorded when the delete was first applied. + // Orphans were already capped when first ingested off the air. + retainUntil = min(retainUntilOverride, maxRetain) + isOrphan = false + } else { + // Post unknown (tombstone raced ahead); keep it around so the + // post is suppressed if it arrives later. + retainUntil = maxRetain + isOrphan = true + } + guard retainUntil > nowMs else { return .rejected } + tombstones.append(StoredTombstone(tombstone: tombstone, packet: packet, rawPacket: rawPacket, retainUntil: retainUntil, isOrphan: isOrphan)) + if isOrphan { + enforceOrphanTombstoneCapsLocked(author: tombstone.authorSigningKey) + } + // Like posts, a locally evicted tombstone stays valid mesh-wide. + return .accepted + } + + /// Orphan tombstones reference posts we never saw, so a peer can mint + /// unlimited valid ones for random IDs; bound them per author and + /// globally, evicting the oldest received first (array order). + private func enforceOrphanTombstoneCapsLocked(author: Data) { + let authorOrphans = tombstones.filter { $0.isOrphan && $0.tombstone.authorSigningKey == author } + if authorOrphans.count > Limits.maxOrphanTombstonesPerAuthor { + removeTombstonesLocked(authorOrphans.prefix(authorOrphans.count - Limits.maxOrphanTombstonesPerAuthor)) + } + let orphans = tombstones.filter(\.isOrphan) + if orphans.count > Limits.maxOrphanTombstones { + removeTombstonesLocked(orphans.prefix(orphans.count - Limits.maxOrphanTombstones)) + } + } + + private func removeTombstonesLocked(_ victims: ArraySlice) { + guard !victims.isEmpty else { return } + let victimIDs = Set(victims.map { $0.tombstone.postID }) + tombstones.removeAll { victimIDs.contains($0.tombstone.postID) } + } + + private func evictOldestLocked(from candidates: [StoredPost], keep: Int) { + let victims = candidates + .sorted { $0.post.createdAt < $1.post.createdAt } + .prefix(max(0, candidates.count - keep)) + guard !victims.isEmpty else { return } + let victimIDs = Set(victims.map { $0.post.postID }) + posts.removeAll { victimIDs.contains($0.post.postID) } + } + + private func pruneExpiredLocked(nowMs: UInt64) { + let postsBefore = posts.count + posts.removeAll { $0.post.expiresAt <= nowMs } + tombstones.removeAll { $0.retainUntil <= nowMs } + if posts.count != postsBefore { + publishSnapshotLocked() + } + } + + private func publishSnapshotLocked() { + let snapshot = posts.map(\.post) + DispatchQueue.main.async { [weak self] in + self?.postsSnapshot = snapshot + } + } + + private func currentMs() -> UInt64 { + UInt64(max(0, now().timeIntervalSince1970) * 1000) + } + + // MARK: - Persistence + + private func persistLocked() { + guard let fileURL else { return } + let payloads = posts.map { PersistedEntry(packet: $0.rawPacket, retainUntil: nil) } + + tombstones.map { PersistedEntry(packet: $0.rawPacket, retainUntil: $0.retainUntil) } + do { + if payloads.isEmpty { + try? FileManager.default.removeItem(at: fileURL) + return + } + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try JSONEncoder().encode(payloads) + var options: Data.WritingOptions = [.atomic] + #if os(iOS) + options.insert(.completeFileProtection) + #endif + try data.write(to: fileURL, options: options) + } catch { + SecureLogger.error("Failed to persist board store: \(error)", category: .session) + } + } + + private func loadFromDisk() { + guard let fileURL, + let data = try? Data(contentsOf: fileURL), + let payloads = try? JSONDecoder().decode([PersistedEntry].self, from: data) else { + return + } + let nowMs = currentMs() + queue.sync { + for entry in payloads { + guard let packet = BitchatPacket.from(entry.packet), + packet.type == MessageType.boardPost.rawValue, + let wire = BoardWire.decode(from: packet.payload), + wire.verifySignature() else { continue } + _ = ingestLocked(wire, packet: packet, rawPacket: entry.packet, nowMs: nowMs, retainUntilOverride: entry.retainUntil) + } + publishSnapshotLocked() + } + } + + private static func defaultFileURL() -> URL? { + guard let base = try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) else { return nil } + return base + .appendingPathComponent("board", isDirectory: true) + .appendingPathComponent("posts.json") + } +} diff --git a/bitchat/Services/RelayController.swift b/bitchat/Services/RelayController.swift index fae850ea..10b4f2c6 100644 --- a/bitchat/Services/RelayController.swift +++ b/bitchat/Services/RelayController.swift @@ -19,6 +19,7 @@ struct RelayController { isHandshake: Bool, isAnnounce: Bool, isRequestSync: Bool = false, + isUrgentBoardPost: Bool = false, degree: Int, highDegreeThreshold: Int) -> RelayDecision { let ttlCap = min(ttl, TransportConfig.messageTTLDefault) @@ -64,7 +65,7 @@ struct RelayController { // - Dense graphs: keep lower but still allow multi-hop bridging // - Thin chains (degree <= 2): every hop counts and flood cost is // minimal, so relay at full incoming depth - // - Announces get a bit more headroom + // - Announces (and urgent board posts) get a bit more headroom let ttlLimit: UInt8 = { if degree >= highDegreeThreshold { return max(UInt8(2), min(ttlCap, UInt8(5))) @@ -72,7 +73,7 @@ struct RelayController { if degree <= 2 { return ttlCap } - let preferred = UInt8(isAnnounce ? 7 : 6) + let preferred = UInt8((isAnnounce || isUrgentBoardPost) ? 7 : 6) return max(UInt8(2), min(ttlCap, preferred)) }() let newTTL = ttlLimit &- 1 diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 27c38700..b02bfdf8 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -124,6 +124,10 @@ protocol Transport: AnyObject { // transport cannot courier (no connected courier, or unsupported). func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool + // Bulletin board (mesh transports only): broadcast a pre-signed board + // payload (post or tombstone) so it spreads over relay and gossip sync. + func sendBoardPayload(_ payload: Data) + // QR verification (optional for transports) func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) @@ -154,6 +158,7 @@ extension Transport { func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false } + func sendBoardPayload(_ payload: Data) {} func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {} func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {} func cancelTransfer(_ transferId: String) {} diff --git a/bitchat/Sync/GossipSyncManager.swift b/bitchat/Sync/GossipSyncManager.swift index f848be7b..2d7118ab 100644 --- a/bitchat/Sync/GossipSyncManager.swift +++ b/bitchat/Sync/GossipSyncManager.swift @@ -76,6 +76,11 @@ final class GossipSyncManager { var fragmentSyncIntervalSeconds: TimeInterval = 30.0 var fileTransferSyncIntervalSeconds: TimeInterval = 60.0 var messageSyncIntervalSeconds: TimeInterval = 15.0 + // Board posts are few but long-lived (days, until each post's own + // expiry), so they get a slow round with their own capacity instead + // of competing with the 15-minute message window. + var boardCapacity: Int = 200 + var boardSyncIntervalSeconds: TimeInterval = 60.0 var responseRateLimitMaxResponses: Int = 8 var responseRateLimitWindowSeconds: TimeInterval = 30.0 } @@ -86,6 +91,12 @@ final class GossipSyncManager { private let archive: GossipMessageArchive? weak var delegate: Delegate? + /// Source of raw signed board packets (posts + tombstones). The board + /// store is the single owner of board retention (expiry, tombstones, + /// caps, persistence), so sync rounds query it instead of keeping a + /// second copy here. Must be thread-safe; set before `start()`. + var boardPacketsProvider: (() -> [BitchatPacket])? + // Storage: broadcast packets by type, and latest announce per sender private var messages = PacketStore() private var fragments = PacketStore() @@ -119,6 +130,9 @@ final class GossipSyncManager { if config.fileTransferCapacity > 0 && config.fileTransferSyncIntervalSeconds > 0 { schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast)) } + if config.boardCapacity > 0 && config.boardSyncIntervalSeconds > 0 { + schedules.append(SyncSchedule(types: .board, interval: config.boardSyncIntervalSeconds, lastSent: .distantPast)) + } syncSchedules = schedules if archive != nil { @@ -155,6 +169,9 @@ final class GossipSyncManager { if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 { types.formUnion(.fileTransfer) } + if self.config.boardCapacity > 0 && self.config.boardSyncIntervalSeconds > 0 && self.boardPacketsProvider != nil { + types.formUnion(.board) + } self.sendRequestSync(to: peerID, types: types) } } @@ -364,6 +381,22 @@ final class GossipSyncManager { } } } + + if requestedTypes.contains(.boardPost) { + // The board store already filters to live posts and tombstones; + // no freshness window applies (posts sync until their own expiry). + let boardPackets = boardPacketsProvider?() ?? [] + for pkt in boardPackets { + if let since, pkt.timestamp < since { continue } + let idBytes = PacketIdUtil.computeId(pkt) + if !mightContain(idBytes) { + var toSend = pkt + toSend.ttl = 0 + toSend.isRSR = true // Mark as solicited response + delegate?.sendPacket(to: peerID, packet: toSend) + } + } + } } // Build REQUEST_SYNC payload using current candidates and GCS params @@ -383,6 +416,9 @@ final class GossipSyncManager { if types.contains(.fileTransfer) { candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh)) } + if types.contains(.boardPost) { + candidates.append(contentsOf: boardPacketsProvider?() ?? []) + } if candidates.isEmpty { let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr) let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types) @@ -399,6 +435,8 @@ final class GossipSyncManager { cap = max(1, config.fragmentCapacity) } else if types == .fileTransfer { cap = max(1, config.fileTransferCapacity) + } else if types == .board { + cap = max(1, config.boardCapacity) } else { cap = max(1, config.seenCapacity) } @@ -490,6 +528,9 @@ final class GossipSyncManager { // fragment traffic can't crowd messages out of the filter. for index in syncSchedules.indices { guard syncSchedules[index].interval > 0 else { continue } + // No board source wired up means nothing to offer or store; + // skip the round entirely. + if syncSchedules[index].types == .board && boardPacketsProvider == nil { continue } if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval { syncSchedules[index].lastSent = now sendPeriodicSync(for: syncSchedules[index].types) diff --git a/bitchat/Sync/SyncTypeFlags.swift b/bitchat/Sync/SyncTypeFlags.swift index fe796809..f8796e4b 100644 --- a/bitchat/Sync/SyncTypeFlags.swift +++ b/bitchat/Sync/SyncTypeFlags.swift @@ -36,6 +36,7 @@ struct SyncTypeFlags: OptionSet { case .fragment: return 5 case .requestSync: return 6 case .fileTransfer: return 7 + case .boardPost: return 8 // Courier envelopes are directed deposits between trusted peers and // must never spread via gossip sync. case .courierEnvelope: return nil @@ -52,6 +53,10 @@ struct SyncTypeFlags: OptionSet { case 5: return .fragment case 6: return .requestSync case 7: return .fileTransfer + // Bit 8 spills the encoded bitfield into a second byte. Decoders since + // type-aware sync (#853) accept 1-8 bytes and map unknown bits to no + // known type, so old clients ignore board rounds instead of choking. + case 8: return .boardPost default: return nil } @@ -61,6 +66,7 @@ struct SyncTypeFlags: OptionSet { static let message = SyncTypeFlags(messageTypes: [.message]) static let fragment = SyncTypeFlags(messageTypes: [.fragment]) static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer]) + static let board = SyncTypeFlags(messageTypes: [.boardPost]) static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message]) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index d995ecff..ae9ac224 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1208,6 +1208,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele GossipMessageArchive.wipeDefault() StoreAndForwardMetrics.shared.reset() + // Drop bulletin-board posts and tombstones (memory and disk); board + // posts are signed with our identity key and persist for days. + BoardStore.shared.wipe() + // Identity manager has cleared persisted identity data above // Clear autocomplete state diff --git a/bitchat/Views/BoardView.swift b/bitchat/Views/BoardView.swift new file mode 100644 index 00000000..8c443847 --- /dev/null +++ b/bitchat/Views/BoardView.swift @@ -0,0 +1,270 @@ +// +// BoardView.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import SwiftUI + +/// The bulletin board for one context: a geohash channel, or the mesh-local +/// board when `geohash` is empty. Urgent posts pin to the top; own posts can +/// be swipe-deleted, which broadcasts a signed tombstone. +struct BoardView: View { + /// Empty string = mesh-local board. + let geohash: String + let senderNickname: String + @ObservedObject var board: BoardManager + + @ThemedPalette private var palette + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.dismiss) private var dismiss + @State private var draft: String = "" + @State private var urgent = false + @State private var expiryDays = 7 + + private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 } + private var posts: [BoardPostPacket] { board.posts(forGeohash: geohash) } + + private enum Strings { + static let boardName = String(localized: "board.title", defaultValue: "board", comment: "Title prefix of the bulletin board sheet") + static let description = String(localized: "board.description", defaultValue: "persistent notices carried by the mesh. posts are signed, spread device-to-device, and expire on their own.", comment: "Explainer under the board sheet title") + static let emptyTitle = String(localized: "board.empty_title", defaultValue: "no notices yet", comment: "Title shown when the board has no posts") + static let emptySubtitle = String(localized: "board.empty_subtitle", defaultValue: "pin the first notice for people around here.", comment: "Subtitle shown when the board has no posts") + static let urgentBadge = String(localized: "board.urgent_badge", defaultValue: "urgent", comment: "Badge shown on urgent board posts") + static let urgentToggle = String(localized: "board.compose.urgent", defaultValue: "urgent", comment: "Label for the urgent toggle in the board composer") + static let placeholder = String(localized: "board.compose.placeholder", defaultValue: "post a notice…", comment: "Placeholder for the board composer text field") + static let send = String(localized: "board.accessibility.post", defaultValue: "Post notice", comment: "Accessibility label for the board post button") + static let deleteAction = String(localized: "board.action.delete", defaultValue: "delete", comment: "Delete action for own board posts") + static let expiryLabel = String(localized: "board.compose.expiry", defaultValue: "expires in", comment: "Label for the board post expiry picker") + static let closeHint = String(localized: "board.accessibility.close", defaultValue: "Close board", comment: "Accessibility label for the board close button") + + static func expiryDaysOption(_ days: Int) -> String { + String( + format: String(localized: "board.compose.expiry_days", defaultValue: "%lldd", comment: "Expiry picker option, number of days abbreviated"), + locale: .current, + days + ) + } + + static func postAccessibilityLabel(author: String, content: String, urgent: Bool) -> String { + let base = String( + format: String(localized: "board.accessibility.post_row", defaultValue: "Notice from %@: %@", comment: "Accessibility label for a board post row"), + locale: .current, + author, content + ) + return urgent ? "\(urgentBadge), \(base)" : base + } + } + + var body: some View { + VStack(spacing: 0) { + headerSection + postList + composer + } + .themedSurface() + #if os(macOS) + .frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680) + #endif + .themedSheetBackground() + } + + private var headerSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + Text(verbatim: geohash.isEmpty ? "\(Strings.boardName) @ #mesh" : "\(Strings.boardName) @ #\(geohash)") + .bitchatFont(size: 18) + Spacer() + SheetCloseButton { dismiss() } + .accessibilityLabel(Strings.closeHint) + } + Text(Strings.description) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 12) + .themedSurface() + } + + private var postList: some View { + Group { + if posts.isEmpty { + ScrollView { + VStack(alignment: .leading, spacing: 4) { + Text(Strings.emptyTitle) + .bitchatFont(size: 13, weight: .semibold) + Text(Strings.emptySubtitle) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + } else { + List { + ForEach(posts, id: \.postID) { post in + postRow(post) + .listRowBackground(palette.background) + .listRowSeparatorTint(palette.divider) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .themedSurface() + } + + private func postRow(_ post: BoardPostPacket) -> some View { + let isOwn = board.isOwnPost(post) + let author = post.authorNickname.trimmedOrNilIfEmpty ?? "anon" + return VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + if post.isUrgent { + Image(systemName: "exclamationmark.triangle.fill") + .font(.bitchatSystem(size: 11)) + .foregroundColor(palette.alertRed) + Text(Strings.urgentBadge) + .bitchatFont(size: 11, weight: .semibold) + .foregroundColor(palette.alertRed) + } + Text(verbatim: "@\(author)") + .bitchatFont(size: 12, weight: .semibold) + Text(timestampText(forMs: post.createdAt)) + .bitchatFont(size: 11) + .foregroundColor(palette.secondary) + Spacer() + if isOwn { + Button { + board.deletePost(post) + } label: { + Image(systemName: "trash") + .font(.bitchatSystem(size: 12)) + .foregroundColor(palette.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel(Strings.deleteAction) + } + } + Text(post.content) + .bitchatFont(size: 14) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 4) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Strings.postAccessibilityLabel(author: author, content: post.content, urgent: post.isUrgent)) + .accessibilityActions { + if isOwn { + Button(Strings.deleteAction) { board.deletePost(post) } + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if isOwn { + Button(role: .destructive) { + board.deletePost(post) + } label: { + Label(Strings.deleteAction, systemImage: "trash") + } + } + } + } + + private var composer: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 10) { + TextField(Strings.placeholder, text: $draft, axis: .vertical) + .textFieldStyle(.plain) + .bitchatFont(size: 14) + .lineLimit(maxDraftLines, reservesSpace: true) + .padding(.vertical, 6) + Button(action: send) { + Image(systemName: "arrow.up.circle.fill") + .font(.bitchatSystem(size: 20)) + .foregroundColor(sendEnabled ? palette.accent : .secondary) + } + .padding(.top, 2) + .buttonStyle(.plain) + .disabled(!sendEnabled) + .accessibilityLabel(Strings.send) + } + HStack(spacing: 12) { + Toggle(isOn: $urgent) { + Text(Strings.urgentToggle) + .bitchatFont(size: 12) + .foregroundColor(urgent ? palette.alertRed : palette.secondary) + } + .toggleStyle(.switch) + .fixedSize() + .accessibilityLabel(Strings.urgentToggle) + Spacer() + Text(Strings.expiryLabel) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + Picker(Strings.expiryLabel, selection: $expiryDays) { + ForEach([1, 3, 7], id: \.self) { days in + Text(Strings.expiryDaysOption(days)).tag(days) + } + } + .pickerStyle(.segmented) + .fixedSize() + .accessibilityLabel(Strings.expiryLabel) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .themedSurface() + .overlay(Divider(), alignment: .top) + } + + private var sendEnabled: Bool { + let trimmed = draft.trimmed + return !trimmed.isEmpty && trimmed.utf8.count <= BoardWireConstants.contentMaxBytes + } + + private func send() { + guard let content = draft.trimmedOrNilIfEmpty else { return } + let sent = board.createPost( + content: content, + geohash: geohash, + urgent: urgent, + expiryDays: expiryDays, + nickname: senderNickname + ) + if sent { + draft = "" + urgent = false + } + } + + private func timestampText(forMs ms: UInt64) -> String { + let date = Date(timeIntervalSince1970: TimeInterval(ms) / 1000) + let now = Date() + if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 { + let rel = Self.relativeFormatter.string(from: date, to: now) ?? "" + return rel.isEmpty ? "" : "\(rel) ago" + } + return Self.absDateFormatter.string(from: date) + } + + private static let relativeFormatter: DateComponentsFormatter = { + let f = DateComponentsFormatter() + f.allowedUnits = [.day, .hour, .minute] + f.maximumUnitCount = 1 + f.unitsStyle = .abbreviated + f.collapsesLargestUnit = true + return f + }() + + private static let absDateFormatter: DateFormatter = { + let f = DateFormatter() + f.setLocalizedDateFormatFromTemplate("MMM d") + return f + }() +} diff --git a/bitchat/Views/ContentHeaderView.swift b/bitchat/Views/ContentHeaderView.swift index eb188ed7..3fc2bd82 100644 --- a/bitchat/Views/ContentHeaderView.swift +++ b/bitchat/Views/ContentHeaderView.swift @@ -25,6 +25,9 @@ struct ContentHeaderView: View { /// Courier envelopes this device is carrying for offline third parties. @State private var carriedMailCount = 0 + /// Bulletin board sheet for the current channel context. + @State private var showBoard = false + var body: some View { HStack(spacing: 0) { Text(verbatim: "bitchat/") @@ -139,6 +142,20 @@ struct ContentHeaderView: View { ) } + Button(action: { showBoard = true }) { + Image(systemName: "pin") + .font(.bitchatSystem(size: 12)) + .foregroundColor(palette.secondary.opacity(0.9)) + .headerTapTarget() + } + .buttonStyle(.plain) + .accessibilityLabel( + String(localized: "content.accessibility.board", defaultValue: "Bulletin board", comment: "Accessibility label for the bulletin board button") + ) + .help( + String(localized: "content.header.board", defaultValue: "Bulletin board: persistent notices for this channel", comment: "Tooltip for the bulletin board button") + ) + if case .location(let channel) = locationChannelsModel.selectedChannel { Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) { Image(systemName: locationChannelsModel.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark") @@ -242,6 +259,13 @@ struct ContentHeaderView: View { .environmentObject(locationChannelsModel) .environmentObject(peerListModel) } + .sheet(isPresented: $showBoard) { + BoardView( + geohash: boardGeohash, + senderNickname: appChromeModel.nickname, + board: appChromeModel.boardManager + ) + } .sheet(isPresented: $showLocationNotes, onDismiss: { notesGeohash = nil }) { @@ -311,6 +335,15 @@ private extension ContentHeaderView { dynamicTypeSize.isAccessibilitySize ? 2 : 1 } + /// The board scope for the current channel: the geohash channel's board, + /// or the mesh-local board ("") in mesh chat. + var boardGeohash: String { + if case .location(let channel) = locationChannelsModel.selectedChannel { + return channel.geohash + } + return "" + } + /// Whether anyone is actually reachable on the current channel — the /// state the count icon's color encodes visually. var headerPeersReachable: Bool { diff --git a/bitchatTests/Protocols/BoardPacketsTests.swift b/bitchatTests/Protocols/BoardPacketsTests.swift new file mode 100644 index 00000000..9e1ff450 --- /dev/null +++ b/bitchatTests/Protocols/BoardPacketsTests.swift @@ -0,0 +1,211 @@ +// +// BoardPacketsTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import CryptoKit +import Foundation +import Testing +@testable import bitchat + +struct BoardPacketsTests { + + private let authorKey = Curve25519.Signing.PrivateKey() + + private func makeSignedPost( + geohash: String = "9q8yy", + content: String = "water point at the north gate", + nickname: String = "ranger", + createdAt: UInt64 = 1_700_000_000_000, + lifetimeMs: UInt64 = 24 * 60 * 60 * 1000, + flags: UInt8 = 0, + signWith key: Curve25519.Signing.PrivateKey? = nil, + claimKey: Data? = nil + ) throws -> BoardPostPacket { + let signer = key ?? authorKey + let publicKey = claimKey ?? signer.publicKey.rawRepresentation + let postID = Data((0..<16).map { _ in UInt8.random(in: 0...255) }) + let expiresAt = createdAt + lifetimeMs + let signingBytes = BoardPostPacket.signingBytes( + postID: postID, + geohash: geohash, + content: content, + authorSigningKey: publicKey, + authorNickname: nickname, + createdAt: createdAt, + expiresAt: expiresAt, + flags: flags + ) + let signature = try signer.signature(for: signingBytes) + return BoardPostPacket( + postID: postID, + geohash: geohash, + content: content, + authorSigningKey: publicKey, + authorNickname: nickname, + createdAt: createdAt, + expiresAt: expiresAt, + flags: flags, + signature: signature + ) + } + + private func makeSignedTombstone( + postID: Data, + deletedAt: UInt64 = 1_700_000_100_000, + signWith key: Curve25519.Signing.PrivateKey? = nil, + claimKey: Data? = nil + ) throws -> BoardTombstonePacket { + let signer = key ?? authorKey + let publicKey = claimKey ?? signer.publicKey.rawRepresentation + let signature = try signer.signature(for: BoardTombstonePacket.signingBytes(postID: postID, deletedAt: deletedAt)) + return BoardTombstonePacket( + postID: postID, + authorSigningKey: publicKey, + deletedAt: deletedAt, + signature: signature + ) + } + + // MARK: - Round trips + + @Test func postRoundTrip() throws { + let post = try makeSignedPost(flags: BoardPostPacket.urgentFlag) + let encoded = BoardWire.post(post).encode() + let decoded = try #require(BoardWire.decode(from: encoded)) + #expect(decoded == .post(post)) + #expect(decoded.verifySignature()) + guard case .post(let roundTripped) = decoded else { + Issue.record("expected a post") + return + } + #expect(roundTripped.isUrgent) + #expect(roundTripped.geohash == "9q8yy") + } + + @Test func meshLocalPostRoundTrip() throws { + let post = try makeSignedPost(geohash: "") + let decoded = try #require(BoardWire.decode(from: BoardWire.post(post).encode())) + #expect(decoded == .post(post)) + #expect(decoded.verifySignature()) + } + + @Test func tombstoneRoundTrip() throws { + let post = try makeSignedPost() + let tombstone = try makeSignedTombstone(postID: post.postID) + let encoded = BoardWire.tombstone(tombstone).encode() + let decoded = try #require(BoardWire.decode(from: encoded)) + #expect(decoded == .tombstone(tombstone)) + #expect(decoded.verifySignature()) + } + + // MARK: - Signature verification + + @Test func forgedPostSignatureFailsVerification() throws { + // Signed by an attacker's key but claiming the victim's key as author. + let attacker = Curve25519.Signing.PrivateKey() + let victim = Curve25519.Signing.PrivateKey() + let forged = try makeSignedPost(signWith: attacker, claimKey: victim.publicKey.rawRepresentation) + let decoded = try #require(BoardWire.decode(from: BoardWire.post(forged).encode())) + #expect(!decoded.verifySignature()) + } + + @Test func tamperedContentFailsVerification() throws { + let post = try makeSignedPost(content: "meet at noon") + let tampered = BoardPostPacket( + postID: post.postID, + geohash: post.geohash, + content: "meet at midnight", + authorSigningKey: post.authorSigningKey, + authorNickname: post.authorNickname, + createdAt: post.createdAt, + expiresAt: post.expiresAt, + flags: post.flags, + signature: post.signature + ) + let decoded = try #require(BoardWire.decode(from: BoardWire.post(tampered).encode())) + #expect(!decoded.verifySignature()) + } + + @Test func forgedTombstoneSignatureFailsVerification() throws { + let post = try makeSignedPost() + let attacker = Curve25519.Signing.PrivateKey() + let forged = try makeSignedTombstone( + postID: post.postID, + signWith: attacker, + claimKey: post.authorSigningKey + ) + let decoded = try #require(BoardWire.decode(from: BoardWire.tombstone(forged).encode())) + #expect(!decoded.verifySignature()) + } + + // MARK: - Decode validation + + @Test func rejectsExpiryBeyondSevenDays() throws { + let tooLong = try makeSignedPost(lifetimeMs: BoardWireConstants.maxLifetimeMs + 1) + #expect(BoardWire.decode(from: BoardWire.post(tooLong).encode()) == nil) + + let exactlySevenDays = try makeSignedPost(lifetimeMs: BoardWireConstants.maxLifetimeMs) + #expect(BoardWire.decode(from: BoardWire.post(exactlySevenDays).encode()) != nil) + } + + @Test func rejectsExpiryBeforeCreation() throws { + let post = try makeSignedPost() + let inverted = BoardPostPacket( + postID: post.postID, + geohash: post.geohash, + content: post.content, + authorSigningKey: post.authorSigningKey, + authorNickname: post.authorNickname, + createdAt: post.expiresAt, + expiresAt: post.createdAt, + flags: post.flags, + signature: post.signature + ) + #expect(BoardWire.decode(from: BoardWire.post(inverted).encode()) == nil) + } + + @Test func rejectsOversizedContent() throws { + let oversized = try makeSignedPost(content: String(repeating: "x", count: BoardWireConstants.contentMaxBytes + 1)) + #expect(BoardWire.decode(from: BoardWire.post(oversized).encode()) == nil) + + let maxed = try makeSignedPost(content: String(repeating: "x", count: BoardWireConstants.contentMaxBytes)) + #expect(BoardWire.decode(from: BoardWire.post(maxed).encode()) != nil) + } + + @Test func rejectsInvalidGeohashCharacters() throws { + let invalid = try makeSignedPost(geohash: "9q8yA") // "A" is outside base32 + #expect(BoardWire.decode(from: BoardWire.post(invalid).encode()) == nil) + } + + @Test func toleratesUnknownTLVs() throws { + let post = try makeSignedPost() + var encoded = BoardWire.post(post).encode() + // Append an unknown TLV; decoders must skip it. + encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD]) + let decoded = try #require(BoardWire.decode(from: encoded)) + #expect(decoded == .post(post)) + #expect(decoded.verifySignature()) + } + + @Test func rejectsTruncatedPayload() throws { + let post = try makeSignedPost() + let encoded = BoardWire.post(post).encode() + #expect(BoardWire.decode(from: encoded.prefix(encoded.count - 1)) == nil) + } + + // MARK: - Urgent flag peek + + @Test func urgentFlagPeekMatchesFullDecode() throws { + let urgent = try makeSignedPost(flags: BoardPostPacket.urgentFlag) + let calm = try makeSignedPost() + let tombstone = try makeSignedTombstone(postID: calm.postID) + #expect(BoardWire.urgentFlag(in: BoardWire.post(urgent).encode())) + #expect(!BoardWire.urgentFlag(in: BoardWire.post(calm).encode())) + #expect(!BoardWire.urgentFlag(in: BoardWire.tombstone(tombstone).encode())) + #expect(!BoardWire.urgentFlag(in: Data())) + } +} diff --git a/bitchatTests/Services/BoardStoreTests.swift b/bitchatTests/Services/BoardStoreTests.swift new file mode 100644 index 00000000..d501de39 --- /dev/null +++ b/bitchatTests/Services/BoardStoreTests.swift @@ -0,0 +1,385 @@ +// +// BoardStoreTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import CryptoKit +import Foundation +import Testing +@testable import bitchat + +struct BoardStoreTests { + + private final class MutableClock: @unchecked Sendable { + var now: Date + init(now: Date) { self.now = now } + } + + private let baseDate = Date(timeIntervalSince1970: 1_700_000_000) + private var baseMs: UInt64 { UInt64(baseDate.timeIntervalSince1970 * 1000) } + + private func makeStore(clock: MutableClock, fileURL: URL? = nil) -> BoardStore { + BoardStore(persistsToDisk: fileURL != nil, fileURL: fileURL, now: { clock.now }) + } + + private func tempFileURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("board-store-\(UUID().uuidString).json") + } + + private func makePost( + author: Curve25519.Signing.PrivateKey, + geohash: String = "9q8yy", + content: String = "note", + createdAt: UInt64, + lifetimeMs: UInt64 = 24 * 60 * 60 * 1000 + ) throws -> (wire: BoardWire, packet: BitchatPacket, post: BoardPostPacket) { + let postID = Data((0..<16).map { _ in UInt8.random(in: 0...255) }) + let key = author.publicKey.rawRepresentation + let expiresAt = createdAt + lifetimeMs + let signingBytes = BoardPostPacket.signingBytes( + postID: postID, + geohash: geohash, + content: content, + authorSigningKey: key, + authorNickname: "tester", + createdAt: createdAt, + expiresAt: expiresAt, + flags: 0 + ) + let post = BoardPostPacket( + postID: postID, + geohash: geohash, + content: content, + authorSigningKey: key, + authorNickname: "tester", + createdAt: createdAt, + expiresAt: expiresAt, + flags: 0, + signature: try author.signature(for: signingBytes) + ) + let wire = BoardWire.post(post) + return (wire, makePacket(payload: wire.encode(), timestamp: createdAt), post) + } + + private func makeTombstone( + for post: BoardPostPacket, + author: Curve25519.Signing.PrivateKey, + deletedAt: UInt64, + claimKey: Data? = nil + ) throws -> (wire: BoardWire, packet: BitchatPacket) { + let tombstone = BoardTombstonePacket( + postID: post.postID, + authorSigningKey: claimKey ?? author.publicKey.rawRepresentation, + deletedAt: deletedAt, + signature: try author.signature(for: BoardTombstonePacket.signingBytes(postID: post.postID, deletedAt: deletedAt)) + ) + let wire = BoardWire.tombstone(tombstone) + return (wire, makePacket(payload: wire.encode(), timestamp: deletedAt)) + } + + private func makePacket(payload: Data, timestamp: UInt64) -> BitchatPacket { + BitchatPacket( + type: MessageType.boardPost.rawValue, + senderID: Data((0..<8).map { _ in UInt8.random(in: 0...255) }), + recipientID: nil, + timestamp: timestamp, + payload: payload, + signature: nil, + ttl: 7 + ) + } + + // MARK: - Ingest basics + + @Test func ingestStoresAndDeduplicates() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let entry = try makePost(author: author, createdAt: baseMs) + + #expect(store.ingest(entry.wire, packet: entry.packet) == .accepted) + #expect(store.ingest(entry.wire, packet: entry.packet) == .duplicate) + #expect(store.posts(forGeohash: "9q8yy").count == 1) + #expect(store.posts(forGeohash: "").isEmpty) + #expect(store.syncCandidates().count == 1) + } + + @Test func rejectsAlreadyExpiredPost() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let entry = try makePost(author: author, createdAt: baseMs - 2 * 60 * 60 * 1000, lifetimeMs: 60 * 60 * 1000) + + #expect(store.ingest(entry.wire, packet: entry.packet) == .rejected) + #expect(store.posts(forGeohash: "9q8yy").isEmpty) + } + + // MARK: - Receive-time timestamp policy + + @Test func rejectsPostCreatedBeyondClockSkew() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let entry = try makePost(author: author, createdAt: baseMs + BoardStore.Limits.clockSkewMs + 60 * 1000) + + #expect(store.ingest(entry.wire, packet: entry.packet) == .rejected) + #expect(store.posts(forGeohash: "9q8yy").isEmpty) + } + + @Test func acceptsPostCreatedWithinClockSkew() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let entry = try makePost(author: author, createdAt: baseMs + BoardStore.Limits.clockSkewMs - 60 * 1000) + + #expect(store.ingest(entry.wire, packet: entry.packet) == .accepted) + #expect(store.posts(forGeohash: "9q8yy").count == 1) + } + + @Test func rejectsPostExpiringTooFarInTheFuture() throws { + // Builds the wire directly, bypassing the decoder's span check, to + // exercise the ingest-level expiresAt bound on its own. + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let entry = try makePost(author: author, createdAt: baseMs, lifetimeMs: 30 * 24 * 60 * 60 * 1000) + + #expect(store.ingest(entry.wire, packet: entry.packet) == .rejected) + #expect(store.posts(forGeohash: "9q8yy").isEmpty) + } + + // MARK: - Caps and eviction + + @Test func perAuthorCapEvictsOldest() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + + var oldestID: Data? + for index in 0..<(BoardStore.Limits.maxPostsPerAuthor + 1) { + let entry = try makePost(author: author, createdAt: baseMs + UInt64(index) * 1000) + if index == 0 { oldestID = entry.post.postID } + #expect(store.ingest(entry.wire, packet: entry.packet) == .accepted) + } + + let posts = store.posts(forGeohash: "9q8yy") + #expect(posts.count == BoardStore.Limits.maxPostsPerAuthor) + #expect(!posts.contains { $0.postID == oldestID }) + } + + @Test func globalCapEvictsOldest() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + + var oldestID: Data? + var author = Curve25519.Signing.PrivateKey() + for index in 0..<(BoardStore.Limits.maxPosts + 1) { + if index % BoardStore.Limits.maxPostsPerAuthor == 0 { + author = Curve25519.Signing.PrivateKey() + } + let entry = try makePost(author: author, createdAt: baseMs + UInt64(index) * 1000) + if index == 0 { oldestID = entry.post.postID } + #expect(store.ingest(entry.wire, packet: entry.packet) == .accepted) + } + + let posts = store.posts(forGeohash: "9q8yy") + #expect(posts.count == BoardStore.Limits.maxPosts) + #expect(!posts.contains { $0.postID == oldestID }) + } + + // MARK: - Expiry sweep + + @Test func expiredPostsAreSwept() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let shortLived = try makePost(author: author, createdAt: baseMs, lifetimeMs: 60 * 60 * 1000) + let longLived = try makePost(author: author, createdAt: baseMs, lifetimeMs: 48 * 60 * 60 * 1000) + store.ingest(shortLived.wire, packet: shortLived.packet) + store.ingest(longLived.wire, packet: longLived.packet) + #expect(store.posts(forGeohash: "9q8yy").count == 2) + + clock.now = baseDate.addingTimeInterval(2 * 60 * 60) // 2h later + let remaining = store.posts(forGeohash: "9q8yy") + #expect(remaining.count == 1) + #expect(remaining.first?.postID == longLived.post.postID) + #expect(store.syncCandidates().count == 1) + } + + // MARK: - Tombstones + + @Test func tombstoneDeletesPostAndPropagatesUntilOriginalExpiry() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let entry = try makePost(author: author, createdAt: baseMs, lifetimeMs: 24 * 60 * 60 * 1000) + store.ingest(entry.wire, packet: entry.packet) + + let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000) + #expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted) + + // Post is gone, tombstone still syncs so the delete propagates. + #expect(store.posts(forGeohash: "9q8yy").isEmpty) + #expect(store.syncCandidates().count == 1) + + // Replayed copy of the deleted post is refused. + #expect(store.ingest(entry.wire, packet: entry.packet) == .rejected) + + // After the post's original expiry the tombstone is dropped too. + clock.now = baseDate.addingTimeInterval(25 * 60 * 60) + #expect(store.syncCandidates().isEmpty) + } + + @Test func tombstoneFromWrongKeyIsRejected() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let attacker = Curve25519.Signing.PrivateKey() + let entry = try makePost(author: author, createdAt: baseMs) + store.ingest(entry.wire, packet: entry.packet) + + // Attacker signs with their own key (self-consistent wire, so it + // passes signature verification) but targets the victim's post. + let forged = try makeTombstone(for: entry.post, author: attacker, deletedAt: baseMs + 1000) + #expect(store.ingest(forged.wire, packet: forged.packet) == .rejected) + #expect(store.posts(forGeohash: "9q8yy").count == 1) + } + + @Test func tombstoneArrivingBeforePostSuppressesIt() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let entry = try makePost(author: author, createdAt: baseMs) + + let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000) + #expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted) + #expect(store.ingest(entry.wire, packet: entry.packet) == .rejected) + #expect(store.posts(forGeohash: "9q8yy").isEmpty) + } + + @Test func orphanTombstoneRetentionIsBoundedByReceiveTime() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + let entry = try makePost(author: author, createdAt: baseMs) // never ingested + + // Attacker-chosen far-future deletedAt must not extend retention. + let farFuture = baseMs + 365 * 24 * 60 * 60 * 1000 + let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: farFuture) + #expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted) + #expect(store.syncCandidates().count == 1) + + // No post can outlive 7 days from receipt, so neither may an orphan + // tombstone (plus skew allowance). + clock.now = baseDate.addingTimeInterval(8 * 24 * 60 * 60) + #expect(store.syncCandidates().isEmpty) + } + + @Test func orphanTombstonePerAuthorCapEvictsOldest() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + + var unseenPosts: [(wire: BoardWire, packet: BitchatPacket, post: BoardPostPacket)] = [] + for index in 0..<(BoardStore.Limits.maxOrphanTombstonesPerAuthor + 1) { + let entry = try makePost(author: author, createdAt: baseMs + UInt64(index)) + unseenPosts.append(entry) + let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000) + #expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted) + } + + #expect(store.syncCandidates().count == BoardStore.Limits.maxOrphanTombstonesPerAuthor) + // The oldest orphan was evicted, so its post is no longer suppressed… + #expect(store.ingest(unseenPosts[0].wire, packet: unseenPosts[0].packet) == .accepted) + // …while the surviving orphans still suppress theirs. + #expect(store.ingest(unseenPosts[1].wire, packet: unseenPosts[1].packet) == .rejected) + } + + @Test func orphanTombstoneGlobalCapEvictsOldest() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + + var author = Curve25519.Signing.PrivateKey() + for index in 0..<(BoardStore.Limits.maxOrphanTombstones + 1) { + if index % BoardStore.Limits.maxOrphanTombstonesPerAuthor == 0 { + author = Curve25519.Signing.PrivateKey() + } + let entry = try makePost(author: author, createdAt: baseMs + UInt64(index)) + let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000) + #expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted) + } + + #expect(store.syncCandidates().count == BoardStore.Limits.maxOrphanTombstones) + } + + @Test func matchedTombstonesAreExemptFromOrphanCaps() throws { + let clock = MutableClock(now: baseDate) + let store = makeStore(clock: clock) + let author = Curve25519.Signing.PrivateKey() + + // Post-then-delete more times than the per-author orphan cap; every + // tombstone matched a live post, so none may be evicted. + let cycles = BoardStore.Limits.maxOrphanTombstonesPerAuthor + 2 + for index in 0.. +// + +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +/// Board posts ride gossip sync through a provider that queries the board +/// store, so retention (expiry, tombstones, caps) has a single owner. +struct GossipSyncBoardTests { + + private let myPeerID = PeerID(str: "0102030405060708") + + private func makeBoardPacket(timestamp: UInt64) throws -> BitchatPacket { + BitchatPacket( + type: MessageType.boardPost.rawValue, + senderID: try #require(Data(hexString: "aabbccddeeff0011")), + recipientID: nil, + timestamp: timestamp, + payload: Data([0x42]), + signature: nil, + ttl: 7 + ) + } + + private func quietConfig() -> GossipSyncManager.Config { + var config = GossipSyncManager.Config() + config.messageSyncIntervalSeconds = 0 + config.fragmentSyncIntervalSeconds = 0 + config.fileTransferSyncIntervalSeconds = 0 + return config + } + + @Test func boardRequestIsServedFromProvider() async throws { + let manager = GossipSyncManager(myPeerID: myPeerID, config: quietConfig(), requestSyncManager: RequestSyncManager()) + let delegate = RecordingBoardDelegate() + manager.delegate = delegate + let boardPacket = try makeBoardPacket(timestamp: UInt64(Date().timeIntervalSince1970 * 1000)) + manager.boardPacketsProvider = { return [boardPacket] } + + let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board) + manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request) + + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) + let sent = try #require(delegate.packets.first) + #expect(sent.type == MessageType.boardPost.rawValue) + #expect(sent.isRSR) + } + + @Test func nonBoardRequestDoesNotServeBoardPackets() async throws { + let manager = GossipSyncManager(myPeerID: myPeerID, config: quietConfig(), requestSyncManager: RequestSyncManager()) + let delegate = RecordingBoardDelegate() + manager.delegate = delegate + let boardPacket = try makeBoardPacket(timestamp: UInt64(Date().timeIntervalSince1970 * 1000)) + manager.boardPacketsProvider = { return [boardPacket] } + + let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .publicMessages) + manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request) + + // Follow with a board request; only its response should arrive, which + // also proves the first request produced nothing. + let boardRequest = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board) + manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: boardRequest) + + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) + #expect(delegate.packets.count == 1) + #expect(delegate.packets.first?.type == MessageType.boardPost.rawValue) + } + + @Test func maintenanceEmitsBoardRoundOnlyWithProvider() throws { + var config = quietConfig() + config.boardSyncIntervalSeconds = 1 + + // Without a provider the board schedule stays silent. + let unwired = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager()) + let unwiredDelegate = RecordingBoardDelegate() + unwired.delegate = unwiredDelegate + unwired._performMaintenanceSynchronously(now: Date()) + #expect(unwiredDelegate.packets.isEmpty) + + // With a provider, maintenance sends a board-typed request. + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager()) + let delegate = RecordingBoardDelegate() + manager.delegate = delegate + manager.boardPacketsProvider = { return [] } + manager._performMaintenanceSynchronously(now: Date()) + + #expect(delegate.packets.count == 1) + let payload = try #require(delegate.packets.first?.payload) + let request = try #require(RequestSyncPacket.decode(from: payload)) + #expect(request.types == .board) + } +} + +private final class RecordingBoardDelegate: GossipSyncManager.Delegate { + private let lock = NSLock() + private var _packets: [BitchatPacket] = [] + + var packets: [BitchatPacket] { + lock.lock() + defer { lock.unlock() } + return _packets + } + + func sendPacket(_ packet: BitchatPacket) { + lock.lock() + _packets.append(packet) + lock.unlock() + } + + func sendPacket(to peerID: PeerID, packet: BitchatPacket) { + lock.lock() + _packets.append(packet) + lock.unlock() + } + + func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket { + packet + } + + func getConnectedPeers() -> [PeerID] { + [] + } +} diff --git a/bitchatTests/Sync/SyncTypeFlagsBoardTests.swift b/bitchatTests/Sync/SyncTypeFlagsBoardTests.swift new file mode 100644 index 00000000..49fd553b --- /dev/null +++ b/bitchatTests/Sync/SyncTypeFlagsBoardTests.swift @@ -0,0 +1,78 @@ +// +// SyncTypeFlagsBoardTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +/// The board sync flag is the first bit outside the original single byte of +/// type flags. These tests pin down the wire compatibility contract: the +/// types TLV has been a variable-length (1-8 byte) little-endian bitfield +/// since type-aware sync, so widening to two bytes must decode everywhere +/// and unknown bits must be ignored, not rejected. +struct SyncTypeFlagsBoardTests { + + @Test func boardFlagEncodesIntoSecondByte() throws { + let data = try #require(SyncTypeFlags.board.toData()) + // Little-endian: low byte first, board bit (bit 8) in byte 2. + #expect(data == Data([0x00, 0x01])) + } + + @Test func boardFlagRoundTrips() throws { + let flags = SyncTypeFlags(messageTypes: [.message, .boardPost]) + let data = try #require(flags.toData()) + let decoded = try #require(SyncTypeFlags.decode(data)) + #expect(decoded.contains(.message)) + #expect(decoded.contains(.boardPost)) + #expect(!decoded.contains(.fragment)) + #expect(Set(decoded.toMessageTypes()) == Set([.message, .boardPost])) + } + + /// An old decoder is modeled by bits it has no mapping for: the shared + /// decode path accepts the bytes and simply maps unknown bits to no + /// message type, so a board-only request reads as "nothing I can serve". + @Test func unknownBitsDecodeToNoTypes() throws { + // Bits 9-15 are unassigned; a future (or unknown) two-byte bitfield + // must decode without error and yield no known types. + let decoded = try #require(SyncTypeFlags.decode(Data([0x00, 0xFE]))) + #expect(decoded.toMessageTypes().isEmpty) + for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost] { + #expect(!decoded.contains(type)) + } + } + + @Test func mixedKnownAndUnknownBitsKeepKnownTypes() throws { + // Known low-byte flags survive alongside unknown high bits. + let decoded = try #require(SyncTypeFlags.decode(Data([0x03, 0xFE]))) + #expect(decoded.contains(.announce)) + #expect(decoded.contains(.message)) + #expect(Set(decoded.toMessageTypes()) == Set([.announce, .message])) + } + + @Test func requestSyncPacketRoundTripsBoardFlag() throws { + let request = RequestSyncPacket( + p: 4, + m: 128, + data: Data([0xAB, 0xCD]), + types: SyncTypeFlags(messageTypes: [.boardPost]) + ) + let decoded = try #require(RequestSyncPacket.decode(from: request.encode())) + let types = try #require(decoded.types) + #expect(types.contains(.boardPost)) + #expect(!types.contains(.message)) + } + + @Test func singleByteLegacyEncodingStillDecodes() throws { + // Requests from old clients keep the one-byte bitfield. + let decoded = try #require(SyncTypeFlags.decode(Data([0x03]))) + #expect(decoded.contains(.announce)) + #expect(decoded.contains(.message)) + #expect(!decoded.contains(.boardPost)) + } +} diff --git a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift index 23f391b9..36db977d 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift @@ -24,7 +24,8 @@ public enum MessageType: UInt8 { // Fragmentation (simplified) case fragment = 0x20 // Single fragment type for large messages case fileTransfer = 0x22 // Binary file/audio/image payloads - + case boardPost = 0x23 // Signed geohash bulletin-board post or tombstone + public var description: String { switch self { case .announce: return "announce" @@ -36,6 +37,7 @@ public enum MessageType: UInt8 { case .noiseEncrypted: return "noiseEncrypted" case .fragment: return "fragment" case .fileTransfer: return "fileTransfer" + case .boardPost: return "boardPost" } } } From 3c610a83cd98c8cf4ccb529722df3ef9bf115b63 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:14:08 +0200 Subject: [PATCH 09/18] Cashu ecash chips: detect, render, and redeem tokens + /pay command (#1376) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Cashu ecash chips: detect, render, and redeem tokens + /pay command Content-level Cashu support, no wire-protocol changes: - CashuTokenDecoder: summarizes V3 (cashuA base64url-JSON) tokens — amount summed across proofs, unit, mint host, memo — and V4 (cashuB) via a minimal bounded CBOR reader. All input is treated as adversarial: size caps, depth/item budgets, overflow guards, display sanitization; malformed payloads fail closed to a generic chip. - PaymentChipView: cashu chips now show "500 sat · mint.example.com" (+ memo) instead of a generic label; tap opens a cashu: wallet URL and falls back to https://redeem.cashu.me when no wallet handles it; context menu adds copy token / redeem in wallet / redeem on web. - extractCashuLinks now returns bare deduplicated bearer strings so the chip can decode them (cashu: URIs still detected via the embedded token). - /pay : validates the token decodes, sends it as the message body; DMs send directly, public channels require an explicit "/pay public" confirm since tokens are bearer instruments. Suggested everywhere except public geohash channels. - Tests: decoder (V3/V4 decode, summation, URI forms, truncation/ garbage/huge fuzzing, CBOR depth bounds) and /pay command flows. Co-Authored-By: Claude Fable 5 * Cashu: strict decode on the /pay SEND path The permissive decoder turned any non-empty cashuB… base64 that failed CBOR parsing into a generic TokenInfo, so the /pay guard accepted base64 junk and truncated V4 tokens and relayed them with a success message. Add a `strict` flag to CashuTokenDecoder.decode: in strict mode there is no permissive V4 fallback and the token must resolve to a known version with a positive amount, else it returns nil. Rendering keeps the permissive path (an unknown chip is fine for display). /pay now decodes with strict:true and surfaces "invalid cashu token" instead of sending. Tests: /pay with truncated cashuB / base64 junk is rejected; valid V3 and valid definite-length V4 still send; decoder strict-mode unit tests. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/Localizable.xcstrings | 60 +++ bitchat/Models/CommandInfo.swift | 17 +- bitchat/Services/CashuTokenDecoder.swift | 339 +++++++++++++++++ bitchat/Services/CommandProcessor.swift | 41 ++ bitchat/ViewModels/ChatViewModel.swift | 8 + .../Views/Components/PaymentChipView.swift | 170 ++++++++- bitchat/Views/MessageTextHelpers.swift | 15 +- bitchatTests/CashuTokenDecoderTests.swift | 350 ++++++++++++++++++ bitchatTests/CommandProcessorTests.swift | 172 +++++++++ 9 files changed, 1149 insertions(+), 23 deletions(-) create mode 100644 bitchat/Services/CashuTokenDecoder.swift create mode 100644 bitchatTests/CashuTokenDecoderTests.swift diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 7301e613..66fd04b4 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -15425,6 +15425,18 @@ } } }, + "content.commands.pay" : { + "comment" : "Autocomplete description for the /pay command that sends a Cashu ecash token", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "send a cashu ecash token in this chat" + } + } + } + }, "content.commands.slap" : { "extractionState" : "manual", "localizations" : { @@ -18469,6 +18481,18 @@ } } }, + "content.input.token_placeholder" : { + "comment" : "Placeholder shown after /pay in the command suggestion panel", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "token" + } + } + } + }, "content.jump.new_count" : { "comment" : "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill", "extractionState" : "manual", @@ -19734,6 +19758,18 @@ } } }, + "content.payment.copy_token" : { + "comment" : "Context menu action copying a Cashu token to the pasteboard", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "copy token" + } + } + } + }, "content.payment.lightning" : { "extractionState" : "manual", "localizations" : { @@ -19913,6 +19949,30 @@ } } }, + "content.payment.redeem_wallet" : { + "comment" : "Context menu action opening a Cashu token in an ecash wallet app", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "redeem in wallet" + } + } + } + }, + "content.payment.redeem_web" : { + "comment" : "Context menu action opening a Cashu token in the web redemption page", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "redeem on web" + } + } + } + }, "encryption.accessibility.establishing" : { "extractionState" : "manual", "localizations" : { diff --git a/bitchat/Models/CommandInfo.swift b/bitchat/Models/CommandInfo.swift index 1f8245be..0ad3d554 100644 --- a/bitchat/Models/CommandInfo.swift +++ b/bitchat/Models/CommandInfo.swift @@ -20,6 +20,7 @@ enum CommandInfo: String, Identifiable { case hug case message = "msg" case slap + case pay case unblock case who case favorite = "fav" @@ -33,6 +34,8 @@ enum CommandInfo: String, Identifiable { switch self { case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite: return "<" + String(localized: "content.input.nickname_placeholder") + ">" + case .pay: + return "<" + String(localized: "content.input.token_placeholder") + ">" case .clear, .help, .who: return nil } @@ -45,6 +48,7 @@ enum CommandInfo: String, Identifiable { case .help: String(localized: "content.commands.help") case .hug: String(localized: "content.commands.hug") case .message: String(localized: "content.commands.message") + case .pay: String(localized: "content.commands.pay") case .slap: String(localized: "content.commands.slap") case .unblock: String(localized: "content.commands.unblock") case .who: String(localized: "content.commands.who") @@ -54,12 +58,19 @@ enum CommandInfo: String, Identifiable { } static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] { - let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who] + var commands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who] + // Cashu tokens are bearer instruments: in a public geohash any nearby + // stranger can redeem one, so don't *suggest* /pay there (the + // processor still allows it behind an explicit "public" confirm). + // Payments make sense in every DM and in mesh public. + if !isGeoPublic { + commands.append(.pay) + } // The processor rejects favorites in geohash contexts, so only // suggest them where they actually work: mesh. if isGeoPublic || isGeoDM { - return baseCommands + return commands } - return baseCommands + [.favorite, .unfavorite] + return commands + [.favorite, .unfavorite] } } diff --git a/bitchat/Services/CashuTokenDecoder.swift b/bitchat/Services/CashuTokenDecoder.swift new file mode 100644 index 00000000..87dfee02 --- /dev/null +++ b/bitchat/Services/CashuTokenDecoder.swift @@ -0,0 +1,339 @@ +// +// CashuTokenDecoder.swift +// bitchat +// +// Decodes Cashu ecash tokens (V3 `cashuA` = base64url JSON, V4 `cashuB` = +// base64url CBOR) just far enough to summarize them for the UI: total +// amount, unit, mint host, and memo. The app never contacts a mint — tokens +// are bearer strings and redemption is delegated to an external wallet. +// +// This parses attacker-controlled message content, so every path is +// bounds-checked, size-capped, and returns nil instead of trapping. +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +enum CashuTokenDecoder { + + struct TokenInfo: Equatable { + /// Token serialization version: "A" (JSON) or "B" (CBOR). + let version: String + /// Sum of all proof amounts; nil when no valid amounts were found. + let amount: Int? + /// Currency unit as declared by the token (commonly "sat"), if any. + let unit: String? + /// Host of the (first) mint URL, for display. + let mintHost: String? + /// Optional sender memo, sanitized for display. + let memo: String? + + /// "500 sat" style summary, defaulting the unit to sats per NUT-00. + var displayAmount: String? { + amount.map { "\($0) \(unit ?? "sat")" } + } + } + + /// Upper bound on accepted token length in characters. Real tokens are a + /// few KB; anything much bigger is abuse we shouldn't spend CPU on. + static let maxTokenLength = 60_000 + /// Per-proof and total amount sanity caps (order of total sats in existence). + private static let maxAmount: Int64 = 2_100_000_000_000_000 + + // MARK: - Public API + + /// Extracts the bare `cashuA…`/`cashuB…` token from raw text that may be + /// a `cashu:`/`cashu://` URI and/or percent-encoded. Returns nil when the + /// input doesn't look like a Cashu token at all. + static func bareToken(from raw: String) -> String? { + var token = raw.trimmingCharacters(in: .whitespacesAndNewlines) + let lower = token.lowercased() + if lower.hasPrefix("cashu://") { + token = String(token.dropFirst(8)) + } else if lower.hasPrefix("cashu:") { + token = String(token.dropFirst(6)) + } + if token.contains("%"), let decoded = token.removingPercentEncoding { + token = decoded + } + guard token.count >= 12, token.count <= maxTokenLength else { return nil } + guard token.hasPrefix("cashuA") || token.hasPrefix("cashuB") else { return nil } + // Base64 / base64url payload charset ('.' appears in some legacy multi-part tokens) + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_+/=.")) + guard token.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { return nil } + return token + } + + /// Decodes a token (raw or `cashu:` URI form) into a display summary. + /// + /// In the default (permissive) mode this is for *rendering*: V3 tokens + /// must parse as JSON, but a V4 token whose CBOR we cannot walk still + /// returns a generic `TokenInfo` (version "B", no amount) because the + /// payload may use encodings this minimal reader doesn't support — an + /// unknown chip is fine for display. + /// + /// In `strict` mode (used by the `/pay` SEND path) there is no permissive + /// fallback: the token must cleanly decode to a known version *and* carry + /// a positive amount, otherwise this returns nil. This stops base64 junk + /// and truncated V4 tokens from being relayed as if they were valid money. + static func decode(_ raw: String, strict: Bool = false) -> TokenInfo? { + guard let token = bareToken(from: raw) else { return nil } + let version = String(token[token.index(token.startIndex, offsetBy: 5)]) + guard let payload = base64URLDecode(String(token.dropFirst(6))), !payload.isEmpty else { + return nil + } + let info: TokenInfo? + switch version { + case "A": + info = decodeV3(payload) + case "B": + if let walked = decodeV4(payload) { + info = walked + } else if strict { + // Couldn't cleanly walk the CBOR — refuse to send it. + return nil + } else { + info = TokenInfo(version: "B", amount: nil, unit: nil, mintHost: nil, memo: nil) + } + default: + return nil + } + guard let info else { return nil } + if strict { + // A sendable token must resolve to a positive, sane amount. + guard let amount = info.amount, amount > 0 else { return nil } + } + return info + } + + // MARK: - Base64url + + private static func base64URLDecode(_ input: String) -> Data? { + var s = input + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + // Normalize padding (wallets emit both padded and unpadded forms) + s = s.replacingOccurrences(of: "=", with: "") + let remainder = s.count % 4 + if remainder == 1 { return nil } + if remainder > 0 { s += String(repeating: "=", count: 4 - remainder) } + return Data(base64Encoded: s) + } + + // MARK: - V3 (JSON) + + private static func decodeV3(_ payload: Data) -> TokenInfo? { + guard let obj = (try? JSONSerialization.jsonObject(with: payload)) as? [String: Any], + let entries = obj["token"] as? [[String: Any]], + !entries.isEmpty else { + return nil + } + var total: Int64 = 0 + var sawAmount = false + var mintHost: String? + for entry in entries { + if mintHost == nil, let mint = entry["mint"] as? String { + mintHost = sanitizedHost(from: mint) + } + for proof in (entry["proofs"] as? [[String: Any]]) ?? [] { + guard let number = proof["amount"] as? NSNumber else { continue } + let value = number.int64Value + guard value > 0, value <= maxAmount else { continue } + total += value + guard total <= maxAmount else { return nil } + sawAmount = true + } + } + return TokenInfo( + version: "A", + amount: sawAmount ? Int(total) : nil, + unit: sanitizedUnit(obj["unit"] as? String), + mintHost: mintHost, + memo: sanitizedMemo(obj["memo"] as? String) + ) + } + + // MARK: - V4 (CBOR) + + /// Minimal walk of the NUT-00 TokenV4 CBOR map: + /// { "m": mint, "u": unit, "d": memo, "t": [ { "i": bytes, "p": [ { "a": amount, … } ] } ] } + private static func decodeV4(_ payload: Data) -> TokenInfo? { + var reader = CBORReader(data: payload) + guard case .map(let pairs)? = reader.parseValue(depth: 0) else { return nil } + var mintHost: String? + var unit: String? + var memo: String? + var total: Int64 = 0 + var sawAmount = false + for (key, value) in pairs { + guard case .text(let name) = key else { continue } + switch (name, value) { + case ("m", .text(let mint)): + mintHost = sanitizedHost(from: mint) + case ("u", .text(let u)): + unit = sanitizedUnit(u) + case ("d", .text(let d)): + memo = sanitizedMemo(d) + case ("t", .array(let groups)): + for case .map(let group) in groups { + for case (.text("p"), .array(let proofs)) in group { + for case .map(let proof) in proofs { + for case (.text("a"), .unsigned(let amount)) in proof { + guard amount > 0, amount <= UInt64(maxAmount) else { continue } + total += Int64(amount) + guard total <= maxAmount else { return nil } + sawAmount = true + } + } + } + } + default: + break + } + } + return TokenInfo( + version: "B", + amount: sawAmount ? Int(total) : nil, + unit: unit, + mintHost: mintHost, + memo: memo + ) + } + + // MARK: - Display Sanitization (values are attacker-controlled) + + private static func sanitizedHost(from mint: String) -> String? { + guard mint.count <= 512, let host = URL(string: mint)?.host, !host.isEmpty else { return nil } + return String(host.lowercased().prefix(48)) + } + + private static func sanitizedUnit(_ unit: String?) -> String? { + guard let unit, !unit.isEmpty, unit.count <= 12, + unit.unicodeScalars.allSatisfy({ CharacterSet.alphanumerics.contains($0) }) else { + return nil + } + return unit + } + + private static func sanitizedMemo(_ memo: String?) -> String? { + guard let memo, memo.count <= 512 else { return nil } + let stripped = CharacterSet.controlCharacters.union(.newlines) + var cleaned = "" + cleaned.unicodeScalars.append(contentsOf: memo.unicodeScalars.filter { !stripped.contains($0) }) + cleaned = cleaned.trimmingCharacters(in: .whitespaces) + guard !cleaned.isEmpty else { return nil } + return String(cleaned.prefix(80)) + } +} + +// MARK: - Minimal CBOR Reader + +/// Just enough definite-length CBOR to traverse a TokenV4 map. Bounded in +/// depth, item count, and byte length; indefinite-length items and anything +/// else exotic make the parse fail (the caller degrades to a generic chip). +private struct CBORReader { + indirect enum Value { + case unsigned(UInt64) + case text(String) + case array([Value]) + case map([(Value, Value)]) + /// Parsed-and-skipped content we don't need (byte strings, negatives, floats…) + case opaque + } + + private let bytes: [UInt8] + private var index = 0 + /// Total item budget so hostile nesting can't run away. + private var itemBudget = 50_000 + private static let maxDepth = 16 + private static let maxContainerCount: UInt64 = 10_000 + + init(data: Data) { + bytes = [UInt8](data) + } + + mutating func parseValue(depth: Int) -> Value? { + guard depth < Self.maxDepth, itemBudget > 0 else { return nil } + itemBudget -= 1 + guard let (major, argument) = readHead() else { return nil } + switch major { + case 0: // unsigned int + return .unsigned(argument) + case 1: // negative int (argument already consumed) + return .opaque + case 2: // byte string + return readBytes(count: argument) != nil ? .opaque : nil + case 3: // text string + guard let raw = readBytes(count: argument) else { return nil } + return String(bytes: raw, encoding: .utf8).map(Value.text) ?? .opaque + case 4: // array + guard argument <= Self.maxContainerCount else { return nil } + var items: [Value] = [] + items.reserveCapacity(Int(min(argument, 64))) + for _ in 0.. (major: UInt8, argument: UInt64)? { + guard index < bytes.count else { return nil } + let head = bytes[index] + index += 1 + let major = head >> 5 + let info = head & 0x1F + switch info { + case 0...23: + return (major, UInt64(info)) + case 24: + return readUInt(width: 1).map { (major, $0) } + case 25: + return readUInt(width: 2).map { (major, $0) } + case 26: + return readUInt(width: 4).map { (major, $0) } + case 27: + return readUInt(width: 8).map { (major, $0) } + default: // 28-30 reserved, 31 indefinite + return nil + } + } + + private mutating func readUInt(width: Int) -> UInt64? { + guard bytes.count - index >= width else { return nil } + var value: UInt64 = 0 + for _ in 0.. [UInt8]? { + guard count <= UInt64(bytes.count - index) else { return nil } + let length = Int(count) + let slice = Array(bytes[index..<(index + length)]) + index += length + return slice + } +} diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index 9f2f8e9b..220e66ef 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -45,6 +45,8 @@ protocol CommandContextProvider: AnyObject { /// Empties the peer's chat (single-writer store intent for `/clear`). func clearPrivateChat(_ peerID: PeerID) func sendPublicRaw(_ content: String) + /// Sends a normal public message (with local echo) to the active channel. + func sendPublicMessage(_ content: String) // MARK: - System Messages func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) @@ -106,6 +108,8 @@ final class CommandProcessor { case "/unfav": if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") } return handleFavorite(args, add: false) + case "/pay": + return handlePay(args) case "/help": return .success(message: Self.helpText) default: @@ -125,6 +129,7 @@ final class CommandProcessor { /slap @name — slap with a large trout /block @name · /unblock @name /fav @name · /unfav @name — favorites (mesh only) + /pay — send a cashu ecash token in this chat /help — this list """ @@ -331,6 +336,42 @@ final class CommandProcessor { return .error(message: "cannot unblock \(nickname): not found") } + /// `/pay ` — validates the token decodes, then sends it as + /// the message body in the current chat. Cashu tokens are bearer + /// instruments (whoever redeems first gets the funds), so posting one to + /// a public channel requires an explicit `/pay public` confirm. + /// The app never contacts a mint; it only relays the string. + private func handlePay(_ args: String) -> CommandResult { + var parts = args.trimmed.split(separator: " ").map(String.init) + guard !parts.isEmpty else { + return .success(message: "usage: /pay — paste a cashu token: /pay cashuA…") + } + + let confirmedPublic = parts.count > 1 && parts.last?.lowercased() == "public" + if confirmedPublic { parts.removeLast() } + + guard parts.count == 1, let token = CashuTokenDecoder.bareToken(from: parts[0]) else { + return .error(message: "that doesn't look like a cashu token — expected cashuA… or cashuB…") + } + guard let info = CashuTokenDecoder.decode(token, strict: true) else { + return .error(message: "invalid cashu token — it doesn't decode to a known token with an amount, not sending it") + } + + let summary = info.displayAmount ?? "a cashu token" + + if let peerID = contextProvider?.selectedPrivateChatPeer { + contextProvider?.sendPrivateMessage(token, to: peerID) + return .success(message: "sent \(summary) — cashu is a bearer token; whoever redeems it first gets the funds") + } + + guard confirmedPublic else { + return .error(message: "this is a public channel — anyone reading it can redeem the token. send anyway: /pay public") + } + + contextProvider?.sendPublicMessage(token) + return .success(message: "sent \(summary) to the public channel — anyone here can redeem it") + } + private func handleFavorite(_ args: String, add: Bool) -> CommandResult { let targetName = args.trimmed guard !targetName.isEmpty else { diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index ae9ac224..2f5fd729 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1684,6 +1684,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele publicConversationCoordinator.sendPublicRaw(content) } + // Send a normal public message (with local echo) to the active channel. + // CommandContextProvider hook for commands that post real messages + // (`/pay`); only called when no private chat is selected. + @MainActor + func sendPublicMessage(_ content: String) { + sendMessage(content) + } + /// Handle incoming public message @MainActor func handlePublicMessage(_ message: BitchatMessage) { diff --git a/bitchat/Views/Components/PaymentChipView.swift b/bitchat/Views/Components/PaymentChipView.swift index e01887d0..bdda8772 100644 --- a/bitchat/Views/Components/PaymentChipView.swift +++ b/bitchat/Views/Components/PaymentChipView.swift @@ -7,12 +7,17 @@ // import SwiftUI +#if os(iOS) +import UIKit +#else +import AppKit +#endif struct PaymentChipView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.openURL) private var openURL @ThemedPalette private var palette - + enum PaymentType { case cashu(String) case lightning(String) @@ -35,14 +40,33 @@ struct PaymentChipView: View { return URL(string: link) } } - + + /// The bare `cashuA…`/`cashuB…` bearer string, when this is a Cashu chip. + var cashuToken: String? { + if case .cashu(let link) = self { + return CashuTokenDecoder.bareToken(from: link) + } + return nil + } + + /// Web fallback for redemption when no wallet handles `cashu:` URLs. + /// The token only reaches the site the user's browser loads; the app + /// itself never contacts a mint. + var cashuWebRedeemURL: URL? { + guard let token = cashuToken, + let enc = token.addingPercentEncoding(withAllowedCharacters: Self.cashuAllowedCharacters) else { + return nil + } + return URL(string: "https://redeem.cashu.me/?token=\(enc)") + } + var emoji: String { switch self { case .cashu: "🥜" case .lightning: "⚡" } } - + var label: String { switch self { case .cashu: @@ -52,27 +76,56 @@ struct PaymentChipView: View { } } } - + let paymentType: PaymentType - + /// Decoded once at construction; tokens are capped in size so this is + /// cheap, and rows re-render often enough that lazy decode in `body` + /// would just repeat the work. + private let cashuInfo: CashuTokenDecoder.TokenInfo? + + init(paymentType: PaymentType) { + self.paymentType = paymentType + if case .cashu(let link) = paymentType { + self.cashuInfo = CashuTokenDecoder.decode(link) + } else { + self.cashuInfo = nil + } + } + private var fgColor: Color { palette.primary } private var bgColor: Color { palette.secondary.opacity(colorScheme == .dark ? 0.18 : 0.12) } private var border: Color { fgColor.opacity(0.25) } - + + /// "500 sat · mint.example.com", degrading to the generic label when the + /// token didn't decode (V4 payloads we can't walk, malformed input…). + private var primaryLabel: String { + guard let info = cashuInfo else { return paymentType.label } + var parts: [String] = [] + if let amount = info.displayAmount { parts.append(amount) } + if let host = info.mintHost { parts.append(host) } + return parts.isEmpty ? paymentType.label : parts.joined(separator: " · ") + } + + private var memoLabel: String? { cashuInfo?.memo } + var body: some View { Button { - #if os(iOS) - if let url = paymentType.url { openURL(url) } - #else - if let url = paymentType.url { NSWorkspace.shared.open(url) } - #endif + primaryAction() } label: { HStack(spacing: 6) { Text(paymentType.emoji) - Text(paymentType.label) - .bitchatFont(size: 12, weight: .semibold) + VStack(alignment: .leading, spacing: 1) { + Text(primaryLabel) + .bitchatFont(size: 12, weight: .semibold) + if let memoLabel { + Text(memoLabel) + .bitchatFont(size: 10) + .opacity(0.7) + .lineLimit(1) + } + } } .padding(.vertical, 6) .padding(.horizontal, 12) @@ -87,13 +140,102 @@ struct PaymentChipView: View { .foregroundColor(fgColor) } .buttonStyle(.plain) + .contextMenu { + if let token = paymentType.cashuToken { + Button { + copyToPasteboard(token) + } label: { + Label(String(localized: "content.payment.copy_token", comment: "Context menu action copying a Cashu token to the pasteboard"), systemImage: "doc.on.doc") + } + Button { + redeemCashu() + } label: { + Label(String(localized: "content.payment.redeem_wallet", comment: "Context menu action opening a Cashu token in an ecash wallet app"), systemImage: "wallet.pass") + } + if let webURL = paymentType.cashuWebRedeemURL { + Button { + openExternalURL(webURL) + } label: { + Label(String(localized: "content.payment.redeem_web", comment: "Context menu action opening a Cashu token in the web redemption page"), systemImage: "safari") + } + } + } + } + .accessibilityLabel(Text(verbatim: accessibilityText)) + } + + private var accessibilityText: String { + var text = "\(paymentType.label): \(primaryLabel)" + if let memoLabel { text += ", \(memoLabel)" } + return text + } + + // MARK: - Actions + + private func primaryAction() { + switch paymentType { + case .cashu: + redeemCashu() + case .lightning: + #if os(iOS) + if let url = paymentType.url { openURL(url) } + #else + if let url = paymentType.url { NSWorkspace.shared.open(url) } + #endif + } + } + + /// Redemption is delegated: try a wallet registered for `cashu:` URLs + /// first, then fall back to the web redemption page. Uses the platform + /// opener directly (not the `openURL` environment) because the message + /// list overrides that action for cashu/lightning schemes without a + /// fallback path. + private func redeemCashu() { + let walletURL = paymentType.url + let webURL = paymentType.cashuWebRedeemURL + #if os(iOS) + if let walletURL { + UIApplication.shared.open(walletURL, options: [:]) { accepted in + if !accepted, let webURL { + UIApplication.shared.open(webURL) + } + } + } else if let webURL { + UIApplication.shared.open(webURL) + } + #else + if let walletURL, NSWorkspace.shared.urlForApplication(toOpen: walletURL) != nil { + NSWorkspace.shared.open(walletURL) + } else if let webURL { + NSWorkspace.shared.open(webURL) + } else if let walletURL { + NSWorkspace.shared.open(walletURL) + } + #endif + } + + private func openExternalURL(_ url: URL) { + #if os(iOS) + UIApplication.shared.open(url) + #else + NSWorkspace.shared.open(url) + #endif + } + + private func copyToPasteboard(_ string: String) { + #if os(iOS) + UIPasteboard.general.string = string + #else + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(string, forType: .string) + #endif } } #Preview { let cashuLink = "https://example.com/cashu" let lightningLink = "https://example.com/lightning" - + List { HStack { PaymentChipView(paymentType: .cashu(cashuLink)) diff --git a/bitchat/Views/MessageTextHelpers.swift b/bitchat/Views/MessageTextHelpers.swift index bd653ec9..c684d3f1 100644 --- a/bitchat/Views/MessageTextHelpers.swift +++ b/bitchat/Views/MessageTextHelpers.swift @@ -21,17 +21,20 @@ extension String { return current >= threshold } - // Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths. + // Extract up to `max` distinct Cashu tokens (cashuA/cashuB), as the bare + // bearer strings. Allow dot '.' and shorter lengths. The `cashu:` URI + // form matches too — the token embedded after the scheme is the match. func extractCashuLinks(max: Int = 3) -> [String] { let regex = MessageFormattingEngine.Patterns.cashu let ns = self as NSString let range = NSRange(location: 0, length: ns.length) var found: [String] = [] - for m in regex.matches(in: self, range: range) { - if m.numberOfRanges > 0 { - let token = ns.substring(with: m.range(at: 0)) - let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token - found.append("cashu:\(enc)") + for m in regex.matches(in: self, range: range) where m.numberOfRanges > 0 { + let token = ns.substring(with: m.range(at: 0)) + // Dedup: repeated tokens are one bearer instrument (and duplicate + // ForEach IDs) — one chip is enough. + if !found.contains(token) { + found.append(token) if found.count >= max { break } } } diff --git a/bitchatTests/CashuTokenDecoderTests.swift b/bitchatTests/CashuTokenDecoderTests.swift new file mode 100644 index 00000000..890a7eeb --- /dev/null +++ b/bitchatTests/CashuTokenDecoderTests.swift @@ -0,0 +1,350 @@ +// +// CashuTokenDecoderTests.swift +// bitchatTests +// +// Tests for the Cashu token summary decoder: V3 JSON decode, minimal V4 +// CBOR traversal, URI normalization, detection ranges, and adversarial +// (truncated / garbage / huge) input. The decoder renders attacker-controlled +// message content, so "never crash" matters as much as "decode correctly". +// This is free and unencumbered software released into the public domain. +// + +import Foundation +import Testing +@testable import bitchat + +struct CashuTokenDecoderTests { + + // MARK: - Token Builders + + private func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + private func makeV3Token( + entries: [(mint: String, amounts: [Int])], + unit: String? = "sat", + memo: String? = nil + ) -> String { + var json: [String: Any] = [ + "token": entries.map { entry in + [ + "mint": entry.mint, + "proofs": entry.amounts.map { + ["amount": $0, "id": "009a1f293253e41e", "secret": "s", "C": "02c"] as [String: Any] + } + ] as [String: Any] + } + ] + if let unit { json["unit"] = unit } + if let memo { json["memo"] = memo } + let data = try! JSONSerialization.data(withJSONObject: json) + return "cashuA" + base64URL(data) + } + + /// Tiny deterministic CBOR encoder (definite lengths only) for building + /// V4 test tokens without depending on the decoder under test. + private enum CBOREncode { + static func head(_ major: UInt8, _ value: UInt64) -> [UInt8] { + switch value { + case 0...23: + return [(major << 5) | UInt8(value)] + case 24...0xFF: + return [(major << 5) | 24, UInt8(value)] + case 0x100...0xFFFF: + return [(major << 5) | 25, UInt8(value >> 8), UInt8(value & 0xFF)] + default: + return [(major << 5) | 26, + UInt8((value >> 24) & 0xFF), UInt8((value >> 16) & 0xFF), + UInt8((value >> 8) & 0xFF), UInt8(value & 0xFF)] + } + } + static func uint(_ v: UInt64) -> [UInt8] { head(0, v) } + static func bytes(_ b: [UInt8]) -> [UInt8] { head(2, UInt64(b.count)) + b } + static func text(_ s: String) -> [UInt8] { + let utf8 = Array(s.utf8) + return head(3, UInt64(utf8.count)) + utf8 + } + static func array(_ items: [[UInt8]]) -> [UInt8] { + head(4, UInt64(items.count)) + items.flatMap { $0 } + } + static func map(_ pairs: [(String, [UInt8])]) -> [UInt8] { + head(5, UInt64(pairs.count)) + pairs.flatMap { text($0.0) + $0.1 } + } + } + + private func makeV4Token( + mint: String = "https://mint.example.com", + unit: String = "sat", + memo: String? = nil, + amounts: [UInt64] = [1, 4] + ) -> String { + var pairs: [(String, [UInt8])] = [ + ("m", CBOREncode.text(mint)), + ("u", CBOREncode.text(unit)) + ] + if let memo { pairs.append(("d", CBOREncode.text(memo))) } + let proofs = amounts.map { amount in + CBOREncode.map([ + ("a", CBOREncode.uint(amount)), + ("s", CBOREncode.text("secret")), + ("c", CBOREncode.bytes([0x02, 0xAB, 0xCD])) + ]) + } + pairs.append(("t", CBOREncode.array([ + CBOREncode.map([ + ("i", CBOREncode.bytes([0x00, 0xAD, 0x26, 0x8C])), + ("p", CBOREncode.array(proofs)) + ]) + ]))) + return "cashuB" + base64URL(Data(CBOREncode.map(pairs))) + } + + // MARK: - V3 Decode + + @Test func v3DecodeValidToken() { + let token = makeV3Token( + entries: [("https://mint.example.com", [2, 8])], + unit: "sat", + memo: "thanks!" + ) + let info = CashuTokenDecoder.decode(token) + #expect(info != nil) + #expect(info?.version == "A") + #expect(info?.amount == 10) + #expect(info?.unit == "sat") + #expect(info?.mintHost == "mint.example.com") + #expect(info?.memo == "thanks!") + #expect(info?.displayAmount == "10 sat") + } + + @Test func v3AmountSumsAcrossEntriesAndProofs() { + let token = makeV3Token(entries: [ + ("https://a.mint.example", [1, 2, 4]), + ("https://b.mint.example", [8, 16]) + ]) + let info = CashuTokenDecoder.decode(token) + #expect(info?.amount == 31) + // First mint wins for the display host + #expect(info?.mintHost == "a.mint.example") + } + + @Test func v3MissingUnitDefaultsToSatForDisplay() { + let token = makeV3Token(entries: [("https://mint.example.com", [5])], unit: nil) + let info = CashuTokenDecoder.decode(token) + #expect(info?.unit == nil) + #expect(info?.displayAmount == "5 sat") + } + + @Test func v3RejectsNonsenseAmounts() { + // Negative and absurd amounts must not poison the sum + let json: [String: Any] = [ + "token": [[ + "mint": "https://mint.example.com", + "proofs": [ + ["amount": -5, "id": "x", "secret": "s", "C": "c"], + ["amount": 3, "id": "x", "secret": "s", "C": "c"] + ] + ] as [String: Any]] + ] + let token = "cashuA" + base64URL(try! JSONSerialization.data(withJSONObject: json)) + #expect(CashuTokenDecoder.decode(token)?.amount == 3) + } + + @Test func v3MemoIsSanitizedForDisplay() { + let token = makeV3Token( + entries: [("https://mint.example.com", [1])], + memo: "line1\nline2\u{0007}" + String(repeating: "x", count: 300) + ) + let memo = CashuTokenDecoder.decode(token)?.memo + #expect(memo != nil) + #expect(memo?.contains("\n") == false) + #expect(memo?.contains("\u{0007}") == false) + #expect((memo?.count ?? 0) <= 80) + } + + // MARK: - V4 (CBOR) Decode + + @Test func v4DecodeValidToken() { + let token = makeV4Token(memo: "Thank you", amounts: [1, 4, 16]) + let info = CashuTokenDecoder.decode(token) + #expect(info?.version == "B") + #expect(info?.amount == 21) + #expect(info?.unit == "sat") + #expect(info?.mintHost == "mint.example.com") + #expect(info?.memo == "Thank you") + } + + @Test func v4UnparseableCBORDegradesToGenericToken() { + // Valid base64 payload, but not CBOR we can walk: still a token, + // rendered as a generic chip with no amount. + let token = "cashuB" + base64URL(Data([0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x01, 0x02])) + let info = CashuTokenDecoder.decode(token) + #expect(info?.version == "B") + #expect(info?.amount == nil) + #expect(info?.mintHost == nil) + } + + // MARK: - Strict Mode (used by the /pay SEND path) + + @Test func strictAcceptsValidV3WithPositiveAmount() { + let token = makeV3Token(entries: [("https://mint.example.com", [2, 8])]) + let info = CashuTokenDecoder.decode(token, strict: true) + #expect(info?.version == "A") + #expect(info?.amount == 10) + } + + @Test func strictAcceptsValidDefiniteLengthV4() { + let token = makeV4Token(amounts: [1, 4, 16]) + let info = CashuTokenDecoder.decode(token, strict: true) + #expect(info?.version == "B") + #expect(info?.amount == 21) + } + + @Test func strictRejectsUnwalkableV4() { + // Valid base64, but not CBOR we can walk: permissive mode returns a + // generic chip, strict mode refuses it. + let token = "cashuB" + base64URL(Data([0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x01, 0x02])) + #expect(CashuTokenDecoder.decode(token)?.version == "B") + #expect(CashuTokenDecoder.decode(token, strict: true) == nil) + } + + @Test func strictRejectsTruncatedV4() { + let token = makeV4Token(amounts: [1, 4, 16]) + // Lop off the tail of the base64 payload — CBOR can no longer be walked. + let truncated = String(token.prefix(token.count - 12)) + #expect(CashuTokenDecoder.decode(truncated, strict: true) == nil) + } + + @Test func strictRejectsAmountlessToken() { + // A well-formed V3 token that carries no positive proof amount. + let json: [String: Any] = [ + "token": [[ + "mint": "https://mint.example.com", + "proofs": [["amount": 0, "id": "x", "secret": "s", "C": "c"] as [String: Any]] + ] as [String: Any]] + ] + let token = "cashuA" + base64URL(try! JSONSerialization.data(withJSONObject: json)) + #expect(CashuTokenDecoder.decode(token)?.amount == nil) + #expect(CashuTokenDecoder.decode(token, strict: true) == nil) + } + + // MARK: - URI Form and Normalization + + @Test func uriFormsDecode() { + let token = makeV3Token(entries: [("https://mint.example.com", [7])]) + for wrapped in ["cashu:\(token)", "cashu://\(token)", "CASHU:\(token)"] { + #expect(CashuTokenDecoder.bareToken(from: wrapped) == token, "failed for \(wrapped)") + #expect(CashuTokenDecoder.decode(wrapped)?.amount == 7) + } + } + + @Test func percentEncodedURIDecodes() { + let token = makeV3Token(entries: [("https://mint.example.com", [7])]) + let encoded = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! + #expect(CashuTokenDecoder.decode("cashu:\(encoded)")?.amount == 7) + } + + @Test func bareTokenRejectsNonTokens() { + #expect(CashuTokenDecoder.bareToken(from: "hello world") == nil) + #expect(CashuTokenDecoder.bareToken(from: "cashuC" + String(repeating: "a", count: 50)) == nil) + #expect(CashuTokenDecoder.bareToken(from: "cashuA{not-base64!}") == nil) + #expect(CashuTokenDecoder.bareToken(from: "cashuA") == nil) // too short + } + + // MARK: - Adversarial Input (never crash, fail closed) + + @Test func truncatedTokensNeverCrash() { + let v3 = makeV3Token(entries: [("https://mint.example.com", [1, 2, 4, 8])], memo: "memo") + let v4 = makeV4Token(memo: "memo", amounts: [1, 2, 4, 8]) + for token in [v3, v4] { + for length in stride(from: 0, to: token.count, by: 3) { + _ = CashuTokenDecoder.decode(String(token.prefix(length))) + } + } + // Truncating the payload must not produce a phantom V3 summary + #expect(CashuTokenDecoder.decode(String(v3.prefix(v3.count - 10))) == nil) + } + + @Test func garbagePayloadsNeverCrash() { + var rng = SystemRandomNumberGenerator() + for _ in 0..<200 { + let length = Int.random(in: 0..<600, using: &rng) + let junk = Data((0.. [UInt8] { + switch value { + case 0...23: return [(major << 5) | UInt8(value)] + case 24...0xFF: return [(major << 5) | 24, UInt8(value)] + default: return [(major << 5) | 25, UInt8(value >> 8), UInt8(value & 0xFF)] + } + } + func text(_ s: String) -> [UInt8] { head(3, UInt64(s.utf8.count)) + Array(s.utf8) } + func bytes(_ b: [UInt8]) -> [UInt8] { head(2, UInt64(b.count)) + b } + func uint(_ v: UInt64) -> [UInt8] { head(0, v) } + func array(_ items: [[UInt8]]) -> [UInt8] { head(4, UInt64(items.count)) + items.flatMap { $0 } } + func map(_ pairs: [(String, [UInt8])]) -> [UInt8] { head(5, UInt64(pairs.count)) + pairs.flatMap { text($0.0) + $0.1 } } + + let proofs = [UInt64(1), 4, 16].map { amount in + map([("a", uint(amount)), ("s", text("secret")), ("c", bytes([0x02, 0xAB, 0xCD]))]) + } + let cbor = map([ + ("m", text("https://mint.example.com")), + ("u", text("sat")), + ("t", array([map([("i", bytes([0x00, 0xAD, 0x26, 0x8C])), ("p", array(proofs))])])) + ]) + let b64 = Data(cbor).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + return "cashuB" + b64 + }() + + @MainActor + private func makePayProcessor(context: MockCommandContextProvider) -> CommandProcessor { + CommandProcessor( + contextProvider: context, + meshService: MockTransport(), + identityManager: MockIdentityManager(MockKeychain()) + ) + } + @MainActor private func withSelectedChannel( _ channel: ChannelID, @@ -477,6 +644,11 @@ private final class MockCommandContextProvider: CommandContextProvider { sentPublicRawMessages.append(content) } + private(set) var sentPublicMessages: [String] = [] + func sendPublicMessage(_ content: String) { + sentPublicMessages.append(content) + } + func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) { localPrivateSystemMessages.append((content, peerID)) } From cee2bcd535657a4ec7d9ee7af8dfa805623c2737 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:35:51 +0200 Subject: [PATCH 10/18] Fix SyncTypeFlags tests: bit 8 (boardPost) is a known type (#1390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1379 (board) mapped bit 8 -> .boardPost in SyncTypeFlags, making it a known bit that spills the encoded bitfield into a second byte. But the phantom-bit tests (added by #1373) predate that change and still assert bit 8 is unknown, so main went red once both landed. Neither PR's CI caught it — each was green against a main without the other. The impl is correct (board is a real sync type); the tests were stale. Update them to treat bits 9+ as phantom, expect the all-known field to serialize to 2 bytes, and add a regression test that the board bit survives decode while the phantom high bits are stripped. Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchatTests/Sync/SyncTypeFlagsTests.swift | 28 +++++++++++++++------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/bitchatTests/Sync/SyncTypeFlagsTests.swift b/bitchatTests/Sync/SyncTypeFlagsTests.swift index a3ab3cc4..d4684afc 100644 --- a/bitchatTests/Sync/SyncTypeFlagsTests.swift +++ b/bitchatTests/Sync/SyncTypeFlagsTests.swift @@ -13,31 +13,41 @@ struct SyncTypeFlagsTests { } @Test func decodeDropsPhantomBits() { - // Bits 8+ map to no message type. They must not survive decode as - // phantom membership. - let phantom = Data([0x00, 0xFF]) // bits 8..15 set, no known type + // Bits 9+ map to no message type (bit 8 is boardPost). They must not + // survive decode as phantom membership. + let phantom = Data([0x00, 0xFE]) // bits 9..15 set, no known type let decoded = SyncTypeFlags.decode(phantom) #expect(decoded?.rawValue == 0) #expect(decoded?.toMessageTypes().isEmpty == true) } + @Test func boardBitSurvivesDecode() { + // Bit 8 maps to boardPost and spills the field into a second byte; + // it must survive decode while the phantom high bits are stripped. + let mixed = Data([0x00, 0xFF]) // bit 8 (board) known, bits 9..15 phantom + let decoded = SyncTypeFlags.decode(mixed) + #expect(decoded?.contains(.board) == true) + #expect(decoded?.rawValue == 0b1_0000_0000) + } + @Test func phantomBitsAreStrippedButKnownBitsSurvive() { - // Low byte = announce(0) + message(1); high byte = phantom. - let mixed = Data([0b0000_0011, 0xFF]) + // Low byte = announce(0) + message(1); high byte bits 9+ are phantom. + let mixed = Data([0b0000_0011, 0xFE]) let decoded = SyncTypeFlags.decode(mixed) #expect(decoded?.contains(.announce) == true) #expect(decoded?.contains(.message) == true) - // Only the two known bits remain; phantom high byte is gone. + // Only the two known bits remain; phantom high bits are gone. #expect(decoded?.rawValue == 0b0000_0011) } @Test func rawValueInitNormalizesPhantomBits() { let flags = SyncTypeFlags(rawValue: 0xFFFF_FFFF_FFFF_FFFF) - // Every known type bit is set; nothing above them survives, so the - // field serializes to a single byte. + // Every known type bit is set; nothing above them survives. boardPost + // occupies bit 8, so the known set spills into a second byte. #expect(flags.contains(.announce)) #expect(flags.contains(.fileTransfer)) + #expect(flags.contains(.board)) let data = flags.toData() - #expect(data?.count == 1) + #expect(data?.count == 2) } } From 70229f0be1b53600f7eb0da209b2a6e2b8a4a49e Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:42:53 +0200 Subject: [PATCH 11/18] Originate v2 source routes and wire fragmentIdFilter targeted resync (#1378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Originate v2 source routes and wire fragmentIdFilter targeted resync Part A — source-route origination policy: - Gate route application (BLESourceRouteOriginationPolicy): only packets we author, directed at a single peer, with TTL headroom, whose recipient is not directly connected. Relays no longer attach routes to (and re-sign) packets they merely forward. - Version-gate paths: MeshTopologyTracker records the highest protocol version observed per peer; BFS routes require every intermediate hop and the recipient to be v2-observed, capped at 4 intermediate hops. - Degrade on failure: BLESourceRouteFailureCache marks a routed send that sees no inbound traffic from the recipient within 10s as failed and floods for 60s before retrying routes. Part B — REQUEST_SYNC fragmentIdFilter (TLV 0x06): - Requester: BLEFragmentAssemblyBuffer reports stalled broadcast reassemblies (no new fragment for 5s, retried at most every 10s); the maintenance pass sends a types=fragment REQUEST_SYNC naming the stalled 8-byte fragment stream IDs to each connected peer. - Responder: GossipSyncManager restricts the fragment diff to exactly the named streams, bypassing the since-cursor while the GCS filter still excludes pieces the requester holds; RSR/TTL-0/rate-limit semantics unchanged and REQUEST_SYNC stays link-local. - Bounds: at most 60 IDs per request (60*17-1 = 1019 bytes <= the 1024-byte decoder cap); oversized 0x06 values are ignored, not fatal. Docs: SOURCE_ROUTING.md gains the iOS origination policy (§8); REQUEST_SYNC_MANAGER.md documents 0x05/0x06 as implemented. Co-Authored-By: Claude Fable 5 * Fix stall-clock refresh on duplicates and overflow suppression in fragment resync Two fixes to stalledBroadcastFragmentIDs bookkeeping in BLEFragmentAssemblyBuffer: - Duplicate fragments no longer reset the stall clock. Fragment packets bypass the packet deduplicator, so relayed duplicates of an already-held index arriving every few seconds kept lastFragmentAt fresh and suppressed the targeted REQUEST_SYNC indefinitely. Now lastFragmentAt only updates when the index is new (actual progress). - Only the streams that will actually be encoded on the wire are rate-limited. Previously every stalled candidate got lastResyncRequestAt set, but encodeFragmentIdFilter serializes at most RequestSyncPacket.maxFragmentIdFilterCount (60) IDs, so overflow streams were suppressed for retryAfter without ever being requested. Selection now caps at that shared constant, oldest stall first, so overflow stays eligible and rotates fairly on the next pass. Tests: duplicates arriving periodically still trigger the stall report; 70 stalled streams yield the 60 oldest on the first pass and the remaining 10 on the next. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/Models/RequestSyncPacket.swift | 47 ++++-- .../BLE/BLEFragmentAssemblyBuffer.swift | 62 +++++++- bitchat/Services/BLE/BLEService.swift | 63 ++++++-- .../BLE/BLESourceRouteFailureCache.swift | 95 +++++++++++++ .../BLE/BLESourceRouteOriginationPolicy.swift | 40 ++++++ bitchat/Services/MeshTopologyTracker.swift | 44 ++++-- bitchat/Services/TransportConfig.swift | 19 +++ bitchat/Sync/GossipSyncManager.swift | 44 ++++-- bitchatTests/GossipSyncManagerTests.swift | 95 ++++++++++++- .../BLEFragmentAssemblyBufferTests.swift | 134 ++++++++++++++++++ .../BLESourceRouteFailureCacheTests.swift | 98 +++++++++++++ ...BLESourceRouteOriginationPolicyTests.swift | 98 +++++++++++++ .../Services/MeshTopologyTrackerTests.swift | 124 +++++++++++++++- ...RequestSyncPacketFragmentFilterTests.swift | 76 ++++++++++ docs/REQUEST_SYNC_MANAGER.md | 8 +- docs/SOURCE_ROUTING.md | 43 +++++- .../Sources/BitFoundation/BitchatPacket.swift | 2 +- 17 files changed, 1049 insertions(+), 43 deletions(-) create mode 100644 bitchat/Services/BLE/BLESourceRouteFailureCache.swift create mode 100644 bitchat/Services/BLE/BLESourceRouteOriginationPolicy.swift create mode 100644 bitchatTests/Services/BLESourceRouteFailureCacheTests.swift create mode 100644 bitchatTests/Services/BLESourceRouteOriginationPolicyTests.swift create mode 100644 bitchatTests/Sync/RequestSyncPacketFragmentFilterTests.swift diff --git a/bitchat/Models/RequestSyncPacket.swift b/bitchat/Models/RequestSyncPacket.swift index b34dd925..0c6cb3cd 100644 --- a/bitchat/Models/RequestSyncPacket.swift +++ b/bitchat/Models/RequestSyncPacket.swift @@ -1,19 +1,21 @@ +import BitFoundation import Foundation // REQUEST_SYNC payload TLV (type, length16, value) // - 0x01: P (uint8) — Golomb-Rice parameter // - 0x02: M (uint32, big-endian) — hash range (N * 2^P) // - 0x03: data (opaque) — GR bitstream bytes (MSB-first) -// - 0x04: types (bitfield) — SyncTypeFlags of covered message types -// - 0x05: sinceTimestamp (uint64, big-endian) — oldest ts the filter covers -// - 0x06: fragmentIdFilter (utf8) — reserved -// -// TODO(v2): fragmentIdFilter (0x06) is parsed and re-serialized but never -// populated or honored — it's the reserved surface for incremental fragment -// sync (request the missing fragments of one file by ID instead of diffing the -// whole fragment set). Either wire it into buildGcsPayload/_handleRequestSync -// or drop the field; don't leave it as silent dead protocol surface. +// - 0x04: types (SyncTypeFlags) — packet types the filter covers +// - 0x05: sinceTimestamp (uint64, big-endian) — filter coverage cursor +// - 0x06: fragmentIdFilter (UTF-8) — comma-separated 16-hex-char (8-byte) +// fragment stream IDs; restricts the fragment diff to exactly those +// streams (targeted resync for stalled reassemblies) struct RequestSyncPacket { + /// Maximum fragment IDs one 0x06 filter may carry. Each ID encodes as + /// 16 hex chars plus a comma separator, so the largest encoded value is + /// 60 * 17 - 1 = 1019 bytes, which fits the 1024-byte decoder cap. + static let maxFragmentIdFilterCount = 60 + let p: Int let m: UInt32 let data: Data @@ -21,6 +23,29 @@ struct RequestSyncPacket { let sinceTimestamp: UInt64? let fragmentIdFilter: String? + /// Encodes 8-byte fragment stream IDs as the 0x06 filter string, + /// dropping malformed IDs and capping at `maxFragmentIdFilterCount`. + static func encodeFragmentIdFilter(_ fragmentIDs: [Data]) -> String? { + let tokens = fragmentIDs + .filter { $0.count == 8 } + .prefix(maxFragmentIdFilterCount) + .map { $0.hexEncodedString() } + guard !tokens.isEmpty else { return nil } + return tokens.joined(separator: ",") + } + + /// Decodes a 0x06 filter string back into 8-byte fragment stream IDs, + /// ignoring malformed tokens and capping at `maxFragmentIdFilterCount`. + static func decodeFragmentIdFilter(_ filter: String?) -> Set? { + guard let filter else { return nil } + var ids: Set = [] + for token in filter.split(separator: ",").prefix(maxFragmentIdFilterCount) { + guard token.count == 16, let id = Data(hexString: String(token)) else { continue } + ids.insert(id) + } + return ids.isEmpty ? nil : ids + } + init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) { self.p = p self.m = m @@ -97,7 +122,9 @@ struct RequestSyncPacket { sinceTimestamp = ts } case 0x06: - if let fid = String(data: v, encoding: .utf8) { + // Same acceptance cap as the GCS payload; an oversized filter + // is ignored rather than failing the whole request. + if v.count <= maxAcceptBytes, let fid = String(data: v, encoding: .utf8) { fragmentIdFilter = fid } default: diff --git a/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift b/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift index 4d41b2d3..83311584 100644 --- a/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift +++ b/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift @@ -64,6 +64,9 @@ struct BLEFragmentAssemblyBuffer { let type: UInt8 let total: Int let timestamp: Date + let isBroadcast: Bool + var lastFragmentAt: Date + var lastResyncRequestAt: Date? } private var fragmentsByKey: [BLEFragmentKey: [Int: Data]] = [:] @@ -105,7 +108,15 @@ struct BLEFragmentAssemblyBuffer { return .oversized(header: header, projectedSize: projectedSize, limit: limit, started: started) } + // Only actual progress resets the stall clock: fragment packets + // bypass the packet deduplicator, so relayed duplicates of an + // already-held index must not keep suppressing the targeted + // REQUEST_SYNC for a stalled stream. + let isNewIndex = fragmentsByKey[header.key]?[header.index] == nil fragmentsByKey[header.key]?[header.index] = header.fragmentData + if isNewIndex { + metadataByKey[header.key]?.lastFragmentAt = now + } guard let fragments = fragmentsByKey[header.key], fragments.count == header.total else { @@ -138,10 +149,59 @@ struct BLEFragmentAssemblyBuffer { } fragmentsByKey[header.key] = [:] - metadataByKey[header.key] = Metadata(type: header.originalType, total: header.total, timestamp: now) + metadataByKey[header.key] = Metadata( + type: header.originalType, + total: header.total, + timestamp: now, + isBroadcast: header.isBroadcastFragment, + lastFragmentAt: now + ) return true } + /// Fragment stream IDs (8-byte, big-endian) of incomplete broadcast + /// reassemblies that have not seen a new fragment for `stalledAfter` + /// seconds — candidates for a targeted REQUEST_SYNC. Each returned + /// stream is marked so it is not re-requested within `retryAfter`. + /// At most `RequestSyncPacket.maxFragmentIdFilterCount` streams are + /// returned per pass — the wire filter cannot carry more — selected + /// oldest-stall first; overflow streams stay unmarked and eligible for + /// the next pass. Directed reassemblies are excluded: peers only archive + /// broadcast fragments for gossip sync, so a targeted request cannot + /// recover them. + mutating func stalledBroadcastFragmentIDs( + stalledAfter: TimeInterval, + retryAfter: TimeInterval, + now: Date = Date() + ) -> [Data] { + var candidates: [(key: BLEFragmentKey, lastFragmentAt: Date)] = [] + for (key, metadata) in metadataByKey { + guard metadata.isBroadcast, + let fragments = fragmentsByKey[key], + fragments.count < metadata.total, + now.timeIntervalSince(metadata.lastFragmentAt) >= stalledAfter else { continue } + if let lastRequest = metadata.lastResyncRequestAt, + now.timeIntervalSince(lastRequest) < retryAfter { continue } + candidates.append((key: key, lastFragmentAt: metadata.lastFragmentAt)) + } + + // Mark only the streams that will actually go on the wire, so the + // overflow is not silently suppressed for `retryAfter`. + let selected = candidates + .sorted { + if $0.lastFragmentAt != $1.lastFragmentAt { + return $0.lastFragmentAt < $1.lastFragmentAt + } + return ($0.key.sender, $0.key.id) < ($1.key.sender, $1.key.id) + } + .prefix(RequestSyncPacket.maxFragmentIdFilterCount) + + return selected.map { candidate in + metadataByKey[candidate.key]?.lastResyncRequestAt = now + return withUnsafeBytes(of: candidate.key.id.bigEndian) { Data($0) } + } + } + private static func assemblyLimit(for originalType: UInt8) -> Int { if originalType == MessageType.fileTransfer.rawValue { // Allow headroom for TLV metadata and binary framing overhead. diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 598f9821..db134e41 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -69,7 +69,9 @@ final class BLEService: NSObject { #endif private var selfBroadcastTracker = BLESelfBroadcastTracker() private let meshTopology = MeshTopologyTracker() - + // Route health for originated source routes; guarded by collectionsQueue. + private var sourceRouteFailures = BLESourceRouteFailureCache() + // 5. Fragment Reassembly (necessary for messages > MTU) private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer() private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler() @@ -586,6 +588,7 @@ final class BLEService: NSObject { let entries = outboundFragmentTransfers.removeAll().map { ($0.id, $0.workItems) } peerRegistry.removeAll() fragmentAssemblyBuffer.removeAll() + sourceRouteFailures = BLESourceRouteFailureCache() // Also clear pending message queues to avoid stale state across sessions pendingNoiseSessionQueues.removeAll() pendingDirectedRelays.removeAll() @@ -2392,13 +2395,31 @@ extension BLEService { } private func computeRoute(to peerID: PeerID) -> [Data]? { - meshTopology.computeRoute(from: myPeerIDData, to: routingData(for: peerID)) + // Version-gated: every hop and the recipient must have been observed + // speaking v2, since a v1-only node drops v2 frames on decode. + meshTopology.computeRoute( + from: myPeerIDData, + to: routingData(for: peerID), + maxHops: TransportConfig.bleSourceRouteMaxIntermediateHops, + requiringVersion: 2 + ) } private func applyRouteIfAvailable(_ packet: BitchatPacket, to recipient: PeerID) -> BitchatPacket { - guard let route = computeRoute(to: recipient), route.count >= 1 else { - return packet - } + let now = Date() + let route = BLESourceRouteOriginationPolicy.route( + for: packet, + to: recipient, + localPeerIDData: myPeerIDData, + isRecipientConnected: { self.isPeerConnected($0) }, + shouldAttemptRoute: { peer in + self.collectionsQueue.sync(flags: .barrier) { + self.sourceRouteFailures.shouldAttemptRoute(to: peer, now: now) + } + }, + computeRoute: { self.computeRoute(to: $0) } + ) + guard let route else { return packet } // Create new packet with route applied and version upgraded to 2 let routedPacket = BitchatPacket( type: packet.type, @@ -2416,6 +2437,9 @@ extension BLEService { SecureLogger.error("❌ Failed to re-sign packet with route", category: .security) return packet // Return original packet if signing fails } + collectionsQueue.sync(flags: .barrier) { + sourceRouteFailures.noteRoutedSend(to: recipient, now: now) + } return signedPacket } @@ -3187,13 +3211,23 @@ extension BLEService { // Update peer info without verbose logging - update the peer we received from, not the original sender updatePeerLastSeen(peerID) - // Track recent traffic timestamps for adaptive behavior + // Track recent traffic timestamps for adaptive behavior; the same + // barrier hop confirms route health for the packet's originator. collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } self.recentTrafficTracker.recordPacket(at: Date()) + self.sourceRouteFailures.noteInboundActivity(from: senderID) + } + + // Per-peer protocol version: originated source routes only use hops + // observed speaking v2 (a v1-only node cannot decode v2 frames). + if packet.version >= 2 { + meshTopology.recordObservedVersion(packet.version, for: packet.senderID) + if peerID != senderID { + meshTopology.recordObservedVersion(packet.version, for: routingData(for: peerID)) + } } - // Process by type switch context.messageType { case .announce: @@ -3760,10 +3794,21 @@ extension BLEService { // Clean old processed messages efficiently messageDeduplicator.cleanup() - // Clean old fragments (> configured seconds old) - collectionsQueue.sync(flags: .barrier) { + // Clean old fragments (> configured seconds old), then ask peers for + // the specific fragment streams whose reassembly has stalled instead + // of waiting for the next periodic GCS fragment round. + let stalledFragmentIDs = collectionsQueue.sync(flags: .barrier) { () -> [Data] in let cutoff = now.addingTimeInterval(-TransportConfig.bleFragmentLifetimeSeconds) fragmentAssemblyBuffer.removeExpired(before: cutoff) + sourceRouteFailures.prune(now: now) + return fragmentAssemblyBuffer.stalledBroadcastFragmentIDs( + stalledAfter: TransportConfig.bleFragmentResyncStallSeconds, + retryAfter: TransportConfig.bleFragmentResyncRetrySeconds, + now: now + ) + } + if !stalledFragmentIDs.isEmpty { + gossipSyncManager?.requestMissingFragments(fragmentIDs: stalledFragmentIDs) } // Clean old connection timeout backoff entries (> window) diff --git a/bitchat/Services/BLE/BLESourceRouteFailureCache.swift b/bitchat/Services/BLE/BLESourceRouteFailureCache.swift new file mode 100644 index 00000000..fcf23d2c --- /dev/null +++ b/bitchat/Services/BLE/BLESourceRouteFailureCache.swift @@ -0,0 +1,95 @@ +import BitFoundation +import Foundation + +/// Tracks whether source-routed sends to a recipient appear to be working. +/// +/// A routed unicast rides exactly one path, so a broken hop silently loses the +/// packet where a flood would have healed around it. Rather than building a +/// retransmission machine (MessageRouter already retries at a higher layer), +/// this cache degrades: a routed send that sees no inbound traffic from the +/// recipient within the confirmation window marks the route as failed, and +/// subsequent sends fall back to flooding until the suppression TTL lapses. +struct BLESourceRouteFailureCache { + struct Config { + /// How long a routed send may go unconfirmed before it counts as a + /// route failure. + var confirmationWindowSeconds: TimeInterval = TransportConfig.bleSourceRouteConfirmationWindowSeconds + /// How long to flood instead of routing after a failure. + var suppressionSeconds: TimeInterval = TransportConfig.bleSourceRouteSuppressionSeconds + } + + private struct State { + var pendingSince: Date? + var suppressedUntil: Date? + } + + private let config: Config + private var states: [PeerID: State] = [:] + + init(config: Config = Config()) { + self.config = config + } + + /// Whether the next directed send to `recipient` may carry a source + /// route. Flips the recipient into suppression when the last routed send + /// went unconfirmed past the confirmation window. + mutating func shouldAttemptRoute(to recipient: PeerID, now: Date = Date()) -> Bool { + guard var state = states[recipient] else { return true } + + if let until = state.suppressedUntil { + guard now >= until else { return false } + state.suppressedUntil = nil + } + + if let pending = state.pendingSince, + now.timeIntervalSince(pending) > config.confirmationWindowSeconds { + // The routed send was never confirmed: treat the route as broken + // and flood until the suppression window lapses. + state.pendingSince = nil + state.suppressedUntil = now.addingTimeInterval(config.suppressionSeconds) + states[recipient] = state + return false + } + + states[recipient] = state + return true + } + + /// Records that a source-routed packet was sent to `recipient`. Keeps the + /// earliest unconfirmed send so back-to-back packets share one deadline. + mutating func noteRoutedSend(to recipient: PeerID, now: Date = Date()) { + var state = states[recipient] ?? State() + if state.pendingSince == nil { + state.pendingSince = now + } + states[recipient] = state + } + + /// Any inbound packet authored by `peer` confirms the pending routed send + /// (delivery acks and replies arrive this way). Deliberately does not + /// lift an active suppression: that traffic may have arrived via flood. + mutating func noteInboundActivity(from peer: PeerID) { + guard var state = states[peer] else { return } + state.pendingSince = nil + if state.suppressedUntil == nil { + states.removeValue(forKey: peer) + } else { + states[peer] = state + } + } + + /// Drops entries that can no longer influence a routing decision. An + /// expired-but-unconverted pending entry is kept for as long as the + /// suppression it would trigger could still be active. + mutating func prune(now: Date = Date()) { + let pendingRetention = config.confirmationWindowSeconds + config.suppressionSeconds + states = states.filter { _, state in + if let until = state.suppressedUntil, now < until { return true } + if let pending = state.pendingSince, + now.timeIntervalSince(pending) <= pendingRetention { + return true + } + return false + } + } +} diff --git a/bitchat/Services/BLE/BLESourceRouteOriginationPolicy.swift b/bitchat/Services/BLE/BLESourceRouteOriginationPolicy.swift new file mode 100644 index 00000000..5cc8bf9c --- /dev/null +++ b/bitchat/Services/BLE/BLESourceRouteOriginationPolicy.swift @@ -0,0 +1,40 @@ +import BitFoundation +import Foundation + +/// Decides whether an outbound directed packet should carry a v2 source +/// route. Pure gating logic so BLEService's hot send path stays a thin wire. +enum BLESourceRouteOriginationPolicy { + /// Returns the intermediate-hop route to attach, or nil to keep the + /// current flood/direct-write behavior unchanged. + /// + /// Routes are only originated when every gate passes: + /// - we authored the packet (relays must not rewrite and re-sign someone + /// else's packet; route-following for in-flight routed packets lives in + /// `BLERouteForwardingPolicy`), + /// - the packet is directed at a single peer (not broadcast), + /// - the packet has TTL headroom to traverse hops (link-local TTL-0 + /// packets like REQUEST_SYNC never route), + /// - the recipient is not directly connected (a direct write already + /// delivers in one hop), + /// - routing to the recipient is not suppressed by a recent unconfirmed + /// routed send, and + /// - the topology yields a complete path. + static func route( + for packet: BitchatPacket, + to recipient: PeerID, + localPeerIDData: Data, + isRecipientConnected: (PeerID) -> Bool, + shouldAttemptRoute: (PeerID) -> Bool, + computeRoute: (PeerID) -> [Data]? + ) -> [Data]? { + guard packet.senderID == localPeerIDData else { return nil } + guard let recipientData = packet.recipientID, + recipientData.count == 8, + !recipientData.allSatisfy({ $0 == 0xFF }) else { return nil } + guard packet.ttl > 1 else { return nil } + guard !isRecipientConnected(recipient) else { return nil } + guard shouldAttemptRoute(recipient) else { return nil } + guard let route = computeRoute(recipient), !route.isEmpty else { return nil } + return route + } +} diff --git a/bitchat/Services/MeshTopologyTracker.swift b/bitchat/Services/MeshTopologyTracker.swift index fdd8749f..eb4e0c3c 100644 --- a/bitchat/Services/MeshTopologyTracker.swift +++ b/bitchat/Services/MeshTopologyTracker.swift @@ -10,6 +10,10 @@ final class MeshTopologyTracker { private var claims: [RoutingID: Set] = [:] // Last time we received an update from a node private var lastSeen: [RoutingID: Date] = [:] + // Highest protocol version observed from each node's decoded packets. + // Nodes absent from this map are assumed v1-only and are never used as + // hops (or targets) for version-gated routes. + private var observedVersions: [RoutingID: (version: UInt8, seenAt: Date)] = [:] // Maximum age for topology claims to be considered fresh for routing // Routes computed using stale topology can fail when the network has changed @@ -19,18 +23,29 @@ final class MeshTopologyTracker { queue.sync(flags: .barrier) { self.claims.removeAll() self.lastSeen.removeAll() + self.observedVersions.removeAll() } } /// Update the topology with a node's self-reported neighbor list - func updateNeighbors(for sourceData: Data?, neighbors: [Data]) { + func updateNeighbors(for sourceData: Data?, neighbors: [Data], at now: Date = Date()) { guard let source = sanitize(sourceData) else { return } // Sanitize neighbors and exclude self-loops let validNeighbors = Set(neighbors.compactMap { sanitize($0) }).subtracting([source]) - + queue.sync(flags: .barrier) { self.claims[source] = validNeighbors - self.lastSeen[source] = Date() + self.lastSeen[source] = now + } + } + + /// Record the protocol version observed on a decoded packet from a node. + /// Only versions above the v1 baseline are stored; the highest wins. + func recordObservedVersion(_ version: UInt8, for peerData: Data?, at now: Date = Date()) { + guard version > 1, let peer = sanitize(peerData) else { return } + queue.sync(flags: .barrier) { + let current = self.observedVersions[peer]?.version ?? 1 + self.observedVersions[peer] = (version: max(version, current), seenAt: now) } } @@ -39,28 +54,37 @@ final class MeshTopologyTracker { queue.sync(flags: .barrier) { self.claims.removeValue(forKey: peer) self.lastSeen.removeValue(forKey: peer) + self.observedVersions.removeValue(forKey: peer) } } - + /// Prune nodes that haven't updated their topology in `age` seconds - func prune(olderThan age: TimeInterval) { - let deadline = Date().addingTimeInterval(-age) + func prune(olderThan age: TimeInterval, now: Date = Date()) { + let deadline = now.addingTimeInterval(-age) queue.sync(flags: .barrier) { let stale = self.lastSeen.filter { $0.value < deadline } for (peer, _) in stale { self.claims.removeValue(forKey: peer) self.lastSeen.removeValue(forKey: peer) } + self.observedVersions = self.observedVersions.filter { $0.value.seenAt >= deadline } } } - func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 10) -> [Data]? { + /// BFS over confirmed, fresh edges. When `requiringVersion` is set, every + /// node on the path except the source (i.e. all intermediate hops and the + /// target) must have been observed speaking at least that protocol + /// version — a v1-only hop cannot decode a v2 routed packet. + func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 10, requiringVersion: UInt8? = nil, now: Date = Date()) -> [Data]? { guard let source = sanitize(start), let target = sanitize(goal) else { return nil } if source == target { return [] } // Direct connection, no intermediate hops return queue.sync { - let now = Date() let freshnessDeadline = now.addingTimeInterval(-Self.routeFreshnessThreshold) + func meetsRequiredVersion(_ peer: RoutingID) -> Bool { + guard let requiringVersion else { return true } + return (observedVersions[peer]?.version ?? 1) >= requiringVersion + } // BFS var visited: Set = [source] @@ -86,6 +110,10 @@ final class MeshTopologyTracker { for neighbor in neighbors { if visited.contains(neighbor) { continue } + // Version gate: skip nodes not known to speak the + // required protocol version. + guard meetsRequiredVersion(neighbor) else { continue } + // CONFIRMED EDGE CHECK: // 'last' claims 'neighbor' (checked above) // Does 'neighbor' claim 'last'? diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 59d15c55..649a8be9 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -208,6 +208,25 @@ enum TransportConfig { static let bleSubscriptionRateLimitWindowSeconds: TimeInterval = 60.0 // Window for tracking subscription attempts static let bleSubscriptionRateLimitMaxAttempts: Int = 5 // Max attempts before extended cooldown + // Source routing (v2 directed packets) + // Longest path we will originate, in intermediate hops between us and the + // recipient. Keep small: every hop must be a fresh, confirmed, v2-capable + // node, and long stale paths fail more often than floods. + static let bleSourceRouteMaxIntermediateHops: Int = 4 + // A routed send with no inbound traffic from the recipient within this + // window counts as a route failure. + static let bleSourceRouteConfirmationWindowSeconds: TimeInterval = 10.0 + // After a route failure, directed sends to that recipient flood instead + // of routing until this lapses. + static let bleSourceRouteSuppressionSeconds: TimeInterval = 60.0 + + // Targeted fragment resync (REQUEST_SYNC fragmentIdFilter) + // A broadcast reassembly with no new fragment for this long is stalled + // and triggers a targeted REQUEST_SYNC naming its fragment stream. + static let bleFragmentResyncStallSeconds: TimeInterval = 5.0 + // Minimum spacing between targeted resync requests for the same stream. + static let bleFragmentResyncRetrySeconds: TimeInterval = 10.0 + // Store-and-forward for directed packets at relays. Spooled packets retry // on each maintenance flush until the window lapses; a longer window lets // brief link gaps (walking between rooms, reconnect churn) heal themselves. diff --git a/bitchat/Sync/GossipSyncManager.swift b/bitchat/Sync/GossipSyncManager.swift index 2d7118ab..5edf1c4c 100644 --- a/bitchat/Sync/GossipSyncManager.swift +++ b/bitchat/Sync/GossipSyncManager.swift @@ -274,11 +274,29 @@ final class GossipSyncManager { delegate?.sendPacket(signed) } - private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) { + /// Targeted fragment recovery: ask connected peers for the specific + /// fragment streams whose reassembly has stalled, instead of waiting on + /// the next periodic GCS fragment round to cover them. + func requestMissingFragments(fragmentIDs: [Data]) { + queue.async { [weak self] in + self?._requestMissingFragments(fragmentIDs) + } + } + + private func _requestMissingFragments(_ fragmentIDs: [Data]) { + guard let filter = RequestSyncPacket.encodeFragmentIdFilter(fragmentIDs) else { return } + guard let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty else { return } + SecureLogger.debug("Requesting \(fragmentIDs.count) stalled fragment stream(s) from \(connectedPeers.count) peer(s)", category: .sync) + for peerID in connectedPeers { + sendRequestSync(to: peerID, types: .fragment, fragmentIdFilter: filter) + } + } + + private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags, fragmentIdFilter: String? = nil) { // Register the request for RSR validation requestSyncManager.registerRequest(to: peerID) - - let payload = buildGcsPayload(for: types) + + let payload = buildGcsPayload(for: types, fragmentIdFilter: fragmentIdFilter) var recipient = Data() var temp = peerID.id while temp.count >= 2 && recipient.count < 8 { @@ -355,9 +373,19 @@ final class GossipSyncManager { } if requestedTypes.contains(.fragment) { + // A fragment-ID filter narrows the diff to exactly the named + // fragment streams (targeted resync for stalled reassemblies) + // and bypasses the since-cursor for them; the GCS filter still + // excludes the pieces the requester already holds. Fragment + // payloads start with the 8-byte stream ID. + let fragmentIdFilter = RequestSyncPacket.decodeFragmentIdFilter(request.fragmentIdFilter) let frags = fragments.allPackets(isFresh: isPacketFresh) for pkt in frags { - if let since, pkt.timestamp < since { continue } + if let fragmentIdFilter { + guard fragmentIdFilter.contains(Data(pkt.payload.prefix(8))) else { continue } + } else if let since, pkt.timestamp < since { + continue + } let idBytes = PacketIdUtil.computeId(pkt) if !mightContain(idBytes) { var toSend = pkt @@ -400,7 +428,7 @@ final class GossipSyncManager { } // Build REQUEST_SYNC payload using current candidates and GCS params - private func buildGcsPayload(for types: SyncTypeFlags) -> Data { + private func buildGcsPayload(for types: SyncTypeFlags, fragmentIdFilter: String? = nil) -> Data { var candidates: [BitchatPacket] = [] if types.contains(.announce) { for (_, pkt) in latestAnnouncementByPeer where isPacketFresh(pkt) { @@ -421,7 +449,7 @@ final class GossipSyncManager { } if candidates.isEmpty { let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr) - let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types) + let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types, fragmentIdFilter: fragmentIdFilter) return req.encode() } @@ -442,7 +470,7 @@ final class GossipSyncManager { } let takeN = min(candidates.count, min(nMax, cap)) if takeN <= 0 { - let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types) + let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types, fragmentIdFilter: fragmentIdFilter) return req.encode() } let included = Array(candidates.prefix(takeN)) @@ -460,7 +488,7 @@ final class GossipSyncManager { let sinceTimestamp: UInt64? = (covered < candidates.count && covered > 0) ? included[covered - 1].timestamp : nil - let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types, sinceTimestamp: sinceTimestamp) + let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter) return req.encode() } diff --git a/bitchatTests/GossipSyncManagerTests.swift b/bitchatTests/GossipSyncManagerTests.swift index 0e7b35eb..81516543 100644 --- a/bitchatTests/GossipSyncManagerTests.swift +++ b/bitchatTests/GossipSyncManagerTests.swift @@ -507,6 +507,98 @@ struct GossipSyncManagerTests { #expect(sentPackets[0].type == MessageType.fragment.rawValue) } + // MARK: - Fragment-ID filter (targeted resync) + + private func makeFragmentPacket(sender: Data, fragmentID: Data, index: UInt16, timestamp: UInt64) -> BitchatPacket { + // Fragment payload: 8-byte stream ID + index + total + original type. + var payload = fragmentID + payload.append(contentsOf: withUnsafeBytes(of: index.bigEndian) { Data($0) }) + payload.append(contentsOf: withUnsafeBytes(of: UInt16(4).bigEndian) { Data($0) }) + payload.append(MessageType.fileTransfer.rawValue) + payload.append(Data([0xEE])) + return BitchatPacket( + type: MessageType.fragment.rawValue, + senderID: sender, + recipientID: nil, + timestamp: timestamp, + payload: payload, + signature: nil, + ttl: 1 + ) + } + + @Test func handleRequestSyncHonorsFragmentIdFilter() async throws { + var config = GossipSyncManager.Config() + config.fragmentCapacity = 10 + config.messageSyncIntervalSeconds = 0 + config.fragmentSyncIntervalSeconds = 0 + config.fileTransferSyncIntervalSeconds = 0 + + let requestSyncManager = RequestSyncManager() + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) + let delegate = RecordingDelegate() + manager.delegate = delegate + + let sender = try #require(Data(hexString: "aabbccddeeff0011")) + let wantedID = try #require(Data(hexString: "0102030405060708")) + let otherID = try #require(Data(hexString: "1112131415161718")) + let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) + + let wanted = makeFragmentPacket(sender: sender, fragmentID: wantedID, index: 1, timestamp: nowMs - 60_000) + let other = makeFragmentPacket(sender: sender, fragmentID: otherID, index: 2, timestamp: nowMs) + manager.onPublicPacketSeen(wanted) + manager.onPublicPacketSeen(other) + + // The since-cursor sits after both fragments; without the filter the + // responder would send nothing for `wanted`. The filter both bypasses + // the cursor and restricts the diff to exactly the named stream. + let request = RequestSyncPacket( + p: 7, + m: 1, + data: Data(), + types: .fragment, + sinceTimestamp: nowMs + 1, + fragmentIdFilter: RequestSyncPacket.encodeFragmentIdFilter([wantedID]) + ) + manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request) + + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) + // Barrier: flush the sync queue so a late second packet would be visible. + manager._performMaintenanceSynchronously(now: Date()) + let sentPackets = delegate.packets + #expect(sentPackets.count == 1) + let sent = try #require(sentPackets.first) + #expect(sent.type == MessageType.fragment.rawValue) + #expect(sent.payload.prefix(8) == wantedID) + #expect(sent.ttl == 0) + #expect(sent.isRSR) + } + + @Test func requestMissingFragmentsSendsFilteredRequestToConnectedPeers() async throws { + var config = GossipSyncManager.Config() + config.messageSyncIntervalSeconds = 0 + config.fragmentSyncIntervalSeconds = 0 + config.fileTransferSyncIntervalSeconds = 0 + + let requestSyncManager = RequestSyncManager() + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) + let delegate = RecordingDelegate() + delegate.connectedPeers = [PeerID(str: "FFFFFFFFFFFFFFFF")] + manager.delegate = delegate + + let stalledID = try #require(Data(hexString: "0102030405060708")) + manager.requestMissingFragments(fragmentIDs: [stalledID]) + + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) + let sent = try #require(delegate.packets.first) + #expect(sent.type == MessageType.requestSync.rawValue) + #expect(sent.ttl == 0) + let request = try #require(RequestSyncPacket.decode(from: sent.payload)) + #expect(request.types == .fragment) + let ids = try #require(RequestSyncPacket.decodeFragmentIdFilter(request.fragmentIdFilter)) + #expect(ids == Set([stalledID])) + } + // MARK: - Archive persistence @Test func publicMessagesRestoreFromArchiveAcrossRestart() async throws { @@ -583,6 +675,7 @@ struct GossipSyncManagerTests { private final class RecordingDelegate: GossipSyncManager.Delegate { var onSend: (() -> Void)? + var connectedPeers: [PeerID] = [] private(set) var lastPacket: BitchatPacket? private(set) var packets: [BitchatPacket] = [] private let lock = NSLock() @@ -604,6 +697,6 @@ private final class RecordingDelegate: GossipSyncManager.Delegate { } func getConnectedPeers() -> [PeerID] { - return [] + return connectedPeers } } diff --git a/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift b/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift index ed7c66a1..a4423ec9 100644 --- a/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift +++ b/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift @@ -135,6 +135,140 @@ struct BLEFragmentAssemblyBufferTests { } } + @Test + func stalledBroadcastAssemblyReportsFragmentIDOnceUntilRetryLapses() throws { + var buffer = BLEFragmentAssemblyBuffer() + let fragmentID = Data((1...8).map { UInt8($0) }) + let packet = makePacket(payload: makePayload(count: 256)) + let fragments = try makeFragments(for: packet, chunkSize: 128, fragmentID: fragmentID) + let first = try #require(BLEFragmentHeader(packet: fragments[0])) + + let t0 = Date(timeIntervalSince1970: 100) + _ = buffer.append(first, maxInFlightAssemblies: 8, now: t0) + + // Not yet stalled. + let early = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(4)) + #expect(early.isEmpty) + + // Stalled: reported once, big-endian stream ID. + let stalled = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(6)) + #expect(stalled == [fragmentID]) + + // Within the retry window: not re-reported. + let repeated = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(8)) + #expect(repeated.isEmpty) + + // After the retry window it is requested again. + let retried = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(17)) + #expect(retried == [fragmentID]) + } + + @Test + func newFragmentResetsStallClockAndCompletionStopsRequests() throws { + var buffer = BLEFragmentAssemblyBuffer() + let fragmentID = Data((10...17).map { UInt8($0) }) + let packet = makePacket(payload: makePayload(count: 384)) + let fragments = try makeFragments(for: packet, chunkSize: 128, fragmentID: fragmentID) + let headers = try fragments.map { try #require(BLEFragmentHeader(packet: $0)) } + #expect(headers.count >= 3) + + let t0 = Date(timeIntervalSince1970: 100) + _ = buffer.append(headers[0], maxInFlightAssemblies: 8, now: t0) + // A fragment arriving at t0+4 resets the stall clock. + _ = buffer.append(headers[1], maxInFlightAssemblies: 8, now: t0.addingTimeInterval(4)) + let afterProgress = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(6)) + #expect(afterProgress.isEmpty) + + // Completion removes the assembly entirely. + var result: BLEFragmentAssemblyBuffer.AppendResult? + for header in headers.dropFirst(2) { + result = buffer.append(header, maxInFlightAssemblies: 8, now: t0.addingTimeInterval(5)) + } + guard case .complete = result else { + Issue.record("Expected assembly to complete") + return + } + let afterCompletion = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(60)) + #expect(afterCompletion.isEmpty) + } + + @Test + func duplicateFragmentsDoNotResetStallClock() throws { + var buffer = BLEFragmentAssemblyBuffer() + let fragmentID = Data((20...27).map { UInt8($0) }) + let packet = makePacket(payload: makePayload(count: 256)) + let fragments = try makeFragments(for: packet, chunkSize: 128, fragmentID: fragmentID) + let first = try #require(BLEFragmentHeader(packet: fragments[0])) + + let t0 = Date(timeIntervalSince1970: 100) + _ = buffer.append(first, maxInFlightAssemblies: 8, now: t0) + + // Relay duplicates of the same index arrive every few seconds; they + // bring no new data, so they must not keep the stream "fresh". + _ = buffer.append(first, maxInFlightAssemblies: 8, now: t0.addingTimeInterval(3)) + _ = buffer.append(first, maxInFlightAssemblies: 8, now: t0.addingTimeInterval(5)) + + let stalled = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(6)) + #expect(stalled == [fragmentID]) + } + + @Test + func overflowStalledStreamsRotateAcrossPasses() throws { + var buffer = BLEFragmentAssemblyBuffer() + let cap = RequestSyncPacket.maxFragmentIdFilterCount + let streamCount = cap + 10 + let t0 = Date(timeIntervalSince1970: 100) + + // Incomplete broadcast assemblies with staggered last-fragment times + // (stream 0 is the oldest stall). + var ids: [Data] = [] + for i in 0..> 8), UInt8(i & 0xFF)]) + ids.append(fragmentID) + let header = try #require(BLEFragmentHeader(packet: makeFragmentPacket( + fragmentID: fragmentID, + index: 0, + total: 2, + originalType: MessageType.message.rawValue, + fragmentData: Data([0x01]) + ))) + _ = buffer.append(header, maxInFlightAssemblies: streamCount, now: t0.addingTimeInterval(Double(i))) + } + + // All streams are stalled; only the cap's worth (oldest first) is + // requested and rate-limited, the overflow stays eligible. + let firstPassAt = t0.addingTimeInterval(Double(streamCount) + 5) + let firstPass = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 60, now: firstPassAt) + #expect(firstPass == Array(ids.prefix(cap))) + + // Next pass picks up exactly the overflow streams. + let secondPass = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 60, now: firstPassAt.addingTimeInterval(1)) + #expect(secondPass == Array(ids.suffix(streamCount - cap))) + + // Nothing left until a retry window lapses. + let thirdPass = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 60, now: firstPassAt.addingTimeInterval(2)) + #expect(thirdPass.isEmpty) + } + + @Test + func directedAssembliesAreNeverReportedAsStalled() throws { + var buffer = BLEFragmentAssemblyBuffer() + let fragment = makeFragmentPacket( + fragmentID: Data(repeating: 0x0A, count: 8), + index: 0, + total: 2, + originalType: MessageType.message.rawValue, + fragmentData: Data([0x01]), + recipientID: Data(hexString: "0102030405060708") + ) + let header = try #require(BLEFragmentHeader(packet: fragment)) + + let t0 = Date(timeIntervalSince1970: 100) + _ = buffer.append(header, maxInFlightAssemblies: 8, now: t0) + let stalled = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(60)) + #expect(stalled.isEmpty) + } + private func makePacket(payload: Data, timestamp: UInt64 = 0x0102030405) -> BitchatPacket { BitchatPacket( type: MessageType.message.rawValue, diff --git a/bitchatTests/Services/BLESourceRouteFailureCacheTests.swift b/bitchatTests/Services/BLESourceRouteFailureCacheTests.swift new file mode 100644 index 00000000..5d3315f6 --- /dev/null +++ b/bitchatTests/Services/BLESourceRouteFailureCacheTests.swift @@ -0,0 +1,98 @@ +// +// BLESourceRouteFailureCacheTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +struct BLESourceRouteFailureCacheTests { + private let recipient = PeerID(str: "0102030405060708") + private let config = BLESourceRouteFailureCache.Config( + confirmationWindowSeconds: 10, + suppressionSeconds: 60 + ) + + private func attempts(_ cache: inout BLESourceRouteFailureCache, at date: Date) -> Bool { + cache.shouldAttemptRoute(to: recipient, now: date) + } + + @Test func allowsRoutingByDefault() { + var cache = BLESourceRouteFailureCache(config: config) + #expect(attempts(&cache, at: Date())) + } + + @Test func unconfirmedRoutedSendSuppressesRouting() { + var cache = BLESourceRouteFailureCache(config: config) + let t0 = Date() + + cache.noteRoutedSend(to: recipient, now: t0) + // Inside the confirmation window: keep routing. + #expect(attempts(&cache, at: t0.addingTimeInterval(5))) + // Past the window with no inbound traffic: route failed, flood. + #expect(!attempts(&cache, at: t0.addingTimeInterval(11))) + // Still suppressed for the suppression TTL. + #expect(!attempts(&cache, at: t0.addingTimeInterval(40))) + // Suppression lapses: routing may be attempted again. + #expect(attempts(&cache, at: t0.addingTimeInterval(11 + 61))) + } + + @Test func inboundActivityConfirmsPendingSend() { + var cache = BLESourceRouteFailureCache(config: config) + let t0 = Date() + + cache.noteRoutedSend(to: recipient, now: t0) + cache.noteInboundActivity(from: recipient) + // Confirmed: no suppression even long after the window. + #expect(attempts(&cache, at: t0.addingTimeInterval(30))) + } + + @Test func inboundActivityDoesNotLiftActiveSuppression() { + var cache = BLESourceRouteFailureCache(config: config) + let t0 = Date() + + cache.noteRoutedSend(to: recipient, now: t0) + // Trip the failure → suppression starts at t0+15. + #expect(!attempts(&cache, at: t0.addingTimeInterval(15))) + // Inbound traffic may have arrived via flood; suppression holds. + cache.noteInboundActivity(from: recipient) + #expect(!attempts(&cache, at: t0.addingTimeInterval(20))) + #expect(attempts(&cache, at: t0.addingTimeInterval(15 + 61))) + } + + @Test func backToBackSendsShareOneDeadline() { + var cache = BLESourceRouteFailureCache(config: config) + let t0 = Date() + + cache.noteRoutedSend(to: recipient, now: t0) + cache.noteRoutedSend(to: recipient, now: t0.addingTimeInterval(8)) + // Deadline runs from the first unconfirmed send. + #expect(!attempts(&cache, at: t0.addingTimeInterval(11))) + } + + @Test func pruneDropsExpiredEntries() { + var cache = BLESourceRouteFailureCache(config: config) + let t0 = Date() + + cache.noteRoutedSend(to: recipient, now: t0) + // Past confirmation + suppression: the entry can no longer matter. + cache.prune(now: t0.addingTimeInterval(75)) + #expect(attempts(&cache, at: t0.addingTimeInterval(76))) + } + + @Test func pruneKeepsEntriesThatStillMatter() { + var cache = BLESourceRouteFailureCache(config: config) + let t0 = Date() + + cache.noteRoutedSend(to: recipient, now: t0) + cache.prune(now: t0.addingTimeInterval(30)) + // The unconverted pending entry survives pruning and still converts + // into a suppression on the next routing decision. + #expect(!attempts(&cache, at: t0.addingTimeInterval(31))) + } +} diff --git a/bitchatTests/Services/BLESourceRouteOriginationPolicyTests.swift b/bitchatTests/Services/BLESourceRouteOriginationPolicyTests.swift new file mode 100644 index 00000000..c762a0c7 --- /dev/null +++ b/bitchatTests/Services/BLESourceRouteOriginationPolicyTests.swift @@ -0,0 +1,98 @@ +// +// BLESourceRouteOriginationPolicyTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +struct BLESourceRouteOriginationPolicyTests { + private let localPeerIDData = Data(hexString: "0102030405060708")! + private let recipient = PeerID(str: "1112131415161718") + private let hop = Data(hexString: "2122232425262728")! + + private func makePacket( + senderID: Data? = nil, + recipientID: Data? = Data(hexString: "1112131415161718"), + ttl: UInt8 = 7 + ) -> BitchatPacket { + BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: senderID ?? localPeerIDData, + recipientID: recipientID, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data([0x01]), + signature: nil, + ttl: ttl + ) + } + + private func route( + packet: BitchatPacket, + isRecipientConnected: Bool = false, + shouldAttemptRoute: Bool = true, + computedRoute: [Data]? = nil + ) -> [Data]? { + BLESourceRouteOriginationPolicy.route( + for: packet, + to: recipient, + localPeerIDData: localPeerIDData, + isRecipientConnected: { _ in isRecipientConnected }, + shouldAttemptRoute: { _ in shouldAttemptRoute }, + computeRoute: { _ in computedRoute ?? [self.hop] } + ) + } + + @Test func routesWhenAllGatesPass() { + #expect(route(packet: makePacket()) == [hop]) + } + + @Test func relayedPacketNeverGetsRoute() { + let relayed = makePacket(senderID: Data(hexString: "aabbccddeeff0011")) + #expect(route(packet: relayed) == nil) + } + + @Test func broadcastRecipientNeverGetsRoute() { + let broadcast = makePacket(recipientID: Data(repeating: 0xFF, count: 8)) + #expect(route(packet: broadcast) == nil) + let noRecipient = makePacket(recipientID: nil) + #expect(route(packet: noRecipient) == nil) + } + + @Test func linkLocalTTLNeverGetsRoute() { + // TTL 0/1 packets (e.g. REQUEST_SYNC) cannot traverse hops. + #expect(route(packet: makePacket(ttl: 0)) == nil) + #expect(route(packet: makePacket(ttl: 1)) == nil) + } + + @Test func directlyConnectedRecipientNeverGetsRoute() { + #expect(route(packet: makePacket(), isRecipientConnected: true) == nil) + } + + @Test func suppressedRecipientFallsBackToFlood() { + #expect(route(packet: makePacket(), shouldAttemptRoute: false) == nil) + } + + @Test func missingOrEmptyRouteFallsBackToFlood() { + var sawComputeRoute = false + let result = BLESourceRouteOriginationPolicy.route( + for: makePacket(), + to: recipient, + localPeerIDData: localPeerIDData, + isRecipientConnected: { _ in false }, + shouldAttemptRoute: { _ in true }, + computeRoute: { _ in + sawComputeRoute = true + return nil + } + ) + #expect(result == nil) + #expect(sawComputeRoute) + #expect(route(packet: makePacket(), computedRoute: []) == nil) + } +} diff --git a/bitchatTests/Services/MeshTopologyTrackerTests.swift b/bitchatTests/Services/MeshTopologyTrackerTests.swift index 1ae0f5f5..fa5d2a4d 100644 --- a/bitchatTests/Services/MeshTopologyTrackerTests.swift +++ b/bitchatTests/Services/MeshTopologyTrackerTests.swift @@ -94,10 +94,132 @@ struct MeshTopologyTrackerTests { tracker.updateNeighbors(for: a, neighbors: [b]) tracker.updateNeighbors(for: b, neighbors: [a]) - + // When start == end, route should be empty (no intermediate hops needed) let route = try #require(tracker.computeRoute(from: a, to: a)) #expect(route == []) } + @Test func noPathReturnsNil() throws { + let tracker = MeshTopologyTracker() + let a = try hex("0101010101010101") + let b = try hex("0202020202020202") + let c = try hex("0303030303030303") + let d = try hex("0404040404040404") + + // Two disconnected islands: A-B and C-D + tracker.updateNeighbors(for: a, neighbors: [b]) + tracker.updateNeighbors(for: b, neighbors: [a]) + tracker.updateNeighbors(for: c, neighbors: [d]) + tracker.updateNeighbors(for: d, neighbors: [c]) + + #expect(tracker.computeRoute(from: a, to: d) == nil) + } + + /// Build a confirmed line topology n0 - n1 - ... - n(count-1). + private func makeLine(_ tracker: MeshTopologyTracker, count: Int) throws -> [Data] { + let nodes = try (0.. 0 { neighbors.append(nodes[i - 1]) } + if i < count - 1 { neighbors.append(nodes[i + 1]) } + tracker.updateNeighbors(for: nodes[i], neighbors: neighbors) + } + return nodes + } + + @Test func maxHopsCapsIntermediateHopCount() throws { + let tracker = MeshTopologyTracker() + // 7 nodes: source + 5 intermediates + target + let nodes = try makeLine(tracker, count: 7) + + // 5 intermediates exceed a 4-hop cap + #expect(tracker.computeRoute(from: nodes[0], to: nodes[6], maxHops: 4) == nil) + // 4 intermediates fit exactly + let route = try #require(tracker.computeRoute(from: nodes[0], to: nodes[5], maxHops: 4)) + #expect(route == Array(nodes[1...4])) + } + + @Test func staleNeighborBlocksRoute() throws { + let tracker = MeshTopologyTracker() + let a = try hex("0101010101010101") + let b = try hex("0202020202020202") + let c = try hex("0303030303030303") + + let staleDate = Date().addingTimeInterval(-120) // past 60s freshness + tracker.updateNeighbors(for: a, neighbors: [b]) + tracker.updateNeighbors(for: b, neighbors: [a, c], at: staleDate) + tracker.updateNeighbors(for: c, neighbors: [b]) + + #expect(tracker.computeRoute(from: a, to: c) == nil) + + // Refreshing B restores the route. + tracker.updateNeighbors(for: b, neighbors: [a, c]) + let route = try #require(tracker.computeRoute(from: a, to: c)) + #expect(route == [b]) + } + + @Test func versionGateBlocksV1AndUnknownHops() throws { + let tracker = MeshTopologyTracker() + let a = try hex("0101010101010101") + let b = try hex("0202020202020202") + let c = try hex("0303030303030303") + + tracker.updateNeighbors(for: a, neighbors: [b]) + tracker.updateNeighbors(for: b, neighbors: [a, c]) + tracker.updateNeighbors(for: c, neighbors: [b]) + + // Without the gate the route exists. + #expect(tracker.computeRoute(from: a, to: c) == [b]) + // Version-unknown hops are assumed v1-only and block gated routes. + #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == nil) + + // A v1 observation does not unlock the gate. + tracker.recordObservedVersion(1, for: b) + tracker.recordObservedVersion(2, for: c) + #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == nil) + + // Once the hop is observed speaking v2 the route opens. + tracker.recordObservedVersion(2, for: b) + let route = try #require(tracker.computeRoute(from: a, to: c, requiringVersion: 2)) + #expect(route == [b]) + } + + @Test func versionGateRequiresV2Target() throws { + let tracker = MeshTopologyTracker() + let a = try hex("0101010101010101") + let b = try hex("0202020202020202") + let c = try hex("0303030303030303") + + tracker.updateNeighbors(for: a, neighbors: [b]) + tracker.updateNeighbors(for: b, neighbors: [a, c]) + tracker.updateNeighbors(for: c, neighbors: [b]) + tracker.recordObservedVersion(2, for: b) + + // The recipient must decode the v2 frame too. + #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == nil) + + tracker.recordObservedVersion(2, for: c) + #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == [b]) + } + + @Test func pruneDropsStaleObservedVersions() throws { + let tracker = MeshTopologyTracker() + let a = try hex("0101010101010101") + let b = try hex("0202020202020202") + let c = try hex("0303030303030303") + + tracker.updateNeighbors(for: a, neighbors: [b]) + tracker.updateNeighbors(for: b, neighbors: [a, c]) + tracker.updateNeighbors(for: c, neighbors: [b]) + let old = Date().addingTimeInterval(-120) + tracker.recordObservedVersion(2, for: b, at: old) + tracker.recordObservedVersion(2, for: c, at: old) + + #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == [b]) + tracker.prune(olderThan: 60) + // Claims are fresh but the version observations aged out. + #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == nil) + } + } diff --git a/bitchatTests/Sync/RequestSyncPacketFragmentFilterTests.swift b/bitchatTests/Sync/RequestSyncPacketFragmentFilterTests.swift new file mode 100644 index 00000000..d055bde6 --- /dev/null +++ b/bitchatTests/Sync/RequestSyncPacketFragmentFilterTests.swift @@ -0,0 +1,76 @@ +// +// RequestSyncPacketFragmentFilterTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +struct RequestSyncPacketFragmentFilterTests { + + @Test func fragmentIdFilterRoundTripsThroughWireEncoding() throws { + let id1 = try #require(Data(hexString: "00112233445566aa")) + let id2 = try #require(Data(hexString: "ffeeddccbbaa9988")) + let filter = try #require(RequestSyncPacket.encodeFragmentIdFilter([id1, id2])) + + let packet = RequestSyncPacket(p: 7, m: 128, data: Data([0x01]), types: .fragment, fragmentIdFilter: filter) + let decoded = try #require(RequestSyncPacket.decode(from: packet.encode())) + + #expect(decoded.fragmentIdFilter == filter) + let ids = try #require(RequestSyncPacket.decodeFragmentIdFilter(decoded.fragmentIdFilter)) + #expect(ids == Set([id1, id2])) + } + + @Test func encodeCapsFilterAtMaxCountWithinDecoderBudget() throws { + let ids = (0..<100).map { i -> Data in + var id = Data(repeating: 0, count: 8) + id[7] = UInt8(i) + return id + } + let filter = try #require(RequestSyncPacket.encodeFragmentIdFilter(ids)) + + let tokens = filter.split(separator: ",") + #expect(tokens.count == RequestSyncPacket.maxFragmentIdFilterCount) + // 60 IDs * 17 bytes ("<16 hex>,") - 1 = 1019 ≤ the 1024-byte cap. + #expect(filter.utf8.count == 1019) + #expect(filter.utf8.count <= 1024) + } + + @Test func encodeDropsMalformedIDs() throws { + let good = try #require(Data(hexString: "0011223344556677")) + let short = Data([0x01, 0x02]) + let filter = try #require(RequestSyncPacket.encodeFragmentIdFilter([short, good])) + #expect(filter == good.hexEncodedString()) + #expect(RequestSyncPacket.encodeFragmentIdFilter([short]) == nil) + #expect(RequestSyncPacket.encodeFragmentIdFilter([]) == nil) + } + + @Test func decodeIgnoresMalformedTokens() throws { + let good = try #require(Data(hexString: "0011223344556677")) + let ids = try #require( + RequestSyncPacket.decodeFragmentIdFilter("zzzz,0011,0011223344556677,") + ) + #expect(ids == Set([good])) + #expect(RequestSyncPacket.decodeFragmentIdFilter(nil) == nil) + #expect(RequestSyncPacket.decodeFragmentIdFilter("not-hex") == nil) + } + + @Test func decoderIgnoresOversizedFilterValue() throws { + // Hand-roll a payload whose 0x06 TLV exceeds the acceptance cap; the + // request must still decode, with the filter dropped. + var payload = RequestSyncPacket(p: 7, m: 128, data: Data([0x01])).encode() + let oversized = Data(repeating: UInt8(ascii: "a"), count: 1025) + payload.append(0x06) + payload.append(UInt8((oversized.count >> 8) & 0xFF)) + payload.append(UInt8(oversized.count & 0xFF)) + payload.append(oversized) + + let decoded = try #require(RequestSyncPacket.decode(from: payload)) + #expect(decoded.fragmentIdFilter == nil) + } +} diff --git a/docs/REQUEST_SYNC_MANAGER.md b/docs/REQUEST_SYNC_MANAGER.md index b304508e..a885f159 100644 --- a/docs/REQUEST_SYNC_MANAGER.md +++ b/docs/REQUEST_SYNC_MANAGER.md @@ -20,9 +20,11 @@ The new implementation introduces a **RequestSyncManager** to track outgoing syn ### Request Sync Payload The `REQUEST_SYNC` packet payload (TLV encoded) has been updated to include: -* **Future Filters**: - * `sinceTimestamp` (Type 0x05): To request packets since a certain time (UInt64 big-endian). - * `fragmentIdFilter` (Type 0x06): To request specific fragments (UTF-8 string). +* `sinceTimestamp` (Type 0x05): filter-coverage cursor (UInt64 big-endian). The requester's GCS filter only covers packets at or after this timestamp; the responder skips older packets instead of re-sending them every round. +* `fragmentIdFilter` (Type 0x06): targeted fragment resync (UTF-8 string). Comma-separated 16-hex-char (8-byte) fragment **stream IDs** — the ID that prefixes every fragment payload. + * **Requester**: when a broadcast reassembly stalls (no new fragment for 5 s), the fragment assembler reports the stream ID and a `REQUEST_SYNC` with `types = fragment` and this filter goes to each connected peer (re-requested at most every 10 s per stream). Directed reassemblies are excluded — peers only archive broadcast fragments for sync. + * **Responder**: when the filter is present, the fragment diff is restricted to exactly the named streams and the `sinceTimestamp` cursor is bypassed for them; the GCS filter still excludes pieces the requester already holds. Responses keep RSR marking, TTL 0, per-peer response rate limiting (8/30 s), and `REQUEST_SYNC` itself remains link-local (TTL 0, never relayed). + * **Bounds**: at most 60 IDs per request. Each ID encodes as 16 hex chars plus a comma separator, so the largest value is 60 × 17 − 1 = 1019 bytes, within the decoder's 1024-byte acceptance cap; oversized filter values are ignored (the rest of the request still decodes). ## Architecture diff --git a/docs/SOURCE_ROUTING.md b/docs/SOURCE_ROUTING.md index 6832a16e..55d00232 100644 --- a/docs/SOURCE_ROUTING.md +++ b/docs/SOURCE_ROUTING.md @@ -2,7 +2,7 @@ This document specifies the Source-Based Routing extension (v2) for the BitChat protocol. This upgrade enables efficient unicast routing across the mesh by allowing senders to specify an explicit path of intermediate relays. -**Status:** Implemented in Android and iOS. Backward compatible (v1 clients ignore routing data). +**Status:** Implemented in Android and iOS: both decode routed packets, forward along routes, and originate routes. iOS origination is policy-gated (see §8). Backward compatible (v1 clients never receive routed frames from iOS: routes are only originated when every node on the path has been observed speaking v2). --- @@ -144,3 +144,44 @@ When a node receives a packet **not** addressed to itself: * **Fallback:** If the Next Hop is unreachable, **fall back to broadcast/flood** to ensure delivery. 3. **If NO (Standard):** * Flood the packet to all connected neighbors (subject to TTL and probability rules). + +--- + +## 8. iOS Origination Policy + +iOS attaches a route (upgrading the packet to v2 and re-signing it) only when +**all** of the following hold at send time (`BLESourceRouteOriginationPolicy`): + +1. **Authored locally.** The packet's `SenderID` is our own peer ID. Relays + never rewrite someone else's packet — adding a route would force a + re-sign under the wrong key. Relays only *follow* existing routes + (`BLERouteForwardingPolicy`). +2. **Directed.** The packet has a single-peer `RecipientID` (not the + broadcast ID). In practice this covers Noise-encrypted private traffic, + private file transfers, and their fragments (fragments inherit the + parent's route and version, per §5). +3. **TTL headroom.** `TTL > 1`. Link-local packets (e.g. `REQUEST_SYNC`, + always TTL 0) never carry routes. +4. **Recipient not directly connected.** A direct write already delivers in + one hop; a route would only add bytes. +5. **Complete v2 path exists.** BFS over the confirmed-edge mesh graph + (`MeshTopologyTracker`, built from verified announce `directNeighbors` + claims, entries expiring after 60 s) finds a path with **at most 4 + intermediate hops** where every intermediate hop **and the recipient** + has been observed originating or relaying a v2 packet. Nodes never seen + speaking v2 are assumed v1-only and are excluded — a v1 client cannot + decode a v2 frame, so routing through it would silently drop the packet. +6. **No recent route failure.** See below. + +If any gate fails, behavior is exactly the pre-routing flood/direct-write +path — v1 peers observe no change. + +### Failure Fallback + +A routed unicast rides one path; a broken hop loses the packet where a flood +would heal around it. iOS keeps a small per-recipient health cache +(`BLESourceRouteFailureCache`): a routed send that sees no inbound packet +authored by the recipient within 10 s counts as a route failure, and directed +sends to that recipient fall back to flooding for the next 60 s before +routing is attempted again. Retransmission of the payload itself stays where +it always was (MessageRouter and higher layers). diff --git a/localPackages/BitFoundation/Sources/BitFoundation/BitchatPacket.swift b/localPackages/BitFoundation/Sources/BitFoundation/BitchatPacket.swift index b46a552d..ca29a2b2 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/BitchatPacket.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/BitchatPacket.swift @@ -14,7 +14,7 @@ import struct Foundation.Date /// including TTL for hop limiting and optional encryption. /// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented public struct BitchatPacket: Codable { - let version: UInt8 + public let version: UInt8 public let type: UInt8 public let senderID: Data public let recipientID: Data? From 23601407609808d1074e33aafe607230b8d0d462 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:48:37 +0200 Subject: [PATCH 12/18] Transitive verification: vouch for verified peers over Noise (#1380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add capability bits to announce TLV Announces now carry an optional capabilities TLV (0x05): a little-endian bitfield with named bits for upcoming features (prekeys, wifiBulk, gateway, groups, board, vouch, meshDiagnostics). Old clients skip the unknown TLV; peers without it decode as nil so features can distinguish "legacy peer" from "advertises nothing". PeerCapabilities lives in BitFoundation with a minimal-length encoding that preserves unknown bits for forward compatibility. Peer capabilities are stored in the BLE peer registry on verified announce and exposed via BLEService.peerCapabilities(_:). The local advertisement set is empty until each feature ships its bit. Co-Authored-By: Claude Fable 5 * Transitive verification: vouch for verified peers over Noise When a Noise session establishes with a peer I verified and that peer advertises the .vouch capability, send signed attestations (up to 16, most recently verified first, at most once per peer per 24h) for the OTHER fingerprints I verified. Receivers accept vouches only from senders they verified themselves, verify the Ed25519 signature against the sender's announce-bound signing key, and surface the result as a new derived trust tier: vouched (unfilled seal) between casual and trusted. Protocol: - NoisePayloadType.vouch = 0x12 carries a batch of TLV attestations: voucheeFingerprint (32B), voucheeSigningKey (32B), timestamp (uint64 ms BE), Ed25519 signature over "bitchat-vouch-v1" | fingerprint | signingKey | timestamp. The voucher is implicit in the authenticated session. - PeerCapabilities.localSupported now advertises .vouch. Storage (SecureIdentityStateManager / IdentityCache): - vouches keyed by vouchee, capped at 8 vouchers each; validity is recomputed on read (voucher still verified-by-me, < 30 days old), so unverifying a voucher retires their vouches without cascade deletes. - New IdentityCache fields are Optional so pre-existing encrypted caches decode cleanly; TrustLevel.vouched is inserted mid-ladder but raw values are strings, so persisted values are unaffected (and vouched itself is never persisted). - Panic wipe clears vouch state with the rest of the identity cache. UI: unfilled checkmark.seal badge in the mesh peer list (filled seal stays exclusive to verified) and a "vouched for by N people you verified" section with voucher names in FingerprintView; VoiceOver labels and xcstrings entries included. Tests: attestation encode/decode + signature (forged/tampered/expired), accept-policy gates, batch cap, trust-level derivation incl. voucher invalidation, persistence compat, and coordinator exchange/accept policies. Full macOS suite: 1088 tests passing. Co-Authored-By: Claude Fable 5 * Fix CI deadlock in vouch tests and live-refresh the fingerprint sheet on vouch acceptance Two fixes for PR #1380 review findings: 1. CI "Run Swift Tests (app)" hang (exit 137): the new SecureIdentityStateManagerVouchTests suite was nonisolated, so Swift Testing ran its tests in parallel on the Swift Concurrency cooperative pool. Each test enqueues a queue.async(.barrier) write (setVerified) and immediately blocks in queue.sync / queue.sync(.barrier) (recordVouch / effectiveTrustLevel). On CI's few-core runners every cooperative-pool thread ended up parked behind a pending barrier that never got a dispatch worker, deadlocking the whole test process until the watchdog SIGKILLed it. The suite is now @MainActor, matching the production isolation of the vouch API (ChatVouchCoordinator is @MainActor) and keeping blocking syncs off the cooperative pool. 2. Codex P2: an open fingerprint sheet did not refresh its vouched badge when a vouch batch was accepted - VerificationModel.bind() never observed the trust-change signal. It now subscribes to the "peerStatusUpdated" notification that ChatVouchCoordinator.notifyPeerTrustChanged() posts (same source PeerListModel uses) and forwards it to objectWillChange. Added a regression test that pins VerificationModel's own subscription (verified to fail without the fix). Co-Authored-By: Claude Fable 5 * Skip media-wipe detached tasks under tests (shared-filesystem race) panicClearAllData and clearCurrentPublicTimeline delete the real ~/Library/Application Support/files tree in detached utility-priority tasks. The SPM test process shares that tree and ChatViewModelTests invoke both methods, so under parallel scheduling the wipe lands at a nondeterministic time — deleting media a concurrently running test just wrote (and the developer's real app data with it). Guard both with the existing TestEnvironment.isRunningTests pattern, mirroring the same fix on feat/mesh-diagnostics (#1377). Co-Authored-By: Claude Fable 5 * Port vouch capability-race fix to feat/vouching (ports b8adcbe9) Ports the on-device-confirmed fix from the integration test branch (commit b8adcbe9) onto feat/vouching so PR #1380 is actually correct. On-device testing confirmed the transitive vouch propagated once the send was triggered on verify / announce arrival rather than auth alone. Vouch attestations only ever sent from peerAuthenticated, gated on the peer's .vouch capability. That capability arrives via the peer's announce, processed independently of the Noise handshake, so at auth time the set was usually empty -> gate failed -> vouch silently skipped and never retried. - Refactor the send path into a reusable attemptVouch(to:fingerprint:now:). - Trigger on peer-list updates (peersUpdated): fired after every verified announce, so the batch goes out once the .vouch bit actually arrives. - Trigger on local verification (vouchToConnectedVerifiedPeers): verifying a peer runs a vouch pass over connected verified peers, covering the verify-while-connected case and propagating the new identity onward. - Relax the capability gate: treat an empty/unknown set as eligible (the Noise 0x12 payload is ignored by non-supporting peers); only skip when a non-empty set explicitly lacks .vouch. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/App/PeerListModel.swift | 19 +- bitchat/App/VerificationModel.swift | 41 +- bitchat/Identity/IdentityModels.swift | 41 +- .../Identity/SecureIdentityStateManager.swift | 172 ++++++++- bitchat/Localizable.xcstrings | 70 ++++ bitchat/Protocols/BitchatProtocol.swift | 5 +- .../Protocols/PeerCapabilities+Local.swift | 2 +- bitchat/Protocols/VouchAttestation.swift | 225 +++++++++++ bitchat/Services/BLE/BLEService.swift | 12 + bitchat/Services/Transport.swift | 15 + .../ChatPublicConversationCoordinator.swift | 5 + .../ChatTransportEventCoordinator.swift | 8 + .../ChatVerificationCoordinator.swift | 13 + bitchat/ViewModels/ChatViewModel.swift | 22 ++ bitchat/ViewModels/ChatVouchCoordinator.swift | 272 ++++++++++++++ bitchat/ViewModels/NostrInboundPipeline.swift | 6 +- bitchat/Views/FingerprintView.swift | 43 +++ bitchat/Views/MeshPeerList.swift | 13 + bitchatTests/AppArchitectureTests.swift | 39 ++ ...ransportEventCoordinatorContextTests.swift | 6 + ...tVerificationCoordinatorContextTests.swift | 3 + .../ChatVouchCoordinatorContextTests.swift | 353 ++++++++++++++++++ bitchatTests/Mocks/MockIdentityManager.swift | 47 ++- .../Protocols/VouchAttestationTests.swift | 176 +++++++++ ...SecureIdentityStateManagerVouchTests.swift | 335 +++++++++++++++++ .../Services/UnifiedPeerServiceTests.swift | 33 ++ 26 files changed, 1957 insertions(+), 19 deletions(-) create mode 100644 bitchat/Protocols/VouchAttestation.swift create mode 100644 bitchat/ViewModels/ChatVouchCoordinator.swift create mode 100644 bitchatTests/ChatVouchCoordinatorContextTests.swift create mode 100644 bitchatTests/Protocols/VouchAttestationTests.swift create mode 100644 bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift diff --git a/bitchat/App/PeerListModel.swift b/bitchat/App/PeerListModel.swift index 38f20a1c..1846b487 100644 --- a/bitchat/App/PeerListModel.swift +++ b/bitchat/App/PeerListModel.swift @@ -14,6 +14,9 @@ struct MeshPeerRow: Identifiable, Equatable { let isMutualFavorite: Bool let encryptionStatus: EncryptionStatus let showsVerifiedBadgeWhenOffline: Bool + /// Vouched-for by someone I verified, without an explicit verification of + /// mine — rendered as the unfilled seal (verified gets the filled one). + let showsVouchedBadge: Bool var id: String { peerID.id } } @@ -183,13 +186,12 @@ final class PeerListModel: ObservableObject { let myPeerID = chatViewModel.meshService.myPeerID let meshRows = allPeers.map { peer in let isMe = peer.peerID == myPeerID - let verifiedBadge: Bool - if !isMe && !peer.isConnected, - let fingerprint = chatViewModel.getFingerprint(for: peer.peerID) { - verifiedBadge = peerIdentityStore.isVerified(fingerprint) - } else { - verifiedBadge = false - } + let fingerprint = isMe ? nil : chatViewModel.getFingerprint(for: peer.peerID) + let isVerifiedFingerprint = fingerprint.map { peerIdentityStore.isVerified($0) } ?? false + let verifiedBadge = !peer.isConnected && isVerifiedFingerprint + // Vouched is subordinate to verified: never show both seals. + let vouchedBadge = !isVerifiedFingerprint + && (fingerprint.map { chatViewModel.isVouchedFingerprint($0) } ?? false) return MeshPeerRow( peerID: peer.peerID, @@ -202,7 +204,8 @@ final class PeerListModel: ObservableObject { isReachable: peer.isReachable, isMutualFavorite: peer.isMutualFavorite, encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID), - showsVerifiedBadgeWhenOffline: verifiedBadge + showsVerifiedBadgeWhenOffline: verifiedBadge, + showsVouchedBadge: vouchedBadge ) } diff --git a/bitchat/App/VerificationModel.swift b/bitchat/App/VerificationModel.swift index bf31f05f..5916a958 100644 --- a/bitchat/App/VerificationModel.swift +++ b/bitchat/App/VerificationModel.swift @@ -9,6 +9,14 @@ struct FingerprintPresentationState: Equatable { let theirFingerprint: String? let myFingerprint: String let isVerified: Bool + /// Number of currently-valid vouches from peers the user verified + /// (0 when the peer is explicitly verified — the stronger badge wins). + let voucherCount: Int + /// Display names of the (verified) vouchers, where known. + let voucherNames: [String] + + /// Vouched for by ≥1 peer the user verified (and not explicitly verified). + var isVouched: Bool { voucherCount > 0 } var canToggleVerification: Bool { encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified @@ -82,6 +90,24 @@ final class VerificationModel: ObservableObject { let encryptionStatus = chatViewModel.getEncryptionStatus(for: statusPeerID) let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID) let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID) + let isVerified = theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false + + // Vouch state is recomputed on read: only vouchers still in the + // verified set count, so removing a verification silently retires the + // vouches that peer gave. + let vouchers: [VouchRecord] + if !isVerified, let theirFingerprint { + vouchers = chatViewModel.identityManager.validVouchers(for: theirFingerprint) + } else { + vouchers = [] + } + let voucherNames = vouchers.compactMap { record -> String? in + guard let social = chatViewModel.identityManager.getSocialIdentity(for: record.voucherFingerprint) else { + return nil + } + if let petname = social.localPetname, !petname.isEmpty { return petname } + return social.claimedNickname.isEmpty ? nil : social.claimedNickname + } return FingerprintPresentationState( statusPeerID: statusPeerID, @@ -89,7 +115,9 @@ final class VerificationModel: ObservableObject { encryptionStatus: encryptionStatus, theirFingerprint: theirFingerprint, myFingerprint: chatViewModel.getMyFingerprint(), - isVerified: theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false + isVerified: isVerified, + voucherCount: vouchers.count, + voucherNames: voucherNames ) } @@ -122,6 +150,17 @@ final class VerificationModel: ObservableObject { self?.objectWillChange.send() } .store(in: &cancellables) + + // Vouch state changes (ChatVouchCoordinator.notifyPeerTrustChanged) + // are signalled via this notification rather than a published + // property, so an open fingerprint sheet refreshes its vouched badge + // live when a vouch batch is accepted. + NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated")) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) } private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String { diff --git a/bitchat/Identity/IdentityModels.swift b/bitchat/Identity/IdentityModels.swift index ad83962f..921a071a 100644 --- a/bitchat/Identity/IdentityModels.swift +++ b/bitchat/Identity/IdentityModels.swift @@ -126,13 +126,37 @@ struct SocialIdentity: Codable { var notes: String? } +/// Trust ladder: unknown → casual → vouched → trusted → verified. +/// +/// Persistence compatibility: `TrustLevel` is stored by its *String* raw +/// value ("unknown", "casual", …), not by ordinal position, so inserting +/// `vouched` mid-ladder cannot corrupt previously persisted values — every +/// pre-existing case keeps the exact raw value it was written with. The +/// `vouched` tier is additionally never persisted into `SocialIdentity` +/// (it's recomputed on read from stored vouches), so downgraded builds never +/// encounter the unfamiliar raw value. enum TrustLevel: String, Codable { case unknown case casual + /// Transitively trusted: vouched for by at least one peer *I* verified. + /// Derived at read time — never written to persistent storage. + case vouched case trusted case verified } +// MARK: - Vouching (transitive verification) + +/// One accepted vouch: a peer I verified (the voucher) attested that they +/// verified the vouchee. Validity is recomputed on read — a record only +/// counts while its voucher remains in `verifiedFingerprints` and its +/// timestamp is within `VouchAttestation.maxAge` — so unverifying a voucher +/// silently invalidates the vouches they gave without a cascade delete. +struct VouchRecord: Codable, Equatable { + let voucherFingerprint: String + let timestamp: Date +} + // MARK: - Identity Cache /// Persistent storage for identity mappings and relationships. @@ -154,7 +178,22 @@ struct IdentityCache: Codable { // Blocked Nostr pubkeys (lowercased hex) for geohash chats var blockedNostrPubkeys: Set = [] - + + // Vouching (transitive verification). All three fields are Optional so + // caches persisted before this feature decode cleanly — the synthesized + // decoder uses decodeIfPresent for optionals, and a missing key must not + // trip the "unreadable cache" recovery path that discards everything. + + // Vouchee fingerprint -> accepted vouches (capped per vouchee) + var vouchesByVouchee: [String: [VouchRecord]]? = nil + + // Peer fingerprint -> when we last sent them a vouch batch (rate limit) + var vouchBatchSentAt: [String: Date]? = nil + + // Fingerprint -> when we verified it (orders outgoing vouch batches; + // entries verified before this field exists sort as oldest) + var verifiedAt: [String: Date]? = nil + // Schema version for future migrations var version: Int = 1 } diff --git a/bitchat/Identity/SecureIdentityStateManager.swift b/bitchat/Identity/SecureIdentityStateManager.swift index fe63f871..021ea776 100644 --- a/bitchat/Identity/SecureIdentityStateManager.swift +++ b/bitchat/Identity/SecureIdentityStateManager.swift @@ -133,6 +133,17 @@ protocol SecureIdentityStateManagerProtocol { func setVerified(fingerprint: String, verified: Bool) func isVerified(fingerprint: String) -> Bool func getVerifiedFingerprints() -> Set + + // MARK: Vouching (transitive verification) + @discardableResult + func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool + func validVouchers(for fingerprint: String) -> [VouchRecord] + func isVouched(fingerprint: String) -> Bool + func effectiveTrustLevel(for fingerprint: String) -> TrustLevel + func lastVouchBatchSent(to fingerprint: String) -> Date? + func markVouchBatchSent(to fingerprint: String, at date: Date) + func signingPublicKey(forFingerprint fingerprint: String) -> Data? + func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] } /// Singleton manager for secure identity state persistence and retrieval. @@ -550,16 +561,20 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { queue.async(flags: .barrier) { if verified { self.cache.verifiedFingerprints.insert(fingerprint) + var verifiedAt = self.cache.verifiedAt ?? [:] + verifiedAt[fingerprint] = Date() + self.cache.verifiedAt = verifiedAt } else { self.cache.verifiedFingerprints.remove(fingerprint) + self.cache.verifiedAt?.removeValue(forKey: fingerprint) } - + // Update trust level if social identity exists if var identity = self.cache.socialIdentities[fingerprint] { identity.trustLevel = verified ? .verified : .casual self.cache.socialIdentities[fingerprint] = identity } - + self.saveIdentityCache() } } @@ -576,6 +591,159 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { } } + // MARK: - Vouching (transitive verification) + + /// Maximum vouchers retained per vouchee (most recent kept). + static let maxVouchersPerVouchee = 8 + + /// Records an accepted vouch, enforcing every accept-policy gate that can + /// be evaluated against stored state (signature verification is the + /// caller's job — it needs the sender's announce-bound signing key): + /// - the voucher must be a fingerprint *I* verified + /// - self-vouches are ignored + /// - vouches for peers I already verified are ignored (nothing to add) + /// - attestations outside the validity window are ignored + /// - at most `maxVouchersPerVouchee` vouchers are kept per vouchee + /// + /// Returns true when the vouch was stored (or refreshed). + @discardableResult + func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool { + recordVouch( + voucheeFingerprint: voucheeFingerprint, + voucherFingerprint: voucherFingerprint, + timestamp: timestamp, + now: Date() + ) + } + + @discardableResult + func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date, now: Date) -> Bool { + queue.sync(flags: .barrier) { + guard voucheeFingerprint != voucherFingerprint, + self.cache.verifiedFingerprints.contains(voucherFingerprint), + !self.cache.verifiedFingerprints.contains(voucheeFingerprint) else { + return false + } + let age = now.timeIntervalSince(timestamp) + guard age <= VouchAttestation.maxAge, age >= -VouchAttestation.maxClockSkew else { + return false + } + + var records = self.cache.vouchesByVouchee?[voucheeFingerprint] ?? [] + if let index = records.firstIndex(where: { $0.voucherFingerprint == voucherFingerprint }) { + let newest = max(records[index].timestamp, timestamp) + records[index] = VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: newest) + } else { + records.append(VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: timestamp)) + } + // Keep the most recent vouchers up to the cap. + records.sort { $0.timestamp > $1.timestamp } + let capped = Array(records.prefix(Self.maxVouchersPerVouchee)) + guard capped.contains(where: { $0.voucherFingerprint == voucherFingerprint }) else { + return false // Full of fresher vouches; nothing changed. + } + + var vouches = self.cache.vouchesByVouchee ?? [:] + vouches[voucheeFingerprint] = capped + self.cache.vouchesByVouchee = vouches + self.saveIdentityCache() + return true + } + } + + /// The vouches that currently count for `fingerprint`. Validity is + /// recomputed here rather than maintained by cascade deletes: a record + /// only counts while its voucher is still verified-by-me and its + /// timestamp is within the expiry window. + func validVouchers(for fingerprint: String) -> [VouchRecord] { + validVouchers(for: fingerprint, now: Date()) + } + + func validVouchers(for fingerprint: String, now: Date) -> [VouchRecord] { + queue.sync { + self.validVouchersLocked(for: fingerprint, now: now) + } + } + + /// Requires `queue`. + private func validVouchersLocked(for fingerprint: String, now: Date) -> [VouchRecord] { + guard let records = cache.vouchesByVouchee?[fingerprint] else { return [] } + return records.filter { record in + record.voucherFingerprint != fingerprint + && cache.verifiedFingerprints.contains(record.voucherFingerprint) + && now.timeIntervalSince(record.timestamp) <= VouchAttestation.maxAge + } + } + + /// True when the peer has at least one valid vouch and no explicit + /// verification of ours. + func isVouched(fingerprint: String) -> Bool { + isVouched(fingerprint: fingerprint, now: Date()) + } + + func isVouched(fingerprint: String, now: Date) -> Bool { + queue.sync { + guard !self.cache.verifiedFingerprints.contains(fingerprint) else { return false } + return !self.validVouchersLocked(for: fingerprint, now: now).isEmpty + } + } + + /// The trust level to display: explicit verification wins, then the + /// persisted level, with `vouched` layered in (derived, never persisted) + /// between `casual` and `trusted`. + func effectiveTrustLevel(for fingerprint: String) -> TrustLevel { + effectiveTrustLevel(for: fingerprint, now: Date()) + } + + func effectiveTrustLevel(for fingerprint: String, now: Date) -> TrustLevel { + queue.sync { + if self.cache.verifiedFingerprints.contains(fingerprint) { return .verified } + let stored = self.cache.socialIdentities[fingerprint]?.trustLevel ?? .unknown + let vouched = !self.validVouchersLocked(for: fingerprint, now: now).isEmpty + switch stored { + case .verified, .trusted: + return stored + case .vouched, .casual, .unknown: + if vouched { return .vouched } + // `.vouched` should never be persisted; degrade defensively. + return stored == .vouched ? .casual : stored + } + } + } + + func lastVouchBatchSent(to fingerprint: String) -> Date? { + queue.sync { cache.vouchBatchSentAt?[fingerprint] } + } + + func markVouchBatchSent(to fingerprint: String, at date: Date) { + queue.async(flags: .barrier) { + var sentAt = self.cache.vouchBatchSentAt ?? [:] + sentAt[fingerprint] = date + self.cache.vouchBatchSentAt = sentAt + self.saveIdentityCache() + } + } + + /// The peer's announce-bound Ed25519 signing key, if seen this session. + func signingPublicKey(forFingerprint fingerprint: String) -> Data? { + queue.sync { cryptographicIdentities[fingerprint]?.signingPublicKey } + } + + /// Verified fingerprints ordered most recently verified first (entries + /// without a recorded verification time sort last), excluding the given + /// fingerprint. Feeds the outgoing vouch batch. + func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] { + queue.sync { + let verifiedAt = cache.verifiedAt ?? [:] + let ordered = cache.verifiedFingerprints + .filter { $0 != fingerprint } + .sorted { + (verifiedAt[$0] ?? .distantPast, $0) > (verifiedAt[$1] ?? .distantPast, $1) + } + return Array(ordered.prefix(limit)) + } + } + var debugNicknameIndex: [String: Set] { queue.sync { cache.nicknameIndex } } diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 66fd04b4..5b91da12 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -22479,6 +22479,18 @@ } } }, + "fingerprint.badge.vouched" : { + "comment" : "Badge shown when a peer is vouched for by people the user verified but not directly verified", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ VOUCHED" + } + } + } + }, "fingerprint.handshake_pending" : { "extractionState" : "manual", "localizations" : { @@ -23016,6 +23028,40 @@ } } }, + "fingerprint.message.vouched_by" : { + "comment" : "How many people the user verified have vouched for this peer", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "vouched for by %#@people@ you verified" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d person" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d people" + } + } + } + } + } + } + } + } + }, "fingerprint.their_label" : { "extractionState" : "manual", "localizations" : { @@ -31984,6 +32030,18 @@ } } }, + "mesh_peers.state.vouched" : { + "comment" : "State label for a peer vouched for by someone the user verified", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "vouched" + } + } + } + }, "mesh_peers.tooltip.new_messages" : { "extractionState" : "manual", "localizations" : { @@ -32163,6 +32221,18 @@ } } }, + "mesh_peers.tooltip.vouched" : { + "comment" : "Tooltip for the vouched (unfilled seal) badge next to a peer", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "vouched for by someone you verified" + } + } + } + }, "recording %@" : { "comment" : "Voice note recording duration indicator", "localizations" : { diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index 267d8cc5..c7497051 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -77,7 +77,9 @@ enum NoisePayloadType: UInt8 { // Verification (QR-based OOB binding) case verifyChallenge = 0x10 // Verification challenge case verifyResponse = 0x11 // Verification response - + // Transitive verification (web of trust) + case vouch = 0x12 // Batch of vouch attestations + var description: String { switch self { case .privateMessage: return "privateMessage" @@ -85,6 +87,7 @@ enum NoisePayloadType: UInt8 { case .delivered: return "delivered" case .verifyChallenge: return "verifyChallenge" case .verifyResponse: return "verifyResponse" + case .vouch: return "vouch" } } } diff --git a/bitchat/Protocols/PeerCapabilities+Local.swift b/bitchat/Protocols/PeerCapabilities+Local.swift index 40f97b9f..29b94b4c 100644 --- a/bitchat/Protocols/PeerCapabilities+Local.swift +++ b/bitchat/Protocols/PeerCapabilities+Local.swift @@ -3,5 +3,5 @@ import BitFoundation extension PeerCapabilities { /// Capabilities this build advertises in its announce packets. /// Each feature adds its bit here when it ships. - static let localSupported: PeerCapabilities = [] + static let localSupported: PeerCapabilities = [.vouch] } diff --git a/bitchat/Protocols/VouchAttestation.swift b/bitchat/Protocols/VouchAttestation.swift new file mode 100644 index 00000000..a765a11b --- /dev/null +++ b/bitchat/Protocols/VouchAttestation.swift @@ -0,0 +1,225 @@ +// +// VouchAttestation.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import CryptoKit +import Foundation + +/// A signed statement that the *sender of the enclosing Noise payload* has +/// verified the identity described here ("transitive verification"). +/// +/// The voucher's identity is deliberately implicit: attestations only travel +/// inside an authenticated Noise session (`NoisePayloadType.vouch`), so the +/// receiver verifies the Ed25519 signature against the session peer's +/// announce-bound signing key and stores the vouch keyed by that peer's +/// fingerprint. Nothing in the attestation names the voucher, so a captured +/// attestation cannot be replayed by a third party whose signing key doesn't +/// match. +/// +/// Wire format — single attestation (TLV, 1-byte type + 1-byte length): +/// - `0x01` voucheeFingerprint: 32 bytes, SHA-256 of the vouchee's Noise static key +/// - `0x02` voucheeSigningKey: 32 bytes, Ed25519; anchors the vouch to a concrete identity +/// - `0x03` timestamp: 8 bytes big-endian, milliseconds since 1970 +/// - `0x04` signature: 64 bytes, Ed25519 by the VOUCHER's signing key over +/// `"bitchat-vouch-v1" | voucheeFingerprint | voucheeSigningKey | timestamp` +/// +/// Unknown TLV types are skipped for forward compatibility. +/// +/// Batch format (the `vouch` Noise payload body): +/// `[count: UInt8]` then per attestation `[length: UInt16 BE][attestation TLV]`. +struct VouchAttestation: Equatable { + static let signingContext = "bitchat-vouch-v1" + /// Receiver-side expiry for attestations. + static let maxAge: TimeInterval = 30 * 24 * 60 * 60 + /// Tolerated clock skew for attestations timestamped in the future. + static let maxClockSkew: TimeInterval = 60 * 60 + /// Upper bound of attestations carried/accepted in one batch payload. + static let maxBatchCount = 16 + + static let fingerprintSize = 32 + static let signingKeySize = 32 + static let signatureSize = 64 + + let voucheeFingerprint: Data // 32 bytes + let voucheeSigningKey: Data // 32 bytes + let timestampMs: UInt64 + let signature: Data // 64 bytes + + private enum TLVType: UInt8 { + case voucheeFingerprint = 0x01 + case voucheeSigningKey = 0x02 + case timestamp = 0x03 + case signature = 0x04 + } + + var voucheeFingerprintHex: String { voucheeFingerprint.hexEncodedString() } + + var timestamp: Date { Date(timeIntervalSince1970: TimeInterval(timestampMs) / 1000) } + + /// The exact bytes the voucher signs. + static func signableBytes( + voucheeFingerprint: Data, + voucheeSigningKey: Data, + timestampMs: UInt64 + ) -> Data { + var message = Data(signingContext.utf8) + message.append(voucheeFingerprint) + message.append(voucheeSigningKey) + var timestampBE = timestampMs.bigEndian + withUnsafeBytes(of: ×tampBE) { message.append(contentsOf: $0) } + return message + } + + var signableBytes: Data { + Self.signableBytes( + voucheeFingerprint: voucheeFingerprint, + voucheeSigningKey: voucheeSigningKey, + timestampMs: timestampMs + ) + } + + /// Builds and signs an attestation. `sign` is the voucher's Ed25519 + /// signing primitive (e.g. `Transport.noiseSignData`). + static func build( + voucheeFingerprint: Data, + voucheeSigningKey: Data, + timestampMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000), + sign: (Data) -> Data? + ) -> VouchAttestation? { + guard voucheeFingerprint.count == fingerprintSize, + voucheeSigningKey.count == signingKeySize else { return nil } + let message = signableBytes( + voucheeFingerprint: voucheeFingerprint, + voucheeSigningKey: voucheeSigningKey, + timestampMs: timestampMs + ) + guard let signature = sign(message), signature.count == signatureSize else { return nil } + return VouchAttestation( + voucheeFingerprint: voucheeFingerprint, + voucheeSigningKey: voucheeSigningKey, + timestampMs: timestampMs, + signature: signature + ) + } + + /// Verifies the Ed25519 signature against the voucher's announce-bound + /// signing key. + func verifySignature(voucherSigningKey: Data) -> Bool { + guard let publicKey = try? Curve25519.Signing.PublicKey(rawRepresentation: voucherSigningKey) else { + return false + } + return publicKey.isValidSignature(signature, for: signableBytes) + } + + /// Whether the attestation is outside its validity window (older than + /// `maxAge`, or timestamped implausibly far in the future). + func isExpired(now: Date = Date()) -> Bool { + let age = now.timeIntervalSince(timestamp) + return age > Self.maxAge || age < -Self.maxClockSkew + } + + // MARK: - Encoding + + func encode() -> Data? { + guard voucheeFingerprint.count == Self.fingerprintSize, + voucheeSigningKey.count == Self.signingKeySize, + signature.count == Self.signatureSize else { return nil } + var data = Data() + func appendTLV(_ type: TLVType, _ value: Data) { + data.append(type.rawValue) + data.append(UInt8(value.count)) + data.append(value) + } + appendTLV(.voucheeFingerprint, voucheeFingerprint) + appendTLV(.voucheeSigningKey, voucheeSigningKey) + var timestampBE = timestampMs.bigEndian + appendTLV(.timestamp, withUnsafeBytes(of: ×tampBE) { Data($0) }) + appendTLV(.signature, signature) + return data + } + + static func decode(from data: Data) -> VouchAttestation? { + var fingerprint: Data? + var signingKey: Data? + var timestampMs: UInt64? + var signature: Data? + + var offset = data.startIndex + while offset < data.endIndex { + guard data.index(offset, offsetBy: 2, limitedBy: data.endIndex) != nil, + offset + 1 < data.endIndex else { return nil } + let type = data[offset] + let length = Int(data[offset + 1]) + let valueStart = offset + 2 + guard let valueEnd = data.index(valueStart, offsetBy: length, limitedBy: data.endIndex) else { + return nil + } + let value = Data(data[valueStart.. Data? { + guard !attestations.isEmpty, attestations.count <= maxBatchCount else { return nil } + var data = Data() + data.append(UInt8(attestations.count)) + for attestation in attestations { + guard let encoded = attestation.encode(), encoded.count <= Int(UInt16.max) else { return nil } + var lengthBE = UInt16(encoded.count).bigEndian + withUnsafeBytes(of: &lengthBE) { data.append(contentsOf: $0) } + data.append(encoded) + } + return data + } + + /// Decodes a batch payload, dropping malformed entries and ignoring + /// anything beyond `maxBatchCount` (sender-declared count is not trusted). + static func decodeList(from data: Data) -> [VouchAttestation] { + guard data.count > 1 else { return [] } + let declaredCount = Int(data[data.startIndex]) + let limit = min(declaredCount, maxBatchCount) + var attestations: [VouchAttestation] = [] + var offset = data.startIndex + 1 + while attestations.count < limit, offset < data.endIndex { + guard let lengthEnd = data.index(offset, offsetBy: 2, limitedBy: data.endIndex) else { break } + let length = Int(data[offset]) << 8 | Int(data[offset + 1]) + guard let entryEnd = data.index(lengthEnd, offsetBy: length, limitedBy: data.endIndex) else { break } + if let attestation = decode(from: Data(data[lengthEnd.. Void) { + // Appends to the encryption service's handler array, so this never + // displaces the callbacks installed by installNoiseSessionCallbacks. + noiseService.addOnPeerAuthenticatedHandler(handler) + } } // MARK: - GossipSyncManager Delegate diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index b02bfdf8..00a17a87 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -132,6 +132,18 @@ protocol Transport: AnyObject { func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) + // Vouching / transitive verification (optional for transports) + /// Capabilities the peer advertised in its last verified announce; + /// empty for peers that predate the capabilities TLV. + func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities + /// Sends an encoded vouch-attestation batch inside the Noise session. + func sendVouchAttestations(_ payload: Data, to peerID: PeerID) + /// Appends a peer-authenticated observer. Unlike + /// `installNoiseSessionCallbacks` this never touches the (single-slot) + /// handshake-required callback, so secondary features can observe + /// session establishment without disturbing the primary registration. + func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) + // Pending file management (BCH-01-002: files held in memory until user accepts) func acceptPendingFile(id: String) -> URL? func declinePendingFile(id: String) @@ -157,6 +169,9 @@ extension Transport { func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} + func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] } + func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {} + func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {} func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false } func sendBoardPayload(_ payload: Data) {} func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {} diff --git a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift index 5a9ad6db..281117f5 100644 --- a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift @@ -293,6 +293,11 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { context.clearPublicConversation(ConversationID(channelID: context.activeChannel)) Task.detached(priority: .utility) { + // Skipped under tests: the test process shares the user's real + // ~/Library/Application Support/files tree, and this detached + // wipe fires at a nondeterministic time — racing tests that + // write media there (see the same guard in panicClearAllData). + guard !TestEnvironment.isRunningTests else { return } do { let base = try FileManager.default.url( for: .applicationSupportDirectory, diff --git a/bitchat/ViewModels/ChatTransportEventCoordinator.swift b/bitchat/ViewModels/ChatTransportEventCoordinator.swift index 75571b2e..a24273d7 100644 --- a/bitchat/ViewModels/ChatTransportEventCoordinator.swift +++ b/bitchat/ViewModels/ChatTransportEventCoordinator.swift @@ -71,6 +71,7 @@ protocol ChatTransportEventContext: AnyObject { // MARK: Verification payloads func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) + func handleVouchPayload(from peerID: PeerID, payload: Data) } extension ChatViewModel: ChatTransportEventContext { @@ -129,6 +130,10 @@ extension ChatViewModel: ChatTransportEventContext { func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) { verificationCoordinator.handleVerifyResponsePayload(from: peerID, payload: payload) } + + func handleVouchPayload(from peerID: PeerID, payload: Data) { + vouchCoordinator.handleVouchPayload(from: peerID, payload: payload) + } } final class ChatTransportEventCoordinator { @@ -371,6 +376,9 @@ private extension ChatTransportEventCoordinator { case .verifyResponse: context.handleVerifyResponsePayload(from: peerID, payload: payload) + + case .vouch: + context.handleVouchPayload(from: peerID, payload: payload) } } diff --git a/bitchat/ViewModels/ChatVerificationCoordinator.swift b/bitchat/ViewModels/ChatVerificationCoordinator.swift index e82ebd35..8f7d484a 100644 --- a/bitchat/ViewModels/ChatVerificationCoordinator.swift +++ b/bitchat/ViewModels/ChatVerificationCoordinator.swift @@ -24,6 +24,10 @@ protocol ChatVerificationContext: AnyObject { func setStoredVerified(_ fingerprint: String, verified: Bool) func isVerifiedFingerprint(_ fingerprint: String) -> Bool func saveIdentityState() + /// After a fingerprint becomes verified, run a transitive-vouch pass over + /// currently connected peers (so verifying a peer you're already connected + /// to sends vouches immediately, and the new identity propagates onward). + func vouchToConnectedVerifiedPeers() // MARK: Encryption status func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) @@ -86,6 +90,10 @@ extension ChatViewModel: ChatVerificationContext { peerIdentityStore.setVerified(fingerprint, verified: verified) } + func vouchToConnectedVerifiedPeers() { + vouchCoordinator.vouchToConnectedVerifiedPeers() + } + var unifiedPeers: [BitchatPeer] { unifiedPeerService.peers } @@ -148,6 +156,9 @@ final class ChatVerificationCoordinator { context.saveIdentityState() context.setStoredVerified(fingerprint, verified: true) context.updateEncryptionStatus(for: peerID) + // Verifying a peer is a vouch trigger: push attestations to my other + // connected verified peers (and to this one if already connected). + context.vouchToConnectedVerifiedPeers() } func unverifyFingerprint(for peerID: PeerID) { @@ -340,6 +351,8 @@ final class ChatVerificationCoordinator { } context.updateEncryptionStatus(for: peerID) + // QR verification just completed — same vouch trigger as manual verify. + context.vouchToConnectedVerifiedPeers() } } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 2f5fd729..a6606549 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -177,6 +177,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele lazy var nostrCoordinator = ChatNostrCoordinator(context: self) lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self) lazy var verificationCoordinator = ChatVerificationCoordinator(context: self) + lazy var vouchCoordinator = ChatVouchCoordinator(context: self) // Computed properties for compatibility @MainActor @@ -1280,6 +1281,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // Delete ALL media files (incoming and outgoing) in background Task.detached(priority: .utility) { + // Skipped under tests: the test process shares the user's real + // ~/Library/Application Support/files tree, and this detached + // utility-priority wipe fires at a nondeterministic time — + // deleting media that concurrently running tests (e.g. the + // sendImage flow) just wrote there, and the developer's real + // app data with it. + guard !TestEnvironment.isRunningTests else { return } do { let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) let filesDir = base.appendingPathComponent("files", isDirectory: true) @@ -1492,6 +1500,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele func setupNoiseCallbacks() { verificationCoordinator.setupNoiseCallbacks() + vouchCoordinator.setupNoiseCallbacks() + } + + /// Whether the fingerprint currently counts as vouched (≥1 valid vouch + /// from a voucher I verified, and no explicit verification of mine). + @MainActor + func isVouchedFingerprint(_ fingerprint: String) -> Bool { + identityManager.isVouched(fingerprint: fingerprint) } // MARK: - BitchatDelegate Methods @@ -1589,6 +1605,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele func didUpdatePeerList(_ peers: [PeerID]) { peerListCoordinator.didUpdatePeerList(peers) + // A peer-list update follows every verified announce, which is where a + // peer's `.vouch` capability actually arrives — retry vouching now that + // capabilities may finally be known (closes the auth-time capability race). + Task { @MainActor [weak self] in + self?.vouchCoordinator.peersUpdated(peers) + } } @MainActor diff --git a/bitchat/ViewModels/ChatVouchCoordinator.swift b/bitchat/ViewModels/ChatVouchCoordinator.swift new file mode 100644 index 00000000..26cb8158 --- /dev/null +++ b/bitchat/ViewModels/ChatVouchCoordinator.swift @@ -0,0 +1,272 @@ +import BitFoundation +import BitLogger +import Foundation + +/// The narrow surface `ChatVouchCoordinator` needs from its owner. +/// +/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the +/// minimal context it actually uses instead of holding an `unowned` back-ref +/// to the whole `ChatViewModel`. This keeps the coordinator independently +/// testable (see `ChatVouchCoordinatorContextTests`) and makes its true +/// dependencies explicit. +@MainActor +protocol ChatVouchContext: AnyObject { + // MARK: Identity & trust state + func getFingerprint(for peerID: PeerID) -> String? + func isVerifiedFingerprint(_ fingerprint: String) -> Bool + /// The peer's announce-bound Ed25519 signing key, if known this session. + func signingKey(forFingerprint fingerprint: String) -> Data? + /// Verified fingerprints ordered most recently verified first. + func recentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] + /// Stores an accepted vouch (identity manager enforces the storage gates). + @discardableResult + func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool + func lastVouchBatchSent(to fingerprint: String) -> Date? + func markVouchBatchSent(to fingerprint: String, at date: Date) + + // MARK: Transport + func peerCapabilities(for peerID: PeerID) -> PeerCapabilities + /// PeerIDs with a currently established mesh session (used to run a vouch + /// pass over peers we are already connected to when we verify someone). + func connectedPeerIDs() -> [PeerID] + /// Appends a session-established observer (additive; never displaces the + /// verification coordinator's callbacks). + func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) + /// Signs `data` with our Noise (Ed25519) signing key. + func noiseSignData(_ data: Data) -> Data? + func sendVouchAttestations(_ payload: Data, to peerID: PeerID) + + // MARK: UI refresh + /// Signals that derived trust state changed so peer list / fingerprint + /// views recompute badges. + func notifyPeerTrustChanged() +} + +extension ChatViewModel: ChatVouchContext { + // `getFingerprint(for:)` and `isVerifiedFingerprint(_:)` are shared + // requirements with the verification context and satisfied by existing + // `ChatViewModel` members. The members below flatten nested service + // accesses into intent-named calls. + + func signingKey(forFingerprint fingerprint: String) -> Data? { + identityManager.signingPublicKey(forFingerprint: fingerprint) + } + + func recentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] { + identityManager.mostRecentlyVerifiedFingerprints(limit: limit, excluding: fingerprint) + } + + @discardableResult + func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool { + identityManager.recordVouch( + voucheeFingerprint: voucheeFingerprint, + voucherFingerprint: voucherFingerprint, + timestamp: timestamp + ) + } + + func lastVouchBatchSent(to fingerprint: String) -> Date? { + identityManager.lastVouchBatchSent(to: fingerprint) + } + + func markVouchBatchSent(to fingerprint: String, at date: Date) { + identityManager.markVouchBatchSent(to: fingerprint, at: date) + } + + func peerCapabilities(for peerID: PeerID) -> PeerCapabilities { + meshService.peerCapabilities(peerID) + } + + func connectedPeerIDs() -> [PeerID] { + Array(connectedPeers) + } + + func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) { + meshService.addPeerAuthenticatedObserver(handler) + } + + func noiseSignData(_ data: Data) -> Data? { + meshService.noiseSignData(data) + } + + func sendVouchAttestations(_ payload: Data, to peerID: PeerID) { + meshService.sendVouchAttestations(payload, to: peerID) + } + + func notifyPeerTrustChanged() { + // PeerListModel refreshes on this notification; the view-model change + // covers FingerprintView / VerificationModel consumers. + NotificationCenter.default.post(name: Notification.Name("peerStatusUpdated"), object: nil) + notifyUIChanged() + } +} + +/// Transitive verification ("vouching"): when a Noise session comes up with a +/// peer I verified, I attest — over that authenticated, encrypted session — +/// to the other identities I have verified. Receivers accept such vouches +/// only from peers *they* verified, giving a serverless +/// verified-by-people-you-verified tier (`TrustLevel.vouched`). +@MainActor +final class ChatVouchCoordinator { + /// Minimum spacing between vouch batches to the same peer (persisted). + static let batchInterval: TimeInterval = 24 * 60 * 60 + + private unowned let context: any ChatVouchContext + + init(context: any ChatVouchContext) { + self.context = context + } + + /// Registers the session-established hook. Additive alongside the + /// verification coordinator's callbacks; call once at bootstrap. + func setupNoiseCallbacks() { + context.addPeerAuthenticatedObserver { [weak self] peerID, fingerprint in + DispatchQueue.main.async { [weak self] in + self?.peerAuthenticated(peerID, fingerprint: fingerprint) + } + } + } + + /// Trigger — session established: on a Noise session coming up with a peer + /// I verified, attempt to send a vouch batch. Kept as the historical entry + /// point; the real work lives in `attemptVouch`. + func peerAuthenticated(_ peerID: PeerID, fingerprint: String, now: Date = Date()) { + attemptVouch(to: peerID, fingerprint: fingerprint, now: now) + } + + /// Trigger — verified announce processed: a peer's `.vouch` capability + /// arrives on its *announce*, which is handled independently of the Noise + /// handshake. This is invoked on every peer-list update (fired after each + /// verified announce), so it closes the capability race — the batch that + /// `peerAuthenticated` couldn't send (capabilities not yet known) goes out + /// once the capability-bearing announce lands. Throttled per peer. + func peersUpdated(_ peerIDs: [PeerID], now: Date = Date()) { + for peerID in peerIDs { + guard let fingerprint = context.getFingerprint(for: peerID) else { continue } + attemptVouch(to: peerID, fingerprint: fingerprint, now: now) + } + } + + /// Trigger — local verification completed: the user just verified a peer. + /// Run a vouch pass over every currently connected peer I verified. This + /// makes vouching fire when verifying someone already connected (whose + /// session is authenticated, so `peerAuthenticated` never re-fires), and it + /// propagates the newly-verified identity to my other verified peers. + /// Throttled per peer by `batchInterval`, so it can't spam. + func vouchToConnectedVerifiedPeers(now: Date = Date()) { + var sentCount = 0 + for peerID in context.connectedPeerIDs() { + guard let fingerprint = context.getFingerprint(for: peerID) else { continue } + if attemptVouch(to: peerID, fingerprint: fingerprint, now: now) { + sentCount += 1 + } + } + if sentCount > 0 { + SecureLogger.info( + "🪪 verify-triggered vouch pass sent to \(sentCount) connected peer(s)", + category: .security + ) + } + } + + /// Exchange policy shared by every trigger: to a peer I verified, send + /// attestations for up to `VouchAttestation.maxBatchCount` *other* verified + /// fingerprints (most recently verified first), at most once per peer per + /// `batchInterval`. Returns whether a batch was actually sent. + @discardableResult + func attemptVouch(to peerID: PeerID, fingerprint: String, now: Date = Date()) -> Bool { + guard context.isVerifiedFingerprint(fingerprint) else { return false } + + // Capability gate, race-tolerant: a peer's `.vouch` bit is carried on + // its announce, processed independently of the Noise handshake, so at + // authentication time the capability set is frequently still empty. + // Treat an empty/unknown set as eligible — the payload is a Noise + // `0x12` (`NoisePayloadType.vouch`) that non-supporting peers harmlessly + // ignore, so sending on an unknown set is safe and avoids the race + // dropping the batch. Only skip when the peer advertised a non-empty + // capability set that explicitly lacks `.vouch`. + let capabilities = context.peerCapabilities(for: peerID) + if !capabilities.isEmpty, !capabilities.contains(.vouch) { return false } + + if let lastSent = context.lastVouchBatchSent(to: fingerprint), + now.timeIntervalSince(lastSent) < Self.batchInterval { + return false + } + + let candidates = context.recentlyVerifiedFingerprints( + limit: VouchAttestation.maxBatchCount, + excluding: fingerprint + ) + var attestations: [VouchAttestation] = [] + for candidate in candidates { + // Only fingerprints whose announce-bound signing key we know can + // be anchored to a concrete identity; skip the rest. + guard let fingerprintData = Data(hexString: candidate), + fingerprintData.count == VouchAttestation.fingerprintSize, + let signingKey = context.signingKey(forFingerprint: candidate), + signingKey.count == VouchAttestation.signingKeySize, + let attestation = VouchAttestation.build( + voucheeFingerprint: fingerprintData, + voucheeSigningKey: signingKey, + timestampMs: UInt64(now.timeIntervalSince1970 * 1000), + sign: context.noiseSignData + ) else { + continue + } + attestations.append(attestation) + } + + guard !attestations.isEmpty, + let payload = VouchAttestation.encodeList(attestations) else { return false } + context.sendVouchAttestations(payload, to: peerID) + context.markVouchBatchSent(to: fingerprint, at: now) + SecureLogger.debug( + "🪪 Sent \(attestations.count) vouch attestation(s) to \(peerID.id.prefix(8))…", + category: .security + ) + return true + } + + /// Accept policy: process inbound vouches only from a sender I verified, + /// only with a valid Ed25519 signature under the sender's announce-bound + /// signing key, and only within the validity window. Self-vouches and + /// vouches for already-verified peers are dropped by the identity + /// manager's storage gates. + func handleVouchPayload(from peerID: PeerID, payload: Data, now: Date = Date()) { + guard let senderFingerprint = context.getFingerprint(for: peerID), + context.isVerifiedFingerprint(senderFingerprint) else { + SecureLogger.debug( + "🪪 Ignoring vouch payload from unverified peer \(peerID.id.prefix(8))…", + category: .security + ) + return + } + guard let senderSigningKey = context.signingKey(forFingerprint: senderFingerprint) else { + SecureLogger.debug( + "🪪 No signing key for vouching peer \(peerID.id.prefix(8))…; dropping batch", + category: .security + ) + return + } + + var acceptedCount = 0 + for attestation in VouchAttestation.decodeList(from: payload) { + guard attestation.verifySignature(voucherSigningKey: senderSigningKey), + !attestation.isExpired(now: now) else { continue } + let stored = context.recordVouch( + voucheeFingerprint: attestation.voucheeFingerprintHex, + voucherFingerprint: senderFingerprint, + timestamp: attestation.timestamp + ) + if stored { acceptedCount += 1 } + } + + if acceptedCount > 0 { + SecureLogger.info( + "🪪 Accepted \(acceptedCount) vouch(es) from \(senderFingerprint.prefix(8))…", + category: .security + ) + context.notifyPeerTrustChanged() + } + } +} diff --git a/bitchat/ViewModels/NostrInboundPipeline.swift b/bitchat/ViewModels/NostrInboundPipeline.swift index 2d2e6825..c5bd6525 100644 --- a/bitchat/ViewModels/NostrInboundPipeline.swift +++ b/bitchat/ViewModels/NostrInboundPipeline.swift @@ -303,7 +303,7 @@ final class NostrInboundPipeline { context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey) case .readReceipt: context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey) - case .verifyChallenge, .verifyResponse: + case .verifyChallenge, .verifyResponse, .vouch: break } } @@ -355,7 +355,7 @@ final class NostrInboundPipeline { context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey) case .readReceipt: context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey) - case .verifyChallenge, .verifyResponse: + case .verifyChallenge, .verifyResponse, .vouch: break } } @@ -434,7 +434,7 @@ final class NostrInboundPipeline { context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID) case .readReceipt: context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID) - case .verifyChallenge, .verifyResponse: + case .verifyChallenge, .verifyResponse, .vouch: break } } diff --git a/bitchat/Views/FingerprintView.swift b/bitchat/Views/FingerprintView.swift index 06fc865f..fb4541f5 100644 --- a/bitchat/Views/FingerprintView.swift +++ b/bitchat/Views/FingerprintView.swift @@ -37,6 +37,14 @@ struct FingerprintView: View { } static let markVerified: LocalizedStringKey = "fingerprint.action.mark_verified" static let removeVerification: LocalizedStringKey = "fingerprint.action.remove_verification" + static let vouchedBadge: LocalizedStringKey = "fingerprint.badge.vouched" + static func vouchedBy(_ count: Int) -> String { + String( + format: String(localized: "fingerprint.message.vouched_by", comment: "How many people the user verified have vouched for this peer"), + locale: .current, + count + ) + } static func unknownPeer() -> String { String(localized: "common.unknown", comment: "Label for an unknown peer") } @@ -146,6 +154,41 @@ struct FingerprintView: View { } } + // Vouched (transitively verified) status: shown whenever the + // peer isn't explicitly verified but people I verified vouch + // for them, independent of the current session state. + if fingerprintState.isVouched && !fingerprintState.isVerified { + VStack(spacing: 8) { + HStack(spacing: 6) { + Image(systemName: "checkmark.seal") + .font(.bitchatSystem(size: 14)) + .foregroundColor(.teal) + Text(Strings.vouchedBadge) + .bitchatFont(size: 14, weight: .bold) + .foregroundColor(.teal) + } + .frame(maxWidth: .infinity) + + Text(Strings.vouchedBy(fingerprintState.voucherCount)) + .bitchatFont(size: 12) + .foregroundColor(textColor.opacity(0.7)) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + + if !fingerprintState.voucherNames.isEmpty { + Text(fingerprintState.voucherNames.joined(separator: ", ")) + .bitchatFont(size: 12) + .foregroundColor(textColor.opacity(0.7)) + .multilineTextAlignment(.center) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity) + } + } + .padding(.top, 8) + .accessibilityElement(children: .combine) + } + // Verification status if fingerprintState.canToggleVerification { VStack(spacing: 12) { diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index 244cd6dc..b09ef476 100644 --- a/bitchat/Views/MeshPeerList.swift +++ b/bitchat/Views/MeshPeerList.swift @@ -25,6 +25,8 @@ struct MeshPeerList: View { static let favorite = String(localized: "mesh_peers.state.favorite", comment: "State label for a favorited peer") static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages") static let blocked = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer") + static let vouched = String(localized: "mesh_peers.state.vouched", comment: "State label for a peer vouched for by someone the user verified") + static let vouchedTooltip = String(localized: "mesh_peers.tooltip.vouched", comment: "Tooltip for the vouched (unfilled seal) badge next to a peer") static let addFavorite = String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite") static let removeFavorite = String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite") static let showFingerprint = String(localized: "mesh_peers.action.fingerprint", comment: "Context menu action that shows a peer's fingerprint/verification screen") @@ -134,6 +136,16 @@ struct MeshPeerList: View { .foregroundColor(baseColor) } } + + // Vouched (transitively verified): unfilled seal, + // deliberately distinct from verified's filled one. + // Never shown alongside a verified badge. + if peer.showsVouchedBadge { + Image(systemName: "checkmark.seal") + .font(.bitchatSystem(size: 10)) + .foregroundColor(baseColor) + .help(Strings.vouchedTooltip) + } } Spacer() @@ -241,6 +253,7 @@ struct MeshPeerList: View { parts.append(Strings.offline) } } + if peer.showsVouchedBadge { parts.append(Strings.vouched) } if peer.isFavorite { parts.append(Strings.favorite) } if peer.hasUnread { parts.append(Strings.unread) } if peer.isBlocked { parts.append(Strings.blocked) } diff --git a/bitchatTests/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift index 0b70f0e4..69b7fc89 100644 --- a/bitchatTests/AppArchitectureTests.swift +++ b/bitchatTests/AppArchitectureTests.swift @@ -591,6 +591,45 @@ struct AppArchitectureTests { #expect(!verificationModel.isVerified(peerID: peerID)) } + @Test("VerificationModel refreshes when peer trust changes (vouch accepted)") + @MainActor + func verificationModelRefreshesOnPeerTrustChange() async { + let viewModel = makeArchitectureViewModel() + var privateConversationModel: PrivateConversationModel? = PrivateConversationModel( + chatViewModel: viewModel, + conversations: viewModel.conversations, + locationChannelsModel: LocationChannelsModel(manager: makeArchitectureLocationManager()) + ) + let verificationModel = VerificationModel( + chatViewModel: viewModel, + privateConversationModel: privateConversationModel! + ) + + // PrivateConversationModel happens to observe the same notification + // and re-assign its published selection, which would ripple into + // VerificationModel; release it so this test pins VerificationModel's + // own subscription rather than that incidental chain. + privateConversationModel = nil + + // The bound @Published sources replay their current values on + // subscription; let those initial main-queue emissions settle so the + // sink below observes only the trust-change signal. + try? await Task.sleep(nanoseconds: 100_000_000) + + // ChatVouchCoordinator.notifyPeerTrustChanged() signals accepted + // vouches via "peerStatusUpdated"; an open fingerprint sheet must + // re-render its vouched badge from that signal alone. + var refreshed = false + let cancellable = verificationModel.objectWillChange.sink { _ in + refreshed = true + } + defer { cancellable.cancel() } + + NotificationCenter.default.post(name: Notification.Name("peerStatusUpdated"), object: nil) + await waitUntil { refreshed } + #expect(refreshed) + } + @Test("PeerListModel publishes mesh and geohash directory state") @MainActor func peerListModelPublishesDirectoryState() async { diff --git a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift index 1c1fc46f..bd112e9b 100644 --- a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift +++ b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift @@ -156,6 +156,12 @@ private final class MockChatTransportEventContext: ChatTransportEventContext { func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) { verifyResponsePayloads.append((peerID, payload)) } + + private(set) var vouchPayloads: [(peerID: PeerID, payload: Data)] = [] + + func handleVouchPayload(from peerID: PeerID, payload: Data) { + vouchPayloads.append((peerID, payload)) + } } // MARK: - Helpers diff --git a/bitchatTests/ChatVerificationCoordinatorContextTests.swift b/bitchatTests/ChatVerificationCoordinatorContextTests.swift index 4ef7f9f7..6034c890 100644 --- a/bitchatTests/ChatVerificationCoordinatorContextTests.swift +++ b/bitchatTests/ChatVerificationCoordinatorContextTests.swift @@ -51,6 +51,9 @@ private final class MockChatVerificationContext: ChatVerificationContext { func saveIdentityState() { saveIdentityStateCount += 1 } + private(set) var vouchToConnectedVerifiedPeersCount = 0 + func vouchToConnectedVerifiedPeers() { vouchToConnectedVerifiedPeersCount += 1 } + // Encryption status private(set) var encryptionStatuses: [PeerID: EncryptionStatus?] = [:] private(set) var updatedEncryptionStatusPeers: [PeerID] = [] diff --git a/bitchatTests/ChatVouchCoordinatorContextTests.swift b/bitchatTests/ChatVouchCoordinatorContextTests.swift new file mode 100644 index 00000000..18175576 --- /dev/null +++ b/bitchatTests/ChatVouchCoordinatorContextTests.swift @@ -0,0 +1,353 @@ +// +// ChatVouchCoordinatorContextTests.swift +// bitchatTests +// +// Exercises `ChatVouchCoordinator` against a mock `ChatVouchContext` — +// proving the exchange policy (verified + capable peers only, batch cap, +// 24h rate limit) and the accept policy (verified senders only, real +// Ed25519 signature verification, expiry) without a `ChatViewModel`. +// Storage-level gates (self-vouch, already-verified vouchee, per-vouchee +// cap) are covered by `SecureIdentityStateManagerVouchTests`. +// + +import CryptoKit +import Foundation +import BitFoundation +import Testing + +@testable import bitchat + +// MARK: - Mock Context + +@MainActor +private final class MockChatVouchContext: ChatVouchContext { + // Identity & trust state + var fingerprintsByPeerID: [PeerID: String] = [:] + var verifiedFingerprints: Set = [] + var signingKeysByFingerprint: [String: Data] = [:] + var recentVerified: [String] = [] + private(set) var recentVerifiedRequests: [(limit: Int, excluding: String)] = [] + private(set) var recordedVouches: [(vouchee: String, voucher: String, timestamp: Date)] = [] + var recordVouchResult = true + var lastBatchSentAt: [String: Date] = [:] + private(set) var markedBatchSent: [(fingerprint: String, date: Date)] = [] + + func getFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] } + func isVerifiedFingerprint(_ fingerprint: String) -> Bool { verifiedFingerprints.contains(fingerprint) } + func signingKey(forFingerprint fingerprint: String) -> Data? { signingKeysByFingerprint[fingerprint] } + + func recentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] { + recentVerifiedRequests.append((limit, fingerprint)) + return Array(recentVerified.filter { $0 != fingerprint }.prefix(limit)) + } + + @discardableResult + func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool { + recordedVouches.append((voucheeFingerprint, voucherFingerprint, timestamp)) + return recordVouchResult + } + + func lastVouchBatchSent(to fingerprint: String) -> Date? { lastBatchSentAt[fingerprint] } + + func markVouchBatchSent(to fingerprint: String, at date: Date) { + markedBatchSent.append((fingerprint, date)) + lastBatchSentAt[fingerprint] = date + } + + // Transport + var capabilitiesByPeerID: [PeerID: PeerCapabilities] = [:] + var mySigningKey = Curve25519.Signing.PrivateKey() + private(set) var installedObservers: [(PeerID, String) -> Void] = [] + private(set) var sentVouchPayloads: [(payload: Data, peerID: PeerID)] = [] + + var connectedPeerIDList: [PeerID] = [] + + func peerCapabilities(for peerID: PeerID) -> PeerCapabilities { capabilitiesByPeerID[peerID] ?? [] } + + func connectedPeerIDs() -> [PeerID] { connectedPeerIDList } + + func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) { + installedObservers.append(handler) + } + + func noiseSignData(_ data: Data) -> Data? { try? mySigningKey.signature(for: data) } + + func sendVouchAttestations(_ payload: Data, to peerID: PeerID) { + sentVouchPayloads.append((payload, peerID)) + } + + // UI refresh + private(set) var trustChangedCount = 0 + + func notifyPeerTrustChanged() { trustChangedCount += 1 } +} + +// MARK: - Tests + +struct ChatVouchCoordinatorContextTests { + private let peerID = PeerID(str: "1122334455667788") + private let peerFingerprint = String(repeating: "0f", count: 32) + + @MainActor + private func makeVerifiedCapablePeer() -> (MockChatVouchContext, ChatVouchCoordinator) { + let context = MockChatVouchContext() + let coordinator = ChatVouchCoordinator(context: context) + context.fingerprintsByPeerID[peerID] = peerFingerprint + context.verifiedFingerprints.insert(peerFingerprint) + context.capabilitiesByPeerID[peerID] = [.vouch] + return (context, coordinator) + } + + // MARK: Exchange policy + + @Test @MainActor + func peerAuthenticated_sendsBatchForVerifiedCapablePeer() throws { + let (context, coordinator) = makeVerifiedCapablePeer() + let vouchees = [String(repeating: "01", count: 32), String(repeating: "02", count: 32)] + context.recentVerified = vouchees + for vouchee in vouchees { + context.signingKeysByFingerprint[vouchee] = Data(repeating: 0x33, count: 32) + } + + coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint) + + // Candidates are requested most-recent-first, excluding the target. + #expect(context.recentVerifiedRequests.count == 1) + #expect(context.recentVerifiedRequests.first?.limit == VouchAttestation.maxBatchCount) + #expect(context.recentVerifiedRequests.first?.excluding == peerFingerprint) + + let sent = try #require(context.sentVouchPayloads.first) + #expect(sent.peerID == peerID) + let attestations = VouchAttestation.decodeList(from: sent.payload) + #expect(attestations.map(\.voucheeFingerprintHex) == vouchees) + // Every attestation carries a valid signature under our signing key. + let myPublicKey = context.mySigningKey.publicKey.rawRepresentation + #expect(attestations.allSatisfy { $0.verifySignature(voucherSigningKey: myPublicKey) }) + + // The rate limit is stamped only after an actual send. + #expect(context.markedBatchSent.map(\.fingerprint) == [peerFingerprint]) + } + + @Test @MainActor + func peerAuthenticated_requiresVerificationAndCapability() { + let (context, coordinator) = makeVerifiedCapablePeer() + context.recentVerified = [String(repeating: "01", count: 32)] + context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32) + + // Not verified by me: nothing. + context.verifiedFingerprints.remove(peerFingerprint) + coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint) + #expect(context.sentVouchPayloads.isEmpty) + + // Verified but advertises a non-empty capability set lacking .vouch: + // nothing. (An *empty*/unknown set is race-tolerant and still sends — + // see `attemptVouch_sendsWhenCapabilitiesUnknown`.) + context.verifiedFingerprints.insert(peerFingerprint) + context.capabilitiesByPeerID[peerID] = [.prekeys] + coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint) + #expect(context.sentVouchPayloads.isEmpty) + #expect(context.markedBatchSent.isEmpty) + } + + // MARK: Capability race tolerance & new triggers + + @Test @MainActor + func attemptVouch_sendsWhenCapabilitiesUnknown() { + // Capability set still empty at attempt time (the peer's .vouch bit + // arrives on a later announce): the batch must still go out. + let (context, coordinator) = makeVerifiedCapablePeer() + context.capabilitiesByPeerID[peerID] = [] + context.recentVerified = [String(repeating: "01", count: 32)] + context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32) + + coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint) + #expect(context.sentVouchPayloads.count == 1) + #expect(context.markedBatchSent.map(\.fingerprint) == [peerFingerprint]) + } + + @Test @MainActor + func vouchToConnectedVerifiedPeers_sendsToConnectedVerifiedCapablePeer() { + let (context, coordinator) = makeVerifiedCapablePeer() + context.connectedPeerIDList = [peerID] + context.recentVerified = [String(repeating: "01", count: 32)] + context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32) + + // Session is already up (no peerAuthenticated re-fire); the verify pass + // is what makes the batch go out. + coordinator.vouchToConnectedVerifiedPeers() + let sent = context.sentVouchPayloads + #expect(sent.count == 1) + #expect(sent.first?.peerID == peerID) + #expect(context.markedBatchSent.map(\.fingerprint) == [peerFingerprint]) + } + + @Test @MainActor + func vouchToConnectedVerifiedPeers_skipsUnverifiedConnectedPeers() { + let (context, coordinator) = makeVerifiedCapablePeer() + context.connectedPeerIDList = [peerID] + context.verifiedFingerprints.remove(peerFingerprint) + context.recentVerified = [String(repeating: "01", count: 32)] + context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32) + + coordinator.vouchToConnectedVerifiedPeers() + #expect(context.sentVouchPayloads.isEmpty) + } + + @Test @MainActor + func peersUpdated_sendsOnceCapabilityBearingAnnounceArrives() { + let (context, coordinator) = makeVerifiedCapablePeer() + context.recentVerified = [String(repeating: "01", count: 32)] + context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32) + + // First announce before the .vouch bit is known: empty set is + // race-tolerant, so it already sends and stamps the throttle. + context.capabilitiesByPeerID[peerID] = [] + coordinator.peersUpdated([peerID]) + #expect(context.sentVouchPayloads.count == 1) + + // A later announce carrying .vouch must not double-send (throttled). + context.capabilitiesByPeerID[peerID] = [.vouch] + coordinator.peersUpdated([peerID]) + #expect(context.sentVouchPayloads.count == 1) + } + + @Test @MainActor + func peerAuthenticated_rateLimitsPerPeerPer24Hours() { + let (context, coordinator) = makeVerifiedCapablePeer() + context.recentVerified = [String(repeating: "01", count: 32)] + context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32) + + let now = Date() + context.lastBatchSentAt[peerFingerprint] = now.addingTimeInterval(-60 * 60) + coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint, now: now) + #expect(context.sentVouchPayloads.isEmpty) + + // Once the interval has elapsed the batch goes out again. + context.lastBatchSentAt[peerFingerprint] = now.addingTimeInterval(-ChatVouchCoordinator.batchInterval - 1) + coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint, now: now) + #expect(context.sentVouchPayloads.count == 1) + } + + @Test @MainActor + func peerAuthenticated_skipsCandidatesWithoutSigningKeysAndEmptyBatches() { + let (context, coordinator) = makeVerifiedCapablePeer() + let withKey = String(repeating: "01", count: 32) + let withoutKey = String(repeating: "02", count: 32) + context.recentVerified = [withoutKey, withKey] + context.signingKeysByFingerprint[withKey] = Data(repeating: 0x33, count: 32) + + coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint) + let attestations = VouchAttestation.decodeList(from: context.sentVouchPayloads[0].payload) + #expect(attestations.map(\.voucheeFingerprintHex) == [withKey]) + + // No signable candidates at all: nothing is sent or rate-stamped. + let freshPeer = PeerID(str: "aabbccddeeff0011") + let freshFingerprint = String(repeating: "0e", count: 32) + context.fingerprintsByPeerID[freshPeer] = freshFingerprint + context.verifiedFingerprints.insert(freshFingerprint) + context.capabilitiesByPeerID[freshPeer] = [.vouch] + context.recentVerified = [withoutKey] + coordinator.peerAuthenticated(freshPeer, fingerprint: freshFingerprint) + #expect(context.sentVouchPayloads.count == 1) + #expect(!context.markedBatchSent.contains { $0.fingerprint == freshFingerprint }) + } + + // MARK: Accept policy + + @MainActor + private func makeInboundBatch( + signedBy key: Curve25519.Signing.PrivateKey, + vouchee: String = String(repeating: "07", count: 32), + timestampMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000) + ) throws -> Data { + let voucheeData = try #require(Data(hexString: vouchee)) + let attestation = try #require(VouchAttestation.build( + voucheeFingerprint: voucheeData, + voucheeSigningKey: Data(repeating: 0x44, count: 32), + timestampMs: timestampMs, + sign: { try? key.signature(for: $0) } + )) + return try #require(VouchAttestation.encodeList([attestation])) + } + + @Test @MainActor + func handleVouchPayload_acceptsValidVouchFromVerifiedSender() throws { + let (context, coordinator) = makeVerifiedCapablePeer() + let senderKey = Curve25519.Signing.PrivateKey() + context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation + + let vouchee = String(repeating: "07", count: 32) + let payload = try makeInboundBatch(signedBy: senderKey, vouchee: vouchee) + coordinator.handleVouchPayload(from: peerID, payload: payload) + + #expect(context.recordedVouches.count == 1) + #expect(context.recordedVouches.first?.vouchee == vouchee) + #expect(context.recordedVouches.first?.voucher == peerFingerprint) + #expect(context.trustChangedCount == 1) + } + + @Test @MainActor + func handleVouchPayload_rejectsUnverifiedOrUnknownSender() throws { + let (context, coordinator) = makeVerifiedCapablePeer() + let senderKey = Curve25519.Signing.PrivateKey() + context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation + let payload = try makeInboundBatch(signedBy: senderKey) + + // Sender's fingerprint is not in my verified set. + context.verifiedFingerprints.remove(peerFingerprint) + coordinator.handleVouchPayload(from: peerID, payload: payload) + #expect(context.recordedVouches.isEmpty) + + // Unknown peer entirely. + coordinator.handleVouchPayload(from: PeerID(str: "ffeeddccbbaa9988"), payload: payload) + #expect(context.recordedVouches.isEmpty) + #expect(context.trustChangedCount == 0) + } + + @Test @MainActor + func handleVouchPayload_rejectsForgedSignaturesAndExpiredAttestations() throws { + let (context, coordinator) = makeVerifiedCapablePeer() + let senderKey = Curve25519.Signing.PrivateKey() + context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation + + // Signed by an imposter key: signature check against the sender's + // announce-bound key fails. + let imposter = Curve25519.Signing.PrivateKey() + let forged = try makeInboundBatch(signedBy: imposter) + coordinator.handleVouchPayload(from: peerID, payload: forged) + #expect(context.recordedVouches.isEmpty) + + // Correctly signed but expired. + let staleMs = UInt64(Date().addingTimeInterval(-31 * 24 * 60 * 60).timeIntervalSince1970 * 1000) + let expired = try makeInboundBatch(signedBy: senderKey, timestampMs: staleMs) + coordinator.handleVouchPayload(from: peerID, payload: expired) + #expect(context.recordedVouches.isEmpty) + #expect(context.trustChangedCount == 0) + + // No signing key known for the sender: batch dropped. + context.signingKeysByFingerprint.removeValue(forKey: peerFingerprint) + let valid = try makeInboundBatch(signedBy: senderKey) + coordinator.handleVouchPayload(from: peerID, payload: valid) + #expect(context.recordedVouches.isEmpty) + } + + @Test @MainActor + func handleVouchPayload_skipsUIRefreshWhenNothingStored() throws { + let (context, coordinator) = makeVerifiedCapablePeer() + let senderKey = Curve25519.Signing.PrivateKey() + context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation + context.recordVouchResult = false // e.g. self-vouch dropped by the store + + let payload = try makeInboundBatch(signedBy: senderKey) + coordinator.handleVouchPayload(from: peerID, payload: payload) + #expect(context.recordedVouches.count == 1) + #expect(context.trustChangedCount == 0) + } + + @Test @MainActor + func setupNoiseCallbacks_installsAdditiveObserver() { + let (context, coordinator) = makeVerifiedCapablePeer() + coordinator.setupNoiseCallbacks() + #expect(context.installedObservers.count == 1) + } +} diff --git a/bitchatTests/Mocks/MockIdentityManager.swift b/bitchatTests/Mocks/MockIdentityManager.swift index 633c388b..1ff1926a 100644 --- a/bitchatTests/Mocks/MockIdentityManager.swift +++ b/bitchatTests/Mocks/MockIdentityManager.swift @@ -107,12 +107,55 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol { func removeEphemeralSession(peerID: PeerID) {} func setVerified(fingerprint: String, verified: Bool) {} - + func isVerified(fingerprint: String) -> Bool { true } - + func getVerifiedFingerprints() -> Set { Set() } + + // MARK: Vouching (transitive verification) + + private var vouchesByVouchee: [String: [VouchRecord]] = [:] + private var vouchBatchSentAt: [String: Date] = [:] + + @discardableResult + func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool { + guard voucheeFingerprint != voucherFingerprint else { return false } + var records = vouchesByVouchee[voucheeFingerprint] ?? [] + records.removeAll { $0.voucherFingerprint == voucherFingerprint } + records.append(VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: timestamp)) + vouchesByVouchee[voucheeFingerprint] = records + return true + } + + func validVouchers(for fingerprint: String) -> [VouchRecord] { + vouchesByVouchee[fingerprint] ?? [] + } + + func isVouched(fingerprint: String) -> Bool { + !(vouchesByVouchee[fingerprint] ?? []).isEmpty + } + + func effectiveTrustLevel(for fingerprint: String) -> TrustLevel { + socialIdentities[fingerprint]?.trustLevel ?? .unknown + } + + func lastVouchBatchSent(to fingerprint: String) -> Date? { + vouchBatchSentAt[fingerprint] + } + + func markVouchBatchSent(to fingerprint: String, at date: Date) { + vouchBatchSentAt[fingerprint] = date + } + + func signingPublicKey(forFingerprint fingerprint: String) -> Data? { + nil + } + + func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] { + [] + } } diff --git a/bitchatTests/Protocols/VouchAttestationTests.swift b/bitchatTests/Protocols/VouchAttestationTests.swift new file mode 100644 index 00000000..064cf4d8 --- /dev/null +++ b/bitchatTests/Protocols/VouchAttestationTests.swift @@ -0,0 +1,176 @@ +import CryptoKit +import Foundation +import Testing + +@testable import bitchat + +struct VouchAttestationTests { + private let voucherKey = Curve25519.Signing.PrivateKey() + + private func makeAttestation( + fingerprint: Data = Data(repeating: 0xAA, count: 32), + signingKey: Data = Data(repeating: 0xBB, count: 32), + timestampMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000), + signedBy key: Curve25519.Signing.PrivateKey? = nil + ) throws -> VouchAttestation { + let signer = key ?? voucherKey + return try #require( + VouchAttestation.build( + voucheeFingerprint: fingerprint, + voucheeSigningKey: signingKey, + timestampMs: timestampMs, + sign: { try? signer.signature(for: $0) } + ) + ) + } + + @Test + func roundTripsAndVerifiesSignature() throws { + let attestation = try makeAttestation() + let encoded = try #require(attestation.encode()) + let decoded = try #require(VouchAttestation.decode(from: encoded)) + + #expect(decoded == attestation) + #expect(decoded.voucheeFingerprintHex == String(repeating: "aa", count: 32)) + #expect(decoded.verifySignature(voucherSigningKey: voucherKey.publicKey.rawRepresentation)) + } + + @Test + func decodeSkipsUnknownTLVsAndRejectsMalformedInput() throws { + let attestation = try makeAttestation() + var encoded = try #require(attestation.encode()) + + // Unknown TLV appended: skipped for forward compatibility. + encoded.append(contentsOf: [0x7F, 0x02, 0x01, 0x02]) + #expect(VouchAttestation.decode(from: encoded) == attestation) + + // Truncation and missing fields are rejected. + #expect(VouchAttestation.decode(from: encoded.dropLast()) == nil) + #expect(VouchAttestation.decode(from: Data([0x01, 0x20])) == nil) + #expect(VouchAttestation.decode(from: Data()) == nil) + + // Wrong field sizes are rejected. + var wrongSize = Data([0x01, 0x10]) + wrongSize.append(Data(repeating: 0xAA, count: 16)) + #expect(VouchAttestation.decode(from: wrongSize) == nil) + } + + @Test + func buildRejectsWrongKeyAndFingerprintSizes() { + let sign: (Data) -> Data? = { try? self.voucherKey.signature(for: $0) } + #expect(VouchAttestation.build( + voucheeFingerprint: Data(repeating: 0xAA, count: 16), + voucheeSigningKey: Data(repeating: 0xBB, count: 32), + sign: sign + ) == nil) + #expect(VouchAttestation.build( + voucheeFingerprint: Data(repeating: 0xAA, count: 32), + voucheeSigningKey: Data(repeating: 0xBB, count: 16), + sign: sign + ) == nil) + } + + @Test + func forgedSignatureFailsVerification() throws { + let attestation = try makeAttestation() + let otherKey = Curve25519.Signing.PrivateKey() + + // Verifying against a key that didn't sign fails. + #expect(!attestation.verifySignature(voucherSigningKey: otherKey.publicKey.rawRepresentation)) + + // An attestation signed by an imposter fails against the real key. + let forged = try makeAttestation(signedBy: otherKey) + #expect(!forged.verifySignature(voucherSigningKey: voucherKey.publicKey.rawRepresentation)) + #expect(!attestation.verifySignature(voucherSigningKey: Data(repeating: 0x01, count: 3))) + } + + @Test + func tamperedFieldsFailVerification() throws { + let attestation = try makeAttestation() + let publicKey = voucherKey.publicKey.rawRepresentation + + var tamperedFingerprint = attestation.voucheeFingerprint + tamperedFingerprint[0] ^= 0xFF + let tampered = VouchAttestation( + voucheeFingerprint: tamperedFingerprint, + voucheeSigningKey: attestation.voucheeSigningKey, + timestampMs: attestation.timestampMs, + signature: attestation.signature + ) + #expect(!tampered.verifySignature(voucherSigningKey: publicKey)) + + let backdated = VouchAttestation( + voucheeFingerprint: attestation.voucheeFingerprint, + voucheeSigningKey: attestation.voucheeSigningKey, + timestampMs: attestation.timestampMs - 1, + signature: attestation.signature + ) + #expect(!backdated.verifySignature(voucherSigningKey: publicKey)) + } + + @Test + func expiryWindowIsEnforced() throws { + let now = Date() + let fresh = try makeAttestation(timestampMs: UInt64(now.timeIntervalSince1970 * 1000)) + #expect(!fresh.isExpired(now: now)) + + let thirtyOneDaysAgo = now.addingTimeInterval(-31 * 24 * 60 * 60) + let expired = try makeAttestation(timestampMs: UInt64(thirtyOneDaysAgo.timeIntervalSince1970 * 1000)) + #expect(expired.isExpired(now: now)) + + let farFuture = now.addingTimeInterval(2 * 60 * 60) + let fromTheFuture = try makeAttestation(timestampMs: UInt64(farFuture.timeIntervalSince1970 * 1000)) + #expect(fromTheFuture.isExpired(now: now)) + + // A verified-but-expired attestation still has a valid signature; the + // two checks are independent gates. + #expect(expired.verifySignature(voucherSigningKey: voucherKey.publicKey.rawRepresentation)) + } + + @Test + func batchRoundTripsAndEnforcesCap() throws { + let attestations = try (0..<3).map { index in + try makeAttestation(fingerprint: Data(repeating: UInt8(index + 1), count: 32)) + } + let payload = try #require(VouchAttestation.encodeList(attestations)) + #expect(VouchAttestation.decodeList(from: payload) == attestations) + + #expect(VouchAttestation.encodeList([]) == nil) + + let tooMany = try (0..<17).map { index in + try makeAttestation(fingerprint: Data(repeating: UInt8(index + 1), count: 32)) + } + #expect(VouchAttestation.encodeList(tooMany) == nil) + } + + @Test + func decodeListIgnoresEntriesBeyondCapAndMalformedEntries() throws { + let attestations = try (0..<17).map { index in + try makeAttestation(fingerprint: Data(repeating: UInt8(index + 1), count: 32)) + } + // Hand-build an oversized batch that lies about its count. + var payload = Data([UInt8(attestations.count)]) + for attestation in attestations { + let encoded = try #require(attestation.encode()) + payload.append(UInt8(encoded.count >> 8)) + payload.append(UInt8(encoded.count & 0xFF)) + payload.append(encoded) + } + let decoded = VouchAttestation.decodeList(from: payload) + #expect(decoded.count == VouchAttestation.maxBatchCount) + #expect(decoded == Array(attestations.prefix(VouchAttestation.maxBatchCount))) + + // A malformed middle entry is dropped without killing the batch. + let good = try makeAttestation() + let goodEncoded = try #require(good.encode()) + var mixed = Data([2]) + mixed.append(contentsOf: [0x00, 0x03, 0xDE, 0xAD, 0xBE]) + mixed.append(UInt8(goodEncoded.count >> 8)) + mixed.append(UInt8(goodEncoded.count & 0xFF)) + mixed.append(goodEncoded) + #expect(VouchAttestation.decodeList(from: mixed) == [good]) + + #expect(VouchAttestation.decodeList(from: Data()) == []) + #expect(VouchAttestation.decodeList(from: Data([5])) == []) + } +} diff --git a/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift b/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift new file mode 100644 index 00000000..6ab46490 --- /dev/null +++ b/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift @@ -0,0 +1,335 @@ +import Foundation +import Testing + +@testable import bitchat + +/// Vouch storage, accept-policy gates, derived trust levels, and persistence +/// compatibility for `SecureIdentityStateManager`. +/// +/// Ordering note: mutations use barrier blocks on the manager's concurrent +/// queue and reads use `queue.sync`, so a read submitted after a mutation +/// always observes it — no polling needed. +/// +/// `@MainActor` matches production (the manager's vouch API is driven by the +/// main-actor `ChatVouchCoordinator`) and keeps the blocking `queue.sync` +/// reads off the Swift Concurrency cooperative pool. Left nonisolated, Swift +/// Testing runs these tests in parallel on that pool, and on CI's few-core +/// runners every pool thread ended up parked in `queue.sync` behind a pending +/// `queue.async(.barrier)` write that never got a dispatch worker — a +/// process-wide deadlock (watchdog SIGKILL, exit 137). +@MainActor +struct SecureIdentityStateManagerVouchTests { + private let voucher = String(repeating: "0a", count: 32) + private let vouchee = String(repeating: "0b", count: 32) + + private func makeManager() -> SecureIdentityStateManager { + SecureIdentityStateManager(MockKeychain()) + } + + // MARK: - Accept-policy gates + + @Test + func recordVouch_rejectsUnverifiedVoucher() { + let manager = makeManager() + + #expect(!manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())) + #expect(manager.validVouchers(for: vouchee).isEmpty) + + manager.setVerified(fingerprint: voucher, verified: true) + #expect(manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())) + #expect(manager.validVouchers(for: vouchee).count == 1) + } + + @Test + func recordVouch_ignoresSelfVouch() { + let manager = makeManager() + manager.setVerified(fingerprint: voucher, verified: true) + + #expect(!manager.recordVouch(voucheeFingerprint: voucher, voucherFingerprint: voucher, timestamp: Date())) + #expect(manager.validVouchers(for: voucher).isEmpty) + } + + @Test + func recordVouch_ignoresAlreadyVerifiedVouchee() { + let manager = makeManager() + manager.setVerified(fingerprint: voucher, verified: true) + manager.setVerified(fingerprint: vouchee, verified: true) + + #expect(!manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())) + #expect(!manager.isVouched(fingerprint: vouchee)) + } + + @Test + func recordVouch_rejectsStaleAndFarFutureTimestamps() { + let manager = makeManager() + manager.setVerified(fingerprint: voucher, verified: true) + + let stale = Date().addingTimeInterval(-31 * 24 * 60 * 60) + #expect(!manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: stale)) + + let farFuture = Date().addingTimeInterval(2 * 60 * 60) + #expect(!manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: farFuture)) + + #expect(manager.validVouchers(for: vouchee).isEmpty) + } + + @Test + func recordVouch_capsVouchersPerVoucheeKeepingMostRecent() { + let manager = makeManager() + let base = Date() + + // 9 verified vouchers vouch with strictly increasing timestamps. + let vouchers = (0..<9).map { String(format: "%02x", $0 + 0x10) + String(repeating: "00", count: 31) } + for (index, voucherFingerprint) in vouchers.enumerated() { + manager.setVerified(fingerprint: voucherFingerprint, verified: true) + let stored = manager.recordVouch( + voucheeFingerprint: vouchee, + voucherFingerprint: voucherFingerprint, + timestamp: base.addingTimeInterval(TimeInterval(index)), + now: base.addingTimeInterval(TimeInterval(index)) + ) + #expect(stored) + } + + let records = manager.validVouchers(for: vouchee) + #expect(records.count == SecureIdentityStateManager.maxVouchersPerVouchee) + // The oldest voucher fell off the end. + #expect(!records.contains { $0.voucherFingerprint == vouchers[0] }) + #expect(records.contains { $0.voucherFingerprint == vouchers[8] }) + + // An attestation older than everything retained is not stored. + let older = String(repeating: "0c", count: 32) + manager.setVerified(fingerprint: older, verified: true) + #expect(!manager.recordVouch( + voucheeFingerprint: vouchee, + voucherFingerprint: older, + timestamp: base.addingTimeInterval(-1), + now: base + )) + + // A repeat vouch from a retained voucher refreshes, not duplicates. + #expect(manager.recordVouch( + voucheeFingerprint: vouchee, + voucherFingerprint: vouchers[8], + timestamp: base.addingTimeInterval(100), + now: base.addingTimeInterval(100) + )) + #expect(manager.validVouchers(for: vouchee).count == SecureIdentityStateManager.maxVouchersPerVouchee) + } + + // MARK: - Derived trust & invalidation + + @Test + func unverifyingVoucher_invalidatesTheirVouchesWithoutDeletingThem() { + let manager = makeManager() + manager.setVerified(fingerprint: voucher, verified: true) + manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()) + #expect(manager.isVouched(fingerprint: vouchee)) + + // Removing my verification of the voucher retires their vouches… + manager.setVerified(fingerprint: voucher, verified: false) + #expect(!manager.isVouched(fingerprint: vouchee)) + #expect(manager.validVouchers(for: vouchee).isEmpty) + + // …but the records survive: re-verifying the voucher restores them + // (recompute on read, no cascade delete). + manager.setVerified(fingerprint: voucher, verified: true) + #expect(manager.isVouched(fingerprint: vouchee)) + } + + @Test + func validVouchers_expireAtReadTime() { + let manager = makeManager() + manager.setVerified(fingerprint: voucher, verified: true) + + let now = Date() + let timestamp = now.addingTimeInterval(-29 * 24 * 60 * 60) + #expect(manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: timestamp, now: now)) + #expect(manager.isVouched(fingerprint: vouchee, now: now)) + + let twoDaysLater = now.addingTimeInterval(2 * 24 * 60 * 60) + #expect(manager.validVouchers(for: vouchee, now: twoDaysLater).isEmpty) + #expect(!manager.isVouched(fingerprint: vouchee, now: twoDaysLater)) + } + + @Test + func effectiveTrustLevel_slotsVouchedBetweenCasualAndTrusted() { + let manager = makeManager() + manager.setVerified(fingerprint: voucher, verified: true) + + // Unknown peer with a valid vouch reads as vouched. + #expect(manager.effectiveTrustLevel(for: vouchee) == .unknown) + manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()) + #expect(manager.effectiveTrustLevel(for: vouchee) == .vouched) + + // Explicit trust outranks a vouch. + manager.updateSocialIdentity(SocialIdentity( + fingerprint: vouchee, + localPetname: nil, + claimedNickname: "bob", + trustLevel: .trusted, + isFavorite: false, + isBlocked: false, + notes: nil + )) + #expect(manager.effectiveTrustLevel(for: vouchee) == .trusted) + + // Explicit verification outranks everything. + manager.setVerified(fingerprint: vouchee, verified: true) + #expect(manager.effectiveTrustLevel(for: vouchee) == .verified) + #expect(!manager.isVouched(fingerprint: vouchee)) + + // Losing the voucher downgrades vouched back to the stored level. + manager.setVerified(fingerprint: vouchee, verified: false) + manager.setVerified(fingerprint: voucher, verified: false) + #expect(manager.effectiveTrustLevel(for: vouchee) == .casual) + } + + // MARK: - Exchange-policy state + + @Test + func mostRecentlyVerifiedFingerprints_ordersAndExcludes() { + let manager = makeManager() + let first = String(repeating: "01", count: 32) + let second = String(repeating: "02", count: 32) + let third = String(repeating: "03", count: 32) + manager.setVerified(fingerprint: first, verified: true) + manager.setVerified(fingerprint: second, verified: true) + manager.setVerified(fingerprint: third, verified: true) + + let ordered = manager.mostRecentlyVerifiedFingerprints(limit: 16, excluding: third) + #expect(ordered == [second, first]) + + let limited = manager.mostRecentlyVerifiedFingerprints(limit: 1, excluding: third) + #expect(limited == [second]) + } + + @Test + func vouchBatchSentAt_roundTrips() { + let manager = makeManager() + #expect(manager.lastVouchBatchSent(to: voucher) == nil) + + let sentAt = Date(timeIntervalSince1970: 1_700_000_000) + manager.markVouchBatchSent(to: voucher, at: sentAt) + #expect(manager.lastVouchBatchSent(to: voucher) == sentAt) + } + + @Test + func signingPublicKey_returnsAnnounceBoundKeyByFingerprint() async { + let manager = makeManager() + let signingKey = Data(repeating: 0x22, count: 32) + manager.upsertCryptographicIdentity( + fingerprint: voucher, + noisePublicKey: Data(repeating: 0x11, count: 32), + signingPublicKey: signingKey, + claimedNickname: nil + ) + + let stored = await waitUntil { manager.signingPublicKey(forFingerprint: voucher) == signingKey } + #expect(stored) + #expect(manager.signingPublicKey(forFingerprint: vouchee) == nil) + } + + // MARK: - Panic wipe + + @Test + func clearAllIdentityData_wipesVouchState() async { + let manager = makeManager() + manager.setVerified(fingerprint: voucher, verified: true) + manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()) + manager.markVouchBatchSent(to: voucher, at: Date()) + #expect(manager.isVouched(fingerprint: vouchee)) + + manager.clearAllIdentityData() + + let wiped = await waitUntil { !manager.isVouched(fingerprint: vouchee) } + #expect(wiped) + #expect(manager.validVouchers(for: vouchee).isEmpty) + #expect(manager.lastVouchBatchSent(to: voucher) == nil) + #expect(manager.mostRecentlyVerifiedFingerprints(limit: 16, excluding: "").isEmpty) + } + + // MARK: - Persistence compatibility + + @Test + func trustLevelRawValuesAreStable() throws { + // Raw values are what's persisted; they must never change when cases + // are added mid-ladder. + #expect(TrustLevel.unknown.rawValue == "unknown") + #expect(TrustLevel.casual.rawValue == "casual") + #expect(TrustLevel.vouched.rawValue == "vouched") + #expect(TrustLevel.trusted.rawValue == "trusted") + #expect(TrustLevel.verified.rawValue == "verified") + + let legacy = Data(#"["unknown","casual","trusted","verified"]"#.utf8) + let decoded = try JSONDecoder().decode([TrustLevel].self, from: legacy) + #expect(decoded == [.unknown, .casual, .trusted, .verified]) + } + + @Test + func identityCachePersistedBeforeVouchingDecodesCleanly() throws { + // A cache captured before the vouch fields existed must decode without + // tripping the "unreadable cache" recovery path. + let legacyJSON = Data(""" + { + "socialIdentities": {}, + "nicknameIndex": {}, + "verifiedFingerprints": ["\(voucher)"], + "lastInteractions": {}, + "blockedNostrPubkeys": [], + "version": 1 + } + """.utf8) + + let decoded = try JSONDecoder().decode(IdentityCache.self, from: legacyJSON) + #expect(decoded.vouchesByVouchee == nil) + #expect(decoded.vouchBatchSentAt == nil) + #expect(decoded.verifiedAt == nil) + #expect(decoded.verifiedFingerprints == [voucher]) + } + + @Test + func identityCacheRoundTripsVouchState() throws { + var cache = IdentityCache() + cache.verifiedFingerprints = [voucher] + cache.vouchesByVouchee = [vouchee: [VouchRecord(voucherFingerprint: voucher, timestamp: Date(timeIntervalSince1970: 1_700_000_000))]] + cache.vouchBatchSentAt = [voucher: Date(timeIntervalSince1970: 1_700_000_001)] + cache.verifiedAt = [voucher: Date(timeIntervalSince1970: 1_700_000_002)] + + let decoded = try JSONDecoder().decode(IdentityCache.self, from: JSONEncoder().encode(cache)) + #expect(decoded.vouchesByVouchee == cache.vouchesByVouchee) + #expect(decoded.vouchBatchSentAt == cache.vouchBatchSentAt) + #expect(decoded.verifiedAt == cache.verifiedAt) + } + + @Test + func vouchStateSurvivesReload() async { + let keychain = MockKeychain() + let manager = SecureIdentityStateManager(keychain) + manager.setVerified(fingerprint: voucher, verified: true) + manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()) + let saved = await waitUntil { manager.isVouched(fingerprint: self.vouchee) } + #expect(saved) + manager.forceSave() + + let reloaded = SecureIdentityStateManager(keychain) + #expect(reloaded.isVouched(fingerprint: vouchee)) + #expect(reloaded.validVouchers(for: vouchee).count == 1) + } + + // MARK: - Helpers + + private func waitUntil( + timeout: TimeInterval = 1.0, + condition: @escaping () -> Bool + ) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { + return true + } + try? await Task.sleep(nanoseconds: 10_000_000) + } + return condition() + } +} diff --git a/bitchatTests/Services/UnifiedPeerServiceTests.swift b/bitchatTests/Services/UnifiedPeerServiceTests.swift index f5a5fd6c..172bf5b1 100644 --- a/bitchatTests/Services/UnifiedPeerServiceTests.swift +++ b/bitchatTests/Services/UnifiedPeerServiceTests.swift @@ -292,4 +292,37 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol { func getVerifiedFingerprints() -> Set { verified } + + // MARK: Vouching (unused by these tests) + + @discardableResult + func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool { + false + } + + func validVouchers(for fingerprint: String) -> [VouchRecord] { + [] + } + + func isVouched(fingerprint: String) -> Bool { + false + } + + func effectiveTrustLevel(for fingerprint: String) -> TrustLevel { + verified.contains(fingerprint) ? .verified : .unknown + } + + func lastVouchBatchSent(to fingerprint: String) -> Date? { + nil + } + + func markVouchBatchSent(to fingerprint: String, at date: Date) {} + + func signingPublicKey(forFingerprint fingerprint: String) -> Data? { + nil + } + + func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] { + [] + } } From d4f0c49787d274fc66744da57e11b78dbc3edc38 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:55:17 +0200 Subject: [PATCH 13/18] =?UTF-8?q?Gateway=20mode:=20opt-in=20mesh=E2=86=94N?= =?UTF-8?q?ostr=20uplink=20for=20geohash=20channels=20(#1384)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add capability bits to announce TLV Announces now carry an optional capabilities TLV (0x05): a little-endian bitfield with named bits for upcoming features (prekeys, wifiBulk, gateway, groups, board, vouch, meshDiagnostics). Old clients skip the unknown TLV; peers without it decode as nil so features can distinguish "legacy peer" from "advertises nothing". PeerCapabilities lives in BitFoundation with a minimal-length encoding that preserves unknown bits for forward compatibility. Peer capabilities are stored in the BLE peer registry on verified announce and exposed via BLEService.peerCapabilities(_:). The local advertisement set is empty until each feature ships its bit. Co-Authored-By: Claude Fable 5 * Gateway mode: opt-in mesh↔Nostr uplink for geohash channels An opt-in "internet gateway" toggle lets one connected phone bridge the local geohash channel for mesh-only peers: signed kind-20000 events ride a new nostrCarrier (0x28) packet — directed to the gateway for uplink, broadcast with TTL for downlink — with Schnorr verification at every hop, CourierStore-style quotas, and explicit loop-prevention rules. - BitFoundation: MessageType.nostrCarrier = 0x28 - NostrCarrierPacket: 2-byte-length TLV codec (direction, geohash, signed event JSON), 16 KiB cap, tolerant decoder - GatewayService: closure-injected policy layer — verify gates (sig, kind, #g tag, age, size), uplink quotas (10/min/depositor rate limit, offline queue of 20 total / 5 per depositor, drop-oldest, flush on reconnect), downlink budget (30/min, bounded drop-oldest backlog), bounded loop-prevention ID sets - BLEService: runtime capability bits (advertise .gateway only while the toggle is on, re-announce on change), signed directed uplink sends, carrier ingress with depositor signature verification - Mesh-only senders uplink automatically from sendGeohash when no relay is connected and a reachable peer advertises .gateway; once-per- channel "sent via mesh gateway" notice - UI: gateway toggle beside the Tor toggle, globe header indicator, VoiceOver labels, xcstrings entries Co-Authored-By: Claude Fable 5 * Gateway: harden downlink freshness, uplink verify ordering, and drain Fixes the confirmed downlink/uplink defects from the PR #1384 review + Codex findings: - Downlink age + #g gate (Codex P2 / review #1): rebroadcastRelayEvent now drops events outside the same freshness window receivers enforce and whose #g tag mismatches the carrier geohash, BEFORE spending any budget — so a 1h/200-event channel-resubscribe backfill no longer burns the 30/min BLE budget on events every receiver drops. - Rate-limit + dedup before Schnorr (review #2): handleUplinkDeposit now runs cheap structural checks + carried-ID dedup + rate-token consume before isValidSignature(), so a replay flood is bounded by cheap work instead of unbounded main-actor verifies. - Quota-dropped deposits not rendered (review #3): enqueueUplink reports acceptance and injectInbound only fires for events actually published/queued, ending the local-timeline divergence. - Drain timer + mark-after-send (Codex P2 / review #4): a burst beyond budget now arms a timer to drain when the window frees; rebroadcast IDs are marked only after an event is actually sent, so overflow- dropped events stay retryable. - Symmetric publish path (review #5): the gateway publish closure now refuses when no geo relay is known, matching the local send path instead of publishing dead traffic to default relays. - Loop-rule doc (review #7): softened to reflect that rule 3 is a call-site convention with unit-tested backstops; added tests for the publishedEventIDs backstop, downlink freshness/mismatch, drain timer, and quota-drop non-injection. Co-Authored-By: Claude Fable 5 * Gateway: stop self-echo of uplinked events onto the mesh Every event a gateway uplinks to the relays comes back through its own geohash subscription. `rebroadcastRelayEvent` deduped against `meshBroadcastEventIDs`, `rebroadcastEventIDs`, and `pendingDownlinks`, but not `publishedEventIDs` — so an event this gateway just published was downlink-rebroadcast onto the same mesh it originated from, doubling BLE airtime per uplinked message and able to starve the 30/min downlink budget on a busy channel (device-confirmed, filed on #1384). Fix: also skip the downlink rebroadcast when the event id is in `publishedEventIDs`. That set is already the bounded (drop-oldest, capacity maxTrackedEventIDs) loop-rule-2 uplink cache, populated only by `publish()`, so genuine inbound-from-internet events (never published here) still rebroadcast normally. Reconciles cleanly with the existing loop-prevention sets — no new state. Adds a GatewayServiceTests case asserting an uplinked event that echoes back via the subscription is not rebroadcast, while a genuine inbound event still is. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/App/LocationChannelsModel.swift | 16 +- bitchat/Localizable.xcstrings | 60 ++ bitchat/Protocols/NostrCarrierPacket.swift | 136 +++++ .../BLE/BLEOutboundPacketPolicy.swift | 2 +- bitchat/Services/BLE/BLEPeerRegistry.swift | 5 + bitchat/Services/BLE/BLEReceivePipeline.swift | 5 +- bitchat/Services/BLE/BLEService.swift | 119 +++- bitchat/Services/Gateway/GatewayService.swift | 492 +++++++++++++++ bitchat/Sync/SyncTypeFlags.swift | 4 + .../ChatViewModelBootstrapper.swift | 67 ++ .../GeohashSubscriptionManager.swift | 26 + bitchat/Views/ContentHeaderView.swift | 13 + bitchat/Views/LocationChannelsSheet.swift | 30 + .../Protocols/NostrCarrierPacketTests.swift | 96 +++ .../Services/GatewayServiceTests.swift | 575 ++++++++++++++++++ .../Sources/BitFoundation/MessageType.swift | 5 + 16 files changed, 1644 insertions(+), 7 deletions(-) create mode 100644 bitchat/Protocols/NostrCarrierPacket.swift create mode 100644 bitchat/Services/Gateway/GatewayService.swift create mode 100644 bitchatTests/Protocols/NostrCarrierPacketTests.swift create mode 100644 bitchatTests/Services/GatewayServiceTests.swift diff --git a/bitchat/App/LocationChannelsModel.swift b/bitchat/App/LocationChannelsModel.swift index c820455a..d7431d6d 100644 --- a/bitchat/App/LocationChannelsModel.swift +++ b/bitchat/App/LocationChannelsModel.swift @@ -12,20 +12,26 @@ final class LocationChannelsModel: ObservableObject { @Published private(set) var bookmarkNames: [String: String] @Published private(set) var locationNames: [GeohashChannelLevel: String] @Published private(set) var userTorEnabled: Bool + @Published private(set) var gatewayEnabled: Bool private let manager: LocationChannelManager private let network: NetworkActivationService + private let gateway: GatewayService private var cancellables = Set() init( manager: LocationChannelManager? = nil, - network: NetworkActivationService? = nil + network: NetworkActivationService? = nil, + gateway: GatewayService? = nil ) { let manager = manager ?? .shared let network = network ?? .shared + let gateway = gateway ?? .shared self.manager = manager self.network = network + self.gateway = gateway + self.gatewayEnabled = gateway.isEnabled self.permissionState = manager.permissionState self.availableChannels = manager.availableChannels self.selectedChannel = manager.selectedChannel @@ -96,6 +102,10 @@ final class LocationChannelsModel: ObservableObject { network.setUserTorEnabled(enabled) } + func setGatewayEnabled(_ enabled: Bool) { + gateway.setEnabled(enabled) + } + func refreshMeshChannelsIfNeeded() { guard case .mesh = selectedChannel, permissionState == .authorized, @@ -160,6 +170,10 @@ final class LocationChannelsModel: ObservableObject { network.$userTorEnabled .receive(on: DispatchQueue.main) .assign(to: &$userTorEnabled) + + gateway.$isEnabled + .receive(on: DispatchQueue.main) + .assign(to: &$gatewayEnabled) } private func level(forLength length: Int) -> GeohashChannelLevel { diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 5b91da12..bed148dd 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -9348,6 +9348,18 @@ } } }, + "content.accessibility.gateway_active" : { + "comment" : "Accessibility label for the internet gateway indicator", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Internet gateway active, sharing your connection with the mesh" + } + } + } + }, "content.accessibility.jump_to_latest" : { "comment" : "Accessibility label for the jump to latest messages button", "extractionState" : "manual", @@ -17884,6 +17896,18 @@ } } }, + "content.header.gateway_active" : { + "comment" : "Tooltip for the internet gateway indicator", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sharing your internet connection with nearby mesh peers" + } + } + } + }, "content.header.people" : { "extractionState" : "manual", "localizations" : { @@ -25819,6 +25843,30 @@ } } }, + "location_channels.gateway.subtitle" : { + "comment" : "Explanation under the internet gateway toggle", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "share your internet with nearby mesh peers so their geohash messages reach the network" + } + } + } + }, + "location_channels.gateway.title" : { + "comment" : "Title for the internet gateway toggle in the location channels sheet", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "internet gateway" + } + } + } + }, "location_channels.loading_nearby" : { "extractionState" : "manual", "localizations" : { @@ -33665,6 +33713,18 @@ } } }, + "system.gateway.sent_via_mesh" : { + "comment" : "System message when a geohash message was handed to a mesh internet gateway because no relay is reachable", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "sent via mesh gateway" + } + } + } + }, "system.geohash.blocked" : { "extractionState" : "manual", "localizations" : { diff --git a/bitchat/Protocols/NostrCarrierPacket.swift b/bitchat/Protocols/NostrCarrierPacket.swift new file mode 100644 index 00000000..e65f2b66 --- /dev/null +++ b/bitchat/Protocols/NostrCarrierPacket.swift @@ -0,0 +1,136 @@ +// +// NostrCarrierPacket.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import Foundation + +/// Wire payload for `MessageType.nostrCarrier` (0x28): a complete, signed +/// Nostr event ferried over the mesh between a mesh-only peer and an +/// internet gateway peer. +/// +/// - `toGateway` rides a DIRECTED packet (recipientID = the gateway peer): +/// a mesh-only sender asks the gateway to publish its locally signed +/// geohash event to Nostr relays. +/// - `fromGateway` rides a BROADCAST packet (default TTL): the gateway +/// rebroadcasts inbound relay events so mesh-only peers see the channel. +/// +/// The carried event is public geohash chat — already plaintext on Nostr — +/// so the carrier adds no encryption. It IS signed by the originator's +/// per-geohash identity, so neither the gateway nor any mesh relay can forge +/// or alter it undetected: gateways and receivers verify the Schnorr +/// signature before acting on it. +/// +/// TLV encoding with 2-byte big-endian lengths (the event JSON exceeds the +/// 1-byte TLV range used by smaller packets). Unknown TLV types are skipped +/// for forward compatibility. +struct NostrCarrierPacket: Equatable { + enum Direction: UInt8 { + case toGateway = 0x01 + case fromGateway = 0x02 + } + + let direction: Direction + let geohash: String + /// Complete signed Nostr event JSON (id, pubkey, created_at, kind, tags, + /// content, sig). + let eventJSON: Data + + /// BLE airtime cap for a carried event. + static let maxEventJSONBytes = 16 * 1024 + static let maxGeohashLength = 12 + + private enum TLVType: UInt8 { + case direction = 0x01 + case geohash = 0x02 + case eventJSON = 0x03 + } + + init?(direction: Direction, geohash: String, eventJSON: Data) { + let geohashBytes = Data(geohash.utf8) + guard !geohashBytes.isEmpty, + geohashBytes.count <= Self.maxGeohashLength, + !eventJSON.isEmpty, + eventJSON.count <= Self.maxEventJSONBytes else { + return nil + } + self.direction = direction + self.geohash = geohash + self.eventJSON = eventJSON + } + + init?(direction: Direction, geohash: String, event: NostrEvent) { + guard let json = try? event.jsonString(), !json.isEmpty else { return nil } + self.init(direction: direction, geohash: geohash, eventJSON: Data(json.utf8)) + } + + /// Decodes the carried event. Callers MUST still verify + /// `event.isValidSignature()` before publishing or displaying it. + func event() -> NostrEvent? { + guard let dict = try? JSONSerialization.jsonObject(with: eventJSON) as? [String: Any] else { + return nil + } + return try? NostrEvent(from: dict) + } + + func encode() -> Data? { + var data = Data() + data.reserveCapacity(eventJSON.count + geohash.utf8.count + 12) + + func appendTLV(_ type: TLVType, _ value: Data) { + data.append(type.rawValue) + data.append(UInt8((value.count >> 8) & 0xFF)) + data.append(UInt8(value.count & 0xFF)) + data.append(value) + } + + appendTLV(.direction, Data([direction.rawValue])) + appendTLV(.geohash, Data(geohash.utf8)) + appendTLV(.eventJSON, eventJSON) + return data + } + + static func decode(_ data: Data) -> NostrCarrierPacket? { + // Defensive slice re-base (Data slices keep parent indices). + let data = Data(data) + var offset = 0 + var direction: Direction? + var geohash: String? + var eventJSON: Data? + + while offset + 3 <= data.count { + let typeRaw = data[offset] + let length = (Int(data[offset + 1]) << 8) | Int(data[offset + 2]) + offset += 3 + guard offset + length <= data.count else { return nil } + let value = data.subdata(in: offset.. [PeerID] { + peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID) + } + func displayNicknames(selfNickname: String) -> [PeerID: String] { let connected = peers.filter { $0.value.isConnected } let tuples = connected.map { ($0.key, $0.value.nickname, true) } diff --git a/bitchat/Services/BLE/BLEReceivePipeline.swift b/bitchat/Services/BLE/BLEReceivePipeline.swift index 02fe152a..691bb3e2 100644 --- a/bitchat/Services/BLE/BLEReceivePipeline.swift +++ b/bitchat/Services/BLE/BLEReceivePipeline.swift @@ -51,8 +51,11 @@ struct BLEReceivePipeline { // Courier envelopes are directed opaque ciphertext like DMs; a // remote handover toward a relayed announce rides this same // deterministic relay treatment instead of the broadcast clamp. + // Directed nostrCarrier uplinks (mesh-only peer -> gateway) need + // the same multi-hop treatment to reach a non-adjacent gateway. isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue - || packet.type == MessageType.courierEnvelope.rawValue) && packet.recipientID != nil, + || packet.type == MessageType.courierEnvelope.rawValue + || packet.type == MessageType.nostrCarrier.rawValue) && packet.recipientID != nil, isFragment: packet.type == MessageType.fragment.rawValue, isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil, isHandshake: packet.type == MessageType.noiseHandshake.rawValue, diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index d4109199..3c1538d3 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -62,6 +62,14 @@ final class BLEService: NSObject { // Local-only store-and-forward counters; nil in unit tests. var sfMetrics: StoreAndForwardMetrics? + // Gateway mode: sink for received nostrCarrier packets (set by app + // wiring, called on the main actor after transport-level checks) and the + // runtime-toggled capability bits ORed into `PeerCapabilities.localSupported` + // for every announce. `directedToUs` distinguishes an uplink deposit + // addressed to this device from a downlink broadcast. + var onNostrCarrierPacket: (@MainActor (_ payload: Data, _ from: PeerID, _ directedToUs: Bool) -> Void)? + private var runtimeCapabilities: PeerCapabilities = [] // collectionsQueue + #if DEBUG // Test-only tap on the outbound pipeline so multi-node tests can ferry // packets between in-process service instances. @@ -638,6 +646,32 @@ final class BLEService: NSObject { collectionsQueue.sync { peerRegistry.capabilities(for: peerID) } } + /// Enables or disables a runtime-advertised capability bit (e.g. the + /// internet-gateway toggle) and re-announces so peers learn promptly. + /// Build-time bits stay in `PeerCapabilities.localSupported`. + func setLocalCapability(_ capability: PeerCapabilities, enabled: Bool) { + let changed: Bool = collectionsQueue.sync(flags: .barrier) { + let before = runtimeCapabilities + if enabled { + runtimeCapabilities.insert(capability) + } else { + runtimeCapabilities.remove(capability) + } + return runtimeCapabilities != before + } + guard changed else { return } + sendAnnounce(forceSend: true) + } + + /// Reachable peers currently advertising the `.gateway` capability. + func reachableGatewayPeers() -> [PeerID] { + let now = Date() + return collectionsQueue.sync { + peerRegistry.peers(advertising: .gateway) + .filter { peerRegistry.isReachable($0, now: now) } + } + } + func getPeerNicknames() -> [PeerID: String] { return collectionsQueue.sync { peerRegistry.displayNicknames(selfNickname: myNickname) @@ -1272,16 +1306,16 @@ final class BLEService: NSObject { let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification let signingPub = noiseService.getSigningPublicKeyData() // For signature verification - let connectedPeerIDs: [Data] = collectionsQueue.sync { - peerRegistry.connectedRoutingData + let (connectedPeerIDs, advertisedCapabilities): ([Data], PeerCapabilities) = collectionsQueue.sync { + (peerRegistry.connectedRoutingData, PeerCapabilities.localSupported.union(runtimeCapabilities)) } - + let announcement = AnnouncementPacket( nickname: myNickname, noisePublicKey: noisePub, signingPublicKey: signingPub, directNeighbors: connectedPeerIDs, - capabilities: PeerCapabilities.localSupported + capabilities: advertisedCapabilities ) guard let payload = announcement.encode() else { @@ -2762,6 +2796,81 @@ extension BLEService { } } + // MARK: Gateway carrier (nostrCarrier) + + /// Sign and send an encoded `toGateway` carrier payload directed at a + /// gateway peer. The packet is signed so the gateway can key its uplink + /// quotas to an authenticated depositor; the carried Nostr event has its + /// own Schnorr signature for content authenticity. Returns false when + /// the gateway is not reachable or signing fails. + func sendNostrCarrier(_ payload: Data, to gatewayPeer: PeerID) -> Bool { + guard isPeerReachable(gatewayPeer) else { return false } + let packet = BitchatPacket( + type: MessageType.nostrCarrier.rawValue, + senderID: myPeerIDData, + recipientID: Data(hexString: gatewayPeer.id), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: messageTTL + ) + guard let signed = noiseService.signPacket(packet) else { return false } + messageQueue.async { [weak self] in + // broadcastPacket applies a known route when one exists and + // otherwise floods the directed packet like a DM, so a gateway + // that is reachable but multi-hop still gets the deposit. + self?.broadcastPacket(signed) + } + return true + } + + /// Broadcast an encoded `fromGateway` carrier payload on the mesh with + /// the default TTL. Unsigned at the packet layer — receivers verify the + /// carried event's own Schnorr signature. + func broadcastNostrCarrier(_ payload: Data) { + let packet = BitchatPacket( + type: MessageType.nostrCarrier.rawValue, + senderID: myPeerIDData, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: messageTTL + ) + messageQueue.async { [weak self] in + self?.broadcastPacket(packet) + } + } + + /// Transport-level handling for a received nostrCarrier packet; policy + /// (verification of the carried event, quotas, loop prevention) lives in + /// `GatewayService` behind `onNostrCarrierPacket`. + private func handleNostrCarrier(_ packet: BitchatPacket, from peerID: PeerID) { + let senderID = PeerID(hexData: packet.senderID) + let directedToUs: Bool + if let recipientID = packet.recipientID { + // Carriers addressed elsewhere ride the generic relay path untouched. + guard recipientID == myPeerIDData else { return } + // Uplink deposit: quotas are keyed by the depositor, so the + // packet signature must verify against the sender's announced + // signing key. Unlike courier deposits the depositor may be + // multi-hop away, so ingress-link identity is not required. + let signingKey = collectionsQueue.sync { peerRegistry.info(for: senderID)?.signingPublicKey } + guard let signingKey, + noiseService.verifyPacketSignature(packet, publicKey: signingKey) else { + SecureLogger.debug("🌐 nostrCarrier uplink from \(senderID.id.prefix(8))… rejected (missing/invalid packet signature)", category: .security) + return + } + directedToUs = true + } else { + directedToUs = false + } + let payload = packet.payload + notifyUI { [weak self] in + self?.onNostrCarrierPacket?(payload, senderID, directedToUs) + } + } + // MARK: Link capability snapshots (thread-safe via bleQueue) private func readLinkState(_ body: (BLELinkStateStore) -> T) -> T { @@ -3269,6 +3378,8 @@ extension BLEService { case .boardPost: // Invalid or deleted posts must not spread; skip the relay step. guard handleBoardPost(packet, from: senderID) else { return } + case .nostrCarrier: + handleNostrCarrier(packet, from: peerID) case .leave: handleLeave(packet, from: senderID) diff --git a/bitchat/Services/Gateway/GatewayService.swift b/bitchat/Services/Gateway/GatewayService.swift new file mode 100644 index 00000000..c8d13756 --- /dev/null +++ b/bitchat/Services/Gateway/GatewayService.swift @@ -0,0 +1,492 @@ +// +// GatewayService.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import BitLogger +import Combine +import Foundation + +/// Policy engine for gateway mode: an opt-in "share my internet with the +/// mesh" bridge. While the toggle is on, this device advertises the +/// `.gateway` capability bit, publishes signed geohash events deposited by +/// mesh-only peers to Nostr relays (uplink), and rebroadcasts inbound relay +/// events onto the mesh (downlink) so mesh-only peers can take part in the +/// local geohash channel. Mesh-only peers need no toggle: their uplink +/// engages automatically when relays are unreachable and a gateway peer +/// exists. +/// +/// Threat model: +/// - Keys never leave the originating device. Mesh-only senders sign events +/// locally with their per-geohash ephemeral identity; the gateway carries +/// only the finished, signed event. +/// - The gateway cannot forge or alter events: every carried event is +/// Schnorr-verified here before it is published or rebroadcast, and again +/// independently by relays and receivers. +/// - Carried contents are public geohash chat, already plaintext on Nostr, +/// so the mesh carrier adds no confidentiality loss. +/// +/// Loop-prevention rules: +/// 1. An event learned from a `fromGateway` mesh broadcast is never +/// re-published to relays, never re-uplinked, and never rebroadcast +/// (`meshBroadcastEventIDs`), so a second gateway on the same mesh cannot +/// echo mesh-carried traffic back out. Mesh-level propagation of the +/// original broadcast packet is the TTL relay's job, not ours. +/// 2. An uplink deposit is published at most once (`publishedEventIDs`) and +/// a relay event is rebroadcast at most once (`rebroadcastEventIDs`), so +/// repeat deposits and relay echoes are absorbed. An event this gateway +/// itself uplinked (`publishedEventIDs`) is additionally never +/// downlink-rebroadcast: it originated on this mesh, so echoing it back +/// when our own relay subscription redelivers it would double BLE airtime +/// (the device-confirmed self-echo bug). +/// 3. Uplink is only attempted for locally composed events at the send site +/// (`GeohashSubscriptionManager.sendGeohash`); events received over the +/// carrier never re-enter the uplink path. This is a call-site convention; +/// the `meshBroadcastEventIDs`/`publishedEventIDs` backstops in +/// `uplinkViaMesh` enforce it defensively and are unit-tested. +/// Rules 1 and 2 are enforced here and unit-tested. +/// Rebroadcast storms at the mesh layer are additionally bounded by the BLE +/// `MessageDeduplicator` and packet TTL, and receivers dedup carried events +/// against their own relay subscriptions via the Nostr event-ID cache in +/// `NostrInboundPipeline`. +/// +/// All dependencies are closure-injected (repo convention) so the policy +/// layer is unit-testable without relays or radios. +@MainActor +final class GatewayService: ObservableObject { + enum Limits { + /// Uplink deposits held while relays are unreachable (CourierStore-style + /// bounded mailbag: bounded total, bounded per depositor). + static let maxQueuedUplinks = 20 + static let maxQueuedUplinksPerDepositor = 5 + /// Uplink deposits accepted per depositor per minute. + static let uplinkEventsPerMinutePerDepositor = 10 + /// Downlink mesh rebroadcasts per minute — BLE airtime is precious. + /// Beyond the budget events queue (bounded, drop-oldest) and drain on + /// a scheduled timer once the window frees (also re-driven by the next + /// inbound relay event); a quiet channel does not strand its backlog. + static let downlinkEventsPerMinute = 30 + static let maxPendingDownlinks = 30 + /// Accepted clock skew for a carried ephemeral event; anything older + /// is stale replay the relays would drop anyway. + static let maxEventAgeSeconds: TimeInterval = 15 * 60 + /// Bounded loop-prevention ID caches (oldest evicted). + static let maxTrackedEventIDs = 512 + } + + struct QueuedUplink { + let depositor: PeerID + let geohash: String + let event: NostrEvent + let queuedAt: Date + } + + static let shared = GatewayService() + + /// The user toggle. While true this device advertises `.gateway` and + /// bridges mesh <-> Nostr for geohash channels. + @Published private(set) var isEnabled: Bool + + // MARK: Wiring (set once by the bootstrapper; fakes in tests) + + /// Publishes a verified event to the geo relays for a geohash. + var publishToRelays: (@MainActor (NostrEvent, String) -> Void)? + /// Broadcasts an encoded `fromGateway` carrier payload on the mesh. + var broadcastToMesh: (@MainActor (Data) -> Void)? + /// Sends an encoded `toGateway` carrier payload directed to a gateway + /// peer. Returns false when the transport could not accept it. + var sendToGatewayPeer: (@MainActor (Data, PeerID) -> Bool)? + /// Reachable mesh peers currently advertising the `.gateway` capability. + var availableGatewayPeers: (@MainActor () -> [PeerID])? + /// Whether any Nostr relay connection is currently working. + var relaysConnected: (@MainActor () -> Bool)? + /// The geohash channel the local user is viewing, if any. + var currentGeohash: (@MainActor () -> String?)? + /// Injects a verified carried event into the same inbound pipeline as + /// relay-received events (blocking, rate limits, dedup, rendering). + var injectInbound: (@MainActor (NostrEvent) -> Void)? + /// Fired on toggle changes (advertise/withdraw the capability bit and + /// force a re-announce). + var onEnabledChanged: (@MainActor (Bool) -> Void)? + /// Schedules a downlink-drain closure to run after a delay. Injected so + /// the drain timer is deterministic in tests; nil arms a real `Task`. + var scheduleDrainTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)? + + // MARK: State + + /// Loop rule 1: event IDs seen in `fromGateway` mesh broadcasts. + private var meshBroadcastEventIDs: BoundedIDSet + /// Loop rule 2 (uplink): event IDs this gateway already published. + private var publishedEventIDs: BoundedIDSet + /// Loop rule 2 (downlink): event IDs this gateway already rebroadcast. + private var rebroadcastEventIDs: BoundedIDSet + + private(set) var queuedUplinks: [QueuedUplink] = [] + private var uplinkDepositTimes: [PeerID: [Date]] = [:] + private var downlinkSendTimes: [Date] = [] + private var pendingDownlinks: [(event: NostrEvent, geohash: String)] = [] + /// True while a drain timer is armed, so a burst schedules at most one. + private var downlinkDrainScheduled = false + + private let defaults: UserDefaults + private let now: () -> Date + private static let enabledKey = "gateway.userEnabled" + + init(defaults: UserDefaults = .standard, now: @escaping () -> Date = Date.init) { + self.defaults = defaults + self.now = now + self.isEnabled = defaults.bool(forKey: Self.enabledKey) + self.meshBroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) + self.publishedEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) + self.rebroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) + } + + // MARK: - Toggle + + func setEnabled(_ enabled: Bool) { + guard enabled != isEnabled else { return } + isEnabled = enabled + defaults.set(enabled, forKey: Self.enabledKey) + if !enabled { + queuedUplinks.removeAll() + pendingDownlinks.removeAll() + uplinkDepositTimes.removeAll() + } + SecureLogger.info("🌐 Gateway mode \(enabled ? "enabled" : "disabled")", category: .session) + onEnabledChanged?(enabled) + } + + // MARK: - Mesh carrier ingress (both roles) + + /// Entry point for received `nostrCarrier` packets. `directedToUs` is + /// true for packets addressed to this device (uplink deposits); false + /// for broadcasts (downlink rebroadcasts from a gateway). + func handleMeshCarrier(_ payload: Data, from peerID: PeerID, directedToUs: Bool) { + guard let carrier = NostrCarrierPacket.decode(payload) else { + SecureLogger.debug("🌐 Gateway: dropping undecodable carrier from \(peerID.id.prefix(8))…", category: .session) + return + } + switch carrier.direction { + case .toGateway: + // Uplink deposits are directed; a broadcast toGateway is malformed. + guard directedToUs else { return } + handleUplinkDeposit(carrier, from: peerID) + case .fromGateway: + // Downlink rides broadcast only; a directed fromGateway is malformed. + guard !directedToUs else { return } + handleDownlinkBroadcast(carrier) + } + } + + // MARK: - Uplink (gateway role: mesh peer -> internet) + + private func handleUplinkDeposit(_ carrier: NostrCarrierPacket, from depositor: PeerID) { + guard isEnabled else { return } + // Cheap structural checks first (parse, size, geohash, kind, #g tag, + // age) — no crypto — so junk and stale replays are dropped before we + // ever pay for a MainActor Schnorr verify. + guard let event = structurallyValidEvent(from: carrier) else { + SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8))… (failed validation)", category: .security) + return + } + // Dedup by the carried event ID BEFORE verification. Loop rule 1: a + // fromGateway-learned event is mesh-carried and must never be + // re-published. Loop rule 2: repeat deposits of an already handled + // event are absorbed. A replay of one valid deposit is short-circuited + // here without a per-packet signature verify. + guard !meshBroadcastEventIDs.contains(event.id), + !publishedEventIDs.contains(event.id), + !queuedUplinks.contains(where: { $0.event.id == event.id }) else { + return + } + // Consume the per-depositor rate token BEFORE the expensive verify so + // a flood of distinct forged/junk deposits is bounded by cheap work, + // not by main-actor Schnorr verifications. + guard allowUplinkDeposit(from: depositor) else { + SecureLogger.debug("🌐 Gateway: rate-limited uplink deposit from \(depositor.id.prefix(8))…", category: .session) + return + } + // Only now pay for cryptographic verification; receivers verify again. + guard event.isValidSignature() else { + SecureLogger.debug("🌐 Gateway: rejected uplink deposit from \(depositor.id.prefix(8))… (bad signature)", category: .security) + return + } + + let accepted: Bool + if relaysConnected?() ?? false { + publish(event, geohash: carrier.geohash) + accepted = true + } else { + accepted = enqueueUplink(QueuedUplink(depositor: depositor, geohash: carrier.geohash, event: event, queuedAt: now())) + } + + // Only render on our own timeline what we actually accepted for + // publish or queue: a quota-dropped deposit is never published and, + // being directed, no other peer will ever see it, so showing it would + // diverge our timeline permanently from what reached the channel. + if accepted, currentGeohash?() == carrier.geohash { + injectInbound?(event) + } + } + + /// Publish everything queued while relays were unreachable. Called when + /// relay connectivity comes back. + func flushQueuedUplinks() { + guard isEnabled, relaysConnected?() ?? false, !queuedUplinks.isEmpty else { return } + let queued = queuedUplinks + queuedUplinks.removeAll() + for item in queued where !publishedEventIDs.contains(item.event.id) { + publish(item.event, geohash: item.geohash) + } + } + + private func publish(_ event: NostrEvent, geohash: String) { + publishedEventIDs.insert(event.id) + publishToRelays?(event, geohash) + SecureLogger.info("🌐 Gateway: published carried event \(event.id.prefix(8))… to relays for #\(geohash)", category: .session) + } + + /// Returns true when the item was actually stored for later publish. + @discardableResult + private func enqueueUplink(_ item: QueuedUplink) -> Bool { + let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count + guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else { + SecureLogger.debug("🌐 Gateway: uplink queue quota reached for \(item.depositor.id.prefix(8))…", category: .session) + return false + } + if queuedUplinks.count >= Limits.maxQueuedUplinks { + queuedUplinks.removeFirst(queuedUplinks.count - Limits.maxQueuedUplinks + 1) + } + queuedUplinks.append(item) + return true + } + + private func allowUplinkDeposit(from depositor: PeerID) -> Bool { + let cutoff = now().addingTimeInterval(-60) + var times = uplinkDepositTimes[depositor, default: []] + times.removeAll { $0 < cutoff } + guard times.count < Limits.uplinkEventsPerMinutePerDepositor else { + uplinkDepositTimes[depositor] = times + return false + } + times.append(now()) + uplinkDepositTimes[depositor] = times + // Bound the tracker itself against a churn of spoofed depositors. + if uplinkDepositTimes.count > Limits.maxTrackedEventIDs { + uplinkDepositTimes = uplinkDepositTimes.filter { !$0.value.isEmpty && $0.value.contains { $0 >= cutoff } } + } + return true + } + + // MARK: - Downlink (gateway role: internet -> mesh) + + /// Called for every event the gateway's own geohash-channel subscription + /// delivers. Wraps it in a `fromGateway` carrier and broadcasts it on + /// the mesh, within the airtime budget. + func rebroadcastRelayEvent(_ event: NostrEvent, geohash: String) { + guard isEnabled, broadcastToMesh != nil else { return } + guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } + // Freshness + geohash gate BEFORE spending any budget. A channel + // (re)subscribe backfills up to an hour of history (limit 200), but + // every receiver's `validatedEvent` drops anything older than the + // same window — so rebroadcasting backfill would burn the whole + // per-minute budget on events no mesh peer accepts. Also require the + // event's own `#g` tag to match the carrier geohash. + guard isFresh(event), + event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == geohash }) else { + return + } + // Loop rule 1: never rebroadcast mesh-carried events back onto the + // mesh. Loop rule 2 (self-echo): never rebroadcast an event this + // gateway itself uplinked (`publishedEventIDs`) — it originated on this + // very mesh, so our own relay subscription echoing it back must not + // double the BLE airtime by pushing it out again. Loop rule 2 + // (downlink): rebroadcast each genuine inbound relay event at most once + // — but mark only AFTER it is actually sent (in `drainPendingDownlinks`), + // so an event dropped by the queue overflow stays retryable on relay + // redelivery. Guard against a redelivery re-queueing an event that is + // still waiting to be sent. + guard !meshBroadcastEventIDs.contains(event.id), + !publishedEventIDs.contains(event.id), + !rebroadcastEventIDs.contains(event.id), + !pendingDownlinks.contains(where: { $0.event.id == event.id }) else { + return + } + // Verify before spending BLE airtime; receivers verify again. + guard event.isValidSignature() else { return } + + pendingDownlinks.append((event, geohash)) + if pendingDownlinks.count > Limits.maxPendingDownlinks { + // Bandwidth guard: drop-oldest — fresher chat is worth more. The + // dropped event is not yet in `rebroadcastEventIDs`, so a later + // relay redelivery can still carry it. + pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks) + } + drainPendingDownlinks() + } + + private func drainPendingDownlinks() { + let cutoff = now().addingTimeInterval(-60) + downlinkSendTimes.removeAll { $0 < cutoff } + while !pendingDownlinks.isEmpty, + downlinkSendTimes.count < Limits.downlinkEventsPerMinute { + let (event, geohash) = pendingDownlinks.removeFirst() + // A queued event may have aged past the window while it waited; + // don't burn airtime on what receivers would now drop. + guard isFresh(event) else { continue } + guard let carrier = NostrCarrierPacket(direction: .fromGateway, geohash: geohash, event: event), + let payload = carrier.encode() else { continue } + broadcastToMesh?(payload) + // Mark-after-send: only now is the relay event definitively + // rebroadcast (loop rule 2). + rebroadcastEventIDs.insert(event.id) + downlinkSendTimes.append(now()) + } + // Budget exhausted with events still queued: arm a timer to drain when + // the window frees, instead of stranding them until the next inbound + // relay event (which may never come on a channel that went quiet). + scheduleDownlinkDrainIfNeeded() + } + + /// Arms a single timer to drain the backlog once the per-minute window + /// frees. No-op when nothing is pending or a drain is already scheduled. + private func scheduleDownlinkDrainIfNeeded() { + guard !pendingDownlinks.isEmpty, !downlinkDrainScheduled else { return } + // The window frees when the oldest recorded send ages out of 60s. + let oldest = downlinkSendTimes.min() ?? now() + let delay = max(0.05, 60 - now().timeIntervalSince(oldest)) + downlinkDrainScheduled = true + let fire: @MainActor () -> Void = { [weak self] in + guard let self else { return } + self.downlinkDrainScheduled = false + self.drainPendingDownlinks() + } + if let scheduleDrainTimer { + scheduleDrainTimer(delay, fire) + } else { + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + fire() + } + } + } + + // MARK: - Downlink (receiver role: carried event arrives over mesh) + + private func handleDownlinkBroadcast(_ carrier: NostrCarrierPacket) { + guard let event = validatedEvent(from: carrier) else { return } + // Mark only AFTER signature verification, so a forged copy carrying a + // real event's ID cannot poison the never-republish set, and use the + // marking as dedup: the same broadcast relayed along several mesh + // paths injects once (the pipeline's Nostr event-ID cache additionally + // dedups against our own relay subscription). + guard meshBroadcastEventIDs.insert(event.id) else { return } + // Only inject events for the channel we're viewing; the inbound + // pipeline files public messages under the current geohash. + guard currentGeohash?() == carrier.geohash else { return } + injectInbound?(event) + } + + // MARK: - Uplink (sender role: mesh-only peer with no relays) + + /// Hands a locally signed event to a mesh gateway peer when we have no + /// working relay connection. Returns true when the event was sent. + /// + /// v1 is deliberately fire-and-forget: no gateway ack. The event also + /// stays in `NostrRelayManager`'s own pending queue, so if our internet + /// comes back the relays dedup the duplicate publish by event ID. + /// + /// Loop rule 3: call sites only pass freshly composed events (see + /// `GeohashSubscriptionManager.sendGeohash`); received carrier events + /// never reach this path, and the mesh-carried guard below backstops it. + func uplinkViaMesh(event: NostrEvent, geohash: String) -> Bool { + if relaysConnected?() ?? true { return false } + guard !meshBroadcastEventIDs.contains(event.id), + !publishedEventIDs.contains(event.id) else { + return false + } + // A single gateway is enough — relays fan out from there, and BLE + // airtime is precious. + guard let gateway = availableGatewayPeers?().first else { return false } + guard let carrier = NostrCarrierPacket(direction: .toGateway, geohash: geohash, event: event), + let payload = carrier.encode() else { + return false + } + guard sendToGatewayPeer?(payload, gateway) ?? false else { return false } + SecureLogger.info("🌐 Gateway: uplinked event \(event.id.prefix(8))… for #\(geohash) via mesh gateway \(gateway.id.prefix(8))…", category: .session) + return true + } + + // MARK: - Validation + + /// Structural and cryptographic checks every carried event must pass + /// before a gateway publishes it or a receiver displays it. Ordered + /// cheap-first; Schnorr verification runs last. + private func validatedEvent(from carrier: NostrCarrierPacket) -> NostrEvent? { + guard let event = structurallyValidEvent(from: carrier), + event.isValidSignature() else { + return nil + } + return event + } + + /// The cheap half of `validatedEvent`: parse + size + geohash + kind + + /// `#g` tag + freshness, with NO signature verification. Callers that can + /// dedup or rate-limit on the carried ID run this first so the expensive + /// Schnorr verify is reached only for events that survive the cheap gates. + private func structurallyValidEvent(from carrier: NostrCarrierPacket) -> NostrEvent? { + guard carrier.eventJSON.count <= NostrCarrierPacket.maxEventJSONBytes, + Self.isValidGeohash(carrier.geohash), + let event = carrier.event(), + event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue, + event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == carrier.geohash }), + isFresh(event) else { + return nil + } + return event + } + + /// True when `event.created_at` is within the accepted clock skew — the + /// SAME freshness window receivers enforce, so a gateway never spends + /// airtime on events every receiver would drop as stale. + private func isFresh(_ event: NostrEvent) -> Bool { + abs(now().timeIntervalSince1970 - TimeInterval(event.created_at)) <= Limits.maxEventAgeSeconds + } + + static func isValidGeohash(_ geohash: String) -> Bool { + let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") + return (1...NostrCarrierPacket.maxGeohashLength).contains(geohash.count) + && geohash.allSatisfy { allowed.contains($0) } + } +} + +/// Insertion-ordered string set with a fixed capacity; the oldest entry is +/// evicted when full. +private struct BoundedIDSet { + private var members: Set = [] + private var order: [String] = [] + let capacity: Int + + init(capacity: Int) { + self.capacity = capacity + } + + func contains(_ id: String) -> Bool { + members.contains(id) + } + + /// Returns false when the ID was already present. + @discardableResult + mutating func insert(_ id: String) -> Bool { + guard members.insert(id).inserted else { return false } + order.append(id) + if order.count > capacity { + members.remove(order.removeFirst()) + } + return true + } +} diff --git a/bitchat/Sync/SyncTypeFlags.swift b/bitchat/Sync/SyncTypeFlags.swift index f8796e4b..eb082c31 100644 --- a/bitchat/Sync/SyncTypeFlags.swift +++ b/bitchat/Sync/SyncTypeFlags.swift @@ -40,6 +40,10 @@ struct SyncTypeFlags: OptionSet { // Courier envelopes are directed deposits between trusted peers and // must never spread via gossip sync. case .courierEnvelope: return nil + // Gateway carriers are ephemeral live traffic (uplinks are directed, + // downlinks are rate-budgeted rebroadcasts); replaying them via sync + // would waste airtime and extend their lifetime. + case .nostrCarrier: return nil } } diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index 35bc0ef4..caca90ec 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -72,6 +72,7 @@ final class ChatViewModelBootstrapper { configureNoiseCallbacks() bindTransferProgress() configureGeoChannels() + configureGateway() bindTeleportState() requestNotifications() registerObservers() @@ -244,6 +245,72 @@ private extension ChatViewModelBootstrapper { ) } + /// Wires the gateway-mode policy layer (`GatewayService`) to the mesh + /// transport, the relay manager, and the inbound Nostr pipeline. All + /// dependencies are closures so the service stays unit-testable with + /// fakes. + func configureGateway() { + // Gateway mode bridges BLE mesh <-> Nostr; a mock transport (tests) + // has no carrier packets to bridge. + guard let bleService = viewModel.meshService as? BLEService else { return } + let gateway = GatewayService.shared + + gateway.publishToRelays = { event, geohash in + let relays = GeoRelayDirectory.shared.closestRelays( + toGeohash: geohash, + count: TransportConfig.nostrGeoRelayCount + ) + // Symmetric with the local send path (GeohashSubscriptionManager + // .sendGeohash): with no known geo relay, refuse rather than + // publish to default relays no geo subscriber reads — that would + // be silent dead traffic, not delivery. + guard !relays.isEmpty else { + SecureLogger.warning("🌐 Gateway: no geo relays for #\(geohash); not publishing carried event", category: .session) + return + } + NostrRelayManager.shared.sendEvent(event, to: relays) + } + gateway.broadcastToMesh = { [weak bleService] payload in + bleService?.broadcastNostrCarrier(payload) + } + gateway.sendToGatewayPeer = { [weak bleService] payload, peer in + bleService?.sendNostrCarrier(payload, to: peer) ?? false + } + gateway.availableGatewayPeers = { [weak bleService] in + bleService?.reachableGatewayPeers() ?? [] + } + gateway.relaysConnected = { NostrRelayManager.shared.isConnected } + gateway.currentGeohash = { [weak viewModel] in viewModel?.currentGeohash } + // Carried events enter the same pipeline as relay-received events so + // blocking, rate limits, dedup, and rendering behave identically. + gateway.injectInbound = { [weak viewModel] event in + viewModel?.handleNostrEvent(event) + } + // The capability bit is advertised ONLY while the toggle is on; a + // change forces a re-announce so peers learn promptly. + gateway.onEnabledChanged = { [weak bleService] enabled in + bleService?.setLocalCapability(.gateway, enabled: enabled) + } + bleService.onNostrCarrierPacket = { payload, from, directedToUs in + GatewayService.shared.handleMeshCarrier(payload, from: from, directedToUs: directedToUs) + } + + // Uplinks deposited while relays were unreachable flush on reconnect. + NostrRelayManager.shared.$isConnected + .receive(on: DispatchQueue.main) + .sink { connected in + if connected { + GatewayService.shared.flushQueuedUplinks() + } + } + .store(in: &viewModel.cancellables) + + // Apply the persisted toggle at launch. + if gateway.isEnabled { + bleService.setLocalCapability(.gateway, enabled: true) + } + } + func bindTeleportState() { viewModel.locationManager.$teleported .receive(on: DispatchQueue.main) diff --git a/bitchat/ViewModels/GeohashSubscriptionManager.swift b/bitchat/ViewModels/GeohashSubscriptionManager.swift index 4e267c59..ce4c9448 100644 --- a/bitchat/ViewModels/GeohashSubscriptionManager.swift +++ b/bitchat/ViewModels/GeohashSubscriptionManager.swift @@ -115,6 +115,9 @@ final class GeohashSubscriptionManager { private weak var context: (any GeohashSubscriptionContext)? private let inbound: NostrInboundPipeline private let presence: GeoPresenceTracker + /// Geohashes already told "sent via mesh gateway" this session, so the + /// notice appears once per channel instead of once per message. + private var gatewayNoticeGeohashes = Set() init(context: any GeohashSubscriptionContext, inbound: NostrInboundPipeline, presence: GeoPresenceTracker) { self.context = context @@ -145,6 +148,9 @@ final class GeohashSubscriptionManager { NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in Task { @MainActor [weak self] in self?.inbound.subscribeNostrEvent(event) + // Gateway downlink: rebroadcast relay events for the viewed + // channel onto the mesh (no-op unless gateway mode is on). + GatewayService.shared.rebroadcastRelayEvent(event, geohash: channel.geohash) } } @@ -235,6 +241,9 @@ final class GeohashSubscriptionManager { NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in Task { @MainActor [weak self] in self?.inbound.handleNostrEvent(event) + // Gateway downlink: rebroadcast relay events for the viewed + // channel onto the mesh (no-op unless gateway mode is on). + GatewayService.shared.rebroadcastRelayEvent(event, geohash: channel.geohash) } } @@ -280,6 +289,23 @@ final class GeohashSubscriptionManager { NostrRelayManager.shared.sendEvent(event, to: targetRelays) } + // Mesh gateway uplink: with no working relay connection, hand the + // locally signed event to a mesh peer advertising the gateway + // capability (keys never leave this device — only the finished, + // signed event travels). Uplink is only ever attempted here, for a + // freshly composed event, never for received carrier events (loop + // rule 3 in GatewayService). + if GatewayService.shared.uplinkViaMesh(event: event, geohash: channel.geohash), + gatewayNoticeGeohashes.insert(channel.geohash).inserted { + context.addPublicSystemMessage( + String( + localized: "system.gateway.sent_via_mesh", + defaultValue: "sent via mesh gateway", + comment: "System message when a geohash message was handed to a mesh internet gateway because no relay is reachable" + ) + ) + } + context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) context.registerNostrKeyMapping(identity.publicKeyHex, for: PeerID(nostr: identity.publicKeyHex)) SecureLogger.debug( diff --git a/bitchat/Views/ContentHeaderView.swift b/bitchat/Views/ContentHeaderView.swift index 3fc2bd82..6a260b07 100644 --- a/bitchat/Views/ContentHeaderView.swift +++ b/bitchat/Views/ContentHeaderView.swift @@ -94,6 +94,19 @@ struct ContentHeaderView: View { }() HStack(spacing: 2) { + if locationChannelsModel.gatewayEnabled { + Image(systemName: "globe") + .font(.bitchatSystem(size: 12)) + .foregroundColor(palette.secondary.opacity(0.8)) + .headerTapTarget() + .accessibilityLabel( + String(localized: "content.accessibility.gateway_active", defaultValue: "Internet gateway active, sharing your connection with the mesh", comment: "Accessibility label for the internet gateway indicator") + ) + .help( + String(localized: "content.header.gateway_active", defaultValue: "Sharing your internet connection with nearby mesh peers", comment: "Tooltip for the internet gateway indicator") + ) + } + if carriedMailCount > 0 { Image(systemName: "figure.walk") .font(.bitchatSystem(size: 12)) diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index 77b45a21..0a6e077a 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -27,6 +27,8 @@ struct LocationChannelsSheet: View { static let removeAccess: LocalizedStringKey = "location_channels.action.remove_access" static let torTitle: LocalizedStringKey = "location_channels.tor.title" static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle" + static let gatewayTitle: LocalizedStringKey = "location_channels.gateway.title" + static let gatewaySubtitle: LocalizedStringKey = "location_channels.gateway.subtitle" static let toggleOn: LocalizedStringKey = "common.toggle.on" static let toggleOff: LocalizedStringKey = "common.toggle.off" @@ -244,6 +246,8 @@ struct LocationChannelsSheet: View { sectionDivider torToggleSection .padding(.top, 12) + gatewayToggleSection + .padding(.top, 8) Button(action: SystemSettings.location.open) { Text(Strings.removeAccess) .bitchatFont(size: 12) @@ -508,6 +512,32 @@ extension LocationChannelsSheet { .cornerRadius(8) } + private var gatewayToggleBinding: Binding { + Binding( + get: { locationChannelsModel.gatewayEnabled }, + set: { locationChannelsModel.setGatewayEnabled($0) } + ) + } + + private var gatewayToggleSection: some View { + VStack(alignment: .leading, spacing: 8) { + Toggle(isOn: gatewayToggleBinding) { + VStack(alignment: .leading, spacing: 2) { + Text(Strings.gatewayTitle) + .bitchatFont(size: 12, weight: .semibold) + .foregroundColor(palette.primary) + Text(Strings.gatewaySubtitle) + .bitchatFont(size: 11) + .foregroundColor(palette.secondary) + } + } + .toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff)) + } + .padding(12) + .background(palette.secondary.opacity(0.12)) + .cornerRadius(8) + } + private var standardGreen: Color { palette.primary } private var standardBlue: Color { palette.accentBlue } } diff --git a/bitchatTests/Protocols/NostrCarrierPacketTests.swift b/bitchatTests/Protocols/NostrCarrierPacketTests.swift new file mode 100644 index 00000000..50ffb287 --- /dev/null +++ b/bitchatTests/Protocols/NostrCarrierPacketTests.swift @@ -0,0 +1,96 @@ +// +// NostrCarrierPacketTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +@testable import bitchat + +@Suite("Nostr carrier packet TLV") +struct NostrCarrierPacketTests { + private func makeEvent(geohash: String = "u4pruy", content: String = "hello mesh") throws -> NostrEvent { + let identity = try NostrIdentity.generate() + return try NostrProtocol.createEphemeralGeohashEvent( + content: content, + geohash: geohash, + senderIdentity: identity, + nickname: "tester" + ) + } + + @Test("round-trips both directions with the signed event intact") + func roundTrip() throws { + let event = try makeEvent() + for direction in [NostrCarrierPacket.Direction.toGateway, .fromGateway] { + let packet = try #require(NostrCarrierPacket(direction: direction, geohash: "u4pruy", event: event)) + let encoded = try #require(packet.encode()) + let decoded = try #require(NostrCarrierPacket.decode(encoded)) + + #expect(decoded == packet) + #expect(decoded.direction == direction) + #expect(decoded.geohash == "u4pruy") + + // The carried event survives byte-exact: same ID, and the + // signature still verifies after the mesh hop. + let carried = try #require(decoded.event()) + #expect(carried.id == event.id) + #expect(carried.sig == event.sig) + #expect(carried.isValidSignature()) + } + } + + @Test("rejects an oversized event at construction and at decode") + func oversizedRejected() throws { + let oversized = Data(repeating: 0x7B, count: NostrCarrierPacket.maxEventJSONBytes + 1) + #expect(NostrCarrierPacket(direction: .toGateway, geohash: "u4pruy", eventJSON: oversized) == nil) + + // Hand-build the TLV bytes to bypass the initializer's cap. + var data = Data([0x01, 0x00, 0x01, NostrCarrierPacket.Direction.toGateway.rawValue]) + let geohash = Data("u4pruy".utf8) + data.append(contentsOf: [0x02, 0x00, UInt8(geohash.count)]) + data.append(geohash) + data.append(contentsOf: [0x03, UInt8((oversized.count >> 8) & 0xFF), UInt8(oversized.count & 0xFF)]) + data.append(oversized) + #expect(NostrCarrierPacket.decode(data) == nil) + } + + @Test("rejects an over-length or empty geohash") + func geohashBoundsEnforced() throws { + let event = try makeEvent() + #expect(NostrCarrierPacket(direction: .toGateway, geohash: "", event: event) == nil) + #expect(NostrCarrierPacket(direction: .toGateway, geohash: String(repeating: "u", count: 13), event: event) == nil) + #expect(NostrCarrierPacket(direction: .toGateway, geohash: String(repeating: "u", count: 12), event: event) != nil) + } + + @Test("skips unknown TLVs for forward compatibility") + func unknownTLVSkipped() throws { + let event = try makeEvent() + let packet = try #require(NostrCarrierPacket(direction: .fromGateway, geohash: "u4pruy", event: event)) + var encoded = try #require(packet.encode()) + // Append an unknown TLV (type 0x7F, 2-byte value). + encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD]) + let decoded = try #require(NostrCarrierPacket.decode(encoded)) + #expect(decoded == packet) + } + + @Test("rejects truncated and missing-field payloads") + func malformedRejected() throws { + let event = try makeEvent() + let packet = try #require(NostrCarrierPacket(direction: .toGateway, geohash: "u4pruy", event: event)) + let encoded = try #require(packet.encode()) + + // Truncation anywhere inside the last TLV fails cleanly. + #expect(NostrCarrierPacket.decode(encoded.dropLast(1)) == nil) + #expect(NostrCarrierPacket.decode(encoded.prefix(4)) == nil) + #expect(NostrCarrierPacket.decode(Data()) == nil) + + // Direction TLV alone (missing geohash and event) fails. + #expect(NostrCarrierPacket.decode(Data([0x01, 0x00, 0x01, 0x01])) == nil) + // Unknown direction value fails. + #expect(NostrCarrierPacket.decode(Data([0x01, 0x00, 0x01, 0x77])) == nil) + } +} diff --git a/bitchatTests/Services/GatewayServiceTests.swift b/bitchatTests/Services/GatewayServiceTests.swift new file mode 100644 index 00000000..30dbf108 --- /dev/null +++ b/bitchatTests/Services/GatewayServiceTests.swift @@ -0,0 +1,575 @@ +// +// GatewayServiceTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +@Suite("Gateway mode policy") +@MainActor +struct GatewayServiceTests { + private static let geohash = "u4pruy" + + /// Closure-injected harness around `GatewayService` recording every + /// side effect, with a controllable clock and relay connectivity. + @MainActor + private final class Fixture { + private final class ClockBox { + var now = Date() + } + + var relaysConnected = true + var currentGeohash: String? = GatewayServiceTests.geohash + var gatewayPeers: [PeerID] = [] + var sendToGatewaySucceeds = true + + private(set) var published: [(event: NostrEvent, geohash: String)] = [] + private(set) var broadcasts: [Data] = [] + private(set) var injected: [NostrEvent] = [] + private(set) var uplinkSends: [(payload: Data, peer: PeerID)] = [] + private(set) var enabledChanges: [Bool] = [] + private(set) var scheduledDrains: [(delay: TimeInterval, work: @MainActor () -> Void)] = [] + + private let clock = ClockBox() + let defaults: UserDefaults + let service: GatewayService + + init(enabled: Bool = true, suite: String = "GatewayServiceTests-\(UUID().uuidString)") { + defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let clock = clock + service = GatewayService(defaults: defaults) { clock.now } + service.publishToRelays = { [weak self] event, geohash in + self?.published.append((event, geohash)) + } + service.broadcastToMesh = { [weak self] payload in + self?.broadcasts.append(payload) + } + service.sendToGatewayPeer = { [weak self] payload, peer in + guard let self, self.sendToGatewaySucceeds else { return false } + self.uplinkSends.append((payload, peer)) + return true + } + service.availableGatewayPeers = { [weak self] in self?.gatewayPeers ?? [] } + service.relaysConnected = { [weak self] in self?.relaysConnected ?? false } + service.currentGeohash = { [weak self] in self?.currentGeohash } + service.injectInbound = { [weak self] event in self?.injected.append(event) } + service.onEnabledChanged = { [weak self] enabled in self?.enabledChanges.append(enabled) } + // Capture drain timers instead of arming a real Task so the drain + // is deterministic under the fake clock. + service.scheduleDrainTimer = { [weak self] delay, work in + self?.scheduledDrains.append((delay, work)) + } + if enabled { + service.setEnabled(true) + } + } + + func advance(_ seconds: TimeInterval) { + clock.now = clock.now.addingTimeInterval(seconds) + } + + /// Fires every currently-scheduled drain timer (simulating the window + /// freeing), as the real Task would after its delay. + func fireScheduledDrains() { + let due = scheduledDrains + scheduledDrains.removeAll() + for item in due { item.work() } + } + } + + // MARK: Event helpers + + private func makeEvent( + geohash: String = GatewayServiceTests.geohash, + content: String = "hello \(UUID().uuidString.prefix(8))" + ) throws -> NostrEvent { + let identity = try NostrIdentity.generate() + return try NostrProtocol.createEphemeralGeohashEvent( + content: content, + geohash: geohash, + senderIdentity: identity, + nickname: "tester" + ) + } + + /// A copy of `event` with tampered content but the original ID and + /// signature — what a forging gateway or mesh peer would produce. + private func forge(_ event: NostrEvent) throws -> NostrEvent { + let dict: [String: Any] = [ + "id": event.id, + "pubkey": event.pubkey, + "created_at": event.created_at, + "kind": event.kind, + "tags": event.tags, + "content": event.content + " (tampered)", + "sig": event.sig ?? "" + ] + return try NostrEvent(from: dict) + } + + private func carrierPayload( + _ event: NostrEvent, + direction: NostrCarrierPacket.Direction = .toGateway, + geohash: String = GatewayServiceTests.geohash + ) throws -> Data { + let packet = try #require(NostrCarrierPacket(direction: direction, geohash: geohash, event: event)) + return try #require(packet.encode()) + } + + private func deposit( + _ event: NostrEvent, + into fixture: Fixture, + from depositor: PeerID = PeerID(str: "1122334455667788"), + geohash: String = GatewayServiceTests.geohash + ) throws { + let payload = try carrierPayload(event, direction: .toGateway, geohash: geohash) + fixture.service.handleMeshCarrier(payload, from: depositor, directedToUs: true) + } + + // MARK: - Uplink verification gates + + @Test("publishes a verified deposit to the geo relays") + func verifiedDepositPublished() throws { + let fixture = Fixture() + let event = try makeEvent() + try deposit(event, into: fixture) + + #expect(fixture.published.count == 1) + #expect(fixture.published.first?.event.id == event.id) + #expect(fixture.published.first?.geohash == Self.geohash) + // Viewing the same geohash: the carried message shows on our own timeline. + #expect(fixture.injected.map(\.id) == [event.id]) + } + + @Test("rejects a forged signature") + func forgedSignatureRejected() throws { + let fixture = Fixture() + let forged = try forge(try makeEvent()) + try deposit(forged, into: fixture) + + #expect(fixture.published.isEmpty) + #expect(fixture.injected.isEmpty) + } + + @Test("rejects wrong kind, geohash mismatch, and stale events") + func structuralGates() throws { + let fixture = Fixture() + + // Wrong kind (kind-1 text note instead of kind-20000 ephemeral). + let identity = try NostrIdentity.generate() + let note = try NostrProtocol.createGeohashTextNote( + content: "note", + geohash: Self.geohash, + senderIdentity: identity + ) + try deposit(note, into: fixture) + #expect(fixture.published.isEmpty) + + // Carrier geohash disagreeing with the event's #g tag. + let mismatched = try makeEvent(geohash: "9q8yyk") + try deposit(mismatched, into: fixture, geohash: Self.geohash) + #expect(fixture.published.isEmpty) + + // Stale event (beyond accepted clock skew). + let stale = try makeEvent() + fixture.advance(GatewayService.Limits.maxEventAgeSeconds + 60) + try deposit(stale, into: fixture) + #expect(fixture.published.isEmpty) + } + + @Test("does nothing while the toggle is off") + func disabledGatewayIgnoresDeposits() throws { + let fixture = Fixture(enabled: false) + try deposit(try makeEvent(), into: fixture) + #expect(fixture.published.isEmpty) + #expect(fixture.service.queuedUplinks.isEmpty) + + fixture.service.rebroadcastRelayEvent(try makeEvent(), geohash: Self.geohash) + #expect(fixture.broadcasts.isEmpty) + } + + // MARK: - Uplink quotas and rate limit + + @Test("rate-limits deposits per depositor per minute") + func uplinkRateLimit() throws { + let fixture = Fixture() + let depositor = PeerID(str: "aabbccddeeff0011") + + for _ in 0.. Date: Tue, 7 Jul 2026 15:08:22 +0200 Subject: [PATCH 14/18] Add mesh diagnostics: /ping, /trace, and topology map (#1377) * Add mesh diagnostics: /ping, /trace, and topology map - New protocol types ping=0x26 / pong=0x27 (9-byte payload: 8-byte nonce + origin TTL) with per-peer inbound rate limiting (5 per 10s) - /ping @name reports RTT and hop count, 10s timeout - /trace @name prints the estimated path from gossiped directNeighbors - Topology map sheet (circular Canvas layout) reachable from App Info - Ping/pong ride the deterministic directed-relay path like DMs - Tests: payload round-trip, hop-count math, command output, edge normalization, layout Co-Authored-By: Claude Fable 5 * Fix CI media-wipe race, per-link ping rate limiting, and /ping output routing Three fixes for PR #1377 review: 1. CI flake (sendImage_privateChatProcessesAndTransfersImage): the panicClearAllData / clearCurrentPublicTimeline detached utility-priority tasks delete the real ~/Library/Application Support/files tree, which the test process shares. The wipe fires at a nondeterministic time and raced the sendImage test's JPEG in files/images/outgoing (write then re-read), so prepareImagePacket threw and the test timed out. Both wipes are now skipped under tests (existing TestEnvironment.isRunningTests pattern); this also stops test runs from deleting the developer's real media. 2. Codex P1: ping packets are unsigned, so keying the pong rate limiter on packet.senderID let one connected peer rotate forged sender IDs to bypass the 5-per-10s budget. The limiter now keys on the ingress link (the directly connected peer that delivered the packet); the pong still goes to the claimed sender. Regression test proves rotating senders over one link exhaust one budget (fails 10 vs 5 pongs on the old code). 3. Codex P2: /ping output arrived up to 10s later and was routed from selectedPrivateChatPeer at callback time, misrouting the result after a chat switch. The origin conversation is now captured when the command is issued (CommandOutputDestination) and deferred output is routed there: a DM result lands in the origin chat's history even if deselected, and a mesh-timeline result pins to #mesh instead of the active channel. Co-Authored-By: Claude Fable 5 * App Info: move NETWORK section under HOW TO USE and uppercase NETWORK/SYMBOLS headers Co-Authored-By: Claude Fable 5 * Conform DiagnosticsMockContext to sendPublicMessage CommandContextProvider gained sendPublicMessage (Cashu /pay, #1376) after this branch forked, so the diagnostics test mock no longer conformed once main was merged in. Add the no-op stub. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/App/AppChromeModel.swift | 22 ++ bitchat/Localizable.xcstrings | 134 +++++++- bitchat/Models/CommandInfo.swift | 12 +- .../BLE/BLEOutboundPacketPolicy.swift | 2 +- bitchat/Services/BLE/BLEReceivePipeline.swift | 5 + bitchat/Services/BLE/BLEService.swift | 171 ++++++++++ bitchat/Services/CommandProcessor.swift | 96 ++++++ bitchat/Services/MeshTopologyTracker.swift | 7 + bitchat/Services/Transport.swift | 53 ++++ bitchat/Services/TransportConfig.swift | 5 + bitchat/Sync/SyncTypeFlags.swift | 3 + bitchat/ViewModels/ChatViewModel.swift | 27 ++ bitchat/Views/AppInfoView.swift | 45 +++ bitchat/Views/ContentView.swift | 2 +- bitchat/Views/MeshTopologyView.swift | 217 +++++++++++++ bitchatTests/BLEServiceCoreTests.swift | 63 ++++ bitchatTests/CommandProcessorTests.swift | 14 + bitchatTests/Mocks/MockTransport.swift | 21 ++ .../Services/MeshDiagnosticsTests.swift | 295 ++++++++++++++++++ .../BitFoundation/MeshPingPayload.swift | 57 ++++ .../Sources/BitFoundation/MessageType.swift | 6 + .../MeshPingPayloadTests.swift | 70 +++++ 22 files changed, 1320 insertions(+), 7 deletions(-) create mode 100644 bitchat/Views/MeshTopologyView.swift create mode 100644 bitchatTests/Services/MeshDiagnosticsTests.swift create mode 100644 localPackages/BitFoundation/Sources/BitFoundation/MeshPingPayload.swift create mode 100644 localPackages/BitFoundation/Tests/BitFoundationTests/MeshPingPayloadTests.swift diff --git a/bitchat/App/AppChromeModel.swift b/bitchat/App/AppChromeModel.swift index d90df814..a920c128 100644 --- a/bitchat/App/AppChromeModel.swift +++ b/bitchat/App/AppChromeModel.swift @@ -62,6 +62,28 @@ final class AppChromeModel: ObservableObject { isAppInfoPresented = true } + /// Builds the mesh topology map model from the transport's gossiped + /// graph plus the live nickname table. Unknown nodes (heard about via a + /// neighbor claim but never announced to us) fall back to a short ID. + func meshTopologyDisplayModel() -> MeshTopologyDisplayModel { + let mesh = chatViewModel.meshService + guard let snapshot = mesh.currentMeshTopology() else { return .empty } + let nicknames = mesh.getPeerNicknames() + + let nodes = snapshot.nodes.map { peerID -> MeshTopologyDisplayModel.Node in + let isSelf = peerID == snapshot.localPeerID + let label: String + if isSelf { + label = chatViewModel.nickname + } else { + label = nicknames[peerID] ?? "\(peerID.id.prefix(8))…" + } + return MeshTopologyDisplayModel.Node(id: peerID.id, label: label, isSelf: isSelf) + } + let edges = snapshot.edges.map { ($0.a.id, $0.b.id) } + return MeshTopologyDisplayModel(nodes: nodes, edges: edges) + } + func triggerScreenshotPrivacyWarning() { showScreenshotPrivacyWarning = true } diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index bed148dd..49e5487e 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -5142,7 +5142,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "symbols" + "value" : "SYMBOLS" } } } @@ -5171,6 +5171,54 @@ } } }, + "app_info.network.title" : { + "comment" : "Section header for network diagnostics in the app info sheet", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "NETWORK" + } + } + } + }, + "app_info.network.topology.description" : { + "comment" : "Row description for the mesh topology map", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "map of peers and links learned from mesh announces" + } + } + } + }, + "app_info.network.topology.hint" : { + "comment" : "Accessibility hint for the mesh topology row", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "opens the mesh topology map" + } + } + } + }, + "app_info.network.topology.title" : { + "comment" : "Row title opening the mesh topology map", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh topology" + } + } + } + }, "app_info.privacy.ephemeral.description" : { "extractionState" : "manual", "localizations" : { @@ -15449,6 +15497,18 @@ } } }, + "content.commands.ping" : { + "comment" : "Description of the /ping command in the suggestions panel", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "measure round-trip time to a mesh peer" + } + } + } + }, "content.commands.slap" : { "extractionState" : "manual", "localizations" : { @@ -15628,6 +15688,18 @@ } } }, + "content.commands.trace" : { + "comment" : "Description of the /trace command in the suggestions panel", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "estimate the mesh path to a peer" + } + } + } + }, "content.commands.unblock" : { "extractionState" : "manual", "localizations" : { @@ -35384,6 +35456,66 @@ } } }, + "topology.caption" : { + "comment" : "Caption under the mesh topology map", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "estimated from gossiped neighbor lists (up to 10 per peer) — your device is highlighted" + } + } + } + }, + "topology.empty" : { + "comment" : "Empty state of the mesh topology map", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "no mesh links yet — the map fills in as peer announces arrive" + } + } + } + }, + "topology.refresh" : { + "comment" : "Accessibility label of the topology refresh button", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "refresh topology" + } + } + } + }, + "topology.summary" : { + "comment" : "Topology map summary: number of peers and links", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$ld peers · %2$ld links" + } + } + } + }, + "topology.title" : { + "comment" : "Title of the mesh topology map sheet", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh topology" + } + } + } + }, "verification.my_qr.accessibility_label" : { "extractionState" : "manual", "localizations" : { diff --git a/bitchat/Models/CommandInfo.swift b/bitchat/Models/CommandInfo.swift index 0ad3d554..bb0a0a65 100644 --- a/bitchat/Models/CommandInfo.swift +++ b/bitchat/Models/CommandInfo.swift @@ -25,6 +25,8 @@ enum CommandInfo: String, Identifiable { case who case favorite = "fav" case unfavorite = "unfav" + case ping + case trace var id: String { rawValue } @@ -32,7 +34,7 @@ enum CommandInfo: String, Identifiable { var placeholder: String? { switch self { - case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite: + case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite, .ping, .trace: return "<" + String(localized: "content.input.nickname_placeholder") + ">" case .pay: return "<" + String(localized: "content.input.token_placeholder") + ">" @@ -54,6 +56,8 @@ enum CommandInfo: String, Identifiable { case .who: String(localized: "content.commands.who") case .favorite: String(localized: "content.commands.favorite") case .unfavorite: String(localized: "content.commands.unfavorite") + case .ping: String(localized: "content.commands.ping") + case .trace: String(localized: "content.commands.trace") } } @@ -66,11 +70,11 @@ enum CommandInfo: String, Identifiable { if !isGeoPublic { commands.append(.pay) } - // The processor rejects favorites in geohash contexts, so only - // suggest them where they actually work: mesh. + // The processor rejects favorites and mesh diagnostics in geohash + // contexts, so only suggest them where they actually work: mesh. if isGeoPublic || isGeoDM { return commands } - return commands + [.favorite, .unfavorite] + return commands + [.favorite, .unfavorite, .ping, .trace] } } diff --git a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift index a6a8c149..10837d9b 100644 --- a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift +++ b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift @@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy { switch MessageType(rawValue: packetType) { case .noiseEncrypted, .noiseHandshake: return true - case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .nostrCarrier: + case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier: return false } } diff --git a/bitchat/Services/BLE/BLEReceivePipeline.swift b/bitchat/Services/BLE/BLEReceivePipeline.swift index 691bb3e2..92e3bec8 100644 --- a/bitchat/Services/BLE/BLEReceivePipeline.swift +++ b/bitchat/Services/BLE/BLEReceivePipeline.swift @@ -51,10 +51,15 @@ struct BLEReceivePipeline { // Courier envelopes are directed opaque ciphertext like DMs; a // remote handover toward a relayed announce rides this same // deterministic relay treatment instead of the broadcast clamp. + // Ping/pong diagnostics ride it too: probes need the same + // deterministic multi-hop relay as DMs (always relay, jitter, + // no TTL cap) so RTT and hop counts reflect the real path. // Directed nostrCarrier uplinks (mesh-only peer -> gateway) need // the same multi-hop treatment to reach a non-adjacent gateway. isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue || packet.type == MessageType.courierEnvelope.rawValue + || packet.type == MessageType.ping.rawValue + || packet.type == MessageType.pong.rawValue || packet.type == MessageType.nostrCarrier.rawValue) && packet.recipientID != nil, isFragment: packet.type == MessageType.fragment.rawValue, isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil, diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 3c1538d3..c7a8570a 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -80,6 +80,24 @@ final class BLEService: NSObject { // Route health for originated source routes; guarded by collectionsQueue. private var sourceRouteFailures = BLESourceRouteFailureCache() + // Mesh diagnostics: outstanding /ping probes keyed by nonce, plus the + // inbound ping budget — keyed by the ingress link (the directly connected + // peer that delivered the packet), since the unsigned claimed sender is + // spoofable — so a directed unencrypted probe cannot be turned into an + // amplification primitive. Both are owned by collectionsQueue barriers + // like the other mutable collections. + private struct PendingMeshPing { + let peerID: PeerID + let sentAt: Date + let completion: @MainActor (MeshPingResult?) -> Void + let timeout: DispatchWorkItem + } + private var pendingMeshPings: [Data: PendingMeshPing] = [:] + private var meshPingResponseLimiter = SyncResponseRateLimiter( + maxResponses: TransportConfig.meshPingInboundMaxPerLink, + window: TransportConfig.meshPingInboundWindowSeconds + ) + // 5. Fragment Reassembly (necessary for messages > MTU) private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer() private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler() @@ -2493,6 +2511,150 @@ extension BLEService { PeerID(routingData: data) } + // MARK: - Mesh Diagnostics (/ping, /trace, topology map) + + /// Sends a directed unencrypted ping probe (8-byte nonce + origin TTL). + /// The completion fires exactly once on the main actor: with RTT/hops + /// when the matching pong returns, or nil after the timeout window. + func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) { + messageQueue.async { [weak self] in + guard let self, + let recipientData = peerID.toShort().routingData, + let payload = MeshPingPayload( + nonce: Data((0.. PendingMeshPing? in + guard pendingMeshPings[pong.nonce]?.peerID == peerID else { return nil } + return pendingMeshPings.removeValue(forKey: pong.nonce) + } + guard let pending else { return } + pending.timeout.cancel() + let rttMs = Int((Date().timeIntervalSince(pending.sentAt) * 1000).rounded()) + let result = MeshPingResult( + rttMs: max(0, rttMs), + hops: MeshPingPayload.hopCount(originTTL: pong.originTTL, receivedTTL: packet.ttl) + ) + Task { @MainActor in pending.completion(result) } + } + + /// Estimated intermediate hops toward `peerID`, BFS over gossiped + /// bidirectionally-confirmed neighbor claims ([] = direct, nil = none). + func computeMeshPath(to peerID: PeerID) -> [PeerID]? { + refreshLocalTopology() + if let route = computeRoute(to: peerID) { + return route.compactMap { PeerID(routingData: $0) } + } + // Confirmed claims can lag a brand-new link (the peer's next announce + // hasn't arrived yet); a live direct connection is still a known path. + return isPeerConnected(peerID) ? [] : nil + } + + /// Mesh graph for the topology map. Edges are advisory: announces cap + /// neighbor lists at 10, so an edge claimed by either endpoint counts. + func currentMeshTopology() -> MeshTopologySnapshot? { + refreshLocalTopology() + let claims = meshTopology.adjacencySnapshot() + var nodes = Set() + var edges = Set() + for (source, neighbors) in claims { + guard let sourcePeer = PeerID(routingData: source) else { continue } + nodes.insert(sourcePeer) + for neighborData in neighbors { + guard let neighborPeer = PeerID(routingData: neighborData), + neighborPeer != sourcePeer else { continue } + nodes.insert(neighborPeer) + edges.insert(MeshTopologyEdge(sourcePeer, neighborPeer)) + } + } + nodes.insert(myPeerID) + return MeshTopologySnapshot( + localPeerID: myPeerID, + nodes: nodes.sorted(), + edges: edges.sorted { ($0.a, $0.b) < ($1.a, $1.b) } + ) + } + private func forwardAlongRouteIfNeeded(_ packet: BitchatPacket) -> Bool { let myRoutingData = routingData(for: myPeerID) ?? (myPeerIDData.isEmpty ? nil : myPeerIDData) let plan = BLERouteForwardingPolicy.plan( @@ -3381,6 +3543,15 @@ extension BLEService { case .nostrCarrier: handleNostrCarrier(packet, from: peerID) + case .ping: + // Rate limiting must key on the ingress link (`peerID`), not the + // packet-claimed sender: pings are unsigned, so `senderID` is + // attacker-controlled and rotating it would reset the budget. + handleMeshPing(packet, fromLink: peerID) + + case .pong: + handleMeshPong(packet, from: senderID) + case .leave: handleLeave(packet, from: senderID) diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index 220e66ef..9555609a 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -22,6 +22,17 @@ struct CommandGeoParticipant { let displayName: String } +/// The conversation a command was typed into, captured when the command is +/// issued so deferred output (e.g. an async /ping result, which can arrive +/// many seconds later) lands there even if the user switches chats first. +enum CommandOutputDestination: Equatable { + /// The #mesh public timeline. Commands that defer output (/ping) are + /// mesh-only, so a non-DM origin is always the mesh timeline. + case meshTimeline + /// The private chat that was open when the command was typed. + case privateChat(PeerID) +} + /// Protocol defining what CommandProcessor needs from its context. /// This breaks the circular dependency between CommandProcessor and ChatViewModel. @MainActor @@ -51,6 +62,13 @@ protocol CommandContextProvider: AnyObject { // MARK: - System Messages func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) func addPublicSystemMessage(_ content: String) + /// The conversation the user is typing into right now. Commands that + /// finish asynchronously capture this BEFORE starting async work, so a + /// chat switch cannot misroute their deferred output. + func currentCommandDestination() -> CommandOutputDestination + /// Routes deferred command output (e.g. an async /ping result) into the + /// conversation captured when the command was issued. + func addCommandOutput(_ content: String, to destination: CommandOutputDestination) // MARK: - Favorites /// Toggles the favorite via the unified peer flow, which persists by the @@ -108,6 +126,12 @@ final class CommandProcessor { case "/unfav": if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") } return handleFavorite(args, add: false) + case "/ping": + if inGeoPublic || inGeoDM { return .error(message: "ping only works for mesh peers in #mesh") } + return handlePing(args) + case "/trace": + if inGeoPublic || inGeoDM { return .error(message: "trace only works for mesh peers in #mesh") } + return handleTrace(args) case "/pay": return handlePay(args) case "/help": @@ -129,6 +153,8 @@ final class CommandProcessor { /slap @name — slap with a large trout /block @name · /unblock @name /fav @name · /unfav @name — favorites (mesh only) + /ping @name — measure round-trip time (mesh only) + /trace @name — estimated mesh path (mesh only) /pay — send a cashu ecash token in this chat /help — this list """ @@ -336,6 +362,76 @@ final class CommandProcessor { return .error(message: "cannot unblock \(nickname): not found") } + // MARK: - Mesh Diagnostics + + private enum MeshPeerResolution { + case resolved(peerID: PeerID, nickname: String) + case failed(CommandResult) + } + + /// Resolves a mesh peer for /ping and /trace. Geohash identities are + /// rejected — diagnostics measure the BLE mesh, not Nostr. + private func resolveMeshPeer(_ args: String, command: String) -> MeshPeerResolution { + let targetName = args.trimmed + guard !targetName.isEmpty else { + return .failed(.error(message: "usage: /\(command) ")) + } + let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName + guard let peerID = contextProvider?.getPeerIDForNickname(nickname), + !peerID.isGeoDM, !peerID.isGeoChat else { + return .failed(.error(message: "cannot \(command) \(nickname): not found on mesh")) + } + return .resolved(peerID: peerID, nickname: nickname) + } + + private func handlePing(_ args: String) -> CommandResult { + let target: (peerID: PeerID, nickname: String) + switch resolveMeshPeer(args, command: "ping") { + case .resolved(let peerID, let nickname): target = (peerID, nickname) + case .failed(let result): return result + } + + let nickname = target.nickname + let currentProvider = contextProvider + // Capture the origin conversation now: the pong can arrive up to + // meshPingTimeoutSeconds later, and reading the selected chat at + // callback time would misroute the result after a chat switch. + let destination = contextProvider?.currentCommandDestination() ?? .meshTimeline + meshService?.sendMeshPing(to: target.peerID) { [weak currentProvider] result in + let provider = currentProvider + guard let result else { + provider?.addCommandOutput("no reply from \(nickname)", to: destination) + return + } + let hopText: String = result.hops.map { hops in + hops == 1 ? " · direct (1 hop)" : " · \(hops) hops" + } ?? "" + provider?.addCommandOutput("pong from \(nickname): \(result.rttMs) ms\(hopText)", to: destination) + } + return .success(message: "pinging \(nickname)…") + } + + private func handleTrace(_ args: String) -> CommandResult { + let target: (peerID: PeerID, nickname: String) + switch resolveMeshPeer(args, command: "trace") { + case .resolved(let peerID, let nickname): target = (peerID, nickname) + case .failed(let result): return result + } + + guard let mesh = meshService, + let intermediates = mesh.computeMeshPath(to: target.peerID) else { + return .success(message: "no known path to \(target.nickname)") + } + // Graph-derived from gossiped neighbor claims, not route-recorded — + // present it as an estimate. + let hopNames = intermediates.map { hop in + mesh.peerNickname(peerID: hop) ?? "\(hop.id.prefix(8))…" + } + let chain = (["you"] + hopNames + [target.nickname]).joined(separator: " → ") + let hops = intermediates.count + 1 + return .success(message: "estimated path: \(chain) (\(hops) hop\(hops == 1 ? "" : "s"))") + } + /// `/pay ` — validates the token decodes, then sends it as /// the message body in the current chat. Cashu tokens are bearer /// instruments (whoever redeems first gets the funds), so posting one to diff --git a/bitchat/Services/MeshTopologyTracker.swift b/bitchat/Services/MeshTopologyTracker.swift index eb4e0c3c..5596b5b1 100644 --- a/bitchat/Services/MeshTopologyTracker.swift +++ b/bitchat/Services/MeshTopologyTracker.swift @@ -49,6 +49,13 @@ final class MeshTopologyTracker { } } + /// Raw directed neighbor claims, for diagnostics (topology map, /trace). + /// Callers treat the claims as advisory: announces cap `directNeighbors` + /// at 10, so an edge may be claimed by only one of its endpoints. + func adjacencySnapshot() -> [Data: Set] { + queue.sync { claims } + } + func removePeer(_ data: Data?) { guard let peer = sanitize(data) else { return } queue.sync(flags: .barrier) { diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 00a17a87..45aed91e 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -31,6 +31,40 @@ struct TransportPeerSnapshot: Equatable, Hashable { } } +/// Outcome of a `/ping` probe over the mesh. +struct MeshPingResult: Equatable { + /// Round-trip time in milliseconds. + let rttMs: Int + /// Total hops to the peer (1 = directly connected), derived from the + /// pong's TTL decrements; nil when the reply carried inconsistent TTLs. + let hops: Int? +} + +/// Undirected mesh link between two peers, normalized so `(a, b)` and +/// `(b, a)` collapse to one edge. +struct MeshTopologyEdge: Hashable { + let a: PeerID + let b: PeerID + + init(_ first: PeerID, _ second: PeerID) { + if first < second { + a = first + b = second + } else { + a = second + b = first + } + } +} + +/// Point-in-time view of the mesh graph learned from gossiped announces +/// (each announce carries up to 10 `directNeighbors`). +struct MeshTopologySnapshot: Equatable { + let localPeerID: PeerID + let nodes: [PeerID] + let edges: [MeshTopologyEdge] +} + enum TransportEvent: @unchecked Sendable { case messageReceived(BitchatMessage) case publicMessageReceived(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) @@ -128,6 +162,17 @@ protocol Transport: AnyObject { // payload (post or tombstone) so it spreads over relay and gossip sync. func sendBoardPayload(_ payload: Data) + // Mesh diagnostics (optional for transports). Defaults are inert so + // queue-backed transports (e.g. NostrTransport) stay untouched. + /// Sends a directed ping probe; the completion fires exactly once on the + /// main actor with the measured result, or nil on timeout/unsupported. + func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) + /// Estimated intermediate hops toward `peerID` from gossiped topology + /// ([] = direct link, nil = no known path). + func computeMeshPath(to peerID: PeerID) -> [PeerID]? + /// Current mesh graph for the topology map; nil when unsupported. + func currentMeshTopology() -> MeshTopologySnapshot? + // QR verification (optional for transports) func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) @@ -174,6 +219,14 @@ extension Transport { func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {} func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false } func sendBoardPayload(_ payload: Data) {} + + // Mesh diagnostics are mesh-transport-only; other transports report + // "no reply"/"no path" rather than pretending to measure anything. + func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) { + Task { @MainActor in completion(nil) } + } + func computeMeshPath(to peerID: PeerID) -> [PeerID]? { nil } + func currentMeshTopology() -> MeshTopologySnapshot? { nil } func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {} func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {} func cancelTransfer(_ transferId: String) {} diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 649a8be9..31659710 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -16,6 +16,11 @@ enum TransportConfig { static let bleFragmentRelayTtlCap: UInt8 = 7 static let bleFragmentRelayTtlCapDense: UInt8 = 5 // Contain fragment floods in dense graphs + // Mesh diagnostics (/ping) + static let meshPingTimeoutSeconds: TimeInterval = 10 // Give up on a probe after this window + static let meshPingInboundMaxPerLink: Int = 5 // Inbound ping budget per ingress link (claimed sender is spoofable)... + static let meshPingInboundWindowSeconds: TimeInterval = 10 // ...per sliding window (anti-amplification) + // UI / Storage Caps static let privateChatCap: Int = 1337 static let meshTimelineCap: Int = 1337 diff --git a/bitchat/Sync/SyncTypeFlags.swift b/bitchat/Sync/SyncTypeFlags.swift index eb082c31..3f455497 100644 --- a/bitchat/Sync/SyncTypeFlags.swift +++ b/bitchat/Sync/SyncTypeFlags.swift @@ -40,6 +40,9 @@ struct SyncTypeFlags: OptionSet { // Courier envelopes are directed deposits between trusted peers and // must never spread via gossip sync. case .courierEnvelope: return nil + // Ping/pong are ephemeral directed probes; replaying them via gossip + // sync would only produce stale, unanswerable echoes. + case .ping, .pong: return nil // Gateway carriers are ephemeral live traffic (uplinks are directed, // downlinks are rate-budgeted rebroadcasts); replaying them via sync // would waste airtime and extend their lifetime. diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index a6606549..62b49da5 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1547,6 +1547,33 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele } } + /// Origin conversation for deferred command output, captured when the + /// command is issued (before any async work starts). + @MainActor + func currentCommandDestination() -> CommandOutputDestination { + if let peerID = selectedPrivateChatPeer { + return .privateChat(peerID) + } + // Deferring commands (/ping) are rejected in geohash channels, so a + // non-DM origin is always the #mesh timeline. + return .meshTimeline + } + + /// Routes deferred command output (async /ping results) into the + /// conversation captured at issue time, immune to chat switches in the + /// meantime. A DM result lands in the origin chat's history even if that + /// chat is no longer selected (or was cleared — it then reappears as the + /// first message when the chat is reopened). + @MainActor + func addCommandOutput(_ content: String, to destination: CommandOutputDestination) { + switch destination { + case .privateChat(let peerID): + addLocalPrivateSystemMessage(content, to: peerID) + case .meshTimeline: + publicConversationCoordinator.addMeshOnlySystemMessage(content) + } + } + // MARK: - Message Reception @MainActor diff --git a/bitchat/Views/AppInfoView.swift b/bitchat/Views/AppInfoView.swift index 152c09df..3f74a354 100644 --- a/bitchat/Views/AppInfoView.swift +++ b/bitchat/Views/AppInfoView.swift @@ -5,6 +5,11 @@ struct AppInfoView: View { @ThemedPalette private var palette @AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue + /// Supplies the mesh topology map data. Nil (previews, missing wiring) + /// hides the topology row entirely. + var topologyProvider: (@MainActor () -> MeshTopologyDisplayModel)? + @State private var showTopology = false + private var selectedTheme: AppTheme { AppTheme(rawValue: appThemeRawValue) ?? .matrix } @@ -75,6 +80,15 @@ struct AppInfoView: View { ] } + enum Network { + static let title: LocalizedStringKey = "app_info.network.title" + static let topology = AppInfoFeatureInfo( + icon: "point.3.connected.trianglepath.dotted", + title: "app_info.network.topology.title", + description: "app_info.network.topology.description" + ) + } + enum Privacy { static let title: LocalizedStringKey = "app_info.privacy.title" static let noTracking = AppInfoFeatureInfo( @@ -129,6 +143,11 @@ struct AppInfoView: View { .themedSheetBackground() } .frame(width: 600, height: 700) + .sheet(isPresented: $showTopology) { + if let topologyProvider { + MeshTopologyView(provider: topologyProvider) + } + } #else NavigationView { ScrollView { @@ -143,6 +162,11 @@ struct AppInfoView: View { } } } + .sheet(isPresented: $showTopology) { + if let topologyProvider { + MeshTopologyView(provider: topologyProvider) + } + } #endif } @@ -199,6 +223,27 @@ struct AppInfoView: View { .foregroundColor(textColor) } + // Network diagnostics + if topologyProvider != nil { + VStack(alignment: .leading, spacing: 16) { + SectionHeader(Strings.Network.title) + + Button { + showTopology = true + } label: { + HStack(spacing: 0) { + FeatureRow(info: Strings.Network.topology) + Image(systemName: "chevron.right") + .font(.bitchatSystem(size: 12)) + .foregroundColor(secondaryTextColor) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityHint(Text("app_info.network.topology.hint")) + } + } + // Features VStack(alignment: .leading, spacing: 16) { SectionHeader(Strings.Features.title) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 86bb31ad..bd434ad1 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -149,7 +149,7 @@ struct ContentView: View { #endif } .sheet(isPresented: $appChromeModel.isAppInfoPresented) { - AppInfoView() + AppInfoView(topologyProvider: { appChromeModel.meshTopologyDisplayModel() }) } .sheet(isPresented: Binding( get: { appChromeModel.showingFingerprintFor != nil && !showSidebar && selectedPrivatePeerID == nil }, diff --git a/bitchat/Views/MeshTopologyView.swift b/bitchat/Views/MeshTopologyView.swift new file mode 100644 index 00000000..a8bc4e3e --- /dev/null +++ b/bitchat/Views/MeshTopologyView.swift @@ -0,0 +1,217 @@ +// +// MeshTopologyView.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import SwiftUI + +/// Display model for the mesh topology map: nodes are known mesh peers, +/// edges are gossiped `directNeighbors` claims. Built on the main actor from +/// a `MeshTopologySnapshot` plus the current nickname table. +struct MeshTopologyDisplayModel { + struct Node: Identifiable, Equatable { + let id: String + let label: String + let isSelf: Bool + } + + let nodes: [Node] + /// Pairs of `Node.id`; every id is present in `nodes`. + let edges: [(String, String)] + + static let empty = MeshTopologyDisplayModel(nodes: [], edges: []) +} + +/// Minimal diagnostics sheet: the mesh graph on a circular layout (self in +/// the center), drawn with Canvas so it stays cheap at any peer count. +struct MeshTopologyView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.appTheme) private var appTheme + @ThemedPalette private var palette + + /// Fetches a fresh model; called on appear and on manual refresh. + let provider: @MainActor () -> MeshTopologyDisplayModel + @State private var model: MeshTopologyDisplayModel = .empty + + var body: some View { + #if os(macOS) + VStack(spacing: 0) { + HStack { + Text("topology.title") + .bitchatFont(size: 16, weight: .bold) + .foregroundColor(palette.primary) + Spacer() + refreshButton + Button("app_info.done") { + dismiss() + } + .buttonStyle(.plain) + .foregroundColor(palette.primary) + } + .padding() + .themedSurface(opacity: 0.95) + + content + } + .frame(width: 500, height: 520) + .themedSheetBackground() + #else + NavigationView { + content + .themedSheetBackground() + .navigationTitle(Text("topology.title")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + refreshButton + } + ToolbarItem(placement: .navigationBarTrailing) { + SheetCloseButton { dismiss() } + .foregroundColor(palette.primary) + } + } + } + #endif + } + + private var refreshButton: some View { + Button { + model = provider() + } label: { + Image(systemName: "arrow.clockwise") + .font(.bitchatSystem(size: 14)) + .foregroundColor(palette.primary) + } + .buttonStyle(.plain) + .accessibilityLabel(Text("topology.refresh")) + } + + @ViewBuilder + private var content: some View { + VStack(spacing: 12) { + if model.nodes.count <= 1 { + Spacer() + Text("topology.empty") + .bitchatFont(size: 14) + .foregroundColor(palette.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + Spacer() + } else { + graphCanvas + .padding(.horizontal, 8) + } + + VStack(spacing: 4) { + Text(summaryText) + .bitchatFont(size: 13, weight: .semibold) + .foregroundColor(palette.primary) + Text("topology.caption") + .bitchatFont(size: 11) + .foregroundColor(palette.secondary) + .multilineTextAlignment(.center) + } + .padding(.horizontal) + .padding(.bottom, 16) + } + .onAppear { model = provider() } + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(summaryText)) + } + + private var summaryText: String { + String( + format: String( + localized: "topology.summary", + comment: "Topology map summary: number of peers and links" + ), + locale: .current, + model.nodes.count, + model.edges.count + ) + } + + private var graphCanvas: some View { + Canvas { context, size in + let positions = Self.layout(nodes: model.nodes, in: size) + let fontDesign = appTheme.bodyFontDesign + + // Edges first so nodes draw on top. + for (fromID, toID) in model.edges { + guard let from = positions[fromID], let to = positions[toID] else { continue } + var path = Path() + path.move(to: from) + path.addLine(to: to) + context.stroke(path, with: .color(palette.secondary.opacity(0.45)), lineWidth: 1) + } + + for node in model.nodes { + guard let center = positions[node.id] else { continue } + let radius: CGFloat = node.isSelf ? 7 : 5 + let dot = Path(ellipseIn: CGRect( + x: center.x - radius, + y: center.y - radius, + width: radius * 2, + height: radius * 2 + )) + context.fill(dot, with: .color(node.isSelf ? palette.accent : palette.primary)) + if node.isSelf { + let ring = Path(ellipseIn: CGRect( + x: center.x - radius - 3, + y: center.y - radius - 3, + width: (radius + 3) * 2, + height: (radius + 3) * 2 + )) + context.stroke(ring, with: .color(palette.accent.opacity(0.6)), lineWidth: 1) + } + context.draw( + Text(node.label) + .font(.system(size: 10, design: fontDesign)) + .foregroundColor(node.isSelf ? palette.accent : palette.secondary), + at: CGPoint(x: center.x, y: center.y + radius + 4), + anchor: .top + ) + } + } + .accessibilityHidden(true) // The combined summary label narrates the graph. + } + + /// Circular layout: self in the center, everyone else evenly spaced on a + /// ring. Deterministic (nodes arrive sorted), so refreshes don't shuffle. + static func layout(nodes: [MeshTopologyDisplayModel.Node], in size: CGSize) -> [String: CGPoint] { + let center = CGPoint(x: size.width / 2, y: size.height / 2) + // Leave room for the label row under each ring node. + let radius = max(20, min(size.width, size.height) / 2 - 36) + var positions: [String: CGPoint] = [:] + + let ringNodes = nodes.filter { !$0.isSelf } + for node in nodes where node.isSelf { + positions[node.id] = center + } + for (index, node) in ringNodes.enumerated() { + let angle = (2 * CGFloat.pi * CGFloat(index)) / CGFloat(max(1, ringNodes.count)) - CGFloat.pi / 2 + positions[node.id] = CGPoint( + x: center.x + radius * cos(angle), + y: center.y + radius * sin(angle) + ) + } + return positions + } +} + +#Preview("Topology") { + MeshTopologyView(provider: { + MeshTopologyDisplayModel( + nodes: [ + .init(id: "self", label: "me", isSelf: true), + .init(id: "a", label: "alice", isSelf: false), + .init(id: "b", label: "bob", isSelf: false), + .init(id: "c", label: "carol", isSelf: false) + ], + edges: [("self", "a"), ("a", "b"), ("self", "c")] + ) + }) +} diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index fe7624ec..6a9cdfc1 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -216,6 +216,69 @@ struct BLEServiceCoreTests { cachedServiceUUIDs: [BLEService.serviceUUID, otherService] )) } + + /// Pings are unsigned, so their claimed sender is attacker-controlled. + /// The pong budget must be keyed on the ingress link (the directly + /// connected peer that delivered the packet): rotating forged sender IDs + /// over one link exhausts one budget instead of resetting it, so a single + /// malicious link cannot turn /ping into an amplification primitive. + @Test + func meshPingResponseBudget_isPerIngressLinkNotClaimedSender() async throws { + let ble = makeService() + let outbound = OutboundPacketTap() + ble._test_onOutboundPacket = outbound.record + + let link = PeerID(str: "1122334455667788") + let budget = TransportConfig.meshPingInboundMaxPerLink + let myRecipientData = try #require(Data(hexString: ble.myPeerID.id)) + + for i in 0..<(budget * 2) { + // A fresh forged sender for every ping, all arriving on one link. + let forgedSender = PeerID(str: String(format: "%016x", 0xA0_0000 + i)) + var nonce = Data(repeating: 0, count: MeshPingPayload.nonceLength) + nonce[0] = UInt8(i) + let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 7)) + let packet = BitchatPacket( + type: MessageType.ping.rawValue, + senderID: Data(hexString: forgedSender.id) ?? Data(), + recipientID: myRecipientData, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload.encode(), + signature: nil, + ttl: 7 + ) + ble._test_handlePacket(packet, fromPeerID: link, preseedPeer: false) + } + + let reachedBudget = await TestHelpers.waitUntil( + { outbound.count(ofType: .pong) >= budget }, + timeout: TestConstants.defaultTimeout + ) + #expect(reachedBudget) + // Give any over-budget pong a chance to surface, then confirm the + // rotated sender IDs never bought a sixth response. + let exceededBudget = await TestHelpers.waitUntil( + { outbound.count(ofType: .pong) > budget }, + timeout: TestConstants.shortTimeout + ) + #expect(!exceededBudget) + #expect(outbound.count(ofType: .pong) == budget) + } +} + +/// Thread-safe capture of packets leaving the service under test. +private final class OutboundPacketTap { + private let lock = NSLock() + private var packets: [BitchatPacket] = [] + + func record(_ packet: BitchatPacket) { + lock.lock(); packets.append(packet); lock.unlock() + } + + func count(ofType type: MessageType) -> Int { + lock.lock(); defer { lock.unlock() } + return packets.filter { $0.type == type.rawValue }.count + } } private func makeService() -> BLEService { diff --git a/bitchatTests/CommandProcessorTests.swift b/bitchatTests/CommandProcessorTests.swift index 1937174e..718df1c8 100644 --- a/bitchatTests/CommandProcessorTests.swift +++ b/bitchatTests/CommandProcessorTests.swift @@ -602,6 +602,8 @@ private final class MockCommandContextProvider: CommandContextProvider { private(set) var sentPublicRawMessages: [String] = [] private(set) var localPrivateSystemMessages: [(content: String, peerID: PeerID)] = [] private(set) var publicSystemMessages: [String] = [] + private(set) var commandOutputs: [String] = [] + private(set) var commandOutputDestinations: [CommandOutputDestination] = [] private(set) var toggledFavorites: [PeerID] = [] private(set) var favoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = [] @@ -657,6 +659,18 @@ private final class MockCommandContextProvider: CommandContextProvider { publicSystemMessages.append(content) } + func currentCommandDestination() -> CommandOutputDestination { + if let peerID = selectedPrivateChatPeer { + return .privateChat(peerID) + } + return .meshTimeline + } + + func addCommandOutput(_ content: String, to destination: CommandOutputDestination) { + commandOutputs.append(content) + commandOutputDestinations.append(destination) + } + func toggleFavorite(peerID: PeerID) { toggledFavorites.append(peerID) } diff --git a/bitchatTests/Mocks/MockTransport.swift b/bitchatTests/Mocks/MockTransport.swift index e4cf08de..5f1243e8 100644 --- a/bitchatTests/Mocks/MockTransport.swift +++ b/bitchatTests/Mocks/MockTransport.swift @@ -196,6 +196,27 @@ final class MockTransport: Transport { return courierSendResult } + // MARK: - Mesh Diagnostics + + private(set) var sentMeshPings: [PeerID] = [] + var meshPingResult: MeshPingResult? + var meshPaths: [PeerID: [PeerID]] = [:] + var meshTopologySnapshot: MeshTopologySnapshot? + + func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) { + sentMeshPings.append(peerID) + let result = meshPingResult + Task { @MainActor in completion(result) } + } + + func computeMeshPath(to peerID: PeerID) -> [PeerID]? { + meshPaths[peerID] + } + + func currentMeshTopology() -> MeshTopologySnapshot? { + meshTopologySnapshot + } + // MARK: - Test Helpers /// Clears all recorded method calls for fresh assertions diff --git a/bitchatTests/Services/MeshDiagnosticsTests.swift b/bitchatTests/Services/MeshDiagnosticsTests.swift new file mode 100644 index 00000000..ced8a92e --- /dev/null +++ b/bitchatTests/Services/MeshDiagnosticsTests.swift @@ -0,0 +1,295 @@ +// +// MeshDiagnosticsTests.swift +// bitchatTests +// +// Tests for /ping and /trace command handling and the topology snapshot +// types backing the mesh topology map. +// This is free and unencumbered software released into the public domain. +// + +import Foundation +import Testing +import BitFoundation +@testable import bitchat + +@Suite(.serialized) +struct MeshDiagnosticsTests { + + // MARK: - Helpers + + @MainActor + private func makeProcessor( + context: DiagnosticsMockContext, + transport: MockTransport + ) -> CommandProcessor { + CommandProcessor( + contextProvider: context, + meshService: transport, + identityManager: MockIdentityManager(MockKeychain()) + ) + } + + /// Waits for the async ping completion (MainActor hop) to land. + @MainActor + private func waitForCommandOutput(_ context: DiagnosticsMockContext) async { + for _ in 0..<100 { + if !context.commandOutputs.isEmpty { return } + await Task.yield() + try? await Task.sleep(nanoseconds: 5_000_000) + } + } + + // MARK: - /ping + + @MainActor + @Test func pingWithoutArgumentShowsUsage() { + let context = DiagnosticsMockContext() + let processor = makeProcessor(context: context, transport: MockTransport()) + let result = processor.process("/ping") + switch result { + case .error(let message): + #expect(message == "usage: /ping ") + default: + Issue.record("Expected usage error") + } + } + + @MainActor + @Test func pingUnknownPeerFails() { + let context = DiagnosticsMockContext() + let processor = makeProcessor(context: context, transport: MockTransport()) + let result = processor.process("/ping @ghost") + switch result { + case .error(let message): + #expect(message == "cannot ping ghost: not found on mesh") + default: + Issue.record("Expected error result") + } + } + + @MainActor + @Test func pingGeoDMPeerIsRejected() { + let context = DiagnosticsMockContext() + context.nicknameToPeerID["alice"] = PeerID(nostr_: "aabbccddeeff00112233445566778899") + let processor = makeProcessor(context: context, transport: MockTransport()) + let result = processor.process("/ping @alice") + switch result { + case .error(let message): + #expect(message == "cannot ping alice: not found on mesh") + default: + Issue.record("Expected error for geo peer") + } + } + + @MainActor + @Test func pingSuccessReportsRttAndHops() async { + let context = DiagnosticsMockContext() + let peerID = PeerID(str: "abcd1234abcd1234") + context.nicknameToPeerID["alice"] = peerID + let transport = MockTransport() + transport.meshPingResult = MeshPingResult(rttMs: 42, hops: 2) + let processor = makeProcessor(context: context, transport: transport) + + let result = processor.process("/ping @alice") + switch result { + case .success(let message): + #expect(message == "pinging alice…") + default: + Issue.record("Expected immediate 'pinging' feedback") + } + #expect(transport.sentMeshPings == [peerID]) + + await waitForCommandOutput(context) + #expect(context.commandOutputs == ["pong from alice: 42 ms · 2 hops"]) + } + + @MainActor + @Test func pingDirectPeerReportsSingleHop() async { + let context = DiagnosticsMockContext() + context.nicknameToPeerID["alice"] = PeerID(str: "abcd1234abcd1234") + let transport = MockTransport() + transport.meshPingResult = MeshPingResult(rttMs: 8, hops: 1) + let processor = makeProcessor(context: context, transport: transport) + + _ = processor.process("/ping alice") + await waitForCommandOutput(context) + #expect(context.commandOutputs == ["pong from alice: 8 ms · direct (1 hop)"]) + } + + @MainActor + @Test func pingTimeoutReportsNoReply() async { + let context = DiagnosticsMockContext() + context.nicknameToPeerID["alice"] = PeerID(str: "abcd1234abcd1234") + let transport = MockTransport() + transport.meshPingResult = nil + let processor = makeProcessor(context: context, transport: transport) + + _ = processor.process("/ping @alice") + await waitForCommandOutput(context) + #expect(context.commandOutputs == ["no reply from alice"]) + } + + @MainActor + @Test func pingOutputRoutesToConversationWhereCommandWasIssued() async { + let context = DiagnosticsMockContext() + let alice = PeerID(str: "abcd1234abcd1234") + let bob = PeerID(str: "b0b0b0b0b0b0b0b0") + context.nicknameToPeerID["alice"] = alice + let transport = MockTransport() + transport.meshPingResult = MeshPingResult(rttMs: 42, hops: 2) + let processor = makeProcessor(context: context, transport: transport) + + // Issue the ping from bob's DM, then switch chats before the async + // result lands. The output must follow the origin conversation, not + // whatever is selected at callback time. + context.selectedPrivateChatPeer = bob + _ = processor.process("/ping @alice") + context.selectedPrivateChatPeer = nil + + await waitForCommandOutput(context) + #expect(context.commandOutputDestinations == [.privateChat(bob)]) + } + + @MainActor + @Test func pingIssuedFromPublicTimelineRoutesToMeshTimeline() async { + let context = DiagnosticsMockContext() + let alice = PeerID(str: "abcd1234abcd1234") + context.nicknameToPeerID["alice"] = alice + let transport = MockTransport() + transport.meshPingResult = MeshPingResult(rttMs: 7, hops: 1) + let processor = makeProcessor(context: context, transport: transport) + + _ = processor.process("/ping @alice") + // Opening a DM afterwards must not swallow the public-timeline result. + context.selectedPrivateChatPeer = alice + + await waitForCommandOutput(context) + #expect(context.commandOutputDestinations == [.meshTimeline]) + } + + // MARK: - /trace + + @MainActor + @Test func traceDirectPeerShowsOneHop() { + let context = DiagnosticsMockContext() + let bob = PeerID(str: "b0b0b0b0b0b0b0b0") + context.nicknameToPeerID["bob"] = bob + let transport = MockTransport() + transport.meshPaths[bob] = [] + let processor = makeProcessor(context: context, transport: transport) + + let result = processor.process("/trace @bob") + switch result { + case .success(let message): + #expect(message == "estimated path: you → bob (1 hop)") + default: + Issue.record("Expected success result") + } + } + + @MainActor + @Test func traceMultiHopUsesNicknamesWithShortIDFallback() { + let context = DiagnosticsMockContext() + let bob = PeerID(str: "b0b0b0b0b0b0b0b0") + let alice = PeerID(str: "a11cea11cea11cea") + let unknown = PeerID(str: "dead00beef001234") + context.nicknameToPeerID["bob"] = bob + let transport = MockTransport() + transport.peerNicknames = [alice: "alice"] + transport.meshPaths[bob] = [alice, unknown] + let processor = makeProcessor(context: context, transport: transport) + + let result = processor.process("/trace bob") + switch result { + case .success(let message): + #expect(message == "estimated path: you → alice → dead00be… → bob (3 hops)") + default: + Issue.record("Expected success result") + } + } + + @MainActor + @Test func traceWithoutPathReportsNoKnownPath() { + let context = DiagnosticsMockContext() + context.nicknameToPeerID["bob"] = PeerID(str: "b0b0b0b0b0b0b0b0") + let processor = makeProcessor(context: context, transport: MockTransport()) + + let result = processor.process("/trace @bob") + switch result { + case .success(let message): + #expect(message == "no known path to bob") + default: + Issue.record("Expected success result") + } + } + + // MARK: - Topology snapshot types + + @Test func topologyEdgeNormalizesEndpointOrder() { + let a = PeerID(str: "aaaa000000000000") + let b = PeerID(str: "bbbb000000000000") + #expect(MeshTopologyEdge(a, b) == MeshTopologyEdge(b, a)) + #expect(Set([MeshTopologyEdge(a, b), MeshTopologyEdge(b, a)]).count == 1) + } + + @Test func topologyLayoutPlacesSelfInCenter() { + let nodes: [MeshTopologyDisplayModel.Node] = [ + .init(id: "self", label: "me", isSelf: true), + .init(id: "a", label: "alice", isSelf: false), + .init(id: "b", label: "bob", isSelf: false) + ] + let size = CGSize(width: 200, height: 200) + let positions = MeshTopologyView.layout(nodes: nodes, in: size) + + #expect(positions["self"] == CGPoint(x: 100, y: 100)) + #expect(positions.count == 3) + // Ring nodes sit on the same radius around the center. + let radiusA = hypot((positions["a"]?.x ?? 0) - 100, (positions["a"]?.y ?? 0) - 100) + let radiusB = hypot((positions["b"]?.x ?? 0) - 100, (positions["b"]?.y ?? 0) - 100) + #expect(abs(radiusA - radiusB) < 0.001) + #expect(radiusA > 0) + } +} + +/// Minimal CommandContextProvider for diagnostics tests; records deferred +/// command output so async /ping results can be asserted. +@MainActor +private final class DiagnosticsMockContext: CommandContextProvider { + var nickname: String = "tester" + var activeChannel: ChannelID = .mesh + var selectedPrivateChatPeer: PeerID? + var blockedUsers: Set = [] + let idBridge = NostrIdentityBridge(keychain: MockKeychain()) + + var nicknameToPeerID: [String: PeerID] = [:] + private(set) var commandOutputs: [String] = [] + private(set) var commandOutputDestinations: [CommandOutputDestination] = [] + + func getPeerIDForNickname(_ nickname: String) -> PeerID? { + nicknameToPeerID[nickname] + } + + func getVisibleGeoParticipants() -> [CommandGeoParticipant] { [] } + func nostrPubkeyForDisplayName(_ displayName: String) -> String? { nil } + func startPrivateChat(with peerID: PeerID) {} + func sendPrivateMessage(_ content: String, to peerID: PeerID) {} + func clearCurrentPublicTimeline() {} + func clearPrivateChat(_ peerID: PeerID) {} + func sendPublicRaw(_ content: String) {} + func sendPublicMessage(_ content: String) {} + func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {} + func addPublicSystemMessage(_ content: String) {} + func toggleFavorite(peerID: PeerID) {} + + func currentCommandDestination() -> CommandOutputDestination { + if let peerID = selectedPrivateChatPeer { + return .privateChat(peerID) + } + return .meshTimeline + } + + func addCommandOutput(_ content: String, to destination: CommandOutputDestination) { + commandOutputs.append(content) + commandOutputDestinations.append(destination) + } +} diff --git a/localPackages/BitFoundation/Sources/BitFoundation/MeshPingPayload.swift b/localPackages/BitFoundation/Sources/BitFoundation/MeshPingPayload.swift new file mode 100644 index 00000000..54265b8c --- /dev/null +++ b/localPackages/BitFoundation/Sources/BitFoundation/MeshPingPayload.swift @@ -0,0 +1,57 @@ +// +// MeshPingPayload.swift +// BitFoundation +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import struct Foundation.Data + +/// Wire payload shared by the `ping` (0x26) and `pong` (0x27) message types. +/// +/// Layout (9 bytes): +/// - 8 bytes: random nonce (a pong echoes the nonce of the ping it answers) +/// - 1 byte: origin TTL — the TTL the packet was launched with, so the +/// receiver can compute the hop count as `originTTL - receivedTTL`. +/// +/// Both directions are unencrypted and unsigned: the payload carries no +/// private data, and the unguessable nonce already binds a pong to a probe +/// the local device actually sent. +public struct MeshPingPayload: Equatable { + public static let nonceLength = 8 + private static let encodedLength = nonceLength + 1 + + public let nonce: Data + public let originTTL: UInt8 + + public init?(nonce: Data, originTTL: UInt8) { + guard nonce.count == Self.nonceLength else { return nil } + self.nonce = nonce + self.originTTL = originTTL + } + + public func encode() -> Data { + var data = Data(capacity: Self.encodedLength) + data.append(nonce) + data.append(originTTL) + return data + } + + /// Accepts payloads with trailing bytes so future revisions can extend + /// the format without breaking older clients. + public static func decode(_ data: Data) -> MeshPingPayload? { + guard data.count >= encodedLength else { return nil } + let nonce = Data(data.prefix(nonceLength)) + let originTTL = data[data.index(data.startIndex, offsetBy: nonceLength)] + return MeshPingPayload(nonce: nonce, originTTL: originTTL) + } + + /// Number of links a packet crossed, derived from TTL decrements plus the + /// final delivery link (a directly connected peer is 1 hop away). + /// Returns nil when the TTLs are inconsistent (received above origin). + public static func hopCount(originTTL: UInt8, receivedTTL: UInt8) -> Int? { + guard originTTL >= receivedTTL else { return nil } + return Int(originTTL - receivedTTL) + 1 + } +} diff --git a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift index d1e2ea0d..763abaa7 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift @@ -26,6 +26,10 @@ public enum MessageType: UInt8 { case fileTransfer = 0x22 // Binary file/audio/image payloads case boardPost = 0x23 // Signed geohash bulletin-board post or tombstone + // Mesh diagnostics + case ping = 0x26 // Directed echo request (nonce + origin TTL) + case pong = 0x27 // Directed echo reply (echoed nonce + origin TTL) + // Gateway mode: signed Nostr event ferried between a mesh-only peer and // an internet gateway peer. case nostrCarrier = 0x28 @@ -42,6 +46,8 @@ public enum MessageType: UInt8 { case .fragment: return "fragment" case .fileTransfer: return "fileTransfer" case .boardPost: return "boardPost" + case .ping: return "ping" + case .pong: return "pong" case .nostrCarrier: return "nostrCarrier" } } diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/MeshPingPayloadTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/MeshPingPayloadTests.swift new file mode 100644 index 00000000..8032bd9c --- /dev/null +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/MeshPingPayloadTests.swift @@ -0,0 +1,70 @@ +// +// MeshPingPayloadTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +@testable import BitFoundation + +struct MeshPingPayloadTests { + + @Test func encodeDecodeRoundTrip() throws { + let nonce = Data([0x01, 0x02, 0x03, 0x04, 0xAA, 0xBB, 0xCC, 0xFF]) + let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 7)) + + let encoded = payload.encode() + #expect(encoded.count == 9) + #expect(encoded.prefix(8) == nonce) + #expect(encoded.last == 7) + + let decoded = try #require(MeshPingPayload.decode(encoded)) + #expect(decoded == payload) + } + + @Test func decodeToleratesTrailingBytes() throws { + let nonce = Data(repeating: 0x42, count: 8) + let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 3)) + var extended = payload.encode() + extended.append(contentsOf: [0xDE, 0xAD]) + + let decoded = try #require(MeshPingPayload.decode(extended)) + #expect(decoded == payload) + } + + @Test func decodeRespectsSliceIndices() throws { + // Data slices keep their parent's indices; decoding must not assume + // startIndex == 0. + let nonce = Data(repeating: 0x11, count: 8) + let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 5)) + let framed = Data([0x00, 0x00]) + payload.encode() + let slice = framed.dropFirst(2) + + let decoded = try #require(MeshPingPayload.decode(slice)) + #expect(decoded == payload) + } + + @Test func rejectsTruncatedPayload() { + #expect(MeshPingPayload.decode(Data(repeating: 0x01, count: 8)) == nil) + #expect(MeshPingPayload.decode(Data()) == nil) + } + + @Test func rejectsWrongNonceLength() { + #expect(MeshPingPayload(nonce: Data(repeating: 0, count: 7), originTTL: 7) == nil) + #expect(MeshPingPayload(nonce: Data(repeating: 0, count: 9), originTTL: 7) == nil) + } + + @Test func hopCountMath() { + // Direct link: no TTL decrement, one hop. + #expect(MeshPingPayload.hopCount(originTTL: 7, receivedTTL: 7) == 1) + // One relay in between: two hops. + #expect(MeshPingPayload.hopCount(originTTL: 7, receivedTTL: 6) == 2) + // Full TTL consumed. + #expect(MeshPingPayload.hopCount(originTTL: 7, receivedTTL: 1) == 7) + // Inconsistent TTLs (received above origin) are rejected. + #expect(MeshPingPayload.hopCount(originTTL: 3, receivedTTL: 7) == nil) + } +} From 87910541ef10a711244562284afeb45f49dba853 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:38:33 +0200 Subject: [PATCH 15/18] Prekey bundles: forward-secret async first contact for courier mail (#1381) * Add capability bits to announce TLV Announces now carry an optional capabilities TLV (0x05): a little-endian bitfield with named bits for upcoming features (prekeys, wifiBulk, gateway, groups, board, vouch, meshDiagnostics). Old clients skip the unknown TLV; peers without it decode as nil so features can distinguish "legacy peer" from "advertises nothing". PeerCapabilities lives in BitFoundation with a minimal-length encoding that preserves unknown bits for forward compatibility. Peer capabilities are stored in the BLE peer registry on verified announce and exposed via BLEService.peerCapabilities(_:). The local advertisement set is empty until each feature ships its bit. Co-Authored-By: Claude Fable 5 * Prekey bundles: forward-secret async first contact for courier mail Courier envelopes were sealed with one-way Noise X to the recipient's long-lived static key, so a later compromise of that key exposed every envelope captured in transit. This adds one-time prekey bundles: - PrekeyBundle (MessageType 0x24): 8 one-time Curve25519 public prekeys bound to the owner's Noise static key by an Ed25519 signature over "bitchat-prekey-bundle-v1" canonical bytes; gossiped mesh-wide on its own 60s sync round (SyncTypeFlags bit 9, 200-peer cap, 24h freshness) and verified against the announce-bound signing key before caching. - Sealed envelope v2: Noise X where the responder static is the one-time prekey, prologue "bitchat-prekey-v1" || prekeyID. Sender identity rides encrypted inside and is authenticated exactly like v1 (blocked-sender check included). CourierEnvelope gains an optional prekeyID TLV that v1 decoders skip as unknown. - Local prekeys live in the Keychain; consumed privates survive a 48h grace window for spray-and-wait redeliveries, then are deleted (the forward-secrecy clock starts at deletion). The batch tops back up and re-gossips when unconsumed count drops below 3, and everything is wiped in panic mode. - Routing: courier sealing picks a cached verified bundle when one exists (one prekey per message, reused across deposit retries), with the advertised .prekeys capability as a veto for on-mesh peers, and falls back to static sealing otherwise. Co-Authored-By: Claude Fable 5 * Prekeys: authenticate bundle packets, fix consume-republish, deflake CI Fixes the prekey-bundle PR review + CI failure: - CI root cause: the receive queue (mesh.message) is concurrent, so a gossiped prekey bundle can be processed before the announce that binds its owner's signing key. The old handler dropped such bundles outright, so under CI parallel load the bundle was permanently lost and the cache/gossip tests flaked (verifiedBundleEntersGossipStore, prekeySealedMailTravelsViaCourierAndOpens). Bundles that arrive before their binding are now retained per-owner (bounded) and re-attempted when the verified announce lands, atomically to avoid a check-then-act race. - Authenticate the OUTER prekey-bundle packet (Codex P2 / review MEDIUM): require senderID == PeerID(bundle.noiseStaticPublicKey) and verify the packet's Ed25519 signature (covers senderID + timestamp) against the owner's bound signing key, in addition to the inner bundle signature. Stops replay under a fresh timestamp / fake senderID. - Key the gossip prekey-bundle store/dedup by the bundle's authenticated identity (noiseStaticPublicKey), not the unauthenticated packet senderID, so one valid bundle sprayed under many fabricated sender IDs can't multiply entries and exhaust the 200-owner cap. - Bump published-bundle generatedAt strictly on consume (Codex P1): consuming a prekey shrinks the published bundle, so it now republishes with a strictly newer generatedAt and re-gossips, so peers replace the cached copy and stop assigning the consumed ID before its 48h grace. - Guard the panic/clear detached Application Support tree-deletes behind TestEnvironment.isRunningTests: the SPM test process shares that tree, so the wipe could land mid-test and flake file-dependent tests. Co-Authored-By: Claude Fable 5 * Update sync tests for prekeyBundle as bit 9 / default sync round Prekeys makes bit 9 (prekeyBundle) a known SyncTypeFlags bit and enables a prekey sync round by default. That broke tests authored by other PRs that assumed bit 9 was phantom or that only their own sync round fires: - SyncTypeFlags(Board)Tests: move the "unknown bits" probes to bits 10+ (0xFE -> 0xFC / 0xFD), since bit 9 is now assigned. - GossipSync(Board)Tests + GossipSyncManagerTests: disable the prekey sync round in configs that run maintenance (as they already do for message/ fragment/fileTransfer), so they isolate the behavior under test. Full app suite (1301 tests) green locally via SPM. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- .../Protocols/PeerCapabilities+Local.swift | 2 +- .../BLE/BLEOutboundPacketPolicy.swift | 2 +- bitchat/Services/BLE/BLEService.swift | 246 ++++++++++- bitchat/Services/Courier/CourierStore.swift | 12 +- bitchat/Services/NoiseEncryptionService.swift | 108 ++++- .../Services/Prekeys/LocalPrekeyStore.swift | 228 ++++++++++ .../Services/Prekeys/PrekeyBundleStore.swift | 210 +++++++++ bitchat/Services/TransportConfig.swift | 12 + bitchat/Sync/GossipSyncManager.swift | 85 +++- bitchat/Sync/SyncTypeFlags.swift | 7 + .../ChatPublicConversationCoordinator.swift | 5 + bitchat/ViewModels/ChatViewModel.swift | 4 + .../EndToEnd/PrekeyEndToEndTests.swift | 399 ++++++++++++++++++ bitchatTests/GossipSyncManagerTests.swift | 103 ++++- bitchatTests/Prekeys/NoisePrekeyTests.swift | 283 +++++++++++++ .../Prekeys/PrekeyBundleStoreTests.swift | 197 +++++++++ bitchatTests/Prekeys/PrekeyBundleTests.swift | 133 ++++++ bitchatTests/Sync/GossipSyncBoardTests.swift | 1 + .../Sync/SyncTypeFlagsBoardTests.swift | 13 +- bitchatTests/Sync/SyncTypeFlagsTests.swift | 15 +- .../BitFoundation/CourierEnvelope.swift | 27 +- .../Sources/BitFoundation/MessageType.swift | 6 +- .../Sources/BitFoundation/PrekeyBundle.swift | 195 +++++++++ 23 files changed, 2254 insertions(+), 39 deletions(-) create mode 100644 bitchat/Services/Prekeys/LocalPrekeyStore.swift create mode 100644 bitchat/Services/Prekeys/PrekeyBundleStore.swift create mode 100644 bitchatTests/EndToEnd/PrekeyEndToEndTests.swift create mode 100644 bitchatTests/Prekeys/NoisePrekeyTests.swift create mode 100644 bitchatTests/Prekeys/PrekeyBundleStoreTests.swift create mode 100644 bitchatTests/Prekeys/PrekeyBundleTests.swift create mode 100644 localPackages/BitFoundation/Sources/BitFoundation/PrekeyBundle.swift diff --git a/bitchat/Protocols/PeerCapabilities+Local.swift b/bitchat/Protocols/PeerCapabilities+Local.swift index 29b94b4c..b5c36f1a 100644 --- a/bitchat/Protocols/PeerCapabilities+Local.swift +++ b/bitchat/Protocols/PeerCapabilities+Local.swift @@ -3,5 +3,5 @@ import BitFoundation extension PeerCapabilities { /// Capabilities this build advertises in its announce packets. /// Each feature adds its bit here when it ships. - static let localSupported: PeerCapabilities = [.vouch] + static let localSupported: PeerCapabilities = [.vouch, .prekeys] } diff --git a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift index 10837d9b..2bec6292 100644 --- a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift +++ b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift @@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy { switch MessageType(rawValue: packetType) { case .noiseEncrypted, .noiseHandshake: return true - case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier: + case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle: return false } } diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index c7a8570a..3e6c5da7 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -62,6 +62,19 @@ final class BLEService: NSObject { // Local-only store-and-forward counters; nil in unit tests. var sfMetrics: StoreAndForwardMetrics? + // Verified one-time prekey bundles gossiped by other peers, used to seal + // courier mail forward-secretly. Injectable for tests. + var prekeyBundleStore: PrekeyBundleStore = .shared + // Throttle for re-broadcasting our own (unchanged) bundle; guarded by + // collectionsQueue barriers. + private var lastPrekeyBundleSentAt: Date? + // Prekey bundles that arrived before their owner's verified announce bound + // a signing key. The receive queue is concurrent, so a bundle can race + // ahead of the announce it depends on; we retain the latest such bundle per + // owner (bounded) and re-attempt attribution when the announce lands. + // Guarded by collectionsQueue barriers. + private var pendingPrekeyBundles: [PeerID: BitchatPacket] = [:] + private static let pendingPrekeyBundleCap = 64 // Gateway mode: sink for received nostrCarrier packets (set by app // wiring, called on the main actor after transport-level checks) and the // runtime-toggled capability bits ORed into `PeerCapabilities.localSupported` @@ -320,7 +333,10 @@ final class BLEService: NSObject { fileTransferSyncIntervalSeconds: TransportConfig.syncFileTransferIntervalSeconds, messageSyncIntervalSeconds: TransportConfig.syncMessageIntervalSeconds, responseRateLimitMaxResponses: TransportConfig.syncResponseRateLimitMaxResponses, - responseRateLimitWindowSeconds: TransportConfig.syncResponseRateLimitWindowSeconds + responseRateLimitWindowSeconds: TransportConfig.syncResponseRateLimitWindowSeconds, + prekeyBundleCapacity: TransportConfig.syncPrekeyBundleCapacity, + prekeyBundleSyncIntervalSeconds: TransportConfig.syncPrekeyBundleIntervalSeconds, + prekeyBundleMaxAgeSeconds: TransportConfig.syncPrekeyBundleMaxAgeSeconds ) // Only real Bluetooth sessions archive to disk; unit tests stay hermetic. @@ -371,6 +387,8 @@ final class BLEService: NSObject { ingressLinks.removeAll() recentTrafficTracker.removeAll() scheduledRelays.cancelAll() + // Let the post-panic identity publish its fresh bundle promptly. + lastPrekeyBundleSentAt = nil return transfers } @@ -1368,6 +1386,10 @@ final class BLEService: NSObject { } // Ensure our own announce is included in sync state gossipSyncManager?.onPublicPacketSeen(signedPacket) + + // Keep our prekey bundle riding alongside presence (throttled; the + // send is a no-op when the bundle was refreshed recently). + sendPrekeyBundle() } // MARK: QR Verification over Noise @@ -1772,6 +1794,10 @@ extension BLEService { handleReceivedPacket(packet, from: fromPeerID) } + func _test_hasGossipPrekeyBundle(for peerID: PeerID) -> Bool { + gossipSyncManager?._hasPrekeyBundle(for: peerID) ?? false + } + func _test_acceptsIngress(packet: BitchatPacket, boundPeerID: PeerID?) -> Bool { let claimedSenderID = PeerID(hexData: packet.senderID) guard case .success = BLEIngressLinkRegistry.packetContext( @@ -2751,10 +2777,13 @@ extension BLEService { // MARK: Courier Store-and-Forward - /// Seal `content` to the recipient's static key (one-way Noise X) and hand - /// the envelope to the given couriers for physical delivery. Returns false - /// when no courier is connected, the payload cannot be built, or sealing - /// fails; link writes are queued asynchronously after the envelope is ready. + /// Seal `content` for the recipient and hand the envelope to the given + /// couriers for physical delivery. When a verified one-time prekey bundle + /// is cached for the recipient, sealing targets one of its prekeys + /// (forward secret, envelope v2); otherwise it falls back to their static + /// key (one-way Noise X, v1) exactly as before. Returns false when no + /// courier is connected, the payload cannot be built, or sealing fails; + /// link writes are queued asynchronously after the envelope is ready. func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { let connected = couriers.filter { isPeerConnected($0) } guard !connected.isEmpty, @@ -2765,7 +2794,15 @@ extension BLEService { let payload: Data do { let now = Date() - let sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey) + let sealed: Data + let prekeyID: UInt32? + if let prekey = assignRecipientPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) { + sealed = try noiseService.sealPrekeyPayload(typedPayload, recipientPrekey: prekey) + prekeyID = prekey.id + } else { + sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey) + prekeyID = nil + } let envelope = CourierEnvelope( recipientTag: CourierEnvelope.recipientTag( noiseStaticKey: recipientNoiseKey, @@ -2773,7 +2810,8 @@ extension BLEService { ), expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000), ciphertext: sealed, - copies: TransportConfig.courierInitialCopies + copies: TransportConfig.courierInitialCopies, + prekeyID: prekeyID ) guard let encoded = envelope.encode() else { return false } payload = encoded @@ -2792,6 +2830,22 @@ extension BLEService { return true } + /// The prekey to seal a courier message with, or nil to fall back to + /// static sealing. The real signal is a verified, unexpired bundle with a + /// spare prekey; the advertised `.prekeys` capability only acts as a veto + /// for peers we currently see on the mesh (a cached bundle can outlive a + /// peer's downgrade to a build that no longer holds the privates). + /// Re-deposits of the same message reuse its assigned prekey, so one + /// message consumes exactly one prekey ID regardless of courier count. + private func assignRecipientPrekey(messageID: String, recipientNoiseKey: Data) -> PrekeyBundle.Prekey? { + let shortID = PeerID(publicKey: recipientNoiseKey) + let knownOnMesh = collectionsQueue.sync { peerRegistry.info(for: shortID) != nil } + if knownOnMesh, !peerCapabilities(shortID).contains(.prekeys) { + return nil + } + return prekeyBundleStore.assignPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) + } + private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket { let packet = BitchatPacket( type: MessageType.courierEnvelope.rawValue, @@ -2827,7 +2881,25 @@ extension BLEService { private func openCourierEnvelope(_ envelope: CourierEnvelope) { do { - let (typedPayload, senderStaticKey) = try noiseService.openCourierPayload(envelope.ciphertext) + let typedPayload: Data + let senderStaticKey: Data + if let prekeyID = envelope.prekeyID { + // Envelope v2: sealed to one of our one-time prekeys. Opening + // consumes the prekey (48h redelivery grace), which shrinks our + // published bundle under a strictly newer generatedAt. Re-gossip + // so peers replace their cached copy and stop assigning the + // consumed ID before the grace lapses; force the broadcast when + // the batch also topped back up (low-water), otherwise let the + // rebroadcast throttle coalesce bursts. + let opened = try noiseService.openPrekeyPayload(envelope.ciphertext, prekeyID: prekeyID) + (typedPayload, senderStaticKey) = (opened.payload, opened.senderStaticKey) + if opened.consumedPrekey { + let replenished = noiseService.replenishPrekeysIfNeeded() + sendPrekeyBundle(force: replenished) + } + } else { + (typedPayload, senderStaticKey) = try noiseService.openCourierPayload(envelope.ciphertext) + } guard let typeRaw = typedPayload.first, let payloadType = NoisePayloadType(rawValue: typeRaw), payloadType == .privateMessage else { @@ -2958,6 +3030,155 @@ extension BLEService { } } + // MARK: One-Time Prekey Bundles + + /// Broadcasts our signed prekey bundle and tracks it for gossip sync. + /// Unforced sends (piggybacked on announces) are throttled — gossip does + /// the spreading, the broadcast just keeps our own gossip entry fresh. + /// Forced sends (bundle changed after consumption) go immediately. + private func sendPrekeyBundle(force: Bool = false) { + let now = Date() + let shouldSend: Bool = collectionsQueue.sync(flags: .barrier) { + if !force, + let last = lastPrekeyBundleSentAt, + now.timeIntervalSince(last) < TransportConfig.prekeyBundleRebroadcastSeconds { + return false + } + lastPrekeyBundleSentAt = now + return true + } + guard shouldSend else { return } + guard let bundle = noiseService.currentPrekeyBundle(), + let payload = bundle.encode() else { + SecureLogger.error("❌ Failed to build prekey bundle", category: .security) + return + } + let packet = BitchatPacket( + type: MessageType.prekeyBundle.rawValue, + senderID: myPeerIDData, + recipientID: nil, + timestamp: UInt64(now.timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: messageTTL + ) + guard let signedPacket = noiseService.signPacket(packet) else { + SecureLogger.error("❌ Failed to sign prekey bundle packet", category: .security) + return + } + if DispatchQueue.getSpecific(key: messageQueueKey) != nil { + broadcastPacket(signedPacket) + } else { + messageQueue.async { [weak self] in + self?.broadcastPacket(signedPacket) + } + } + gossipSyncManager?.onPublicPacketSeen(signedPacket) + } + + /// Ingests a gossiped prekey bundle. Attribution is layered: the outer + /// packet must originate from the bundle owner (fabricated sender IDs, used + /// to multiply cache/gossip entries, are rejected), and BOTH the inner + /// bundle signature and the outer packet signature must verify against the + /// owner's announce-bound signing key. Verifying the outer packet — whose + /// signed bytes cover senderID and timestamp — stops a valid bundle from + /// being replayed under a fresh timestamp or spoofed sender to pass + /// freshness or poison attribution. Only after that does the packet enter + /// our own gossip store, so we never help spread a bundle we couldn't + /// attribute. + private func handlePrekeyBundle(_ packet: BitchatPacket, from peerID: PeerID) { + guard let bundle = PrekeyBundle.decode(packet.payload) else { + SecureLogger.debug("🔑 Ignoring malformed prekey bundle from \(peerID.id.prefix(8))…", category: .security) + return + } + // Our own bundle is tracked at send time; a copy echoing back adds nothing. + guard bundle.noiseStaticPublicKey != noiseService.getStaticPublicKeyData() else { return } + let owner = PeerID(publicKey: bundle.noiseStaticPublicKey) + // The owner's genuine bundle (direct or relayed) always carries the + // owner's senderID + outer signature; gossip resends preserve both. A + // packet whose senderID isn't the owner can't be authenticated here. + guard PeerID(hexData: packet.senderID) == owner else { + SecureLogger.debug("🔑 Ignoring prekey bundle whose sender ≠ owner \(owner.id.prefix(8))…", category: .security) + return + } + // Look up the announce-bound signing key and stash-if-unbound in ONE + // barrier: the receive queue is concurrent, so this bundle can race + // ahead of the announce that binds the key. Reading the live registry + // and stashing atomically closes the check-then-act gap against + // handleAnnounce's drain (see drainPendingPrekeyBundles). + let signingKey: Data? = collectionsQueue.sync(flags: .barrier) { + if let info = peerRegistry.info(for: owner), + info.noisePublicKey == bundle.noiseStaticPublicKey, + let key = info.signingPublicKey { + return key + } + // Offline-verified identities are stable across this race. + for candidate in identityManager.getCryptoIdentitiesByPeerIDPrefix(owner) + where candidate.publicKey == bundle.noiseStaticPublicKey { + if let key = candidate.signingPublicKey { return key } + } + // No binding yet: retain the latest bundle per owner, bounded, and + // retry once the verified announce lands. + if pendingPrekeyBundles[owner] != nil + || pendingPrekeyBundles.count < Self.pendingPrekeyBundleCap { + pendingPrekeyBundles[owner] = packet + } + return nil + } + guard let signingKey else { + SecureLogger.debug("🔑 Deferring prekey bundle without a bound signing key (owner \(owner.id.prefix(8))…)", category: .security) + return + } + ingestVerifiedPrekeyBundle(bundle, packet: packet, owner: owner, signingKey: signingKey) + } + + /// Verify a bundle's inner + outer signatures against the owner's bound + /// signing key and, on success, cache it and let it enter our gossip store. + private func ingestVerifiedPrekeyBundle(_ bundle: PrekeyBundle, packet: BitchatPacket, owner: PeerID, signingKey: Data) { + guard noiseService.verifyPrekeyBundleSignature(bundle, signingPublicKey: signingKey), + noiseService.verifyPacketSignature(packet, publicKey: signingKey) else { + SecureLogger.debug("🔑 Ignoring prekey bundle without verifiable signature (owner \(owner.id.prefix(8))…)", category: .security) + return + } + if prekeyBundleStore.ingest(bundle) { + SecureLogger.debug("🔑 Cached prekey bundle for \(owner.id.prefix(8))… (\(bundle.prekeys.count) prekeys)", category: .security) + } + gossipSyncManager?.onPublicPacketSeen(packet) + } + + /// Re-attempt any prekey bundle that arrived before this owner's announce + /// bound a signing key. Called from handleAnnounce after a verified + /// announce, in a barrier ordered after the registry write, so a bundle + /// stashed before the write is always observed here. + private func drainPendingPrekeyBundles(for owner: PeerID) { + let pending: BitchatPacket? = collectionsQueue.sync(flags: .barrier) { + pendingPrekeyBundles.removeValue(forKey: owner) + } + guard let packet = pending, + let bundle = PrekeyBundle.decode(packet.payload), + let signingKey = announceBoundSigningKey(forNoiseKey: bundle.noiseStaticPublicKey) else { return } + ingestVerifiedPrekeyBundle(bundle, packet: packet, owner: owner, signingKey: signingKey) + } + + /// Ed25519 signing key bound to a Noise static key by a verified + /// announce: from the live registry when the owner is on the mesh, else + /// from identities persisted for offline verification. + private func announceBoundSigningKey(forNoiseKey noiseKey: Data) -> Data? { + let shortID = PeerID(publicKey: noiseKey) + if let info = collectionsQueue.sync(execute: { peerRegistry.info(for: shortID) }), + info.noisePublicKey == noiseKey, + let signingKey = info.signingPublicKey { + return signingKey + } + for candidate in identityManager.getCryptoIdentitiesByPeerIDPrefix(shortID) + where candidate.publicKey == noiseKey { + if let signingKey = candidate.signingPublicKey { + return signingKey + } + } + return nil + } + // MARK: Gateway carrier (nostrCarrier) /// Sign and send an encoded `toGateway` carrier payload directed at a @@ -3537,6 +3758,9 @@ extension BLEService { case .courierEnvelope: handleCourierEnvelope(packet, from: peerID) + case .prekeyBundle: + handlePrekeyBundle(packet, from: senderID) + case .boardPost: // Invalid or deleted posts must not spread; skip the relay step. guard handleBoardPost(packet, from: senderID) else { return } @@ -3615,6 +3839,12 @@ extension BLEService { private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) { let result = announceHandler.handle(packet, from: peerID) + // A verified announce is the moment a signing key becomes bound to this + // owner's noise key: retry any prekey bundle that raced ahead of it. + if let result, result.isVerified { + drainPendingPrekeyBundles(for: result.peerID) + } + // Courier work: an announce is the moment we learn a peer's Noise // static key, so check whether we're carrying mail addressed to them // (or spray-able mail they could carry). Verified announces only. diff --git a/bitchat/Services/Courier/CourierStore.swift b/bitchat/Services/Courier/CourierStore.swift index 063c94d5..a24eb371 100644 --- a/bitchat/Services/Courier/CourierStore.swift +++ b/bitchat/Services/Courier/CourierStore.swift @@ -42,9 +42,11 @@ final class CourierStore { var sprayedTo: Set /// Last speculative multi-hop handover toward a relayed announce. var lastRemoteHandoverAt: Date? + /// Prekey-sealed (envelope v2) discriminator; nil for static-sealed v1. + let prekeyID: UInt32? var envelope: CourierEnvelope { - CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies) + CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies, prekeyID: prekeyID) } init( @@ -56,7 +58,8 @@ final class CourierStore { tier: CourierDepositTier, copies: UInt8, sprayedTo: Set = [], - lastRemoteHandoverAt: Date? = nil + lastRemoteHandoverAt: Date? = nil, + prekeyID: UInt32? = nil ) { self.recipientTag = recipientTag self.expiry = expiry @@ -67,6 +70,7 @@ final class CourierStore { self.copies = copies self.sprayedTo = sprayedTo self.lastRemoteHandoverAt = lastRemoteHandoverAt + self.prekeyID = prekeyID } // Files written before tiers/spray lack the newer fields; treat that @@ -82,6 +86,7 @@ final class CourierStore { copies = try container.decodeIfPresent(UInt8.self, forKey: .copies) ?? 1 sprayedTo = try container.decodeIfPresent(Set.self, forKey: .sprayedTo) ?? [] lastRemoteHandoverAt = try container.decodeIfPresent(Date.self, forKey: .lastRemoteHandoverAt) + prekeyID = try container.decodeIfPresent(UInt32.self, forKey: .prekeyID) } } @@ -186,7 +191,8 @@ final class CourierStore { depositorNoiseKey: depositorNoiseKey, storedAt: date, tier: tier, - copies: envelope.copies + copies: envelope.copies, + prekeyID: envelope.prekeyID )) persistLocked() return true diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index 8a99d9ec..9e7e6eae 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -172,6 +172,10 @@ final class NoiseEncryptionService { // Security components private let rateLimiter = NoiseRateLimiter() private let keychain: KeychainManagerProtocol + + // One-time prekeys for forward-secret courier sealing (lazy generation + // inside the store; the batch is minted on first bundle build). + private let localPrekeys: LocalPrekeyStore // Session maintenance private var rekeyTimer: Timer? @@ -200,6 +204,7 @@ final class NoiseEncryptionService { init(keychain: KeychainManagerProtocol) { self.keychain = keychain + self.localPrekeys = LocalPrekeyStore(keychain: keychain) // BCH-01-009: Load or create static identity key with proper error handling let loadedKey: Curve25519.KeyAgreement.PrivateKey @@ -412,13 +417,111 @@ final class NoiseEncryptionService { } return (payload: payload, senderStaticKey: senderKey.rawRepresentation) } - + + // MARK: - One-Time Prekey Envelopes (forward-secret Noise X) + + /// Domain separation for prekey-sealed envelopes: distinct from both the + /// interactive XX transcripts and static-sealed courier envelopes, and + /// bound to the specific prekey ID so a ciphertext cannot be replayed + /// against a different prekey. + private static let prekeyProloguePrefix = Data("bitchat-prekey-v1".utf8) + + private static func prekeyPrologue(for prekeyID: UInt32) -> Data { + var prologue = prekeyProloguePrefix + var big = prekeyID.bigEndian + withUnsafeBytes(of: &big) { prologue.append(contentsOf: $0) } + return prologue + } + + /// Encrypt a payload to one of the recipient's gossiped one-time prekeys + /// (Noise X where the responder static is the prekey, not the identity + /// key). Unlike `sealCourierPayload`, this is forward secret: once the + /// recipient consumes the prekey and its grace window lapses, the private + /// key is deleted and captured ciphertext becomes undecryptable even if + /// the recipient's identity key is later compromised. The initiator's + /// static still rides inside (encrypted), so the recipient authenticates + /// the sender exactly as with static-sealed envelopes. + func sealPrekeyPayload(_ payload: Data, recipientPrekey: PrekeyBundle.Prekey) throws -> Data { + let remoteKey = try NoiseHandshakeState.validatePublicKey(recipientPrekey.publicKey) + let handshake = NoiseHandshakeState( + role: .initiator, + pattern: .X, + keychain: keychain, + localStaticKey: staticIdentityKey, + remoteStaticKey: remoteKey, + prologue: Self.prekeyPrologue(for: recipientPrekey.id) + ) + return try handshake.writeMessage(payload: payload) + } + + /// Decrypt an envelope sealed to one of our one-time prekeys. On success + /// the prekey is marked consumed (its private key survives a 48h grace + /// window for spray-and-wait redeliveries, then is deleted for good). + /// Returns the payload, the sender's authenticated static key (same + /// contract as `openCourierPayload`), and whether this open actually + /// retired the prekey — false for a redelivery of already-consumed mail — + /// so the caller can re-gossip the shrunken bundle only when it changed. + func openPrekeyPayload(_ envelopeCiphertext: Data, prekeyID: UInt32) throws -> (payload: Data, senderStaticKey: Data, consumedPrekey: Bool) { + guard let prekeyPrivate = localPrekeys.privateKey(for: prekeyID) else { + throw NoiseEncryptionError.unknownPrekey + } + let handshake = NoiseHandshakeState( + role: .responder, + pattern: .X, + keychain: keychain, + localStaticKey: prekeyPrivate, + prologue: Self.prekeyPrologue(for: prekeyID) + ) + let payload = try handshake.readMessage(envelopeCiphertext) + guard let senderKey = handshake.getRemoteStaticPublicKey() else { + throw NoiseError.missingKeys + } + let consumedPrekey = localPrekeys.markConsumed(prekeyID) + return (payload: payload, senderStaticKey: senderKey.rawRepresentation, consumedPrekey: consumedPrekey) + } + + /// Current signed prekey bundle for gossip, minting the initial batch on + /// first use. Nil only when signing fails. + func currentPrekeyBundle() -> PrekeyBundle? { + let (prekeys, generatedAt) = localPrekeys.currentBundlePrekeys() + guard !prekeys.isEmpty else { return nil } + let unsigned = PrekeyBundle( + noiseStaticPublicKey: getStaticPublicKeyData(), + prekeys: prekeys, + generatedAt: generatedAt, + signature: Data(count: PrekeyBundle.signatureLength) + ) + guard let signature = signData(unsigned.signableBytes()) else { return nil } + return PrekeyBundle( + noiseStaticPublicKey: unsigned.noiseStaticPublicKey, + prekeys: prekeys, + generatedAt: generatedAt, + signature: signature + ) + } + + /// Verify a peer's bundle signature against their announce-bound Ed25519 + /// signing key. + func verifyPrekeyBundleSignature(_ bundle: PrekeyBundle, signingPublicKey: Data) -> Bool { + verifySignature(bundle.signature, for: bundle.signableBytes(), publicKey: signingPublicKey) + } + + /// Prune dead prekeys and top the batch back up when consumption runs it + /// low. Returns true when the published bundle changed and should be + /// re-gossiped. + @discardableResult + func replenishPrekeysIfNeeded() -> Bool { + localPrekeys.replenishIfNeeded() + } + /// Clear persistent identity (for panic mode) func clearPersistentIdentity() { // Clear from keychain let deletedStatic = keychain.deleteIdentityKey(forKey: "noiseStaticKey") let deletedSigning = keychain.deleteIdentityKey(forKey: "ed25519SigningKey") SecureLogger.logKeyOperation(.delete, keyType: "identity keys", success: deletedStatic && deletedSigning) + // One-time prekey privates go with the identity they were bound to. + localPrekeys.wipe() SecureLogger.warning("Panic mode activated - identity cleared", category: .security) // Stop rekey timer stopRekeyTimer() @@ -812,4 +915,7 @@ struct NoiseMessage: Codable { enum NoiseEncryptionError: Error { case handshakeRequired case sessionNotEstablished + /// Envelope references a prekey ID we don't hold (never ours, already + /// deleted after its grace window, or wiped in a panic). + case unknownPrekey } diff --git a/bitchat/Services/Prekeys/LocalPrekeyStore.swift b/bitchat/Services/Prekeys/LocalPrekeyStore.swift new file mode 100644 index 00000000..26f9ec67 --- /dev/null +++ b/bitchat/Services/Prekeys/LocalPrekeyStore.swift @@ -0,0 +1,228 @@ +// +// LocalPrekeyStore.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import BitLogger +import CryptoKit +import Foundation + +/// Owns this device's one-time Curve25519 prekey private keys. +/// +/// Privates persist in the Keychain (single blob, same protection class as +/// the identity keys). A batch of `batchSize` unconsumed prekeys backs the +/// gossiped bundle; when consumption drops the unconsumed count below +/// `replenishThreshold`, the batch tops back up and the bundle's +/// `generatedAt` bumps so peers replace their cached copy. +/// +/// Redelivery grace: spray-and-wait means the same prekey-sealed ciphertext +/// (or a re-seal of the same message to the same prekey ID) can arrive via +/// several couriers days apart. A consumed prekey's private key is therefore +/// retained for `consumedGraceSeconds` after first use and only then deleted. +/// Tradeoff: during the grace window a compromise of the device still exposes +/// mail sealed to that prekey — the forward-secrecy clock starts at deletion, +/// not at first open. Refusing new ciphertexts while accepting redeliveries +/// is not possible (the recipient cannot distinguish them), so the window is +/// kept short and fixed. +final class LocalPrekeyStore { + struct Record: Codable { + let id: UInt32 + let privateKey: Data + let createdAt: Date + var consumedAt: Date? + } + + private struct Persisted: Codable { + var records: [Record] + var nextID: UInt32 + var generatedAt: UInt64 + } + + enum Policy { + static let batchSize = PrekeyBundle.maxPrekeys + static let replenishThreshold = 3 + /// How long a consumed prekey private survives for duplicate courier + /// deliveries of mail sealed to it. + static let consumedGraceSeconds: TimeInterval = 48 * 60 * 60 + /// Unconsumed prekeys older than this are rotated out: no honest + /// sender seals to a bundle that stale (see + /// `PrekeyBundleStore.Limits.maxBundleAgeForSealingSeconds`). + static let unconsumedRetentionSeconds: TimeInterval = 30 * 24 * 60 * 60 + } + + private static let keychainKey = "prekeysV1" + + private let keychain: KeychainManagerProtocol + private let now: () -> Date + private let queue = DispatchQueue(label: "chat.bitchat.prekeys.local") + + // Guarded by `queue`. + private var records: [Record] = [] + private var nextID: UInt32 = 0 + private var generatedAt: UInt64 = 0 + private var loaded = false + + init(keychain: KeychainManagerProtocol, now: @escaping () -> Date = Date.init) { + self.keychain = keychain + self.now = now + } + + // MARK: - Bundle contents (public prekeys) + + /// Unconsumed public prekeys for the gossiped bundle, generating the + /// initial batch on first use. Sorted by ID for canonical signing bytes. + func currentBundlePrekeys() -> (prekeys: [PrekeyBundle.Prekey], generatedAt: UInt64) { + queue.sync { + loadLocked() + _ = replenishLocked() + let prekeys = records + .filter { $0.consumedAt == nil } + .sorted { $0.id < $1.id } + .compactMap { record -> PrekeyBundle.Prekey? in + guard let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: record.privateKey) else { return nil } + return PrekeyBundle.Prekey(id: record.id, publicKey: key.publicKey.rawRepresentation) + } + return (prekeys, generatedAt) + } + } + + // MARK: - Opening (private prekeys) + + /// Private key for a prekey ID: unconsumed, or consumed within the + /// redelivery grace window. + func privateKey(for id: UInt32) -> Curve25519.KeyAgreement.PrivateKey? { + queue.sync { + loadLocked() + let date = now() + guard let record = records.first(where: { $0.id == id }) else { return nil } + if let consumedAt = record.consumedAt, + date.timeIntervalSince(consumedAt) > Policy.consumedGraceSeconds { + return nil + } + return try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: record.privateKey) + } + } + + /// Marks a prekey consumed (starts its grace clock). Idempotent: a + /// redelivery within the grace window does not restart the clock. + /// + /// Returns true when this call actually retired a prekey, i.e. the + /// published bundle shrank. Consuming a prekey drops it from + /// `currentBundlePrekeys()`, so `generatedAt` must advance strictly too: + /// otherwise peers that cached the old bundle reject the same-`generatedAt` + /// replacement in `PrekeyBundleStore.ingest`, keep assigning the consumed + /// ID, and their mail starts failing `unknownPrekey` once the 48h grace + /// lapses. The caller re-gossips on a true result. + @discardableResult + func markConsumed(_ id: UInt32) -> Bool { + queue.sync { + loadLocked() + guard let index = records.firstIndex(where: { $0.id == id }), + records[index].consumedAt == nil else { return false } + records[index].consumedAt = now() + advanceGeneratedAtLocked() + persistLocked() + return true + } + } + + /// Prunes dead prekeys and tops the unconsumed batch back up when it runs + /// low. Returns true when the published bundle changed (caller should + /// re-gossip). + @discardableResult + func replenishIfNeeded() -> Bool { + queue.sync { + loadLocked() + return replenishLocked() + } + } + + var unconsumedCount: Int { + queue.sync { + loadLocked() + return records.filter { $0.consumedAt == nil }.count + } + } + + /// Panic wipe: drop all prekey privates from memory and the Keychain. + func wipe() { + queue.sync { + records.removeAll() + nextID = 0 + generatedAt = 0 + loaded = true + _ = keychain.deleteIdentityKey(forKey: Self.keychainKey) + } + } + + // MARK: - Internals (call only on `queue`) + + private func replenishLocked() -> Bool { + let date = now() + + // Consumed prekeys past the grace window are gone for good; stale + // unconsumed ones rotate out (their bundle is too old to seal to). + let recordsBefore = records.count + let unconsumedBefore = records.filter { $0.consumedAt == nil }.count + records.removeAll { record in + if let consumedAt = record.consumedAt { + return date.timeIntervalSince(consumedAt) > Policy.consumedGraceSeconds + } + return date.timeIntervalSince(record.createdAt) > Policy.unconsumedRetentionSeconds + } + // Only a change to the *unconsumed* set alters the published bundle; + // grace-expired consumed keys were never in it. + let unconsumed = records.filter { $0.consumedAt == nil }.count + var bundleChanged = unconsumed != unconsumedBefore + + if unconsumed < Policy.replenishThreshold { + for _ in unconsumed.. +// + +import BitFoundation +import BitLogger +import Foundation + +/// Signature-verified one-time prekey bundles received from other peers. +/// +/// One bundle per Noise static key: a newer `generatedAt` replaces the cached +/// copy, keeping the IDs we already sealed with marked used so a prekey is +/// never reused across messages. Assignments are remembered per message ID so +/// deposit retries of the same message re-use its prekey (and its budget) +/// instead of burning a fresh one per courier. +/// +/// Only public key material lives here; it persists to disk so a sender can +/// prekey-seal for recipients met long ago. Included in the panic wipe. +final class PrekeyBundleStore { + struct StoredBundle: Codable { + let noiseKey: Data + var generatedAt: UInt64 + var prekeyIDs: [UInt32] + var prekeyPublicKeys: [Data] + /// IDs this device already sealed with (never reused). + var usedIDs: Set + /// messageID → prekey ID, so re-deposits of one message share one prekey. + var assignments: [String: UInt32] + var updatedAt: Date + } + + enum Limits { + static let maxPeers = 200 + /// Don't seal to bundles older than this: the owner may have rotated + /// the unconsumed keys out (see `LocalPrekeyStore.Policy`). + static let maxBundleAgeForSealingSeconds: TimeInterval = 7 * 24 * 60 * 60 + } + + static let shared = PrekeyBundleStore() + + private var bundles: [Data: StoredBundle] = [:] + private let queue = DispatchQueue(label: "chat.bitchat.prekeys.bundles") + private let fileURL: URL? + private let maxPeers: Int + private let now: () -> Date + + /// - Parameter fileURL: Overrides the on-disk location (tests). Ignored + /// when `persistsToDisk` is false. + init( + persistsToDisk: Bool = true, + fileURL: URL? = nil, + maxPeers: Int = Limits.maxPeers, + now: @escaping () -> Date = Date.init + ) { + self.now = now + self.maxPeers = maxPeers + self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil + loadFromDisk() + } + + // MARK: - Ingest + + /// Stores a bundle whose signature the caller has already verified + /// against the owner's announce-bound signing key. Returns false when an + /// equal-or-newer bundle is already cached (nothing changed). + @discardableResult + func ingest(_ bundle: PrekeyBundle) -> Bool { + guard bundle.noiseStaticPublicKey.count == PrekeyBundle.keyLength, + !bundle.prekeys.isEmpty else { return false } + return queue.sync { + if let existing = bundles[bundle.noiseStaticPublicKey], + existing.generatedAt >= bundle.generatedAt { + return false + } + let previous = bundles[bundle.noiseStaticPublicKey] + let newIDs = Set(bundle.prekeys.map(\.id)) + // Keep consumption state for IDs the fresh bundle still offers + // (a top-up keeps the owner's unconsumed keys); drop the rest. + let carriedUsed = (previous?.usedIDs ?? []).intersection(newIDs) + let carriedAssignments = (previous?.assignments ?? [:]).filter { newIDs.contains($0.value) } + bundles[bundle.noiseStaticPublicKey] = StoredBundle( + noiseKey: bundle.noiseStaticPublicKey, + generatedAt: bundle.generatedAt, + prekeyIDs: bundle.prekeys.map(\.id), + prekeyPublicKeys: bundle.prekeys.map(\.publicKey), + usedIDs: carriedUsed, + assignments: carriedAssignments, + updatedAt: now() + ) + enforceCapLocked() + persistLocked() + return true + } + } + + // MARK: - Sealing support + + /// Whether an unexpired bundle with sealable prekeys is cached for a peer. + func hasUsableBundle(for noiseKey: Data) -> Bool { + queue.sync { + guard let bundle = bundles[noiseKey], isFreshLocked(bundle) else { return false } + return bundle.usedIDs.count < bundle.prekeyIDs.count + } + } + + /// The prekey to seal a message with: the message's existing assignment if + /// any (re-deposits reuse it), else the lowest unused ID, which is then + /// marked used. Nil when no fresh bundle is cached or all its prekeys are + /// spent — callers fall back to static sealing. + func assignPrekey(messageID: String, recipientNoiseKey: Data) -> PrekeyBundle.Prekey? { + queue.sync { + guard var bundle = bundles[recipientNoiseKey], isFreshLocked(bundle) else { return nil } + + if let assigned = bundle.assignments[messageID], + let index = bundle.prekeyIDs.firstIndex(of: assigned) { + return PrekeyBundle.Prekey(id: assigned, publicKey: bundle.prekeyPublicKeys[index]) + } + + guard let index = bundle.prekeyIDs.indices + .filter({ !bundle.usedIDs.contains(bundle.prekeyIDs[$0]) }) + .min(by: { bundle.prekeyIDs[$0] < bundle.prekeyIDs[$1] }) else { + return nil + } + let id = bundle.prekeyIDs[index] + bundle.usedIDs.insert(id) + bundle.assignments[messageID] = id + bundle.updatedAt = now() + bundles[recipientNoiseKey] = bundle + persistLocked() + return PrekeyBundle.Prekey(id: id, publicKey: bundle.prekeyPublicKeys[index]) + } + } + + // MARK: - Maintenance + + /// Panic wipe: drop all cached bundles from memory and disk. + func wipe() { + queue.sync { + bundles.removeAll() + if let fileURL { + try? FileManager.default.removeItem(at: fileURL) + } + } + } + + // MARK: - Internals (call only on `queue`) + + private func isFreshLocked(_ bundle: StoredBundle) -> Bool { + let ageSeconds = now().timeIntervalSince1970 - Double(bundle.generatedAt) / 1000 + return ageSeconds <= Limits.maxBundleAgeForSealingSeconds + } + + private func enforceCapLocked() { + while bundles.count > maxPeers { + guard let victim = bundles.min(by: { $0.value.updatedAt < $1.value.updatedAt }) else { return } + bundles.removeValue(forKey: victim.key) + } + } + + private func persistLocked() { + guard let fileURL else { return } + do { + if bundles.isEmpty { + try? FileManager.default.removeItem(at: fileURL) + return + } + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try JSONEncoder().encode(Array(bundles.values)) + var options: Data.WritingOptions = [.atomic] + #if os(iOS) + options.insert(.completeFileProtection) + #endif + try data.write(to: fileURL, options: options) + } catch { + SecureLogger.error("Failed to persist prekey bundle store: \(error)", category: .security) + } + } + + private func loadFromDisk() { + guard let fileURL else { return } + queue.sync { + guard let data = try? Data(contentsOf: fileURL), + let stored = try? JSONDecoder().decode([StoredBundle].self, from: data) else { + return + } + for bundle in stored where bundle.prekeyIDs.count == bundle.prekeyPublicKeys.count { + bundles[bundle.noiseKey] = bundle + } + } + } + + private static func defaultFileURL() -> URL? { + guard let base = try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) else { return nil } + return base + .appendingPathComponent("prekeys", isDirectory: true) + .appendingPathComponent("bundles.json") + } +} diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 31659710..07e11666 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -313,4 +313,16 @@ enum TransportConfig { // Cooldown between speculative multi-hop handovers of the same envelope // toward a recipient heard only via relayed announces. static let courierRemoteHandoverCooldownSeconds: TimeInterval = 10 * 60 + + // One-time prekey bundles (forward-secret courier sealing) + // Own gossip-sync round for bundles: modest cadence, bounded peer count, + // and a long freshness window so bundles persist mesh-wide while their + // owners are away. + static let syncPrekeyBundleCapacity: Int = 200 + static let syncPrekeyBundleIntervalSeconds: TimeInterval = 60.0 + static let syncPrekeyBundleMaxAgeSeconds: TimeInterval = 24 * 60 * 60 + // Unforced re-broadcasts of our own (unchanged) bundle, piggybacked on + // announces, keep it alive in peers' gossip stores; changed bundles are + // sent immediately. + static let prekeyBundleRebroadcastSeconds: TimeInterval = 60 * 60 } diff --git a/bitchat/Sync/GossipSyncManager.swift b/bitchat/Sync/GossipSyncManager.swift index 5edf1c4c..b0d72e47 100644 --- a/bitchat/Sync/GossipSyncManager.swift +++ b/bitchat/Sync/GossipSyncManager.swift @@ -83,6 +83,11 @@ final class GossipSyncManager { var boardSyncIntervalSeconds: TimeInterval = 60.0 var responseRateLimitMaxResponses: Int = 8 var responseRateLimitWindowSeconds: TimeInterval = 30.0 + // Prekey bundles: one per peer, own sync round, long freshness so + // bundles persist mesh-wide while their owners are offline. + var prekeyBundleCapacity: Int = 200 + var prekeyBundleSyncIntervalSeconds: TimeInterval = 60.0 + var prekeyBundleMaxAgeSeconds: TimeInterval = 24 * 60 * 60 } private let myPeerID: PeerID @@ -102,6 +107,10 @@ final class GossipSyncManager { private var fragments = PacketStore() private var fileTransfers = PacketStore() private var latestAnnouncementByPeer: [PeerID: BitchatPacket] = [:] + // Latest verified prekey bundle per owner. Unlike announces, bundles are + // NOT dropped on leave/stale peer: their whole purpose is reaching a + // sender while the owner is away. + private var latestPrekeyBundleByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:] private var archiveDirty = false // Timer @@ -130,6 +139,9 @@ final class GossipSyncManager { if config.fileTransferCapacity > 0 && config.fileTransferSyncIntervalSeconds > 0 { schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast)) } + if config.prekeyBundleCapacity > 0 && config.prekeyBundleSyncIntervalSeconds > 0 { + schedules.append(SyncSchedule(types: .prekeyBundle, interval: config.prekeyBundleSyncIntervalSeconds, lastSent: .distantPast)) + } if config.boardCapacity > 0 && config.boardSyncIntervalSeconds > 0 { schedules.append(SyncSchedule(types: .board, interval: config.boardSyncIntervalSeconds, lastSent: .distantPast)) } @@ -169,6 +181,9 @@ final class GossipSyncManager { if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 { types.formUnion(.fileTransfer) } + if self.config.prekeyBundleCapacity > 0 && self.config.prekeyBundleSyncIntervalSeconds > 0 { + types.formUnion(.prekeyBundle) + } if self.config.boardCapacity > 0 && self.config.boardSyncIntervalSeconds > 0 && self.boardPacketsProvider != nil { types.formUnion(.board) } @@ -186,9 +201,15 @@ final class GossipSyncManager { // messages get the long town-crier window; fragments, file transfers and // announces keep the short one. private func isPacketFresh(_ packet: BitchatPacket) -> Bool { - let maxAgeSeconds = packet.type == MessageType.message.rawValue - ? config.publicMessageMaxAgeSeconds - : config.maxMessageAgeSeconds + let maxAgeSeconds: TimeInterval + switch packet.type { + case MessageType.message.rawValue: + maxAgeSeconds = config.publicMessageMaxAgeSeconds + case MessageType.prekeyBundle.rawValue: + maxAgeSeconds = config.prekeyBundleMaxAgeSeconds + default: + maxAgeSeconds = config.maxMessageAgeSeconds + } let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) let ageThresholdMs = UInt64(maxAgeSeconds * 1000) @@ -241,6 +262,29 @@ final class GossipSyncManager { guard isPacketFresh(packet) else { return } let idHex = PacketIdUtil.computeId(packet).hexEncodedString() fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity)) + case .prekeyBundle: + // Callers only feed verified bundles here (own bundles at send + // time, peers' after signature verification), so gossip never + // spreads a bundle this node couldn't attribute. + guard isBroadcastRecipient else { return } + guard isPacketFresh(packet) else { return } + // Key by the bundle's authenticated identity (its noise static key), + // NOT the unauthenticated packet senderID. Otherwise one valid + // bundle re-broadcast under many fabricated sender IDs would create + // one cache entry each and exhaust the per-owner cap, starving + // legitimate bundles. One owner ⇒ at most one entry. + guard let bundle = PrekeyBundle.decode(packet.payload) else { return } + let owner = PeerID(publicKey: bundle.noiseStaticPublicKey) + if let existing = latestPrekeyBundleByPeer[owner], + existing.packet.timestamp >= packet.timestamp { + return + } + // Bounded owner count; replacing a known owner's bundle is always + // allowed so the cap can't block refreshes. + guard latestPrekeyBundleByPeer[owner] != nil + || latestPrekeyBundleByPeer.count < max(1, config.prekeyBundleCapacity) else { return } + let idHex = PacketIdUtil.computeId(packet).hexEncodedString() + latestPrekeyBundleByPeer[owner] = (id: idHex, packet: packet) default: break } @@ -410,6 +454,23 @@ final class GossipSyncManager { } } + // Like announces, prekey bundles are exempt from the since-cursor: + // there is at most one per owner (newer replaces older), so the + // resend cost is bounded and a joining peer must be able to learn + // bundles generated long before it arrived. + if requestedTypes.contains(.prekeyBundle) { + for (_, pair) in latestPrekeyBundleByPeer { + let (idHex, pkt) = pair + guard isPacketFresh(pkt) else { continue } + let idBytes = Data(hexString: idHex) ?? Data() + if !mightContain(idBytes) { + var toSend = pkt + toSend.ttl = 0 + toSend.isRSR = true // Mark as solicited response + delegate?.sendPacket(to: peerID, packet: toSend) + } + } + } if requestedTypes.contains(.boardPost) { // The board store already filters to live posts and tombstones; // no freshness window applies (posts sync until their own expiry). @@ -444,6 +505,11 @@ final class GossipSyncManager { if types.contains(.fileTransfer) { candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh)) } + if types.contains(.prekeyBundle) { + for (_, pair) in latestPrekeyBundleByPeer where isPacketFresh(pair.packet) { + candidates.append(pair.packet) + } + } if types.contains(.boardPost) { candidates.append(contentsOf: boardPacketsProvider?() ?? []) } @@ -463,6 +529,8 @@ final class GossipSyncManager { cap = max(1, config.fragmentCapacity) } else if types == .fileTransfer { cap = max(1, config.fileTransferCapacity) + } else if types == .prekeyBundle { + cap = max(1, config.prekeyBundleCapacity) } else if types == .board { cap = max(1, config.boardCapacity) } else { @@ -506,6 +574,9 @@ final class GossipSyncManager { } fragments.removeExpired(isFresh: isPacketFresh) fileTransfers.removeExpired(isFresh: isPacketFresh) + latestPrekeyBundleByPeer = latestPrekeyBundleByPeer.filter { _, pair in + isPacketFresh(pair.packet) + } } // MARK: - Archive (public message persistence) @@ -596,6 +667,8 @@ final class GossipSyncManager { } private func removeState(for peerID: PeerID) { + // Deliberately keeps the peer's prekey bundle: bundles exist to reach + // owners who left the mesh, and they age out on their own schedule. _ = latestAnnouncementByPeer.removeValue(forKey: peerID) let messageCountBefore = messages.packets.count messages.remove { PeerID(hexData: $0.senderID) == peerID } @@ -621,6 +694,12 @@ extension GossipSyncManager { } } + func _hasPrekeyBundle(for peerID: PeerID) -> Bool { + queue.sync { + latestPrekeyBundleByPeer[peerID] != nil + } + } + func _messageCount(for peerID: PeerID) -> Int { queue.sync { messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count diff --git a/bitchat/Sync/SyncTypeFlags.swift b/bitchat/Sync/SyncTypeFlags.swift index 3f455497..cb743ce1 100644 --- a/bitchat/Sync/SyncTypeFlags.swift +++ b/bitchat/Sync/SyncTypeFlags.swift @@ -47,6 +47,11 @@ struct SyncTypeFlags: OptionSet { // downlinks are rate-budgeted rebroadcasts); replaying them via sync // would waste airtime and extend their lifetime. case .nostrCarrier: return nil + // Prekey bundles gossip like board posts. The bitfield is a + // wire-tolerant little-endian UInt64 (1-8 bytes, unknown high bits + // ignored by `type(forBit:)`), so bits 8+ need no format change: old + // clients decode the wider flags and simply never match the new bits. + case .prekeyBundle: return 9 } } @@ -64,6 +69,7 @@ struct SyncTypeFlags: OptionSet { // type-aware sync (#853) accept 1-8 bytes and map unknown bits to no // known type, so old clients ignore board rounds instead of choking. case 8: return .boardPost + case 9: return .prekeyBundle default: return nil } @@ -74,6 +80,7 @@ struct SyncTypeFlags: OptionSet { static let fragment = SyncTypeFlags(messageTypes: [.fragment]) static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer]) static let board = SyncTypeFlags(messageTypes: [.boardPost]) + static let prekeyBundle = SyncTypeFlags(messageTypes: [.prekeyBundle]) static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message]) diff --git a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift index 281117f5..c5f19c44 100644 --- a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift @@ -292,6 +292,11 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { func clearCurrentPublicTimeline() { context.clearPublicConversation(ConversationID(channelID: context.activeChannel)) + // The SPM test process shares the real Application Support tree, so this + // detached deletion can land mid-test under parallel scheduling and flake + // a file-dependent test. Tests never need the on-disk media cleared. + guard !TestEnvironment.isRunningTests else { return } + Task.detached(priority: .utility) { // Skipped under tests: the test process shares the user's real // ~/Library/Application Support/files tree, and this detached diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 62b49da5..21e250f2 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1209,6 +1209,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele GossipMessageArchive.wipeDefault() StoreAndForwardMetrics.shared.reset() + // Drop cached peers' prekey bundles (who we could write to is + // metadata too). Our own prekey privates are keychain-backed and go + // with deleteAllKeychainData above plus the identity reset below. + PrekeyBundleStore.shared.wipe() // Drop bulletin-board posts and tombstones (memory and disk); board // posts are signed with our identity key and persist for days. BoardStore.shared.wipe() diff --git a/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift b/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift new file mode 100644 index 00000000..456932a7 --- /dev/null +++ b/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift @@ -0,0 +1,399 @@ +// +// PrekeyEndToEndTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +import CoreBluetooth +import BitFoundation +@testable import bitchat + +/// Forward-secret courier flow through real BLEService instances: Bob gossips +/// a signed prekey bundle, Alice verifies and caches it, seals to a one-time +/// prekey instead of Bob's static key, Carol carries the opaque envelope, and +/// Bob opens it with the matching prekey private. +struct PrekeyEndToEndTests { + + // MARK: - Helpers + + private final class PacketTap { + private let lock = NSLock() + private var packets: [BitchatPacket] = [] + + func record(_ packet: BitchatPacket) { + lock.lock(); packets.append(packet); lock.unlock() + } + + func first(ofType type: MessageType) -> BitchatPacket? { + lock.lock(); defer { lock.unlock() } + return packets.first { $0.type == type.rawValue } + } + } + + private final class NoiseCaptureDelegate: BitchatDelegate { + private let lock = NSLock() + private var payloads: [(peerID: PeerID, type: NoisePayloadType, payload: Data)] = [] + + func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) { + lock.lock(); payloads.append((peerID, type, payload)); lock.unlock() + } + + func snapshot() -> [(peerID: PeerID, type: NoisePayloadType, payload: Data)] { + lock.lock(); defer { lock.unlock() } + return payloads + } + + // Unused BitchatDelegate requirements. + func didReceiveMessage(_ message: BitchatMessage) {} + func didConnectToPeer(_ peerID: PeerID) {} + func didDisconnectFromPeer(_ peerID: PeerID) {} + func didUpdatePeerList(_ peers: [PeerID]) {} + func didUpdateBluetoothState(_ state: CBManagerState) {} + func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {} + } + + private func makeService() -> BLEService { + let keychain = MockKeychain() + let service = BLEService( + keychain: keychain, + idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()), + identityManager: MockIdentityManager(keychain), + initializeBluetoothManagers: false + ) + service.courierStore = CourierStore(persistsToDisk: false) + service.prekeyBundleStore = PrekeyBundleStore(persistsToDisk: false) + return service + } + + private func preseedConnectedPeer(_ peer: BLEService, in service: BLEService) { + let packet = BitchatPacket( + type: MessageType.message.rawValue, + senderID: Data(hexString: peer.myPeerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data("ping".utf8), + signature: nil, + ttl: 1 + ) + service._test_handlePacket(packet, fromPeerID: peer.myPeerID) + } + + /// Broadcast announce + prekey bundle from `peer` and return both packets. + private func captureAnnounceAndBundle(from peer: BLEService, tap: PacketTap) async throws -> (announce: BitchatPacket, bundle: BitchatPacket) { + peer.sendBroadcastAnnounce() + let published = await TestHelpers.waitUntil( + { tap.first(ofType: .announce) != nil && tap.first(ofType: .prekeyBundle) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(published) + return ( + announce: try #require(tap.first(ofType: .announce)), + bundle: try #require(tap.first(ofType: .prekeyBundle)) + ) + } + + // MARK: - Tests + + @Test func prekeySealedMailTravelsViaCourierAndOpens() async throws { + let alice = makeService() + let carol = makeService() + let bob = makeService() + carol.courierDepositPolicy = { _, _ in .favorite } + + let bobDelegate = NoiseCaptureDelegate() + bob.delegate = bobDelegate + + let aliceOut = PacketTap() + alice._test_onOutboundPacket = aliceOut.record + let carolOut = PacketTap() + carol._test_onOutboundPacket = carolOut.record + let bobOut = PacketTap() + bob._test_onOutboundPacket = bobOut.record + + preseedConnectedPeer(carol, in: alice) + + // 1. While Bob is still around, Alice hears his verified announce + // (binding his signing key) and his gossiped prekey bundle. + let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut) + alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false) + alice._test_handlePacket(bundlePacket, fromPeerID: bob.myPeerID, preseedPeer: false) + + let cached = await TestHelpers.waitUntil( + { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, + timeout: TestConstants.defaultTimeout + ) + #expect(cached) + + // 2. Bob goes dark; Alice seals for him and deposits with Carol. + // The envelope must be v2: sealed to a one-time prekey. + #expect(alice.sendCourierMessage( + "burn after reading", + messageID: "prekey-msg-1", + recipientNoiseKey: bob.noiseStaticPublicKeyData(), + via: [carol.myPeerID] + )) + let deposited = await TestHelpers.waitUntil( + { aliceOut.first(ofType: .courierEnvelope) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(deposited) + let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) + let sealedEnvelope = try #require(CourierEnvelope.decode(depositPacket.payload)) + #expect(sealedEnvelope.prekeyID != nil) + + // 3. Carol carries it (opaque, prekey or not). + carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData()) + let carried = await TestHelpers.waitUntil( + { !carol.courierStore.isEmpty }, + timeout: TestConstants.defaultTimeout + ) + #expect(carried) + + // 4. Bob resurfaces near Carol → handover, and the v2 discriminator + // survives the store round-trip. + bob.sendBroadcastAnnounce() + let reannounced = await TestHelpers.waitUntil( + { bobOut.first(ofType: .announce) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(reannounced) + let handoverTrigger = try #require(bobOut.first(ofType: .announce)) + carol._test_handlePacket(handoverTrigger, fromPeerID: bob.myPeerID, preseedPeer: false) + + let handedOver = await TestHelpers.waitUntil( + { carolOut.first(ofType: .courierEnvelope) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(handedOver) + let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope)) + let handedEnvelope = try #require(CourierEnvelope.decode(handoverPacket.payload)) + #expect(handedEnvelope.prekeyID == sealedEnvelope.prekeyID) + + // 5. Bob opens it with the matching one-time prekey private and sees + // Alice as the authenticated sender. + bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID) + let received = await TestHelpers.waitUntil( + { !bobDelegate.snapshot().isEmpty }, + timeout: TestConstants.defaultTimeout + ) + #expect(received) + + let delivered = try #require(bobDelegate.snapshot().first) + #expect(delivered.type == .privateMessage) + #expect(delivered.peerID == PeerID(hexData: alice.noiseStaticPublicKeyData())) + let message = try #require(PrivateMessagePacket.decode(from: delivered.payload)) + #expect(message.messageID == "prekey-msg-1") + #expect(message.content == "burn after reading") + + // 6. Redelivery tolerance: the same envelope arriving via another + // packet (spray-and-wait) still opens inside the grace window. + let redelivery = BitchatPacket( + type: MessageType.courierEnvelope.rawValue, + senderID: Data(hexString: carol.myPeerID.id) ?? Data(), + recipientID: handoverPacket.recipientID, + timestamp: handoverPacket.timestamp + 1, + payload: handoverPacket.payload, + signature: nil, + ttl: 1 + ) + bob._test_handlePacket(redelivery, fromPeerID: carol.myPeerID) + let redelivered = await TestHelpers.waitUntil( + { bobDelegate.snapshot().count == 2 }, + timeout: TestConstants.defaultTimeout + ) + #expect(redelivered) + } + + @Test func withoutBundleSealingFallsBackToStatic() async throws { + let alice = makeService() + let bob = makeService() + + let bobDelegate = NoiseCaptureDelegate() + bob.delegate = bobDelegate + let aliceOut = PacketTap() + alice._test_onOutboundPacket = aliceOut.record + + // Bob is a connected "courier" who happens to be the recipient: the + // envelope reaches him directly and the recipient tag matches. + preseedConnectedPeer(bob, in: alice) + + // Alice never saw a bundle for Bob → v1 static-sealed envelope. + #expect(alice.sendCourierMessage( + "plain static seal", + messageID: "static-msg-1", + recipientNoiseKey: bob.noiseStaticPublicKeyData(), + via: [bob.myPeerID] + )) + let deposited = await TestHelpers.waitUntil( + { aliceOut.first(ofType: .courierEnvelope) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(deposited) + let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) + let envelope = try #require(CourierEnvelope.decode(depositPacket.payload)) + #expect(envelope.prekeyID == nil) + + // Bob opens the v1 envelope exactly as before the prekey change. + // (No preseed: Alice is absent from Bob's mesh, so the sender should + // resolve to her full noise-key ID like the courier case.) + bob._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, preseedPeer: false) + let received = await TestHelpers.waitUntil( + { !bobDelegate.snapshot().isEmpty }, + timeout: TestConstants.defaultTimeout + ) + #expect(received) + let delivered = try #require(bobDelegate.snapshot().first) + #expect(delivered.peerID == PeerID(hexData: alice.noiseStaticPublicKeyData())) + let message = try #require(PrivateMessagePacket.decode(from: delivered.payload)) + #expect(message.content == "plain static seal") + } + + @Test func unverifiableBundleIsIgnored() async throws { + let alice = makeService() + let bob = makeService() + + let bobOut = PacketTap() + bob._test_onOutboundPacket = bobOut.record + + // Alice receives Bob's bundle but never saw a verified announce, so + // no signing key is bound to his noise key: the bundle must not be + // cached or enter Alice's gossip store. + let (_, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut) + alice._test_handlePacket(bundlePacket, fromPeerID: bob.myPeerID, preseedPeer: false) + + let cached = await TestHelpers.waitUntil( + { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, + timeout: TestConstants.shortTimeout + ) + #expect(!cached) + } + + @Test func forgedBundleSignatureIsRejected() async throws { + let alice = makeService() + let bob = makeService() + + let bobOut = PacketTap() + bob._test_onOutboundPacket = bobOut.record + + let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut) + alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false) + + // Mallory tampers with the gossiped bundle in flight. + let bundle = try #require(PrekeyBundle.decode(bundlePacket.payload)) + var forgedSignature = bundle.signature + forgedSignature[0] ^= 0x01 + let forged = PrekeyBundle( + noiseStaticPublicKey: bundle.noiseStaticPublicKey, + prekeys: bundle.prekeys, + generatedAt: bundle.generatedAt, + signature: forgedSignature + ) + let forgedPacket = BitchatPacket( + type: MessageType.prekeyBundle.rawValue, + senderID: bundlePacket.senderID, + recipientID: nil, + timestamp: bundlePacket.timestamp, + payload: try #require(forged.encode()), + signature: bundlePacket.signature, + ttl: bundlePacket.ttl + ) + alice._test_handlePacket(forgedPacket, fromPeerID: bob.myPeerID, preseedPeer: false) + + let cached = await TestHelpers.waitUntil( + { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, + timeout: TestConstants.shortTimeout + ) + #expect(!cached) + } + + @Test func verifiedBundleEntersGossipStore() async throws { + let alice = makeService() + let bob = makeService() + + let bobOut = PacketTap() + bob._test_onOutboundPacket = bobOut.record + + let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut) + alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false) + alice._test_handlePacket(bundlePacket, fromPeerID: bob.myPeerID, preseedPeer: false) + + let cached = await TestHelpers.waitUntil( + { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, + timeout: TestConstants.defaultTimeout + ) + #expect(cached) + // The verified bundle now participates in Alice's sync rounds. + #expect(alice._test_hasGossipPrekeyBundle(for: bob.myPeerID)) + } + + @Test func spoofedSenderPrekeyBundleIsRejected() async throws { + let alice = makeService() + let bob = makeService() + + let bobOut = PacketTap() + bob._test_onOutboundPacket = bobOut.record + + let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut) + alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false) + + // A relay re-broadcasts Bob's genuine bundle under a fabricated sender + // ID (the DoS that would multiply cache/gossip entries and exhaust the + // per-owner cap). Attribution is by the bundle's own key and the outer + // signature is bound to Bob's sender ID, so the spoof is dropped — no + // cache entry, and no gossip entry under either the fake or real ID. + let fakeSender = Data((0..<8).map { _ in UInt8.random(in: 0...255) }) + let spoofed = BitchatPacket( + type: MessageType.prekeyBundle.rawValue, + senderID: fakeSender, + recipientID: nil, + timestamp: bundlePacket.timestamp + 5_000, + payload: bundlePacket.payload, + signature: bundlePacket.signature, + ttl: bundlePacket.ttl + ) + alice._test_handlePacket(spoofed, fromPeerID: PeerID(hexData: fakeSender), preseedPeer: false) + + let cached = await TestHelpers.waitUntil( + { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, + timeout: TestConstants.shortTimeout + ) + #expect(!cached) + #expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID)) + #expect(!alice._test_hasGossipPrekeyBundle(for: PeerID(hexData: fakeSender))) + } + + @Test func replayedPrekeyBundleWithFreshTimestampIsRejected() async throws { + let alice = makeService() + let bob = makeService() + + let bobOut = PacketTap() + bob._test_onOutboundPacket = bobOut.record + + let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut) + alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false) + + // Rewriting the outer timestamp (to defeat the freshness window) + // invalidates the packet signature, which covers senderID + timestamp. + let replay = BitchatPacket( + type: MessageType.prekeyBundle.rawValue, + senderID: bundlePacket.senderID, + recipientID: nil, + timestamp: bundlePacket.timestamp + 5_000, + payload: bundlePacket.payload, + signature: bundlePacket.signature, + ttl: bundlePacket.ttl + ) + alice._test_handlePacket(replay, fromPeerID: bob.myPeerID, preseedPeer: false) + + let cached = await TestHelpers.waitUntil( + { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, + timeout: TestConstants.shortTimeout + ) + #expect(!cached) + #expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID)) + } +} diff --git a/bitchatTests/GossipSyncManagerTests.swift b/bitchatTests/GossipSyncManagerTests.swift index 81516543..5f15e377 100644 --- a/bitchatTests/GossipSyncManagerTests.swift +++ b/bitchatTests/GossipSyncManagerTests.swift @@ -139,6 +139,7 @@ struct GossipSyncManagerTests { config.messageSyncIntervalSeconds = 1 config.fragmentSyncIntervalSeconds = 1 config.fileTransferSyncIntervalSeconds = 1 + config.prekeyBundleSyncIntervalSeconds = 1 config.maintenanceIntervalSeconds = 0 let requestSyncManager = RequestSyncManager() @@ -195,19 +196,22 @@ struct GossipSyncManagerTests { manager._performMaintenanceSynchronously(now: Date()) // One request per due schedule so each type group gets the full - // filter capacity: publicMessages, fragment, and fileTransfer. + // filter capacity: publicMessages, fragment, fileTransfer, and + // prekeyBundle. let sentPackets = delegate.packets - #expect(sentPackets.count == 3) + #expect(sentPackets.count == 4) let decoded = sentPackets.compactMap { RequestSyncPacket.decode(from: $0.payload) } - #expect(decoded.count == 3) + #expect(decoded.count == 4) let allTypes = decoded.compactMap(\.types).reduce(SyncTypeFlags(rawValue: 0)) { $0.union($1) } #expect(allTypes.contains(.announce)) #expect(allTypes.contains(.message)) #expect(allTypes.contains(.fragment)) #expect(allTypes.contains(.fileTransfer)) + #expect(allTypes.contains(.prekeyBundle)) #expect(decoded.contains { $0.types == .publicMessages }) #expect(decoded.contains { $0.types == .fragment }) #expect(decoded.contains { $0.types == .fileTransfer }) + #expect(decoded.contains { $0.types == .prekeyBundle }) } @Test func truncatedFilterCarriesSinceCursor() throws { @@ -292,6 +296,7 @@ struct GossipSyncManagerTests { config.messageSyncIntervalSeconds = 0 config.fragmentSyncIntervalSeconds = 0 config.fileTransferSyncIntervalSeconds = 0 + config.prekeyBundleSyncIntervalSeconds = 0 let requestSyncManager = RequestSyncManager() let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) @@ -362,6 +367,7 @@ struct GossipSyncManagerTests { config.messageSyncIntervalSeconds = 0 config.fragmentSyncIntervalSeconds = 0 config.fileTransferSyncIntervalSeconds = 0 + config.prekeyBundleSyncIntervalSeconds = 0 let requestSyncManager = RequestSyncManager() let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) @@ -401,6 +407,7 @@ struct GossipSyncManagerTests { config.messageSyncIntervalSeconds = 0 config.fragmentSyncIntervalSeconds = 0 config.fileTransferSyncIntervalSeconds = 0 + config.prekeyBundleSyncIntervalSeconds = 0 config.responseRateLimitMaxResponses = 1 config.responseRateLimitWindowSeconds = 60 @@ -455,6 +462,7 @@ struct GossipSyncManagerTests { #expect(types.contains(.message)) #expect(types.contains(.fragment)) #expect(types.contains(.fileTransfer)) + #expect(types.contains(.prekeyBundle)) } @Test func handleRequestSyncHonorsTypeFilter() async throws { @@ -533,6 +541,7 @@ struct GossipSyncManagerTests { config.messageSyncIntervalSeconds = 0 config.fragmentSyncIntervalSeconds = 0 config.fileTransferSyncIntervalSeconds = 0 + config.prekeyBundleSyncIntervalSeconds = 0 let requestSyncManager = RequestSyncManager() let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) @@ -579,7 +588,6 @@ struct GossipSyncManagerTests { config.messageSyncIntervalSeconds = 0 config.fragmentSyncIntervalSeconds = 0 config.fileTransferSyncIntervalSeconds = 0 - let requestSyncManager = RequestSyncManager() let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) let delegate = RecordingDelegate() @@ -599,6 +607,93 @@ struct GossipSyncManagerTests { #expect(ids == Set([stalledID])) } + @Test func prekeyBundlesServeSyncAndSurviveStalePeerCleanup() async throws { + var config = GossipSyncManager.Config() + config.messageSyncIntervalSeconds = 0 + config.fragmentSyncIntervalSeconds = 0 + config.fileTransferSyncIntervalSeconds = 0 + config.prekeyBundleSyncIntervalSeconds = 0 + config.stalePeerCleanupIntervalSeconds = 0 + config.stalePeerTimeoutSeconds = 5 + + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager()) + let delegate = RecordingDelegate() + manager.delegate = delegate + + // Bundles are keyed by their authenticated identity (the noise static + // key), not the packet senderID, so the payload must be a real bundle. + let noiseKey = Data(repeating: 0xAB, count: 32) + let senderPeer = PeerID(publicKey: noiseKey) + let sender = try #require(Data(hexString: senderPeer.id)) + let bundle = PrekeyBundle( + noiseStaticPublicKey: noiseKey, + prekeys: [PrekeyBundle.Prekey(id: 0, publicKey: Data(repeating: 0x11, count: 32))], + generatedAt: UInt64(Date().timeIntervalSince1970 * 1000), + signature: Data(count: PrekeyBundle.signatureLength) + ) + let bundlePacket = BitchatPacket( + type: MessageType.prekeyBundle.rawValue, + senderID: sender, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: try #require(bundle.encode()), + signature: nil, + ttl: 1 + ) + manager.onPublicPacketSeen(bundlePacket) + manager._performMaintenanceSynchronously(now: Date()) + #expect(manager._hasPrekeyBundle(for: senderPeer)) + + // Bundles outlive the owner's announce: a leave plus stale cleanup + // must not drop them (they exist to reach offline owners). + manager.removeAnnouncementForPeer(senderPeer) + manager._performMaintenanceSynchronously(now: Date().addingTimeInterval(config.stalePeerTimeoutSeconds + 1)) + #expect(manager._hasPrekeyBundle(for: senderPeer)) + + // And a .prekeyBundle sync request is answered with the stored packet. + let request = RequestSyncPacket(p: 7, m: 1, data: Data(), types: .prekeyBundle) + manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request) + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) + let served = try #require(delegate.packets.first) + #expect(served.type == MessageType.prekeyBundle.rawValue) + #expect(served.isRSR) + } + + @Test func prekeyBundleGossipIsKeyedByOwnerNotSenderID() { + // One valid bundle re-broadcast under many fabricated sender IDs must + // collapse to a single entry keyed by the bundle's own identity — the + // spray-to-exhaust-the-cap DoS produces one entry, not N. + let manager = GossipSyncManager(myPeerID: myPeerID, requestSyncManager: RequestSyncManager()) + let noiseKey = Data(repeating: 0xCD, count: 32) + let ownerPeer = PeerID(publicKey: noiseKey) + let bundle = PrekeyBundle( + noiseStaticPublicKey: noiseKey, + prekeys: [PrekeyBundle.Prekey(id: 0, publicKey: Data(repeating: 0x22, count: 32))], + generatedAt: UInt64(Date().timeIntervalSince1970 * 1000), + signature: Data(count: PrekeyBundle.signatureLength) + ) + guard let payload = bundle.encode() else { return } + + for i in 0..<5 { + let fakeSender = Data((0..<8).map { j in UInt8(truncatingIfNeeded: i * 31 + j) }) + let packet = BitchatPacket( + type: MessageType.prekeyBundle.rawValue, + senderID: fakeSender, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000) + UInt64(i), + payload: payload, + signature: nil, + ttl: 1 + ) + manager.onPublicPacketSeen(packet) + manager._performMaintenanceSynchronously(now: Date()) + // No fabricated sender ID ever creates its own entry. + #expect(!manager._hasPrekeyBundle(for: PeerID(hexData: fakeSender))) + } + // Exactly the owner-keyed entry exists. + #expect(manager._hasPrekeyBundle(for: ownerPeer)) + } + // MARK: - Archive persistence @Test func publicMessagesRestoreFromArchiveAcrossRestart() async throws { diff --git a/bitchatTests/Prekeys/NoisePrekeyTests.swift b/bitchatTests/Prekeys/NoisePrekeyTests.swift new file mode 100644 index 00000000..44a39d11 --- /dev/null +++ b/bitchatTests/Prekeys/NoisePrekeyTests.swift @@ -0,0 +1,283 @@ +// +// NoisePrekeyTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +import CryptoKit +import BitFoundation +@testable import bitchat + +/// Forward-secret one-way Noise X envelopes sealed to one-time prekeys +/// instead of the recipient's identity static key. +struct NoisePrekeyTests { + + @Test func sealAndOpenRoundTrip() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let bundle = try #require(bob.currentPrekeyBundle()) + let prekey = try #require(bundle.prekeys.first) + + let payload = Data("meet at the north gate".utf8) + let sealed = try alice.sealPrekeyPayload(payload, recipientPrekey: prekey) + + let opened = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id) + #expect(opened.payload == payload) + // The X pattern authenticates the sender: Bob learns Alice's real static key. + #expect(opened.senderStaticKey == alice.getStaticPublicKeyData()) + } + + @Test func wrongPrekeyIDCannotOpen() throws { + // The prologue binds the ciphertext to a specific prekey ID; opening + // with a different (existing) prekey must fail. + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let bundle = try #require(bob.currentPrekeyBundle()) + #expect(bundle.prekeys.count >= 2) + + let sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: bundle.prekeys[0]) + #expect(throws: (any Error).self) { + _ = try bob.openPrekeyPayload(sealed, prekeyID: bundle.prekeys[1].id) + } + } + + @Test func unknownPrekeyIDThrows() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let bundle = try #require(bob.currentPrekeyBundle()) + let prekey = try #require(bundle.prekeys.first) + + let sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: prekey) + #expect(throws: NoiseEncryptionError.unknownPrekey) { + _ = try bob.openPrekeyPayload(sealed, prekeyID: 0xDEAD_BEEF) + } + } + + @Test func wrongRecipientCannotOpen() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let carol = NoiseEncryptionService(keychain: MockKeychain()) + let bobBundle = try #require(bob.currentPrekeyBundle()) + // Ensure Carol holds a prekey under the same ID as Bob's. + _ = try #require(carol.currentPrekeyBundle()) + let prekey = try #require(bobBundle.prekeys.first) + + let sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: prekey) + #expect(throws: (any Error).self) { + _ = try carol.openPrekeyPayload(sealed, prekeyID: prekey.id) + } + } + + @Test func tamperedCiphertextFailsToOpen() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let bundle = try #require(bob.currentPrekeyBundle()) + let prekey = try #require(bundle.prekeys.first) + + var sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: prekey) + sealed[sealed.count - 1] ^= 0x01 + #expect(throws: (any Error).self) { + _ = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id) + } + } + + @Test func consumedPrekeyStillOpensRedeliveredCiphertext() throws { + // Spray-and-wait can deliver the same ciphertext via several couriers + // days apart; the consumed private survives a grace window for that. + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let bundle = try #require(bob.currentPrekeyBundle()) + let prekey = try #require(bundle.prekeys.first) + + let sealed = try alice.sealPrekeyPayload(Data("hello".utf8), recipientPrekey: prekey) + let first = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id) + let second = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id) + #expect(first.payload == second.payload) + } + + @Test func prekeyAndStaticSealsAreNotInterchangeable() throws { + // Domain-separated prologues: a static-sealed envelope must not open + // via the prekey path and vice versa, even with matching key material. + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let bundle = try #require(bob.currentPrekeyBundle()) + let prekey = try #require(bundle.prekeys.first) + + let staticSealed = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: bob.getStaticPublicKeyData()) + #expect(throws: (any Error).self) { + _ = try bob.openPrekeyPayload(staticSealed, prekeyID: prekey.id) + } + + let prekeySealed = try alice.sealPrekeyPayload(Data("x".utf8), recipientPrekey: prekey) + #expect(throws: (any Error).self) { + _ = try bob.openCourierPayload(prekeySealed) + } + } + + @Test func sealRejectsInvalidPrekeyPublicKey() { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + #expect(throws: (any Error).self) { + _ = try alice.sealPrekeyPayload(Data("x".utf8), recipientPrekey: PrekeyBundle.Prekey(id: 1, publicKey: Data(repeating: 0, count: 32))) + } + #expect(throws: (any Error).self) { + _ = try alice.sealPrekeyPayload(Data("x".utf8), recipientPrekey: PrekeyBundle.Prekey(id: 1, publicKey: Data(repeating: 1, count: 8))) + } + } + + @Test func sealsAreNotLinkableAcrossSends() throws { + // Fresh ephemeral per seal even to the same prekey. + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let bundle = try #require(bob.currentPrekeyBundle()) + let prekey = try #require(bundle.prekeys.first) + let payload = Data("same message".utf8) + + let a = try alice.sealPrekeyPayload(payload, recipientPrekey: prekey) + let b = try alice.sealPrekeyPayload(payload, recipientPrekey: prekey) + #expect(a != b) + #expect(a.prefix(32) != b.prefix(32)) + } +} + +/// Local one-time prekey lifecycle: batch generation, consumption, the 48h +/// redelivery grace window, replenishment, and the panic wipe. +struct LocalPrekeyStoreTests { + + private final class Clock { + var now: Date + init(_ now: Date = Date()) { self.now = now } + } + + private func makeStore(clock: Clock, keychain: MockKeychain = MockKeychain()) -> LocalPrekeyStore { + LocalPrekeyStore(keychain: keychain, now: { clock.now }) + } + + private func bundle(noiseKey: Data, prekeys: [PrekeyBundle.Prekey], generatedAt: UInt64) -> PrekeyBundle { + PrekeyBundle( + noiseStaticPublicKey: noiseKey, + prekeys: prekeys, + generatedAt: generatedAt, + signature: Data(count: PrekeyBundle.signatureLength) + ) + } + + @Test func mintsFullBatchOnFirstUse() { + let store = makeStore(clock: Clock()) + let (prekeys, generatedAt) = store.currentBundlePrekeys() + #expect(prekeys.count == LocalPrekeyStore.Policy.batchSize) + #expect(generatedAt > 0) + #expect(Set(prekeys.map(\.id)).count == prekeys.count) + } + + @Test func consumptionBelowThresholdTriggersReplenishAndBumpsGeneration() { + let clock = Clock() + let store = makeStore(clock: clock) + let (initial, firstGeneratedAt) = store.currentBundlePrekeys() + + // Consuming down to the threshold does not regenerate... + let keepUnconsumed = LocalPrekeyStore.Policy.replenishThreshold + for prekey in initial.dropLast(keepUnconsumed) { + store.markConsumed(prekey.id) + } + #expect(!store.replenishIfNeeded()) + #expect(store.unconsumedCount == keepUnconsumed) + + // ...one more consumption does, topping back up to a full batch with + // a newer generation stamp. + clock.now = clock.now.addingTimeInterval(60) + store.markConsumed(initial[initial.count - keepUnconsumed].id) + #expect(store.replenishIfNeeded()) + let (replenished, secondGeneratedAt) = store.currentBundlePrekeys() + #expect(replenished.count == LocalPrekeyStore.Policy.batchSize) + #expect(secondGeneratedAt > firstGeneratedAt) + // Surviving unconsumed prekeys stay in the fresh bundle. + let survivorIDs = Set(initial.suffix(keepUnconsumed - 1).map(\.id)) + #expect(survivorIDs.isSubset(of: Set(replenished.map(\.id)))) + } + + @Test func consumingAPrekeyRepublishesANewerBundlePeersAccept() { + // Codex P1: consuming a prekey (even above the replenish threshold) + // must republish a strictly newer bundle so a peer that cached the old + // one replaces it and stops assigning the consumed ID before its 48h + // grace lapses. + let clock = Clock() + let store = makeStore(clock: clock) + let noiseKey = Data(repeating: 0xC0, count: 32) + + // Owner publishes; a peer caches it and would assign the first prekey. + let (initial, firstGeneratedAt) = store.currentBundlePrekeys() + let peerCache = PrekeyBundleStore(persistsToDisk: false) + #expect(peerCache.ingest(bundle(noiseKey: noiseKey, prekeys: initial, generatedAt: firstGeneratedAt))) + let consumedID = initial[0].id + #expect(peerCache.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == consumedID) + + // The owner opens mail sealed to that prekey: it's retired and the + // republished bundle is strictly newer and no longer offers the ID. + #expect(store.markConsumed(consumedID)) + let (afterConsume, secondGeneratedAt) = store.currentBundlePrekeys() + #expect(secondGeneratedAt > firstGeneratedAt) + #expect(!afterConsume.contains { $0.id == consumedID }) + + // The peer accepts the replacement (a same-generatedAt copy would be + // rejected) and stops assigning the consumed ID for new mail. + #expect(peerCache.ingest(bundle(noiseKey: noiseKey, prekeys: afterConsume, generatedAt: secondGeneratedAt))) + #expect(peerCache.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)?.id != consumedID) + + // 48h grace: the owner can still open a redelivery of the in-flight + // ciphertext sealed to the consumed ID until the window lapses. + clock.now = clock.now.addingTimeInterval(LocalPrekeyStore.Policy.consumedGraceSeconds - 60) + #expect(store.privateKey(for: consumedID) != nil) + clock.now = clock.now.addingTimeInterval(120) + #expect(store.privateKey(for: consumedID) == nil) + } + + @Test func consumedPrivateSurvivesGraceWindowThenDies() { + let clock = Clock() + let store = makeStore(clock: clock) + let (prekeys, _) = store.currentBundlePrekeys() + let id = prekeys[0].id + + store.markConsumed(id) + // Within the grace window: still retrievable for redeliveries. + clock.now = clock.now.addingTimeInterval(LocalPrekeyStore.Policy.consumedGraceSeconds - 60) + #expect(store.privateKey(for: id) != nil) + + // Past the grace window: gone (even before replenish prunes it). + clock.now = clock.now.addingTimeInterval(120) + #expect(store.privateKey(for: id) == nil) + store.replenishIfNeeded() + #expect(store.privateKey(for: id) == nil) + } + + @Test func persistsAcrossInstances() { + let keychain = MockKeychain() + let clock = Clock() + let first = LocalPrekeyStore(keychain: keychain, now: { clock.now }) + let (prekeys, generatedAt) = first.currentBundlePrekeys() + first.markConsumed(prekeys[0].id) + + let second = LocalPrekeyStore(keychain: keychain, now: { clock.now }) + let (reloaded, reloadedGeneratedAt) = second.currentBundlePrekeys() + // Consuming a prekey shrinks the published bundle, so its generation + // stamp advances strictly (even without the clock moving) — peers must + // see a newer bundle to replace the one that still offered the + // consumed ID. + #expect(reloadedGeneratedAt > generatedAt) + #expect(Set(reloaded.map(\.id)) == Set(prekeys.dropFirst().map(\.id))) + // The consumed key is still openable within grace after a relaunch. + #expect(second.privateKey(for: prekeys[0].id) != nil) + } + + @Test func wipeRemovesEverything() { + let keychain = MockKeychain() + let store = LocalPrekeyStore(keychain: keychain) + let (prekeys, _) = store.currentBundlePrekeys() + store.wipe() + #expect(store.privateKey(for: prekeys[0].id) == nil) + #expect(keychain.getIdentityKey(forKey: "prekeysV1") == nil) + } +} diff --git a/bitchatTests/Prekeys/PrekeyBundleStoreTests.swift b/bitchatTests/Prekeys/PrekeyBundleStoreTests.swift new file mode 100644 index 00000000..319757f0 --- /dev/null +++ b/bitchatTests/Prekeys/PrekeyBundleStoreTests.swift @@ -0,0 +1,197 @@ +// +// PrekeyBundleStoreTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +import CryptoKit +import BitFoundation +@testable import bitchat + +/// Sender-side cache of peers' verified prekey bundles: latest-wins ingest, +/// per-message prekey assignment (never reused across messages), expiry, and +/// the peer cap. +struct PrekeyBundleStoreTests { + + private func makeBundle( + noiseKey: Data = Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation, + ids: [UInt32] = [0, 1, 2], + generatedAt: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000) + ) -> PrekeyBundle { + PrekeyBundle( + noiseStaticPublicKey: noiseKey, + prekeys: ids.map { PrekeyBundle.Prekey(id: $0, publicKey: Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation) }, + generatedAt: generatedAt, + signature: Data(count: PrekeyBundle.signatureLength) + ) + } + + @Test func ingestKeepsLatestByGeneratedAt() { + let store = PrekeyBundleStore(persistsToDisk: false) + let noiseKey = Data(repeating: 0xB0, count: 32) + let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) + + let old = makeBundle(noiseKey: noiseKey, ids: [0, 1], generatedAt: nowMs - 1000) + let new = makeBundle(noiseKey: noiseKey, ids: [2, 3], generatedAt: nowMs) + + #expect(store.ingest(new)) + // Older (and equal) bundles never displace a newer one. + #expect(!store.ingest(old)) + #expect(!store.ingest(new)) + + let assigned = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey) + #expect(assigned?.id == 2) + } + + @Test func assignmentsConsumeDistinctPrekeysPerMessage() { + let store = PrekeyBundleStore(persistsToDisk: false) + let noiseKey = Data(repeating: 0xB1, count: 32) + #expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [10, 11]))) + + let first = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey) + let second = store.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey) + #expect(first?.id == 10) + #expect(second?.id == 11) + // Exhausted: fall back to static sealing. + #expect(store.assignPrekey(messageID: "m3", recipientNoiseKey: noiseKey) == nil) + #expect(!store.hasUsableBundle(for: noiseKey)) + } + + @Test func redepositOfSameMessageReusesItsPrekey() { + let store = PrekeyBundleStore(persistsToDisk: false) + let noiseKey = Data(repeating: 0xB2, count: 32) + #expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [5, 6, 7]))) + + let first = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey) + let retry = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey) + #expect(first?.id == retry?.id) + #expect(first?.publicKey == retry?.publicKey) + // Only one prekey was burned. + let next = store.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey) + #expect(next?.id == 6) + } + + @Test func topUpBundleKeepsConsumptionStateForSurvivingIDs() { + let store = PrekeyBundleStore(persistsToDisk: false) + let noiseKey = Data(repeating: 0xB3, count: 32) + let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) + #expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [0, 1], generatedAt: nowMs - 1000))) + #expect(store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == 0) + + // The owner topped up: ID 1 survives (still unconsumed on their side), + // ID 0 rotated out, new IDs appear. + #expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [1, 8, 9], generatedAt: nowMs))) + // m1's assignment referenced a rotated-out ID; a re-deposit picks a + // fresh one rather than sealing to a dead key. + #expect(store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == 1) + #expect(store.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)?.id == 8) + } + + @Test func expiredBundleIsNeverUsed() { + var current = Date() + let store = PrekeyBundleStore(persistsToDisk: false, now: { current }) + let noiseKey = Data(repeating: 0xB4, count: 32) + #expect(store.ingest(makeBundle(noiseKey: noiseKey, generatedAt: UInt64(current.timeIntervalSince1970 * 1000)))) + #expect(store.hasUsableBundle(for: noiseKey)) + + current = current.addingTimeInterval(PrekeyBundleStore.Limits.maxBundleAgeForSealingSeconds + 60) + #expect(!store.hasUsableBundle(for: noiseKey)) + #expect(store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey) == nil) + } + + @Test func peerCapEvictsLeastRecentlyUpdated() { + var current = Date() + let store = PrekeyBundleStore(persistsToDisk: false, maxPeers: 2, now: { current }) + let keys = (0..<3).map { Data(repeating: UInt8(0xC0 + $0), count: 32) } + + for key in keys { + #expect(store.ingest(makeBundle(noiseKey: key, generatedAt: UInt64(current.timeIntervalSince1970 * 1000)))) + current = current.addingTimeInterval(1) + } + // Oldest entry evicted; the two most recent survive. + #expect(!store.hasUsableBundle(for: keys[0])) + #expect(store.hasUsableBundle(for: keys[1])) + #expect(store.hasUsableBundle(for: keys[2])) + } + + @Test func persistsAcrossInstancesAndWipes() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("prekey-bundle-store-tests-\(UUID().uuidString)", isDirectory: true) + let fileURL = dir.appendingPathComponent("bundles.json") + defer { try? FileManager.default.removeItem(at: dir) } + + let noiseKey = Data(repeating: 0xB5, count: 32) + let first = PrekeyBundleStore(fileURL: fileURL) + #expect(first.ingest(makeBundle(noiseKey: noiseKey, ids: [1, 2]))) + #expect(first.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == 1) + + // Consumption state survives a relaunch, so a restart can't reuse a prekey. + let second = PrekeyBundleStore(fileURL: fileURL) + #expect(second.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)?.id == 2) + + second.wipe() + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + let third = PrekeyBundleStore(fileURL: fileURL) + #expect(!third.hasUsableBundle(for: noiseKey)) + } +} + +/// Envelope v2 wire compatibility: the prekey ID rides an optional TLV that +/// v1 decoders skip as unknown. +struct CourierEnvelopeV2Tests { + + @Test func prekeyIDRoundTrips() throws { + let envelope = CourierEnvelope( + recipientTag: Data(repeating: 0x11, count: CourierEnvelope.tagLength), + expiry: UInt64(Date().timeIntervalSince1970 * 1000) + 60_000, + ciphertext: Data("ciphertext".utf8), + copies: 4, + prekeyID: 0xAABB_CCDD + ) + let encoded = try #require(envelope.encode()) + let decoded = try #require(CourierEnvelope.decode(encoded)) + #expect(decoded == envelope) + #expect(decoded.prekeyID == 0xAABB_CCDD) + } + + @Test func v1EnvelopeDecodesWithNilPrekeyID() throws { + let envelope = CourierEnvelope( + recipientTag: Data(repeating: 0x22, count: CourierEnvelope.tagLength), + expiry: UInt64(Date().timeIntervalSince1970 * 1000) + 60_000, + ciphertext: Data("legacy".utf8) + ) + let encoded = try #require(envelope.encode()) + let decoded = try #require(CourierEnvelope.decode(encoded)) + #expect(decoded.prekeyID == nil) + } + + @Test func v1EncodingIsByteIdenticalWithoutPrekeyID() throws { + // Static-sealed envelopes must stay on the pre-prekey wire format. + let tag = Data(repeating: 0x33, count: CourierEnvelope.tagLength) + let expiry: UInt64 = 1_800_000_000_000 + let ciphertext = Data("same".utf8) + let v1 = try #require(CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext).encode()) + let v1Explicit = try #require(CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext, prekeyID: nil).encode()) + #expect(v1 == v1Explicit) + // And a v2 envelope is the v1 bytes plus one trailing TLV a v1 + // decoder skips as unknown. + let v2 = try #require(CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext, prekeyID: 7).encode()) + #expect(v2.prefix(v1.count) == v1) + #expect(v2.count == v1.count + 3 + 4) + } + + @Test func withCopiesPreservesPrekeyID() { + let envelope = CourierEnvelope( + recipientTag: Data(repeating: 0x44, count: CourierEnvelope.tagLength), + expiry: 1, + ciphertext: Data([0x01]), + copies: 4, + prekeyID: 9 + ) + #expect(envelope.withCopies(2).prekeyID == 9) + } +} diff --git a/bitchatTests/Prekeys/PrekeyBundleTests.swift b/bitchatTests/Prekeys/PrekeyBundleTests.swift new file mode 100644 index 00000000..8d9b212b --- /dev/null +++ b/bitchatTests/Prekeys/PrekeyBundleTests.swift @@ -0,0 +1,133 @@ +// +// PrekeyBundleTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +import CryptoKit +import BitFoundation +@testable import bitchat + +/// Wire format and signature binding for gossiped one-time prekey bundles. +struct PrekeyBundleTests { + + private func makePrekeys(_ count: Int) -> [PrekeyBundle.Prekey] { + (0.. CourierEnvelope { - CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies) + CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies, prekeyID: prekeyID) } public var isExpired: Bool { @@ -97,6 +106,14 @@ public struct CourierEnvelope: Equatable { encoded.append(copies) } + // Omitted for v1 static-sealed envelopes so they stay byte-identical + // to the pre-prekey wire format. + if let prekeyID { + encoded.append(TLVType.prekeyID.rawValue) + appendBE(UInt16(4), into: &encoded) + appendBE(prekeyID, into: &encoded) + } + return encoded } @@ -108,6 +125,7 @@ public struct CourierEnvelope: Equatable { var expiry: UInt64? var ciphertext: Data? var copies: UInt8 = 1 + var prekeyID: UInt32? while cursor < end { let typeRaw = data[cursor] @@ -133,6 +151,9 @@ public struct CourierEnvelope: Equatable { case .copies: guard length == 1 else { return nil } copies = value.first ?? 1 + case .prekeyID: + guard length == 4 else { return nil } + prekeyID = value.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } case nil: // Unknown TLV: skip for forward compatibility. continue @@ -140,7 +161,7 @@ public struct CourierEnvelope: Equatable { } guard let recipientTag, let expiry, let ciphertext else { return nil } - return CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies) + return CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies, prekeyID: prekeyID) } // MARK: - Recipient Tags diff --git a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift index 763abaa7..47f46b85 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift @@ -16,15 +16,16 @@ public enum MessageType: UInt8 { case leave = 0x03 // "I'm leaving" case courierEnvelope = 0x04 // Store-and-forward envelope carried by a trusted peer 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 case boardPost = 0x23 // Signed geohash bulletin-board post or tombstone + case prekeyBundle = 0x24 // Signed batch of one-time prekeys (gossiped) // Mesh diagnostics case ping = 0x26 // Directed echo request (nonce + origin TTL) @@ -46,6 +47,7 @@ public enum MessageType: UInt8 { case .fragment: return "fragment" case .fileTransfer: return "fileTransfer" case .boardPost: return "boardPost" + case .prekeyBundle: return "prekeyBundle" case .ping: return "ping" case .pong: return "pong" case .nostrCarrier: return "nostrCarrier" diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PrekeyBundle.swift b/localPackages/BitFoundation/Sources/BitFoundation/PrekeyBundle.swift new file mode 100644 index 00000000..0e73a2c0 --- /dev/null +++ b/localPackages/BitFoundation/Sources/BitFoundation/PrekeyBundle.swift @@ -0,0 +1,195 @@ +// +// PrekeyBundle.swift +// BitFoundation +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// TLV payload for gossiped one-time prekey bundles (MessageType 0x24). +/// +/// A bundle publishes a batch of one-time Curve25519 public prekeys bound to +/// the owner's Noise static key by an Ed25519 signature over domain-prefixed +/// canonical bytes. Anyone holding the owner's announce-verified signing key +/// can verify a bundle offline, which is what lets bundles spread and persist +/// mesh-wide via gossip sync while the owner is away. Senders seal courier +/// mail to one of these prekeys (one-way Noise X) instead of the owner's +/// long-lived static key, restoring forward secrecy for async first contact. +public struct PrekeyBundle: Equatable { + public struct Prekey: Equatable { + public let id: UInt32 + /// Curve25519.KeyAgreement public key (32 bytes). + public let publicKey: Data + + public init(id: UInt32, publicKey: Data) { + self.id = id + self.publicKey = publicKey + } + } + + /// Noise static public key identifying whose prekeys these are (32 bytes). + public let noiseStaticPublicKey: Data + /// One-time prekeys, at most `maxPrekeys` per bundle. + public let prekeys: [Prekey] + /// Milliseconds since epoch when this bundle was generated; newer bundles + /// replace older ones for the same noise key. + public let generatedAt: UInt64 + /// Ed25519 signature over `signableBytes()` by the owner's announce-bound + /// signing key. + public let signature: Data + + public static let keyLength = 32 + public static let signatureLength = 64 + public static let maxPrekeys = 8 + private static let prekeyEntryLength = 4 + keyLength + + /// Domain separation for the bundle signature so it can never be confused + /// with announce or packet signatures. + private static let signingContext = Data("bitchat-prekey-bundle-v1".utf8) + + private enum TLVType: UInt8 { + case noiseStaticPublicKey = 0x01 + case prekeys = 0x02 + case generatedAt = 0x03 + case signature = 0x04 + } + + public init(noiseStaticPublicKey: Data, prekeys: [Prekey], generatedAt: UInt64, signature: Data) { + self.noiseStaticPublicKey = noiseStaticPublicKey + self.prekeys = prekeys + self.generatedAt = generatedAt + self.signature = signature + } + + /// Canonical bytes covered by the Ed25519 signature: domain context, + /// owner key, prekey count, each (id, key) pair, and the generation time. + /// Encoders and verifiers must derive these identically. + public func signableBytes() -> Data { + var out = Data() + out.reserveCapacity(1 + Self.signingContext.count + Self.keyLength + 1 + + prekeys.count * Self.prekeyEntryLength + 8) + out.append(UInt8(min(Self.signingContext.count, 255))) + out.append(Self.signingContext.prefix(255)) + out.append(paddedKey(noiseStaticPublicKey)) + out.append(UInt8(min(prekeys.count, 255))) + for prekey in prekeys.prefix(255) { + appendBE(prekey.id, into: &out) + out.append(paddedKey(prekey.publicKey)) + } + appendBE(generatedAt, into: &out) + return out + } + + public func encode() -> Data? { + guard noiseStaticPublicKey.count == Self.keyLength, + signature.count == Self.signatureLength, + !prekeys.isEmpty, prekeys.count <= Self.maxPrekeys, + prekeys.allSatisfy({ $0.publicKey.count == Self.keyLength }) else { + return nil + } + + var entries = Data() + entries.reserveCapacity(prekeys.count * Self.prekeyEntryLength) + for prekey in prekeys { + appendBE(prekey.id, into: &entries) + entries.append(prekey.publicKey) + } + + var encoded = Data() + encoded.reserveCapacity(4 * 3 + Self.keyLength + entries.count + 8 + Self.signatureLength) + + encoded.append(TLVType.noiseStaticPublicKey.rawValue) + appendBE(UInt16(noiseStaticPublicKey.count), into: &encoded) + encoded.append(noiseStaticPublicKey) + + encoded.append(TLVType.prekeys.rawValue) + appendBE(UInt16(entries.count), into: &encoded) + encoded.append(entries) + + encoded.append(TLVType.generatedAt.rawValue) + appendBE(UInt16(8), into: &encoded) + appendBE(generatedAt, into: &encoded) + + encoded.append(TLVType.signature.rawValue) + appendBE(UInt16(signature.count), into: &encoded) + encoded.append(signature) + + return encoded + } + + public static func decode(_ data: Data) -> PrekeyBundle? { + var cursor = data.startIndex + let end = data.endIndex + + var noiseStaticPublicKey: Data? + var prekeys: [Prekey]? + var generatedAt: UInt64? + var signature: Data? + + while cursor < end { + let typeRaw = data[cursor] + cursor = data.index(after: cursor) + + guard data.distance(from: cursor, to: end) >= 2 else { return nil } + let length = Int(data[cursor]) << 8 | Int(data[data.index(after: cursor)]) + cursor = data.index(cursor, offsetBy: 2) + guard data.distance(from: cursor, to: end) >= length else { return nil } + let value = data[cursor.. 0, length % prekeyEntryLength == 0, + length / prekeyEntryLength <= maxPrekeys else { return nil } + var parsed: [Prekey] = [] + var entryStart = value.startIndex + while entryStart < value.endIndex { + let idEnd = value.index(entryStart, offsetBy: 4) + let id = value[entryStart.. Data { + let fixed = key.prefix(Self.keyLength) + guard fixed.count < Self.keyLength else { return Data(fixed) } + return Data(fixed) + Data(repeating: 0, count: Self.keyLength - fixed.count) + } +} + +private func appendBE(_ value: T, into data: inout Data) { + var big = value.bigEndian + withUnsafeBytes(of: &big) { data.append(contentsOf: $0) } +} From 81a10f73f099e4c131992d4b4afde5dc0a2f8804 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:59:55 +0200 Subject: [PATCH 16/18] Private groups: creator-managed encrypted group chat over the mesh (#1383) * Add capability bits to announce TLV Announces now carry an optional capabilities TLV (0x05): a little-endian bitfield with named bits for upcoming features (prekeys, wifiBulk, gateway, groups, board, vouch, meshDiagnostics). Old clients skip the unknown TLV; peers without it decode as nil so features can distinguish "legacy peer" from "advertises nothing". PeerCapabilities lives in BitFoundation with a minimal-length encoding that preserves unknown bits for forward compatibility. Peer capabilities are stored in the BLE peer registry on verified announce and exposed via BLEService.peerCapabilities(_:). The local advertisement set is empty until each feature ships its bit. Co-Authored-By: Claude Fable 5 * Private groups: creator-managed encrypted group chat over the mesh Small encrypted crews (hard cap 16) between public broadcast and 1:1 DMs: Protocol - MessageType.groupMessage = 0x25: broadcast packets with a cleartext 16-byte group ID + epoch, ChaCha20-Poly1305 ciphertext (epoch bound as AEAD AAD), inner Ed25519 sender signature over "bitchat-group-msg-v1"|groupID|messageID|timestamp|content - NoisePayloadType.groupInvite = 0x06 / .groupKeyUpdate = 0x07: creator-signed group state (key, epoch, roster) 1:1 over Noise; signature over "bitchat-group-v1"|groupID|epoch|key-hash|roster-hash and the Noise session peer must BE the creator - SyncTypeFlags bit 10 (groupMessage): variable-length LE bitfield widens 1 -> 2 bytes inside the length-prefixed REQUEST_SYNC TLV; old clients ignore unknown bits and answer with types they know - PeerCapabilities.localSupported now advertises .groups Storage - GroupStore: symmetric keys in the keychain, roster/name/epoch as protected JSON in Application Support; wiped in panicClearAllData() Behavior - Non-members relay 0x25 like any broadcast but cannot read it; group messages join gossip-sync backfill with the public-message window - Receivers drop wrong-epoch envelopes, bad sender signatures, and senders missing from the creator-signed roster - Fire-and-flood delivery (no per-member acks in v1) UI - Groups open as chat windows through the private-chat sheet (virtual "group_" peer IDs); groups section in the people sheet; /group create/invite/remove/leave/list commands; invitees get a system message + notification and the group appears in their people sheet Co-Authored-By: Claude Fable 5 * Private groups: fix TLV truncation, roster downgrade, removal notice, block, media, signable bytes Addresses the Codex review and adversarial-review findings on #1383: - TLV encoding now throws GroupTLVError.valueTooLong instead of clamping to 65535 and truncating, so an oversize group message fails to seal and surfaces send_failed rather than shipping ciphertext recipients drop. - Roster nicknames truncate on a Character boundary (never mid-scalar), so a multi-byte nickname can no longer make the whole signed roster undecodable. - Invites now bump the epoch (rotate the key) like removals, giving every roster change a strictly-increasing epoch so out-of-order invite states no longer last-writer-wins a just-added member back out. - Removing a member now sends them a creator-signed roster-without-them under a throwaway all-zero key (never the rotated key), so their client deactivates the group and surfaces "removed" instead of going silently dark. - /block is enforced in the group receive path: a blocked member's messages are dropped from display and notifications, consistent with every other inbound path. - Media affordances are disabled in group chats (both computed sites) so the composer can't strand a media placeholder that never sends; media-in-groups is a documented v2 item. - Creator signature now covers the group name and the sender signature covers the epoch (wire-format-affecting; needs Android parity before ship). - Explicit isGroup guard in markPrivateMessagesAsRead so read/delivered receipts can never leak into group conversations under a future refactor. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/App/ConversationUIModel.swift | 4 +- bitchat/App/PeerListModel.swift | 36 ++ bitchat/App/PrivateConversationModels.swift | 35 +- bitchat/Localizable.xcstrings | 385 +++++++++++ bitchat/Models/CommandInfo.swift | 10 +- bitchat/Protocols/BitchatProtocol.swift | 12 + .../Protocols/PeerCapabilities+Local.swift | 2 +- .../BLE/BLEOutboundPacketPolicy.swift | 2 +- bitchat/Services/BLE/BLEService.swift | 65 ++ bitchat/Services/CommandProcessor.swift | 41 ++ bitchat/Services/Groups/GroupProtocol.swift | 569 +++++++++++++++++ bitchat/Services/Groups/GroupStore.swift | 194 ++++++ bitchat/Services/Transport.swift | 14 + bitchat/Sync/GossipSyncManager.swift | 42 +- bitchat/Sync/SyncTypeFlags.swift | 9 + bitchat/ViewModels/ChatGroupCoordinator.swift | 602 ++++++++++++++++++ .../ViewModels/ChatLifecycleCoordinator.swift | 7 + .../ChatPeerIdentityCoordinator.swift | 9 + .../ChatTransportEventCoordinator.swift | 18 + bitchat/ViewModels/ChatViewModel.swift | 16 +- .../ChatViewModel+PrivateChat.swift | 6 + bitchat/ViewModels/NostrInboundPipeline.swift | 12 +- bitchat/Views/ContentSheetViews.swift | 62 +- bitchat/Views/GroupChatList.swift | 86 +++ ...ransportEventCoordinatorContextTests.swift | 12 + bitchatTests/CommandProcessorTests.swift | 28 + bitchatTests/GossipSyncManagerTests.swift | 5 +- .../Services/GroupProtocolTests.swift | 374 +++++++++++ bitchatTests/Services/GroupStoreTests.swift | 170 +++++ .../Services/MeshDiagnosticsTests.swift | 5 + .../Sync/SyncTypeFlagsBoardTests.swift | 14 +- .../Sync/SyncTypeFlagsGroupTests.swift | 62 ++ bitchatTests/Sync/SyncTypeFlagsTests.swift | 18 +- .../Sources/BitFoundation/MessageType.swift | 2 + .../Sources/BitFoundation/PeerID.swift | 24 + 35 files changed, 2903 insertions(+), 49 deletions(-) create mode 100644 bitchat/Services/Groups/GroupProtocol.swift create mode 100644 bitchat/Services/Groups/GroupStore.swift create mode 100644 bitchat/ViewModels/ChatGroupCoordinator.swift create mode 100644 bitchat/Views/GroupChatList.swift create mode 100644 bitchatTests/Services/GroupProtocolTests.swift create mode 100644 bitchatTests/Services/GroupStoreTests.swift create mode 100644 bitchatTests/Sync/SyncTypeFlagsGroupTests.swift diff --git a/bitchat/App/ConversationUIModel.swift b/bitchat/App/ConversationUIModel.swift index 461449cc..f8633e0d 100644 --- a/bitchat/App/ConversationUIModel.swift +++ b/bitchat/App/ConversationUIModel.swift @@ -193,7 +193,9 @@ final class ConversationUIModel: ObservableObject { private func refreshComputedState() { if let selectedPeerID = privateConversationModel.selectedPeerID { - canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat) + // Media transfer is not wired for groups in v1; keep it off so the + // composer can't strand a media placeholder that never sends. + canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat || selectedPeerID.isGroup) return } diff --git a/bitchat/App/PeerListModel.swift b/bitchat/App/PeerListModel.swift index 1846b487..b96c2e72 100644 --- a/bitchat/App/PeerListModel.swift +++ b/bitchat/App/PeerListModel.swift @@ -29,11 +29,22 @@ struct GeohashPersonRow: Identifiable, Equatable { let isBlocked: Bool } +struct GroupChatRow: Identifiable, Equatable { + let peerID: PeerID + let name: String + let memberCount: Int + let isCreator: Bool + let hasUnread: Bool + + var id: String { peerID.id } +} + @MainActor final class PeerListModel: ObservableObject { @Published private(set) var allPeers: [BitchatPeer] = [] @Published private(set) var meshRows: [MeshPeerRow] = [] @Published private(set) var geohashPeople: [GeohashPersonRow] = [] + @Published private(set) var groupRows: [GroupChatRow] = [] @Published private(set) var reachableMeshPeerCount = 0 @Published private(set) var connectedMeshPeerCount = 0 @Published private(set) var visibleGeohashPeerCount = 0 @@ -132,6 +143,13 @@ final class PeerListModel: ObservableObject { } .store(in: &cancellables) + chatViewModel.groupStore.$groups + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refresh() + } + .store(in: &cancellables) + peerIdentityStore.$encryptionStatuses .receive(on: DispatchQueue.main) .sink { [weak self] _ in @@ -220,22 +238,40 @@ final class PeerListModel: ObservableObject { } let geohashPeople = buildGeohashPeople() + let groupRows = buildGroupRows() self.meshRows = meshRows reachableMeshPeerCount = meshCounts.reachable connectedMeshPeerCount = meshCounts.connected self.geohashPeople = geohashPeople visibleGeohashPeerCount = geohashPeople.count + self.groupRows = groupRows renderID = ( meshRows.map { "\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)" } + geohashPeople.map { "geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)" + } + + groupRows.map { + "group:\($0.id)-\($0.name)-\($0.memberCount)-\($0.hasUnread)" } ).joined(separator: "|") } + private func buildGroupRows() -> [GroupChatRow] { + let myFingerprint = chatViewModel.meshService.noiseIdentityFingerprint() + return chatViewModel.groupStore.groups.map { group in + GroupChatRow( + peerID: group.peerID, + name: group.name, + memberCount: group.members.count, + isCreator: group.creatorFingerprint == myFingerprint, + hasUnread: chatViewModel.hasUnreadMessages(for: group.peerID) + ) + } + } + private func buildGeohashPeople() -> [GeohashPersonRow] { let myHex = currentGeohashIdentityHex() let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() }) diff --git a/bitchat/App/PrivateConversationModels.swift b/bitchat/App/PrivateConversationModels.swift index 2a465948..d9920646 100644 --- a/bitchat/App/PrivateConversationModels.swift +++ b/bitchat/App/PrivateConversationModels.swift @@ -108,7 +108,13 @@ struct PrivateConversationHeaderState: Equatable { let encryptionStatus: EncryptionStatus? var supportsFavoriteToggle: Bool { - !conversationPeerID.isGeoDM + !conversationPeerID.isGeoDM && !conversationPeerID.isGroup + } + + /// Group chats have no single peer identity behind the header: no + /// fingerprint screen, no per-peer encryption badge. + var isGroupConversation: Bool { + conversationPeerID.isGroup } } @@ -206,6 +212,13 @@ final class PrivateConversationModel: ObservableObject { } .store(in: &cancellables) + chatViewModel.groupStore.$groups + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshSelectedConversation() + } + .store(in: &cancellables) + NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated")) .receive(on: DispatchQueue.main) .sink { [weak self] _ in @@ -229,6 +242,26 @@ final class PrivateConversationModel: ObservableObject { } private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState { + // Group chats: the "peer" is the whole crew. Name + member count in + // the header; availability reads as mesh since group traffic floods + // the local mesh, and the per-peer encryption badge does not apply. + if conversationPeerID.isGroup { + let displayName: String + if let group = chatViewModel.groupStore.group(for: conversationPeerID) { + displayName = "#\(group.name) (\(group.members.count))" + } else { + displayName = String(localized: "common.unknown", comment: "Fallback label for unknown peer") + } + return PrivateConversationHeaderState( + conversationPeerID: conversationPeerID, + headerPeerID: conversationPeerID, + displayName: displayName, + availability: .meshReachable, + isFavorite: false, + encryptionStatus: nil + ) + } + let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID) let peer = chatViewModel.getPeer(byID: headerPeerID) let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer) diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 49e5487e..f7979b8d 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -9408,6 +9408,17 @@ } } }, + "content.accessibility.group_chat" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group chat" + } + } + } + }, "content.accessibility.jump_to_latest" : { "comment" : "Accessibility label for the jump to latest messages button", "extractionState" : "manual", @@ -15115,6 +15126,17 @@ } } }, + "content.commands.group" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "create or manage private groups" + } + } + } + }, "content.commands.help" : { "comment" : "Description of the /help command in the suggestions panel", "extractionState" : "manual", @@ -18338,6 +18360,17 @@ } } }, + "content.input.group_placeholder" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "create|invite|leave|list" + } + } + } + }, "content.input.placeholder.location" : { "comment" : "Composer placeholder for a public geohash channel, naming it", "extractionState" : "manual", @@ -20069,6 +20102,17 @@ } } }, + "content.private.caption_group" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "encrypted group · members only" + } + } + } + }, "encryption.accessibility.establishing" : { "extractionState" : "manual", "localizations" : { @@ -24626,6 +24670,50 @@ } } }, + "groups.accessibility.open_group_hint" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opens the group chat" + } + } + } + }, + "groups.member_count %@" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "(%@)" + } + } + } + }, + "groups.section.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "groups" + } + } + } + }, + "groups.state.creator" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Creator" + } + } + } + }, "location_channels.accessibility.add_bookmark" : { "comment" : "Accessibility action name for bookmarking a channel", "extractionState" : "manual", @@ -34155,6 +34243,303 @@ } } }, + "system.group.already_member" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ is already a member" + } + } + } + }, + "system.group.cannot_remove_creator" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "the creator cannot be removed" + } + } + } + }, + "system.group.create_failed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "could not create the group" + } + } + } + }, + "system.group.created" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "created group '%@' — use /group invite @name to add people" + } + } + } + }, + "system.group.creator_only" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "only the group creator can do that" + } + } + } + }, + "system.group.full" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "group is full (max %@ members)" + } + } + } + }, + "system.group.identity_unavailable" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "your identity keys are not ready yet" + } + } + } + }, + "system.group.invite_failed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "could not build the group invite" + } + } + } + }, + "system.group.invited" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "invited %1$@ to '%2$@'" + } + } + } + }, + "system.group.joined" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you were added to group '%1$@' by %2$@ — it now appears in your people list" + } + } + } + }, + "system.group.left" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "left group '%@'" + } + } + } + }, + "system.group.list_header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "your groups:" + } + } + } + }, + "system.group.member_not_found" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' is not a member of this group" + } + } + } + }, + "system.group.name_too_long" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "group names are limited to 40 characters" + } + } + } + }, + "system.group.none" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you are not in any groups — /group create to start one" + } + } + } + }, + "system.group.not_in_group" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "open a group chat first" + } + } + } + }, + "system.group.peer_identity_unknown" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "cannot verify %@'s identity yet — wait for their announce" + } + } + } + }, + "system.group.peer_not_connected" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ must be connected over mesh to be invited" + } + } + } + }, + "system.group.peer_not_found" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' not found" + } + } + } + }, + "system.group.removed_from" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you were removed from group '%@'" + } + } + } + }, + "system.group.removed_member" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "removed %@ and rotated the group key" + } + } + } + }, + "system.group.rotate_failed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "could not rotate the group key" + } + } + } + }, + "system.group.send_failed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "could not encrypt the message" + } + } + } + }, + "system.group.unknown" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you are no longer in this group" + } + } + } + }, + "system.group.usage_create" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage: /group create " + } + } + } + }, + "system.group.usage_invite" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage: /group invite @name" + } + } + } + }, + "system.group.usage_remove" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage: /group remove @name" + } + } + } + }, "system.location.not_in_channel" : { "extractionState" : "manual", "localizations" : { diff --git a/bitchat/Models/CommandInfo.swift b/bitchat/Models/CommandInfo.swift index bb0a0a65..b4f309d9 100644 --- a/bitchat/Models/CommandInfo.swift +++ b/bitchat/Models/CommandInfo.swift @@ -16,6 +16,7 @@ enum CommandInfo: String, Identifiable { // suggesting a spelling the processor rejects teaches users dead ends. case block case clear + case group case help case hug case message = "msg" @@ -36,6 +37,8 @@ enum CommandInfo: String, Identifiable { switch self { case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite, .ping, .trace: return "<" + String(localized: "content.input.nickname_placeholder") + ">" + case .group: + return "<" + String(localized: "content.input.group_placeholder") + ">" case .pay: return "<" + String(localized: "content.input.token_placeholder") + ">" case .clear, .help, .who: @@ -47,6 +50,7 @@ enum CommandInfo: String, Identifiable { switch self { case .block: String(localized: "content.commands.block") case .clear: String(localized: "content.commands.clear") + case .group: String(localized: "content.commands.group") case .help: String(localized: "content.commands.help") case .hug: String(localized: "content.commands.hug") case .message: String(localized: "content.commands.message") @@ -70,11 +74,11 @@ enum CommandInfo: String, Identifiable { if !isGeoPublic { commands.append(.pay) } - // The processor rejects favorites and mesh diagnostics in geohash - // contexts, so only suggest them where they actually work: mesh. + // The processor rejects favorites, groups, and mesh diagnostics in + // geohash contexts, so only suggest them where they work: mesh. if isGeoPublic || isGeoDM { return commands } - return commands + [.favorite, .unfavorite, .ping, .trace] + return commands + [.favorite, .unfavorite, .ping, .trace, .group] } } diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index c7497051..476ae876 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -74,6 +74,9 @@ enum NoisePayloadType: UInt8 { case privateMessage = 0x01 // Private chat message case readReceipt = 0x02 // Message was read case delivered = 0x03 // Message was delivered + // Private groups (0x04/0x05 reserved by other features) + case groupInvite = 0x06 // Creator-signed group state (invite) + case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update) // Verification (QR-based OOB binding) case verifyChallenge = 0x10 // Verification challenge case verifyResponse = 0x11 // Verification response @@ -85,6 +88,8 @@ enum NoisePayloadType: UInt8 { case .privateMessage: return "privateMessage" case .readReceipt: return "readReceipt" case .delivered: return "delivered" + case .groupInvite: return "groupInvite" + case .groupKeyUpdate: return "groupKeyUpdate" case .verifyChallenge: return "verifyChallenge" case .verifyResponse: return "verifyResponse" case .vouch: return "vouch" @@ -119,6 +124,9 @@ protocol BitchatDelegate: AnyObject { // Low-level events for better separation of concerns func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) + // Encrypted group broadcast (opaque envelope; decrypted by the group coordinator) + func didReceiveGroupMessage(payload: Data, timestamp: Date) + // Bluetooth state updates for user notifications func didUpdateBluetoothState(_ state: CBManagerState) func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) @@ -138,6 +146,10 @@ extension BitchatDelegate { // Default empty implementation } + func didReceiveGroupMessage(payload: Data, timestamp: Date) { + // Default empty implementation + } + func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) { // Default empty implementation } diff --git a/bitchat/Protocols/PeerCapabilities+Local.swift b/bitchat/Protocols/PeerCapabilities+Local.swift index b5c36f1a..819464b9 100644 --- a/bitchat/Protocols/PeerCapabilities+Local.swift +++ b/bitchat/Protocols/PeerCapabilities+Local.swift @@ -3,5 +3,5 @@ import BitFoundation extension PeerCapabilities { /// Capabilities this build advertises in its announce packets. /// Each feature adds its bit here when it ships. - static let localSupported: PeerCapabilities = [.vouch, .prekeys] + static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups] } diff --git a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift index 2bec6292..64cfa73e 100644 --- a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift +++ b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift @@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy { switch MessageType(rawValue: packetType) { case .noiseEncrypted, .noiseHandshake: return true - case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle: + case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage: return false } } diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 3e6c5da7..8d9234af 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -1394,6 +1394,48 @@ final class BLEService: NSObject { // MARK: QR Verification over Noise + // MARK: Private Groups + + /// Sends creator-signed group state (invite) 1:1 over the Noise session, + /// queueing behind a handshake when none is established yet. + func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) { + sendNoisePayload(NoisePayload(type: .groupInvite, data: statePayload).encode(), to: peerID) + } + + /// Sends creator-signed group state (key rotation / roster update) 1:1 + /// over the Noise session. + func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) { + sendNoisePayload(NoisePayload(type: .groupKeyUpdate, data: statePayload).encode(), to: peerID) + } + + /// Broadcasts a sealed group message (MessageType 0x25) like a public + /// message: fire-and-flood with gossip-sync backfill. The outer packet is + /// intentionally unsigned — receivers authenticate the sender's Ed25519 + /// signature inside the ciphertext, which still verifies for backfilled + /// copies long after the sender's announce has expired. + func broadcastGroupMessage(_ envelope: Data) { + guard !envelope.isEmpty else { return } + messageQueue.async { [weak self] in + guard let self else { return } + let packet = BitchatPacket( + type: MessageType.groupMessage.rawValue, + senderID: Data(hexString: self.myPeerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: envelope, + signature: nil, + ttl: self.messageTTL + ) + // Pre-mark our own broadcast as processed to avoid handling a + // relayed self copy. + let dedupID = BLESelfBroadcastTracker.dedupID(for: packet) + self.messageDeduplicator.markProcessed(dedupID) + self.broadcastPacket(packet) + // Track our own broadcast for gossip sync + self.gossipSyncManager?.onPublicPacketSeen(packet) + } + } + func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { let payload = VerificationService.shared.buildVerifyChallenge(noiseKeyHex: noiseKeyHex, nonceA: nonceA) sendNoisePayload(payload, to: peerID) @@ -3758,6 +3800,9 @@ extension BLEService { case .courierEnvelope: handleCourierEnvelope(packet, from: peerID) + case .groupMessage: + handleGroupMessage(packet, from: senderID) + case .prekeyBundle: handlePrekeyBundle(packet, from: senderID) @@ -4095,6 +4140,26 @@ extension BLEService { ) } + /// Group broadcasts are opaque ciphertext to this layer: track them for + /// gossip backfill and hand the payload to the UI layer, where the group + /// coordinator decrypts and authenticates against the roster. Non-members + /// still relay (generic broadcast relay path) but never decode. + private func handleGroupMessage(_ packet: BitchatPacket, from peerID: PeerID) { + let isBroadcastRecipient: Bool = { + guard let recipient = packet.recipientID else { return true } + return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF } + }() + guard isBroadcastRecipient, !packet.payload.isEmpty else { return } + + gossipSyncManager?.onPublicPacketSeen(packet) + + let payload = packet.payload + let timestamp = Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000) + notifyUI { [weak self] in + self?.deliverTransportEvent(.groupMessageReceived(payload: payload, timestamp: timestamp)) + } + } + private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) { noisePacketHandler.handleHandshake(packet, from: peerID) } diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index 9555609a..b8a4785e 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -74,6 +74,15 @@ protocol CommandContextProvider: AnyObject { /// Toggles the favorite via the unified peer flow, which persists by the /// real noise key and notifies the peer over mesh or Nostr. func toggleFavorite(peerID: PeerID) + + // MARK: - Groups + // Group logic lives in `ChatGroupCoordinator`; these forward the parsed + // /group subcommands. + func groupCreate(named name: String) -> CommandResult + func groupInvite(nickname: String) -> CommandResult + func groupRemove(nickname: String) -> CommandResult + func groupLeave() -> CommandResult + func groupList() -> CommandResult } /// Processes chat commands in a focused, efficient way @@ -120,6 +129,9 @@ final class CommandProcessor { return handleBlock(args) case "/unblock": return handleUnblock(args) + case "/group": + if inGeoPublic || inGeoDM { return .error(message: "groups are only for mesh peers in #mesh") } + return handleGroup(args) case "/fav": if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") } return handleFavorite(args, add: true) @@ -153,6 +165,9 @@ final class CommandProcessor { /slap @name — slap with a large trout /block @name · /unblock @name /fav @name · /unfav @name — favorites (mesh only) + /group create — start an encrypted group + /group invite @name · /group remove @name — manage members (creator) + /group leave · /group list — leave or list your groups /ping @name — measure round-trip time (mesh only) /trace @name — estimated mesh path (mesh only) /pay — send a cashu ecash token in this chat @@ -362,6 +377,32 @@ final class CommandProcessor { return .error(message: "cannot unblock \(nickname): not found") } + private static let groupUsage = "usage: /group create · invite @name · remove @name · leave · list" + + private func handleGroup(_ args: String) -> CommandResult { + let parts = args.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) + guard let subcommand = parts.first else { + return .error(message: Self.groupUsage) + } + let rest = parts.count > 1 ? String(parts[1]) : "" + guard let provider = contextProvider else { return .handled } + + switch subcommand { + case "create": + return provider.groupCreate(named: rest) + case "invite": + return provider.groupInvite(nickname: rest) + case "remove": + return provider.groupRemove(nickname: rest) + case "leave": + return provider.groupLeave() + case "list": + return provider.groupList() + default: + return .error(message: Self.groupUsage) + } + } + // MARK: - Mesh Diagnostics private enum MeshPeerResolution { diff --git a/bitchat/Services/Groups/GroupProtocol.swift b/bitchat/Services/Groups/GroupProtocol.swift new file mode 100644 index 00000000..dc4a0abf --- /dev/null +++ b/bitchat/Services/Groups/GroupProtocol.swift @@ -0,0 +1,569 @@ +// +// GroupProtocol.swift +// bitchat +// +// Wire formats and crypto for private groups: creator-signed group state +// (invites and key updates over Noise) and ChaCha20-Poly1305 group messages +// broadcast as MessageType.groupMessage (0x25). +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import CryptoKit +import Foundation + +// MARK: - Models + +/// A member of a private group as pinned in the creator-signed roster. +struct GroupMember: Codable, Equatable { + /// SHA-256 fingerprint (64 hex chars) of the member's Noise static key. + let fingerprint: String + /// The member's Ed25519 signing public key (32 bytes, from their announce). + let signingKey: Data + /// Nickname at invite time; display fallback when the peer is offline. + var nickname: String +} + +/// Creator-managed encrypted group. Metadata only — the symmetric key lives +/// in the keychain (see `GroupStore`). +struct BitchatGroup: Codable, Equatable { + static let maxMembers = 16 + static let groupIDLength = 16 + static let keyLength = 32 + + /// 16 random bytes; travels in cleartext on group message packets so + /// relays can dedup/filter without membership. + let groupID: Data + var name: String + /// Bumps on every key rotation; messages are bound to the epoch they + /// were sealed under. + var epoch: UInt32 + var members: [GroupMember] + /// Fingerprint of the creator — the only identity allowed to sign group + /// state (invites, key updates) in v1. + let creatorFingerprint: String + + /// Virtual conversation ID this group's chat is keyed under. + var peerID: PeerID { PeerID(groupID: groupID) } + + var creator: GroupMember? { + members.first { $0.fingerprint == creatorFingerprint } + } + + func isMember(fingerprint: String) -> Bool { + members.contains { $0.fingerprint == fingerprint } + } + + func member(withSigningKey signingKey: Data) -> GroupMember? { + members.first { $0.signingKey == signingKey } + } +} + +// MARK: - TLV helpers + +enum GroupTLVError: Error, Equatable { + /// A TLV value exceeded the 16-bit length field. Encoding fails instead + /// of silently truncating (which would ship a value the receiver drops). + case valueTooLong +} + +private enum GroupTLV { + /// Appends a (type, 16-bit length, value) triple. Throws rather than + /// truncating when `value` does not fit the 16-bit length field, so an + /// oversize field surfaces a send failure instead of a silently truncated + /// blob the recipient rejects during decrypt/verify. + static func put(_ type: UInt8, _ value: Data, into out: inout Data) throws { + guard value.count <= Int(UInt16.max) else { throw GroupTLVError.valueTooLong } + out.append(type) + let length = UInt16(value.count) + out.append(UInt8((length >> 8) & 0xFF)) + out.append(UInt8(length & 0xFF)) + out.append(value) + } + + /// Iterates (type, value) pairs; returns nil on malformed framing. + static func parse(_ data: Data) -> [(type: UInt8, value: Data)]? { + var fields: [(UInt8, Data)] = [] + var offset = data.startIndex + while offset < data.endIndex { + guard data.distance(from: offset, to: data.endIndex) >= 3 else { return nil } + let type = data[offset] + let high = Int(data[data.index(offset, offsetBy: 1)]) + let low = Int(data[data.index(offset, offsetBy: 2)]) + let length = (high << 8) | low + let valueStart = data.index(offset, offsetBy: 3) + guard data.distance(from: valueStart, to: data.endIndex) >= length else { return nil } + let valueEnd = data.index(valueStart, offsetBy: length) + fields.append((type, Data(data[valueStart.. Data { + var bigEndian = epoch.bigEndian + return withUnsafeBytes(of: &bigEndian) { Data($0) } + } + + static func epoch(from data: Data) -> UInt32? { + guard data.count == 4 else { return nil } + return data.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } + } + + static func timestampData(_ timestampMs: UInt64) -> Data { + var bigEndian = timestampMs.bigEndian + return withUnsafeBytes(of: &bigEndian) { Data($0) } + } + + static func timestamp(from data: Data) -> UInt64? { + guard data.count == 8 else { return nil } + return data.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) } + } +} + +// MARK: - Roster wire form + +enum GroupRosterCoding { + private static let fingerprintLength = 32 + private static let signingKeyLength = 32 + private static let maxNicknameBytes = 64 + + /// Deterministic roster blob: count byte, then per member the raw 32-byte + /// fingerprint, 32-byte signing key, and length-prefixed UTF-8 nickname. + /// The creator signature covers the SHA-256 of these exact bytes. + static func encode(_ members: [GroupMember]) -> Data? { + guard members.count <= BitchatGroup.maxMembers else { return nil } + var out = Data([UInt8(members.count)]) + for member in members { + guard let fingerprintData = Data(hexString: member.fingerprint), + fingerprintData.count == fingerprintLength, + member.signingKey.count == signingKeyLength else { return nil } + out.append(fingerprintData) + out.append(member.signingKey) + // Truncate on a Character boundary so the byte prefix is always + // valid UTF-8; a raw byte-prefix could split a multi-byte scalar + // and make the whole signed roster undecodable on the recipient. + let nickname = truncatedNicknameBytes(member.nickname) + out.append(UInt8(nickname.count)) + out.append(nickname) + } + return out + } + + static func decode(_ data: Data) -> [GroupMember]? { + guard let count = data.first, count <= UInt8(BitchatGroup.maxMembers) else { return nil } + var members: [GroupMember] = [] + var offset = data.index(after: data.startIndex) + for _ in 0..= fixed else { return nil } + let fingerprintEnd = data.index(offset, offsetBy: fingerprintLength) + let fingerprint = Data(data[offset..= nickLength else { return nil } + let nickEnd = data.index(nickStart, offsetBy: nickLength) + guard let nickname = String(data: Data(data[nickStart.. Data { + var candidate = nickname + while Data(candidate.utf8).count > maxNicknameBytes { + candidate.removeLast() + } + return Data(candidate.utf8) + } +} + +// MARK: - Group state payload (groupInvite / groupKeyUpdate over Noise) + +/// Creator-signed group state. The same wire form serves invites (0x06) and +/// key updates (0x07); receivers verify the creator signature — computed over +/// "bitchat-group-v1" | groupID | epoch | SHA256(key) | SHA256(roster) — +/// against the creator's signing key pinned in the roster, and require the +/// Noise session peer to BE the creator before accepting any state. +struct GroupStatePayload: Equatable { + let groupID: Data + let name: String + /// Symmetric ChaCha20-Poly1305 group key (32 bytes) for `epoch`. + let key: Data + let epoch: UInt32 + let members: [GroupMember] + let creatorFingerprint: String + /// Ed25519 signature by the creator. + let signature: Data + + private enum FieldType: UInt8 { + case groupID = 0x01 + case name = 0x02 + case key = 0x03 + case epoch = 0x04 + case roster = 0x05 + case creatorFingerprint = 0x06 + case signature = 0x07 + } + + static let signingDomain = Data("bitchat-group-v1".utf8) + + /// The bytes the creator signs. Binding the key, roster, and name by hash + /// keeps the signed content fixed-size. The name is covered so a relay + /// that caches/replays a signed state (e.g. store-and-forward) cannot swap + /// the display name while keeping a valid creator signature. + static func signingContent(groupID: Data, epoch: UInt32, key: Data, rosterBlob: Data, name: String) -> Data { + var content = signingDomain + content.append(groupID) + content.append(GroupTLV.epochData(epoch)) + content.append(key.sha256Hash()) + content.append(rosterBlob.sha256Hash()) + content.append(Data(name.utf8).sha256Hash()) + return content + } + + /// Builds a signed state payload. Returns nil when the roster cannot be + /// encoded (over cap, malformed member) or signing fails. + static func makeSigned( + group: BitchatGroup, + key: Data, + sign: (Data) -> Data? + ) -> GroupStatePayload? { + guard let rosterBlob = GroupRosterCoding.encode(group.members) else { return nil } + let content = signingContent(groupID: group.groupID, epoch: group.epoch, key: key, rosterBlob: rosterBlob, name: group.name) + guard let signature = sign(content) else { return nil } + return GroupStatePayload( + groupID: group.groupID, + name: group.name, + key: key, + epoch: group.epoch, + members: group.members, + creatorFingerprint: group.creatorFingerprint, + signature: signature + ) + } + + func encode() -> Data? { + guard let rosterBlob = GroupRosterCoding.encode(members), + let fingerprintData = Data(hexString: creatorFingerprint), + fingerprintData.count == 32 else { return nil } + var out = Data() + do { + try GroupTLV.put(FieldType.groupID.rawValue, groupID, into: &out) + try GroupTLV.put(FieldType.name.rawValue, Data(name.utf8), into: &out) + try GroupTLV.put(FieldType.key.rawValue, key, into: &out) + try GroupTLV.put(FieldType.epoch.rawValue, GroupTLV.epochData(epoch), into: &out) + try GroupTLV.put(FieldType.roster.rawValue, rosterBlob, into: &out) + try GroupTLV.put(FieldType.creatorFingerprint.rawValue, fingerprintData, into: &out) + try GroupTLV.put(FieldType.signature.rawValue, signature, into: &out) + } catch { + return nil + } + return out + } + + static func decode(_ data: Data) -> GroupStatePayload? { + guard let fields = GroupTLV.parse(data) else { return nil } + var groupID: Data? + var name: String? + var key: Data? + var epoch: UInt32? + var rosterBlob: Data? + var members: [GroupMember]? + var creatorFingerprint: String? + var signature: Data? + + for (type, value) in fields { + switch FieldType(rawValue: type) { + case .groupID where value.count == BitchatGroup.groupIDLength: + groupID = value + case .name: + name = String(data: value, encoding: .utf8) + case .key where value.count == BitchatGroup.keyLength: + key = value + case .epoch: + epoch = GroupTLV.epoch(from: value) + case .roster: + rosterBlob = value + members = GroupRosterCoding.decode(value) + case .creatorFingerprint where value.count == 32: + creatorFingerprint = value.hexEncodedString() + case .signature where value.count == 64: + signature = value + default: + break // forward compatible; ignore unknown TLVs + } + } + + guard let groupID, let name, let key, let epoch, + rosterBlob != nil, let members, !members.isEmpty, + let creatorFingerprint, let signature else { return nil } + return GroupStatePayload( + groupID: groupID, + name: name, + key: key, + epoch: epoch, + members: members, + creatorFingerprint: creatorFingerprint, + signature: signature + ) + } + + /// Verifies the creator signature against the creator's signing key + /// pinned in the roster, and that the creator is actually in the roster. + func verifyCreatorSignature() -> Bool { + guard members.count <= BitchatGroup.maxMembers, + let creator = members.first(where: { $0.fingerprint == creatorFingerprint }), + let rosterBlob = GroupRosterCoding.encode(members) else { return false } + let content = GroupStatePayload.signingContent(groupID: groupID, epoch: epoch, key: key, rosterBlob: rosterBlob, name: name) + return GroupCrypto.verify(signature: signature, for: content, publicKey: creator.signingKey) + } + + var asGroup: BitchatGroup { + BitchatGroup( + groupID: groupID, + name: name, + epoch: epoch, + members: members, + creatorFingerprint: creatorFingerprint + ) + } +} + +// MARK: - Group message envelope (MessageType 0x25 payload) + +/// Cleartext framing of a group message broadcast. Only the group ID, epoch, +/// and nonce are visible to relays; everything about the message — sender, +/// content, timestamps — is inside the ChaCha20-Poly1305 ciphertext. +struct GroupMessageEnvelope: Equatable { + let groupID: Data + let epoch: UInt32 + let nonce: Data + /// ChaChaPoly ciphertext || 16-byte tag. + let ciphertext: Data + + private enum FieldType: UInt8 { + case groupID = 0x01 + case epoch = 0x02 + case nonce = 0x03 + case ciphertext = 0x04 + } + + func encode() throws -> Data { + var out = Data() + try GroupTLV.put(FieldType.groupID.rawValue, groupID, into: &out) + try GroupTLV.put(FieldType.epoch.rawValue, GroupTLV.epochData(epoch), into: &out) + try GroupTLV.put(FieldType.nonce.rawValue, nonce, into: &out) + try GroupTLV.put(FieldType.ciphertext.rawValue, ciphertext, into: &out) + return out + } + + static func decode(_ data: Data) -> GroupMessageEnvelope? { + guard let fields = GroupTLV.parse(data) else { return nil } + var groupID: Data? + var epoch: UInt32? + var nonce: Data? + var ciphertext: Data? + for (type, value) in fields { + switch FieldType(rawValue: type) { + case .groupID where value.count == BitchatGroup.groupIDLength: + groupID = value + case .epoch: + epoch = GroupTLV.epoch(from: value) + case .nonce where value.count == 12: + nonce = value + case .ciphertext where !value.isEmpty: + ciphertext = value + default: + break + } + } + guard let groupID, let epoch, let nonce, let ciphertext else { return nil } + return GroupMessageEnvelope(groupID: groupID, epoch: epoch, nonce: nonce, ciphertext: ciphertext) + } +} + +/// Decrypted, signature-verified inner content of a group message. +struct GroupMessagePlaintext: Equatable { + let messageID: String + let senderSigningKey: Data + let senderNickname: String + let timestampMs: UInt64 + let content: String +} + +// MARK: - Crypto + +enum GroupCryptoError: Error, Equatable { + case malformedPayload + case signingFailed + case sealFailed + case wrongEpoch + case decryptionFailed + case badSenderSignature +} + +enum GroupCrypto { + static let messageSigningDomain = Data("bitchat-group-msg-v1".utf8) + + private enum InnerField: UInt8 { + case messageID = 0x01 + case senderSigningKey = 0x02 + case senderNickname = 0x03 + case timestamp = 0x04 + case content = 0x05 + case signature = 0x06 + } + + /// Bytes the sender signs: domain | groupID | epoch | messageID | timestamp | content. + /// Covering the epoch stops a current member from re-sealing another + /// member's decrypted inner bytes under a later epoch key (the signature + /// would no longer verify at the new epoch). + static func messageSigningContent(groupID: Data, epoch: UInt32, messageID: String, timestampMs: UInt64, content: String) -> Data { + var data = messageSigningDomain + data.append(groupID) + data.append(GroupTLV.epochData(epoch)) + data.append(Data(messageID.utf8)) + data.append(GroupTLV.timestampData(timestampMs)) + data.append(Data(content.utf8)) + return data + } + + static func verify(signature: Data, for data: Data, publicKey: Data) -> Bool { + guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKey) else { return false } + return key.isValidSignature(signature, for: data) + } + + /// Seals a group message: builds the signed inner TLV and encrypts it with + /// the epoch key. The cleartext group ID and epoch are bound into the AEAD + /// as additional data so ciphertext cannot be replayed across groups or + /// epochs. Returns the encoded 0x25 packet payload. + static func sealMessage( + content: String, + messageID: String, + senderNickname: String, + senderSigningKey: Data, + timestampMs: UInt64, + groupID: Data, + epoch: UInt32, + key: Data, + sign: (Data) -> Data? + ) throws -> Data { + let signingContent = messageSigningContent( + groupID: groupID, + epoch: epoch, + messageID: messageID, + timestampMs: timestampMs, + content: content + ) + guard let signature = sign(signingContent), signature.count == 64 else { + throw GroupCryptoError.signingFailed + } + + var inner = Data() + try GroupTLV.put(InnerField.messageID.rawValue, Data(messageID.utf8), into: &inner) + try GroupTLV.put(InnerField.senderSigningKey.rawValue, senderSigningKey, into: &inner) + try GroupTLV.put(InnerField.senderNickname.rawValue, Data(senderNickname.utf8), into: &inner) + try GroupTLV.put(InnerField.timestamp.rawValue, GroupTLV.timestampData(timestampMs), into: &inner) + try GroupTLV.put(InnerField.content.rawValue, Data(content.utf8), into: &inner) + try GroupTLV.put(InnerField.signature.rawValue, signature, into: &inner) + + do { + let symmetricKey = SymmetricKey(data: key) + var aad = groupID + aad.append(GroupTLV.epochData(epoch)) + let sealed = try ChaChaPoly.seal(inner, using: symmetricKey, authenticating: aad) + var ciphertext = sealed.ciphertext + ciphertext.append(sealed.tag) + let envelope = GroupMessageEnvelope( + groupID: groupID, + epoch: epoch, + nonce: Data(sealed.nonce), + ciphertext: ciphertext + ) + return try envelope.encode() + } catch { + throw GroupCryptoError.sealFailed + } + } + + /// Opens a group message envelope with the epoch key: decrypts, parses the + /// inner TLV, and verifies the sender's Ed25519 signature. Roster + /// membership of the sender is the CALLER's check — this function only + /// proves the payload was authored by `senderSigningKey`. + static func openMessage(_ envelope: GroupMessageEnvelope, key: Data) throws -> GroupMessagePlaintext { + let inner: Data + do { + let symmetricKey = SymmetricKey(data: key) + var aad = envelope.groupID + aad.append(GroupTLV.epochData(envelope.epoch)) + let nonce = try ChaChaPoly.Nonce(data: envelope.nonce) + guard envelope.ciphertext.count > 16 else { throw GroupCryptoError.decryptionFailed } + let tag = envelope.ciphertext.suffix(16) + let body = envelope.ciphertext.prefix(envelope.ciphertext.count - 16) + let sealedBox = try ChaChaPoly.SealedBox(nonce: nonce, ciphertext: body, tag: tag) + inner = try ChaChaPoly.open(sealedBox, using: symmetricKey, authenticating: aad) + } catch { + throw GroupCryptoError.decryptionFailed + } + + guard let fields = GroupTLV.parse(inner) else { throw GroupCryptoError.malformedPayload } + var messageID: String? + var senderSigningKey: Data? + var senderNickname: String? + var timestampMs: UInt64? + var content: String? + var signature: Data? + for (type, value) in fields { + switch InnerField(rawValue: type) { + case .messageID: + messageID = String(data: value, encoding: .utf8) + case .senderSigningKey where value.count == 32: + senderSigningKey = value + case .senderNickname: + senderNickname = String(data: value, encoding: .utf8) + case .timestamp: + timestampMs = GroupTLV.timestamp(from: value) + case .content: + content = String(data: value, encoding: .utf8) + case .signature where value.count == 64: + signature = value + default: + break + } + } + guard let messageID, !messageID.isEmpty, + let senderSigningKey, + let senderNickname, + let timestampMs, + let content, + let signature else { throw GroupCryptoError.malformedPayload } + + let signingContent = messageSigningContent( + groupID: envelope.groupID, + epoch: envelope.epoch, + messageID: messageID, + timestampMs: timestampMs, + content: content + ) + guard verify(signature: signature, for: signingContent, publicKey: senderSigningKey) else { + throw GroupCryptoError.badSenderSignature + } + + return GroupMessagePlaintext( + messageID: messageID, + senderSigningKey: senderSigningKey, + senderNickname: senderNickname, + timestampMs: timestampMs, + content: content + ) + } +} diff --git a/bitchat/Services/Groups/GroupStore.swift b/bitchat/Services/Groups/GroupStore.swift new file mode 100644 index 00000000..3a45dcb7 --- /dev/null +++ b/bitchat/Services/Groups/GroupStore.swift @@ -0,0 +1,194 @@ +// +// GroupStore.swift +// bitchat +// +// Persistence for private groups: symmetric keys in the keychain, metadata +// (roster, name, epoch) as protected JSON in Application Support. Both are +// dropped by the panic wipe. +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import BitLogger +import Combine +import Foundation +import Security + +@MainActor +final class GroupStore: ObservableObject { + /// All groups this device is a member of, in creation/join order. + @Published private(set) var groups: [BitchatGroup] = [] + + private let keychain: KeychainManagerProtocol + private let fileURL: URL? + + /// - Parameter fileURL: Overrides the on-disk location (tests). Ignored + /// when `persistsToDisk` is false. + init(keychain: KeychainManagerProtocol, persistsToDisk: Bool = true, fileURL: URL? = nil) { + self.keychain = keychain + self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil + loadFromDisk() + } + + // MARK: - Reads + + func group(withID groupID: Data) -> BitchatGroup? { + groups.first { $0.groupID == groupID } + } + + func group(for peerID: PeerID) -> BitchatGroup? { + guard let groupID = peerID.groupIDData else { return nil } + return group(withID: groupID) + } + + /// Current-epoch symmetric key for the group, from the keychain. + func key(forGroupID groupID: Data) -> Data? { + keychain.getIdentityKey(forKey: Self.keychainKey(for: groupID)) + } + + // MARK: - Mutations + + /// Creates a new group with a random 16-byte ID and 32-byte key at + /// epoch 1, with the creator as sole member. Returns nil when key + /// generation or persistence fails. + func createGroup(named name: String, creator: GroupMember) -> BitchatGroup? { + guard let groupID = Self.randomBytes(BitchatGroup.groupIDLength), + let key = Self.randomBytes(BitchatGroup.keyLength) else { return nil } + let group = BitchatGroup( + groupID: groupID, + name: name, + epoch: 1, + members: [creator], + creatorFingerprint: creator.fingerprint + ) + guard upsert(group, key: key) else { return nil } + return group + } + + /// Inserts or replaces a group and its current key. Rejects rosters over + /// the hard cap or groups whose creator is missing from the roster. + @discardableResult + func upsert(_ group: BitchatGroup, key: Data) -> Bool { + guard group.groupID.count == BitchatGroup.groupIDLength, + key.count == BitchatGroup.keyLength, + !group.members.isEmpty, + group.members.count <= BitchatGroup.maxMembers, + group.creator != nil else { return false } + guard keychain.saveIdentityKey(key, forKey: Self.keychainKey(for: group.groupID)) else { + SecureLogger.error("Failed to store group key in keychain", category: .security) + return false + } + if let index = groups.firstIndex(where: { $0.groupID == group.groupID }) { + groups[index] = group + } else { + groups.append(group) + } + persist() + return true + } + + /// Updates the roster of an existing group without changing key or epoch + /// (creator-side invite). Enforces the member cap. + @discardableResult + func updateRoster(groupID: Data, members: [GroupMember]) -> BitchatGroup? { + guard let index = groups.firstIndex(where: { $0.groupID == groupID }), + !members.isEmpty, + members.count <= BitchatGroup.maxMembers, + members.contains(where: { $0.fingerprint == groups[index].creatorFingerprint }) else { return nil } + groups[index].members = members + persist() + return groups[index] + } + + /// Rotates the group key (creator-side removal/rotation): new random key, + /// epoch + 1, and the given roster. Returns the updated group and new key. + func rotateKey(groupID: Data, members: [GroupMember]) -> (group: BitchatGroup, key: Data)? { + guard let existing = group(withID: groupID), + let newKey = Self.randomBytes(BitchatGroup.keyLength) else { return nil } + var rotated = existing + rotated.epoch = existing.epoch &+ 1 + rotated.members = members + guard upsert(rotated, key: newKey) else { return nil } + return (rotated, newKey) + } + + func removeGroup(withID groupID: Data) { + groups.removeAll { $0.groupID == groupID } + _ = keychain.deleteIdentityKey(forKey: Self.keychainKey(for: groupID)) + persist() + } + + /// Panic wipe: drop all group keys and metadata from memory and disk. + /// (The panic flow also nukes the whole keychain; deleting per-group keys + /// here keeps the store safe to wipe on its own.) + func wipe() { + for group in groups { + _ = keychain.deleteIdentityKey(forKey: Self.keychainKey(for: group.groupID)) + } + groups.removeAll() + if let fileURL { + try? FileManager.default.removeItem(at: fileURL) + } + } + + // MARK: - Internals + + private static func keychainKey(for groupID: Data) -> String { + "groupKey-\(groupID.hexEncodedString())" + } + + private static func randomBytes(_ count: Int) -> Data? { + var bytes = Data(count: count) + let status = bytes.withUnsafeMutableBytes { buffer -> OSStatus in + guard let baseAddress = buffer.baseAddress else { return errSecParam } + return SecRandomCopyBytes(kSecRandomDefault, count, baseAddress) + } + return status == errSecSuccess ? bytes : nil + } + + private func persist() { + guard let fileURL else { return } + do { + if groups.isEmpty { + try? FileManager.default.removeItem(at: fileURL) + return + } + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try JSONEncoder().encode(groups) + var options: Data.WritingOptions = [.atomic] + #if os(iOS) + options.insert(.completeFileProtection) + #endif + try data.write(to: fileURL, options: options) + } catch { + SecureLogger.error("Failed to persist group store: \(error)", category: .session) + } + } + + private func loadFromDisk() { + guard let fileURL, + let data = try? Data(contentsOf: fileURL), + let stored = try? JSONDecoder().decode([BitchatGroup].self, from: data) else { + return + } + // Only groups whose key survived in the keychain are usable. + groups = stored.filter { key(forGroupID: $0.groupID) != nil } + } + + private static func defaultFileURL() -> URL? { + guard let base = try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) else { return nil } + return base + .appendingPathComponent("groups", isDirectory: true) + .appendingPathComponent("groups.json") + } +} diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 45aed91e..d6bdef33 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -69,6 +69,9 @@ enum TransportEvent: @unchecked Sendable { case messageReceived(BitchatMessage) case publicMessageReceived(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) case noisePayloadReceived(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) + /// Encrypted group broadcast (MessageType 0x25). Opaque here — the group + /// coordinator decrypts and authenticates against the roster. + case groupMessageReceived(payload: Data, timestamp: Date) case peerConnected(PeerID) case peerDisconnected(PeerID) case peerListUpdated([PeerID]) @@ -158,6 +161,12 @@ protocol Transport: AnyObject { // transport cannot courier (no connected courier, or unsupported). func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool + // Private groups (mesh transports only): creator-signed state travels + // 1:1 over Noise sessions; group messages flood like public broadcasts. + func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) + func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) + func broadcastGroupMessage(_ envelope: Data) + // Bulletin board (mesh transports only): broadcast a pre-signed board // payload (post or tombstone) so it spreads over relay and gossip sync. func sendBoardPayload(_ payload: Data) @@ -214,6 +223,9 @@ extension Transport { func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} + func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {} + func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {} + func broadcastGroupMessage(_ envelope: Data) {} func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] } func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {} func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {} @@ -259,6 +271,8 @@ extension BitchatDelegate { ) case let .noisePayloadReceived(peerID, type, payload, timestamp): didReceiveNoisePayload(from: peerID, type: type, payload: payload, timestamp: timestamp) + case let .groupMessageReceived(payload, timestamp): + didReceiveGroupMessage(payload: payload, timestamp: timestamp) case .peerConnected(let peerID): didConnectToPeer(peerID) case .peerDisconnected(let peerID): diff --git a/bitchat/Sync/GossipSyncManager.swift b/bitchat/Sync/GossipSyncManager.swift index b0d72e47..3dee21e0 100644 --- a/bitchat/Sync/GossipSyncManager.swift +++ b/bitchat/Sync/GossipSyncManager.swift @@ -73,6 +73,7 @@ final class GossipSyncManager { var stalePeerTimeoutSeconds: TimeInterval = 60.0 var fragmentCapacity: Int = 600 var fileTransferCapacity: Int = 200 + var groupMessageCapacity: Int = 200 var fragmentSyncIntervalSeconds: TimeInterval = 30.0 var fileTransferSyncIntervalSeconds: TimeInterval = 60.0 var messageSyncIntervalSeconds: TimeInterval = 15.0 @@ -106,6 +107,7 @@ final class GossipSyncManager { private var messages = PacketStore() private var fragments = PacketStore() private var fileTransfers = PacketStore() + private var groupMessages = PacketStore() private var latestAnnouncementByPeer: [PeerID: BitchatPacket] = [:] // Latest verified prekey bundle per owner. Unlike announces, bundles are // NOT dropped on leave/stale peer: their whole purpose is reaching a @@ -131,7 +133,13 @@ final class GossipSyncManager { ) var schedules: [SyncSchedule] = [] if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 { - schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast)) + // Group messages ride the public-message cadence; old clients + // ignore the extended bit and answer with announces/messages only. + var messageTypes: SyncTypeFlags = .publicMessages + if config.groupMessageCapacity > 0 { + messageTypes.formUnion(.groupMessage) + } + schedules.append(SyncSchedule(types: messageTypes, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast)) } if config.fragmentCapacity > 0 && config.fragmentSyncIntervalSeconds > 0 { schedules.append(SyncSchedule(types: .fragment, interval: config.fragmentSyncIntervalSeconds, lastSent: .distantPast)) @@ -175,6 +183,9 @@ final class GossipSyncManager { guard let self = self else { return } var types: SyncTypeFlags = .publicMessages + if self.config.groupMessageCapacity > 0 { + types.formUnion(.groupMessage) + } if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 { types.formUnion(.fragment) } @@ -201,9 +212,11 @@ final class GossipSyncManager { // messages get the long town-crier window; fragments, file transfers and // announces keep the short one. private func isPacketFresh(_ packet: BitchatPacket) -> Bool { + // Group messages share the whole-message window: members off the mesh + // for a while should backfill their crew's history like public chat. let maxAgeSeconds: TimeInterval switch packet.type { - case MessageType.message.rawValue: + case MessageType.message.rawValue, MessageType.groupMessage.rawValue: maxAgeSeconds = config.publicMessageMaxAgeSeconds case MessageType.prekeyBundle.rawValue: maxAgeSeconds = config.prekeyBundleMaxAgeSeconds @@ -262,6 +275,13 @@ final class GossipSyncManager { guard isPacketFresh(packet) else { return } let idHex = PacketIdUtil.computeId(packet).hexEncodedString() fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity)) + case .groupMessage: + // Opaque ciphertext to non-members; carried and served like any + // other broadcast so members get backfill from any relay. + guard isBroadcastRecipient else { return } + guard isPacketFresh(packet) else { return } + let idHex = PacketIdUtil.computeId(packet).hexEncodedString() + groupMessages.insert(idHex: idHex, packet: packet, capacity: max(1, config.groupMessageCapacity)) case .prekeyBundle: // Callers only feed verified bundles here (own bundles at send // time, peers' after signature verification), so gossip never @@ -454,6 +474,19 @@ final class GossipSyncManager { } } + if requestedTypes.contains(.groupMessage) { + let groupPkts = groupMessages.allPackets(isFresh: isPacketFresh) + for pkt in groupPkts { + if let since, pkt.timestamp < since { continue } + let idBytes = PacketIdUtil.computeId(pkt) + if !mightContain(idBytes) { + var toSend = pkt + toSend.ttl = 0 + toSend.isRSR = true // Mark as solicited response + delegate?.sendPacket(to: peerID, packet: toSend) + } + } + } // Like announces, prekey bundles are exempt from the since-cursor: // there is at most one per owner (newer replaces older), so the // resend cost is bounded and a joining peer must be able to learn @@ -505,6 +538,9 @@ final class GossipSyncManager { if types.contains(.fileTransfer) { candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh)) } + if types.contains(.groupMessage) { + candidates.append(contentsOf: groupMessages.allPackets(isFresh: isPacketFresh)) + } if types.contains(.prekeyBundle) { for (_, pair) in latestPrekeyBundleByPeer where isPacketFresh(pair.packet) { candidates.append(pair.packet) @@ -574,6 +610,7 @@ final class GossipSyncManager { } fragments.removeExpired(isFresh: isPacketFresh) fileTransfers.removeExpired(isFresh: isPacketFresh) + groupMessages.removeExpired(isFresh: isPacketFresh) latestPrekeyBundleByPeer = latestPrekeyBundleByPeer.filter { _, pair in isPacketFresh(pair.packet) } @@ -677,6 +714,7 @@ final class GossipSyncManager { } fragments.remove { PeerID(hexData: $0.senderID) == peerID } fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID } + groupMessages.remove { PeerID(hexData: $0.senderID) == peerID } } } diff --git a/bitchat/Sync/SyncTypeFlags.swift b/bitchat/Sync/SyncTypeFlags.swift index cb743ce1..3b4b0c82 100644 --- a/bitchat/Sync/SyncTypeFlags.swift +++ b/bitchat/Sync/SyncTypeFlags.swift @@ -37,6 +37,13 @@ struct SyncTypeFlags: OptionSet { case .requestSync: return 6 case .fileTransfer: return 7 case .boardPost: return 8 + // Extended bits are compat-safe by construction: `toData()` encodes + // the bitfield little-endian with trailing zero bytes trimmed (bit 10 + // widens the wire form from 1 to 2 bytes inside the length-prefixed + // REQUEST_SYNC TLV 0x04), and `decode(_:)` accepts 1...8 bytes while + // `type(forBit:)` maps unknown bits to nil — so old clients simply + // ignore the group bit and answer with the types they know. + case .groupMessage: return 10 // Courier envelopes are directed deposits between trusted peers and // must never spread via gossip sync. case .courierEnvelope: return nil @@ -70,6 +77,7 @@ struct SyncTypeFlags: OptionSet { // known type, so old clients ignore board rounds instead of choking. case 8: return .boardPost case 9: return .prekeyBundle + case 10: return .groupMessage default: return nil } @@ -81,6 +89,7 @@ struct SyncTypeFlags: OptionSet { static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer]) static let board = SyncTypeFlags(messageTypes: [.boardPost]) static let prekeyBundle = SyncTypeFlags(messageTypes: [.prekeyBundle]) + static let groupMessage = SyncTypeFlags(messageTypes: [.groupMessage]) static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message]) diff --git a/bitchat/ViewModels/ChatGroupCoordinator.swift b/bitchat/ViewModels/ChatGroupCoordinator.swift new file mode 100644 index 00000000..a83cb024 --- /dev/null +++ b/bitchat/ViewModels/ChatGroupCoordinator.swift @@ -0,0 +1,602 @@ +import BitFoundation +import BitLogger +import Foundation + +/// The narrow surface `ChatGroupCoordinator` needs from its owner. +/// +/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the +/// minimal context it actually uses instead of holding an `unowned` back-ref +/// to the whole `ChatViewModel`. Group chats are keyed like direct chats +/// (virtual "group_" peer IDs), so the conversation intents below reuse the +/// private-chat store operations. +@MainActor +protocol ChatGroupContext: AnyObject { + // MARK: Identity & state + var nickname: String { get } + var myPeerID: PeerID { get } + var selectedPrivateChatPeer: PeerID? { get } + var groupStore: GroupStore { get } + + /// Fingerprint of our own Noise static identity key. + func myNoiseFingerprint() -> String + /// Our Ed25519 signing public key. + func mySigningPublicKey() -> Data + /// Signs `data` with our Noise signing key. + func signWithNoiseKey(_ data: Data) -> Data? + + // MARK: Peers + func getPeerIDForNickname(_ nickname: String) -> PeerID? + func isPeerConnected(_ peerID: PeerID) -> Bool + func peerNickname(for peerID: PeerID) -> String? + /// The peer's Noise fingerprint from the live session/registry. + func meshFingerprint(for peerID: PeerID) -> String? + /// The peer's persisted crypto identity (fingerprint + signing key), if + /// the identity store has a signature-verified announce for them. + func cryptoIdentity(for peerID: PeerID) -> (fingerprint: String, signingKey: Data)? + /// The connected short peer ID whose fingerprint matches, if any. + func connectedPeerID(forFingerprint fingerprint: String) -> PeerID? + /// Whether the user has blocked the identity with this Noise fingerprint. + func isFingerprintBlocked(_ fingerprint: String) -> Bool + + // MARK: Transport + func sendGroupInvitePayload(_ payload: Data, to peerID: PeerID) + func sendGroupKeyUpdatePayload(_ payload: Data, to peerID: PeerID) + func broadcastGroupMessagePayload(_ payload: Data) + + // MARK: Conversation intents (group chats are direct-keyed) + @discardableResult + func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool + func markPrivateChatUnread(_ peerID: PeerID) + func removePrivateChat(_ peerID: PeerID) + func startPrivateChat(with peerID: PeerID) + func endPrivateChat() + func addSystemMessage(_ content: String) + func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) + func notifyUIChanged() + func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID) +} + +extension ChatViewModel: ChatGroupContext { + // `nickname`, `myPeerID`, `selectedPrivateChatPeer`, `groupStore`, + // `getPeerIDForNickname(_:)`, `isPeerConnected(_:)`, `peerNickname(for:)`, + // `appendPrivateMessage(_:to:)`, `markPrivateChatUnread(_:)`, + // `removePrivateChat(_:)`, `startPrivateChat(with:)`, + // `addSystemMessage(_:)`, `addLocalPrivateSystemMessage(_:to:)`, + // `notifyUIChanged()`, and `notifyPrivateMessage(from:message:peerID:)` + // are shared requirements with the other contexts or satisfied by + // existing `ChatViewModel` members. The members below flatten nested + // service accesses into intent-named calls. + + func myNoiseFingerprint() -> String { + meshService.noiseIdentityFingerprint() + } + + func mySigningPublicKey() -> Data { + meshService.noiseSigningPublicKeyData() + } + + func signWithNoiseKey(_ data: Data) -> Data? { + meshService.noiseSignData(data) + } + + func meshFingerprint(for peerID: PeerID) -> String? { + meshService.getFingerprint(for: peerID) + } + + /// The persisted, signature-verified identity behind a short mesh peer + /// ID. Cross-checked against the live session fingerprint so a roster + /// entry can never be pinned to a signing key from a different identity. + func cryptoIdentity(for peerID: PeerID) -> (fingerprint: String, signingKey: Data)? { + guard let fingerprint = meshService.getFingerprint(for: peerID) else { return nil } + let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID) + guard let identity = candidates.first(where: { $0.fingerprint == fingerprint }), + let signingKey = identity.signingPublicKey else { return nil } + return (fingerprint, signingKey) + } + + /// Short mesh peer IDs are the fingerprint's first 16 hex chars, so the + /// connected peer for a roster fingerprint is a direct derivation. + func connectedPeerID(forFingerprint fingerprint: String) -> PeerID? { + let shortID = PeerID(str: String(fingerprint.prefix(16))) + return meshService.isPeerConnected(shortID) ? shortID : nil + } + + func isFingerprintBlocked(_ fingerprint: String) -> Bool { + identityManager.isBlocked(fingerprint: fingerprint) + } + + func sendGroupInvitePayload(_ payload: Data, to peerID: PeerID) { + meshService.sendGroupInvite(payload, to: peerID) + } + + func sendGroupKeyUpdatePayload(_ payload: Data, to peerID: PeerID) { + meshService.sendGroupKeyUpdate(payload, to: peerID) + } + + func broadcastGroupMessagePayload(_ payload: Data) { + meshService.broadcastGroupMessage(payload) + } + + // MARK: CommandContextProvider group commands (parsed by CommandProcessor) + + func groupCreate(named name: String) -> CommandResult { + groupCoordinator.createGroup(named: name) + } + + func groupInvite(nickname: String) -> CommandResult { + groupCoordinator.inviteMember(nickname: nickname) + } + + func groupRemove(nickname: String) -> CommandResult { + groupCoordinator.removeMember(nickname: nickname) + } + + func groupLeave() -> CommandResult { + groupCoordinator.leaveGroup() + } + + func groupList() -> CommandResult { + groupCoordinator.listGroups() + } +} + +/// Owns the private-groups feature: creating groups, creator-managed invites +/// and key rotation over Noise, and sealing/opening group message broadcasts. +/// Delivery is fire-and-flood like public chat — no per-member acks in v1 — +/// with gossip-sync backfill as the only offline catch-up. +@MainActor +final class ChatGroupCoordinator { + private unowned let context: any ChatGroupContext + + private static let maxGroupNameLength = 40 + + init(context: any ChatGroupContext) { + self.context = context + } + + // MARK: - Commands + + func createGroup(named rawName: String) -> CommandResult { + let name = rawName.trimmed + guard !name.isEmpty else { + return .error(message: String(localized: "system.group.usage_create", comment: "Usage hint for /group create")) + } + guard name.count <= Self.maxGroupNameLength else { + return .error(message: String(localized: "system.group.name_too_long", comment: "Error when a group name exceeds the length cap")) + } + + let myFingerprint = context.myNoiseFingerprint() + let mySigningKey = context.mySigningPublicKey() + guard !myFingerprint.isEmpty, mySigningKey.count == 32 else { + return .error(message: String(localized: "system.group.identity_unavailable", comment: "Error when the local identity is not ready for group operations")) + } + + let creator = GroupMember(fingerprint: myFingerprint, signingKey: mySigningKey, nickname: context.nickname) + guard let group = context.groupStore.createGroup(named: name, creator: creator) else { + return .error(message: String(localized: "system.group.create_failed", comment: "Error when group creation fails")) + } + + context.startPrivateChat(with: group.peerID) + return .success(message: String( + format: String(localized: "system.group.created", comment: "System message after creating a group; placeholder is the group name"), + locale: .current, + name + )) + } + + func inviteMember(nickname rawNickname: String) -> CommandResult { + let nickname = normalizedNickname(rawNickname) + guard !nickname.isEmpty else { + return .error(message: String(localized: "system.group.usage_invite", comment: "Usage hint for /group invite")) + } + guard let group = selectedGroup() else { + return .error(message: String(localized: "system.group.not_in_group", comment: "Error when a group command requires an open group chat")) + } + guard group.creatorFingerprint == context.myNoiseFingerprint() else { + return .error(message: String(localized: "system.group.creator_only", comment: "Error when a non-creator attempts a creator-only group action")) + } + guard let peerID = context.getPeerIDForNickname(nickname) else { + return .error(message: String( + format: String(localized: "system.group.peer_not_found", comment: "Error when the invitee nickname is unknown; placeholder is the nickname"), + locale: .current, + nickname + )) + } + guard context.isPeerConnected(peerID) else { + return .error(message: String( + format: String(localized: "system.group.peer_not_connected", comment: "Error when the invitee is not connected over mesh; placeholder is the nickname"), + locale: .current, + nickname + )) + } + guard let identity = context.cryptoIdentity(for: peerID) else { + return .error(message: String( + format: String(localized: "system.group.peer_identity_unknown", comment: "Error when the invitee's verified identity is unavailable; placeholder is the nickname"), + locale: .current, + nickname + )) + } + guard !group.isMember(fingerprint: identity.fingerprint) else { + return .error(message: String( + format: String(localized: "system.group.already_member", comment: "Error when the invitee is already a member; placeholder is the nickname"), + locale: .current, + nickname + )) + } + guard group.members.count < BitchatGroup.maxMembers else { + return .error(message: String( + format: String(localized: "system.group.full", comment: "Error when the group is at the member cap; placeholder is the cap"), + locale: .current, + "\(BitchatGroup.maxMembers)" + )) + } + + let newMember = GroupMember( + fingerprint: identity.fingerprint, + signingKey: identity.signingKey, + nickname: context.peerNickname(for: peerID) ?? nickname + ) + // Rotate the key (epoch + 1) on every roster change, not just removals. + // A monotonically increasing epoch per roster gives the receiver a + // strict ordering: two out-of-order invite states can no longer share + // an epoch and last-writer-wins a just-added member back out. + let members = group.members + [newMember] + guard let (updated, key) = context.groupStore.rotateKey(groupID: group.groupID, members: members), + let payload = signedStatePayload(for: updated, key: key) else { + return .error(message: String(localized: "system.group.invite_failed", comment: "Error when building or signing a group invite fails")) + } + + context.sendGroupInvitePayload(payload, to: peerID) + distributeState(payload, group: updated, excluding: [identity.fingerprint], type: .keyUpdate) + + return .success(message: String( + format: String(localized: "system.group.invited", comment: "System message after inviting someone; placeholders are the nickname and the group name"), + locale: .current, + nickname, + updated.name + )) + } + + /// Creator-side removal: rotates the group key (epoch + 1) and sends the + /// new state to every remaining member so the removed member's key stops + /// decrypting future traffic. + func removeMember(nickname rawNickname: String) -> CommandResult { + let nickname = normalizedNickname(rawNickname) + guard !nickname.isEmpty else { + return .error(message: String(localized: "system.group.usage_remove", comment: "Usage hint for /group remove")) + } + guard let group = selectedGroup() else { + return .error(message: String(localized: "system.group.not_in_group", comment: "Error when a group command requires an open group chat")) + } + guard group.creatorFingerprint == context.myNoiseFingerprint() else { + return .error(message: String(localized: "system.group.creator_only", comment: "Error when a non-creator attempts a creator-only group action")) + } + guard let member = group.members.first(where: { $0.nickname.caseInsensitiveCompare(nickname) == .orderedSame }) else { + return .error(message: String( + format: String(localized: "system.group.member_not_found", comment: "Error when the member to remove is not in the roster; placeholder is the nickname"), + locale: .current, + nickname + )) + } + guard member.fingerprint != group.creatorFingerprint else { + return .error(message: String(localized: "system.group.cannot_remove_creator", comment: "Error when the creator tries to remove themselves")) + } + + let remaining = group.members.filter { $0.fingerprint != member.fingerprint } + guard let (rotated, newKey) = context.groupStore.rotateKey(groupID: group.groupID, members: remaining), + let payload = signedStatePayload(for: rotated, key: newKey) else { + return .error(message: String(localized: "system.group.rotate_failed", comment: "Error when rotating the group key fails")) + } + + distributeState(payload, group: rotated, excluding: [], type: .keyUpdate) + notifyRemovedMember(member, rotated: rotated) + + return .success(message: String( + format: String(localized: "system.group.removed_member", comment: "System message after removing a member and rotating the key; placeholder is the nickname"), + locale: .current, + member.nickname + )) + } + + func leaveGroup() -> CommandResult { + guard let group = selectedGroup() else { + return .error(message: String(localized: "system.group.not_in_group", comment: "Error when a group command requires an open group chat")) + } + // Close the chat window first so the confirmation message doesn't + // resurrect the conversation we're about to remove. + context.endPrivateChat() + context.removePrivateChat(group.peerID) + context.groupStore.removeGroup(withID: group.groupID) + context.notifyUIChanged() + return .success(message: String( + format: String(localized: "system.group.left", comment: "System message after leaving a group; placeholder is the group name"), + locale: .current, + group.name + )) + } + + func listGroups() -> CommandResult { + let groups = context.groupStore.groups + guard !groups.isEmpty else { + return .success(message: String(localized: "system.group.none", comment: "System message when the user is in no groups")) + } + let myFingerprint = context.myNoiseFingerprint() + let lines = groups.map { group -> String in + let role = group.creatorFingerprint == myFingerprint ? " (creator)" : "" + return "#\(group.name)\(role) — \(group.members.count)/\(BitchatGroup.maxMembers)" + } + return .success(message: String(localized: "system.group.list_header", comment: "Header line for the /group list output") + "\n" + lines.joined(separator: "\n")) + } + + // MARK: - Sending + + /// Fire-and-flood send: local echo goes straight to `.sent` because group + /// messages have no per-member acknowledgments in v1. + func sendGroupMessage(_ content: String, to groupPeerID: PeerID) { + guard !content.isEmpty, content.count <= InputValidator.Limits.maxMessageLength else { return } + guard let group = context.groupStore.group(for: groupPeerID), + let key = context.groupStore.key(forGroupID: group.groupID) else { + context.addSystemMessage(String(localized: "system.group.unknown", comment: "System message when sending into an unknown group")) + return + } + + let messageID = UUID().uuidString + let timestamp = Date() + let payload: Data + do { + payload = try GroupCrypto.sealMessage( + content: content, + messageID: messageID, + senderNickname: context.nickname, + senderSigningKey: context.mySigningPublicKey(), + timestampMs: UInt64(timestamp.timeIntervalSince1970 * 1000), + groupID: group.groupID, + epoch: group.epoch, + key: key, + sign: { [weak context] data in context?.signWithNoiseKey(data) } + ) + } catch { + SecureLogger.error("Failed to seal group message: \(error)", category: .encryption) + context.addLocalPrivateSystemMessage( + String(localized: "system.group.send_failed", comment: "System message when sealing a group message fails"), + to: groupPeerID + ) + return + } + + let message = BitchatMessage( + id: messageID, + sender: context.nickname, + content: content, + timestamp: timestamp, + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: group.name, + senderPeerID: context.myPeerID, + mentions: nil, + deliveryStatus: .sent + ) + context.appendPrivateMessage(message, to: groupPeerID) + context.broadcastGroupMessagePayload(payload) + context.notifyUIChanged() + } + + // MARK: - Receiving + + /// Decrypt-verify path for an incoming 0x25 broadcast. Drops silently for + /// unknown groups (non-members relay but never read), wrong epochs, bad + /// sender signatures, and senders missing from the pinned roster. + func handleGroupMessagePayload(_ payload: Data, timestamp: Date) { + guard let envelope = GroupMessageEnvelope.decode(payload) else { return } + guard let group = context.groupStore.group(withID: envelope.groupID) else { return } + guard envelope.epoch == group.epoch else { + SecureLogger.debug("Dropping group message with epoch \(envelope.epoch) (current \(group.epoch))", category: .encryption) + return + } + guard let key = context.groupStore.key(forGroupID: group.groupID) else { return } + + let plaintext: GroupMessagePlaintext + do { + plaintext = try GroupCrypto.openMessage(envelope, key: key) + } catch { + SecureLogger.debug("Failed to open group message: \(error)", category: .encryption) + return + } + + // Sender must be pinned in the creator-signed roster; key possession + // alone is not authorship. + guard let member = group.member(withSigningKey: plaintext.senderSigningKey) else { + SecureLogger.warning("Dropping group message from non-roster sender", category: .security) + return + } + // Our own broadcast echoed back via relay or sync replay. + guard plaintext.senderSigningKey != context.mySigningPublicKey() else { return } + // Honor /block inside groups too: drop display + notification for a + // blocked member, consistent with every other inbound path. + guard !context.isFingerprintBlocked(member.fingerprint) else { + SecureLogger.debug("Dropping group message from blocked member", category: .security) + return + } + + let groupPeerID = group.peerID + // Trust the authenticated inner timestamp (clamped so a future-dated + // message cannot pin itself to the bottom of the timeline). + let messageDate = min(Date(timeIntervalSince1970: TimeInterval(plaintext.timestampMs) / 1000), Date()) + let senderName = member.nickname.isEmpty ? plaintext.senderNickname : member.nickname + let senderPeerID = PeerID(str: String(member.fingerprint.prefix(16))) + let message = BitchatMessage( + id: plaintext.messageID, + sender: senderName, + content: plaintext.content, + timestamp: messageDate, + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: group.name, + senderPeerID: senderPeerID, + mentions: nil + ) + + guard context.appendPrivateMessage(message, to: groupPeerID) else { return } + + let isViewing = context.selectedPrivateChatPeer == groupPeerID + if !isViewing { + context.markPrivateChatUnread(groupPeerID) + let isRecent = Date().timeIntervalSince(messageDate) < 30 + if isRecent { + context.notifyPrivateMessage( + from: "\(senderName) @ \(group.name)", + message: plaintext.content, + peerID: groupPeerID + ) + } + } + context.notifyUIChanged() + } + + /// Accepts creator-signed group state arriving as an invite. The Noise + /// session peer must BE the creator, the signature must verify against + /// the creator key pinned in the roster, and we must be in the roster. + func handleGroupInvitePayload(from peerID: PeerID, payload: Data) { + applyGroupState(from: peerID, payload: payload, isInvite: true) + } + + /// Accepts creator-signed state updates (rotation/roster). A state whose + /// roster no longer includes us means we were removed: drop the group. + func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) { + applyGroupState(from: peerID, payload: payload, isInvite: false) + } +} + +private extension ChatGroupCoordinator { + enum StateSendType { + case invite + case keyUpdate + } + + func normalizedNickname(_ raw: String) -> String { + let trimmed = raw.trimmed + return trimmed.hasPrefix("@") ? String(trimmed.dropFirst()) : trimmed + } + + func selectedGroup() -> BitchatGroup? { + guard let selected = context.selectedPrivateChatPeer, selected.isGroup else { return nil } + return context.groupStore.group(for: selected) + } + + func signedStatePayload(for group: BitchatGroup, key: Data) -> Data? { + GroupStatePayload.makeSigned(group: group, key: key) { [weak context] data in + context?.signWithNoiseKey(data) + }?.encode() + } + + /// Sends the state payload to every connected roster member except us and + /// the excluded fingerprints. Offline members catch up the next time the + /// creator sends them state (v1 limitation, documented in the PR). + func distributeState(_ payload: Data, group: BitchatGroup, excluding excludedFingerprints: Set, type: StateSendType) { + let myFingerprint = context.myNoiseFingerprint() + for member in group.members { + guard member.fingerprint != myFingerprint, + !excludedFingerprints.contains(member.fingerprint), + let peerID = context.connectedPeerID(forFingerprint: member.fingerprint) else { continue } + switch type { + case .invite: + context.sendGroupInvitePayload(payload, to: peerID) + case .keyUpdate: + context.sendGroupKeyUpdatePayload(payload, to: peerID) + } + } + } + + /// Tells a just-removed member they're out so their client can deactivate + /// the group instead of silently going dark (dropping every message under + /// the epoch it no longer has the key for). The notice is a creator-signed + /// state whose roster excludes the removee — their `applyGroupState` + /// removal branch fires on the missing-self roster and surfaces the + /// "removed from group" system message. + /// + /// It carries a throwaway all-zero key, never the rotated key, so the + /// removee cannot decrypt post-removal traffic. State is sent 1:1 over + /// authenticated Noise, so no remaining member ever receives this blob + /// (and even if one did, its own missing-self check would not match). + /// If the removee is offline the notice can't be delivered — same v1 + /// limitation as any other missed key update, documented in the PR. + func notifyRemovedMember(_ removed: GroupMember, rotated: BitchatGroup) { + guard let peerID = context.connectedPeerID(forFingerprint: removed.fingerprint) else { return } + let throwawayKey = Data(count: BitchatGroup.keyLength) + guard let payload = signedStatePayload(for: rotated, key: throwawayKey) else { return } + context.sendGroupKeyUpdatePayload(payload, to: peerID) + } + + func applyGroupState(from peerID: PeerID, payload: Data, isInvite: Bool) { + guard let state = GroupStatePayload.decode(payload) else { + SecureLogger.warning("Malformed group state payload from \(peerID.id.prefix(8))…", category: .security) + return + } + // The Noise session already authenticated `peerID`; require that the + // authenticated peer IS the creator whose key signed the state, so a + // member can't re-invite or rotate on the creator's behalf. + guard let senderFingerprint = context.meshFingerprint(for: peerID), + senderFingerprint == state.creatorFingerprint else { + SecureLogger.warning("Dropping group state from non-creator \(peerID.id.prefix(8))…", category: .security) + return + } + guard state.verifyCreatorSignature() else { + SecureLogger.warning("Dropping group state with invalid creator signature", category: .security) + return + } + + let myFingerprint = context.myNoiseFingerprint() + let existing = context.groupStore.group(withID: state.groupID) + + // A creator-signed roster that no longer includes us is a removal. + guard state.members.contains(where: { $0.fingerprint == myFingerprint }) else { + if let existing { + if context.selectedPrivateChatPeer == existing.peerID { + context.endPrivateChat() + } + context.removePrivateChat(existing.peerID) + context.groupStore.removeGroup(withID: existing.groupID) + context.addSystemMessage(String( + format: String(localized: "system.group.removed_from", comment: "System message when removed from a group; placeholder is the group name"), + locale: .current, + existing.name + )) + context.notifyUIChanged() + } + return + } + + // Never regress the epoch: state travels over live Noise sessions, + // so an older epoch here is a stale (or misbehaving) creator device. + if let existing, state.epoch < existing.epoch { + SecureLogger.warning("Dropping stale group state (epoch \(state.epoch) < \(existing.epoch))", category: .security) + return + } + + let isNewMembership = existing == nil + guard context.groupStore.upsert(state.asGroup, key: state.key) else { + SecureLogger.error("Failed to store group state for \(state.name)", category: .session) + return + } + + if isNewMembership { + let inviter = state.members.first { $0.fingerprint == state.creatorFingerprint }?.nickname + ?? context.peerNickname(for: peerID) + ?? "?" + let notice = String( + format: String(localized: "system.group.joined", comment: "System message when added to a group; placeholders are the group name and the inviter"), + locale: .current, + state.name, + inviter + ) + context.addSystemMessage(notice) + context.markPrivateChatUnread(state.asGroup.peerID) + context.notifyPrivateMessage(from: inviter, message: notice, peerID: state.asGroup.peerID) + } else if isInvite == false, let existing, state.epoch > existing.epoch { + SecureLogger.info("Group '\(state.name)' rotated to epoch \(state.epoch)", category: .session) + } + context.notifyUIChanged() + } +} diff --git a/bitchat/ViewModels/ChatLifecycleCoordinator.swift b/bitchat/ViewModels/ChatLifecycleCoordinator.swift index 60c199f6..1241f71b 100644 --- a/bitchat/ViewModels/ChatLifecycleCoordinator.swift +++ b/bitchat/ViewModels/ChatLifecycleCoordinator.swift @@ -185,6 +185,13 @@ final class ChatLifecycleCoordinator { func markPrivateMessagesAsRead(from peerID: PeerID) { context.markChatAsRead(from: peerID) + // Group chats are keyed under a virtual group_ peerID; no member IS the + // conversation peer, so the receipt loops below (which gate on + // senderPeerID == peerID) must never emit a read/delivered receipt for + // one. This guard makes that explicit so a future refactor of the + // receipt matching can't silently start leaking receipts into groups. + guard !peerID.isGroup else { return } + if peerID.isGeoDM, let recipientHex = context.nostrKeyMapping[peerID], case .location(let channel) = context.activeChannel, diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index 46e0787a..30d566b8 100644 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -323,6 +323,15 @@ final class ChatPeerIdentityCoordinator { func startPrivateChat(with peerID: PeerID) { guard peerID != context.myPeerID else { return } + // Group chats are virtual conversations: no peer identity, favorites, + // handshake, or message consolidation applies — just select the chat. + if peerID.isGroup { + context.selectedPrivateChatFingerprint = nil + context.beginPrivateChatSession(with: peerID) + context.markPrivateChatRead(peerID) + return + } + let peerNickname = context.peerNickname(for: peerID) ?? "unknown" if context.unifiedIsBlocked(peerID) { diff --git a/bitchat/ViewModels/ChatTransportEventCoordinator.swift b/bitchat/ViewModels/ChatTransportEventCoordinator.swift index a24273d7..34b1653c 100644 --- a/bitchat/ViewModels/ChatTransportEventCoordinator.swift +++ b/bitchat/ViewModels/ChatTransportEventCoordinator.swift @@ -71,6 +71,10 @@ protocol ChatTransportEventContext: AnyObject { // MARK: Verification payloads func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) + + // MARK: Group payloads (creator-signed state over Noise) + func handleGroupInvitePayload(from peerID: PeerID, payload: Data) + func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) func handleVouchPayload(from peerID: PeerID, payload: Data) } @@ -131,6 +135,14 @@ extension ChatViewModel: ChatTransportEventContext { verificationCoordinator.handleVerifyResponsePayload(from: peerID, payload: payload) } + func handleGroupInvitePayload(from peerID: PeerID, payload: Data) { + groupCoordinator.handleGroupInvitePayload(from: peerID, payload: payload) + } + + func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) { + groupCoordinator.handleGroupKeyUpdatePayload(from: peerID, payload: payload) + } + func handleVouchPayload(from peerID: PeerID, payload: Data) { vouchCoordinator.handleVouchPayload(from: peerID, payload: payload) } @@ -377,6 +389,12 @@ private extension ChatTransportEventCoordinator { case .verifyResponse: context.handleVerifyResponsePayload(from: peerID, payload: payload) + case .groupInvite: + context.handleGroupInvitePayload(from: peerID, payload: payload) + + case .groupKeyUpdate: + context.handleGroupKeyUpdatePayload(from: peerID, payload: payload) + case .vouch: context.handleVouchPayload(from: peerID, payload: payload) } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 21e250f2..c0c0d8b1 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -102,7 +102,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele @MainActor var canSendMediaInCurrentContext: Bool { if let peer = selectedPrivateChatPeer { - return !(peer.isGeoDM || peer.isGeoChat) + // Media transfer is not wired for groups in v1 (sendFilePrivate + // rejects the virtual group_ recipient), so keep the affordance off. + return !(peer.isGeoDM || peer.isGeoChat || peer.isGroup) } switch activeChannel { case .mesh: return true @@ -177,6 +179,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele lazy var nostrCoordinator = ChatNostrCoordinator(context: self) lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self) lazy var verificationCoordinator = ChatVerificationCoordinator(context: self) + lazy var groupCoordinator = ChatGroupCoordinator(context: self) lazy var vouchCoordinator = ChatVouchCoordinator(context: self) // Computed properties for compatibility @@ -306,6 +309,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele var nostrRelayManager: NostrRelayManager? private let userDefaults = UserDefaults.standard let keychain: KeychainManagerProtocol + /// Private group membership: keys in the keychain, metadata on disk. + let groupStore: GroupStore private let nicknameKey = "bitchat.nickname" // Location channel state (macOS supports manual geohash selection) var activeChannel: ChannelID { @@ -813,6 +818,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele ) self.keychain = keychain + self.groupStore = GroupStore(keychain: keychain) self.idBridge = idBridge self.identityManager = identityManager self.conversations = conversations @@ -1209,6 +1215,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele GossipMessageArchive.wipeDefault() StoreAndForwardMetrics.shared.reset() + // Drop private group keys and rosters (keychain + disk) + groupStore.wipe() // Drop cached peers' prekey bundles (who we could write to is // metadata too). Our own prekey privates are keychain-backed and go // with deleteAllKeychainData above plus the identity reset below. @@ -1609,6 +1617,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele ) } + func didReceiveGroupMessage(payload: Data, timestamp: Date) { + Task { @MainActor [weak self] in + self?.groupCoordinator.handleGroupMessagePayload(payload, timestamp: timestamp) + } + } + // MARK: - QR Verification API @MainActor func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool { diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift index af8d06aa..939adf6a 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift @@ -13,6 +13,12 @@ extension ChatViewModel { @MainActor func sendPrivateMessage(_ content: String, to peerID: PeerID) { + // Group chats reuse the private-chat surface but broadcast a sealed + // envelope instead of routing to a single peer. + if peerID.isGroup { + groupCoordinator.sendGroupMessage(content, to: peerID) + return + } privateConversationCoordinator.sendPrivateMessage(content, to: peerID) } diff --git a/bitchat/ViewModels/NostrInboundPipeline.swift b/bitchat/ViewModels/NostrInboundPipeline.swift index c5bd6525..be50a687 100644 --- a/bitchat/ViewModels/NostrInboundPipeline.swift +++ b/bitchat/ViewModels/NostrInboundPipeline.swift @@ -303,7 +303,9 @@ final class NostrInboundPipeline { context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey) case .readReceipt: context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey) - case .verifyChallenge, .verifyResponse, .vouch: + // Group state travels only over mesh Noise sessions in v1; anything + // claiming to be group traffic over Nostr is ignored. + case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch: break } } @@ -355,7 +357,9 @@ final class NostrInboundPipeline { context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey) case .readReceipt: context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey) - case .verifyChallenge, .verifyResponse, .vouch: + // Group state travels only over mesh Noise sessions in v1; anything + // claiming to be group traffic over Nostr is ignored. + case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch: break } } @@ -434,7 +438,9 @@ final class NostrInboundPipeline { context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID) case .readReceipt: context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID) - case .verifyChallenge, .verifyResponse, .vouch: + // Group state travels only over mesh Noise sessions + // in v1; group traffic over Nostr is ignored. + case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch: break } } diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index dd72d8b6..4d9d5c5b 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -221,6 +221,13 @@ private struct ContentPeopleListView: View { } ) } else { + GroupChatList( + groups: peerListModel.groupRows, + onTapGroup: { peerID in + peerListModel.startConversation(with: peerID) + showSidebar = true + } + ) MeshPeerList( onTapPeer: { peerID in peerListModel.startConversation(with: peerID) @@ -455,6 +462,10 @@ private struct ContentPrivateChatSheetView: View { } private var privacyCaptionText: String { + // Group chats are ChaCha20-Poly1305 sealed to the roster's shared key. + if privateConversationModel.selectedPeerID?.isGroup == true { + return String(localized: "content.private.caption_group", comment: "Caption above the group chat composer noting messages are encrypted to group members") + } // Geohash DMs are NIP-17 gift-wrapped — always end-to-end encrypted, // even though they carry no Noise session status. Mesh DMs earn the // "encrypted" claim only once the Noise handshake has secured. @@ -503,30 +514,39 @@ private struct ContentPrivateHeaderInfoButton: View { var body: some View { Button(action: { + // A group has no single fingerprint to show. + guard !headerState.isGroupConversation else { return } appChromeModel.showFingerprint(for: headerState.headerPeerID) }) { HStack(spacing: 6) { - switch headerState.availability { - case .bluetoothConnected: - Image(systemName: "dot.radiowaves.left.and.right") + if headerState.isGroupConversation { + Image(systemName: "person.3.fill") .font(.bitchatSystem(size: 14)) .foregroundColor(palette.primary) - .accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")) - case .meshReachable: - Image(systemName: "point.3.filled.connected.trianglepath.dotted") - .font(.bitchatSystem(size: 14)) - .foregroundColor(palette.primary) - .accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")) - case .nostrAvailable: - Image(systemName: "globe") - .font(.bitchatSystem(size: 14)) - .foregroundColor(.purple) - .accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")) - case .offline: - // Absence of a glyph was the only offline signal; say it. - Text("mesh_peers.state.offline") - .bitchatFont(size: 11) - .foregroundColor(palette.secondary) + .accessibilityLabel(String(localized: "content.accessibility.group_chat", comment: "Accessibility label for the group chat indicator")) + } else { + switch headerState.availability { + case .bluetoothConnected: + Image(systemName: "dot.radiowaves.left.and.right") + .font(.bitchatSystem(size: 14)) + .foregroundColor(palette.primary) + .accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")) + case .meshReachable: + Image(systemName: "point.3.filled.connected.trianglepath.dotted") + .font(.bitchatSystem(size: 14)) + .foregroundColor(palette.primary) + .accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")) + case .nostrAvailable: + Image(systemName: "globe") + .font(.bitchatSystem(size: 14)) + .foregroundColor(.purple) + .accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")) + case .offline: + // Absence of a glyph was the only offline signal; say it. + Text("mesh_peers.state.offline") + .bitchatFont(size: 11) + .foregroundColor(palette.secondary) + } } Text(headerState.displayName) @@ -571,7 +591,9 @@ private struct ContentPrivateHeaderInfoButton: View { ) ) .accessibilityHint( - String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint") + headerState.isGroupConversation + ? "" + : String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint") ) .frame(minHeight: headerHeight) } diff --git a/bitchat/Views/GroupChatList.swift b/bitchat/Views/GroupChatList.swift new file mode 100644 index 00000000..8b62d8a2 --- /dev/null +++ b/bitchat/Views/GroupChatList.swift @@ -0,0 +1,86 @@ +import BitFoundation +import SwiftUI + +/// Compact "groups" section for the people sheet: one row per private group +/// this device belongs to, tappable to open the group chat window. +struct GroupChatList: View { + @ThemedPalette private var palette + + let groups: [GroupChatRow] + let onTapGroup: (PeerID) -> Void + + private enum Strings { + static let header = String(localized: "groups.section.header", comment: "Section header above the private groups list") + static let creator = String(localized: "groups.state.creator", comment: "State label for a group the user created") + static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages") + static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator") + static let openGroupHint = String(localized: "groups.accessibility.open_group_hint", comment: "Accessibility hint on a group row explaining activation opens the group chat") + static let memberCountFormat = String(localized: "groups.member_count %@", comment: "Member count shown next to a group name; placeholder is the count") + } + + var body: some View { + if !groups.isEmpty { + VStack(alignment: .leading, spacing: 0) { + Text(Strings.header) + .bitchatFont(size: 11, weight: .medium) + .foregroundColor(palette.secondary) + .padding(.horizontal) + .padding(.top, 10) + .padding(.bottom, 2) + .accessibilityAddTraits(.isHeader) + + ForEach(groups) { group in + HStack(spacing: 4) { + Image(systemName: "person.3.fill") + .font(.bitchatSystem(size: 10)) + .foregroundColor(palette.primary) + + Text("#\(group.name)") + .bitchatFont(size: 14) + .foregroundColor(palette.primary) + .lineLimit(1) + .truncationMode(.tail) + + Text(String(format: Strings.memberCountFormat, locale: .current, "\(group.memberCount)")) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + + if group.isCreator { + Image(systemName: "crown.fill") + .font(.bitchatSystem(size: 9)) + .foregroundColor(.yellow) + .help(Strings.creator) + } + + Spacer() + + if group.hasUnread { + Image(systemName: "envelope.fill") + .font(.bitchatSystem(size: 10)) + .foregroundColor(.orange) + .help(Strings.newMessagesTooltip) + } + } + .padding(.horizontal) + .padding(.vertical, 6) + .contentShape(Rectangle()) + .onTapGesture { onTapGroup(group.peerID) } + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityDescription(for: group)) + .accessibilityAddTraits(.isButton) + .accessibilityHint(Strings.openGroupHint) + } + } + } + } + + private func accessibilityDescription(for group: GroupChatRow) -> String { + var parts: [String] = [ + group.name, + String(format: Strings.memberCountFormat, locale: .current, "\(group.memberCount)") + ] + if group.isCreator { parts.append(Strings.creator) } + if group.hasUnread { parts.append(Strings.unread) } + return parts.joined(separator: ", ") + } +} diff --git a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift index bd112e9b..b0d39782 100644 --- a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift +++ b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift @@ -157,6 +157,18 @@ private final class MockChatTransportEventContext: ChatTransportEventContext { verifyResponsePayloads.append((peerID, payload)) } + // Group payloads + private(set) var groupInvitePayloads: [(peerID: PeerID, payload: Data)] = [] + private(set) var groupKeyUpdatePayloads: [(peerID: PeerID, payload: Data)] = [] + + func handleGroupInvitePayload(from peerID: PeerID, payload: Data) { + groupInvitePayloads.append((peerID, payload)) + } + + func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) { + groupKeyUpdatePayloads.append((peerID, payload)) + } + private(set) var vouchPayloads: [(peerID: PeerID, payload: Data)] = [] func handleVouchPayload(from peerID: PeerID, payload: Data) { diff --git a/bitchatTests/CommandProcessorTests.swift b/bitchatTests/CommandProcessorTests.swift index 718df1c8..3da82140 100644 --- a/bitchatTests/CommandProcessorTests.swift +++ b/bitchatTests/CommandProcessorTests.swift @@ -678,4 +678,32 @@ private final class MockCommandContextProvider: CommandContextProvider { func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { favoriteNotifications.append((peerID, isFavorite)) } + + // Groups: record the parsed subcommand + argument the processor forwarded. + private(set) var groupCommands: [(subcommand: String, argument: String)] = [] + + func groupCreate(named name: String) -> CommandResult { + groupCommands.append(("create", name)) + return .handled + } + + func groupInvite(nickname: String) -> CommandResult { + groupCommands.append(("invite", nickname)) + return .handled + } + + func groupRemove(nickname: String) -> CommandResult { + groupCommands.append(("remove", nickname)) + return .handled + } + + func groupLeave() -> CommandResult { + groupCommands.append(("leave", "")) + return .handled + } + + func groupList() -> CommandResult { + groupCommands.append(("list", "")) + return .handled + } } diff --git a/bitchatTests/GossipSyncManagerTests.swift b/bitchatTests/GossipSyncManagerTests.swift index 5f15e377..de1fa209 100644 --- a/bitchatTests/GossipSyncManagerTests.swift +++ b/bitchatTests/GossipSyncManagerTests.swift @@ -208,7 +208,10 @@ struct GossipSyncManagerTests { #expect(allTypes.contains(.fragment)) #expect(allTypes.contains(.fileTransfer)) #expect(allTypes.contains(.prekeyBundle)) - #expect(decoded.contains { $0.types == .publicMessages }) + #expect(allTypes.contains(.groupMessage)) + // The message schedule also asks for group messages (bit 10); + // responders that don't know the bit just ignore it. + #expect(decoded.contains { $0.types == SyncTypeFlags.publicMessages.union(.groupMessage) }) #expect(decoded.contains { $0.types == .fragment }) #expect(decoded.contains { $0.types == .fileTransfer }) #expect(decoded.contains { $0.types == .prekeyBundle }) diff --git a/bitchatTests/Services/GroupProtocolTests.swift b/bitchatTests/Services/GroupProtocolTests.swift new file mode 100644 index 00000000..f9f19e7f --- /dev/null +++ b/bitchatTests/Services/GroupProtocolTests.swift @@ -0,0 +1,374 @@ +// +// GroupProtocolTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import CryptoKit +import Foundation +import Testing +import BitFoundation +@testable import bitchat + +struct GroupProtocolTests { + + // MARK: - Fixtures + + /// Deterministic member identity: an Ed25519 keypair plus the 64-hex + /// fingerprint the roster pins. + private struct TestIdentity { + let signingKey: Curve25519.Signing.PrivateKey + let fingerprint: String + + init(seed: UInt8) { + signingKey = Curve25519.Signing.PrivateKey() + fingerprint = Data(repeating: seed, count: 32).hexEncodedString() + } + + var member: GroupMember { + GroupMember( + fingerprint: fingerprint, + signingKey: signingKey.publicKey.rawRepresentation, + nickname: "peer-\(fingerprint.prefix(4))" + ) + } + + func sign(_ data: Data) -> Data? { + try? signingKey.signature(for: data) + } + } + + private let creator = TestIdentity(seed: 0xC1) + private let member = TestIdentity(seed: 0xA2) + private let outsider = TestIdentity(seed: 0xE3) + + private let groupID = Data((0..<16).map { UInt8($0) }) + private let key = Data(repeating: 0x42, count: 32) + + private func makeGroup(extraMembers: [GroupMember] = [], epoch: UInt32 = 1) -> BitchatGroup { + BitchatGroup( + groupID: groupID, + name: "trail crew", + epoch: epoch, + members: [creator.member, member.member] + extraMembers, + creatorFingerprint: creator.fingerprint + ) + } + + // MARK: - State payload (invite / key update) + + @Test func statePayloadRoundTripAndSignatureVerify() throws { + let group = makeGroup() + let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign)) + let encoded = try #require(payload.encode()) + + let decoded = try #require(GroupStatePayload.decode(encoded)) + #expect(decoded == payload) + #expect(decoded.groupID == groupID) + #expect(decoded.name == "trail crew") + #expect(decoded.key == key) + #expect(decoded.epoch == 1) + #expect(decoded.members == group.members) + #expect(decoded.creatorFingerprint == creator.fingerprint) + #expect(decoded.verifyCreatorSignature()) + #expect(decoded.asGroup == group) + } + + @Test func forgedCreatorSignatureIsRejected() throws { + let group = makeGroup() + // Signed by a member who is in the roster but is NOT the creator. + let forged = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: member.sign)) + #expect(!forged.verifyCreatorSignature()) + + // An outsider signing is equally rejected. + let outsiderForged = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: outsider.sign)) + #expect(!outsiderForged.verifyCreatorSignature()) + } + + @Test func tamperedStateFailsSignature() throws { + let group = makeGroup() + let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign)) + + // Bumping the epoch invalidates the signature. + let epochTampered = GroupStatePayload( + groupID: payload.groupID, + name: payload.name, + key: payload.key, + epoch: payload.epoch + 1, + members: payload.members, + creatorFingerprint: payload.creatorFingerprint, + signature: payload.signature + ) + #expect(!epochTampered.verifyCreatorSignature()) + + // So does swapping the key. + let keyTampered = GroupStatePayload( + groupID: payload.groupID, + name: payload.name, + key: Data(repeating: 0x99, count: 32), + epoch: payload.epoch, + members: payload.members, + creatorFingerprint: payload.creatorFingerprint, + signature: payload.signature + ) + #expect(!keyTampered.verifyCreatorSignature()) + + // And so does adding a member to the roster. + let rosterTampered = GroupStatePayload( + groupID: payload.groupID, + name: payload.name, + key: payload.key, + epoch: payload.epoch, + members: payload.members + [outsider.member], + creatorFingerprint: payload.creatorFingerprint, + signature: payload.signature + ) + #expect(!rosterTampered.verifyCreatorSignature()) + } + + @Test func creatorMissingFromRosterIsRejected() throws { + // State claiming a creator whose fingerprint is not in the roster has + // no key to verify against and must fail closed. + let group = BitchatGroup( + groupID: groupID, + name: "orphan", + epoch: 1, + members: [member.member], + creatorFingerprint: creator.fingerprint + ) + guard let rosterBlob = GroupRosterCoding.encode(group.members) else { + Issue.record("roster should encode") + return + } + let content = GroupStatePayload.signingContent(groupID: groupID, epoch: 1, key: key, rosterBlob: rosterBlob, name: group.name) + let payload = GroupStatePayload( + groupID: groupID, + name: group.name, + key: key, + epoch: 1, + members: group.members, + creatorFingerprint: creator.fingerprint, + signature: creator.sign(content) ?? Data() + ) + #expect(!payload.verifyCreatorSignature()) + } + + @Test func rosterCapIsEnforcedOnTheWire() { + // 17 members cannot be encoded (hard cap is 16)… + let seventeen = (0..<17).map { TestIdentity(seed: UInt8($0 + 1)).member } + #expect(GroupRosterCoding.encode(seventeen) == nil) + + // …and a hand-built blob claiming 17 members fails to decode. + let sixteen = (0..<16).map { TestIdentity(seed: UInt8($0 + 1)).member } + guard var blob = GroupRosterCoding.encode(sixteen) else { + Issue.record("16-member roster should encode") + return + } + #expect(GroupRosterCoding.decode(blob)?.count == 16) + blob[blob.startIndex] = 17 + #expect(GroupRosterCoding.decode(blob) == nil) + } + + // MARK: - Message seal / open + + @Test func messageRoundTrip() throws { + let group = makeGroup() + let timestampMs: UInt64 = 1_750_000_000_000 + let sealed = try GroupCrypto.sealMessage( + content: "summit at noon", + messageID: "msg-1", + senderNickname: "alice", + senderSigningKey: member.member.signingKey, + timestampMs: timestampMs, + groupID: groupID, + epoch: group.epoch, + key: key, + sign: member.sign + ) + + let envelope = try #require(GroupMessageEnvelope.decode(sealed)) + #expect(envelope.groupID == groupID) + #expect(envelope.epoch == group.epoch) + + let plaintext = try GroupCrypto.openMessage(envelope, key: key) + #expect(plaintext.messageID == "msg-1") + #expect(plaintext.content == "summit at noon") + #expect(plaintext.senderNickname == "alice") + #expect(plaintext.timestampMs == timestampMs) + #expect(plaintext.senderSigningKey == member.member.signingKey) + + // The roster resolves the sender; an outsider's key would not. + #expect(group.member(withSigningKey: plaintext.senderSigningKey) != nil) + #expect(group.member(withSigningKey: outsider.member.signingKey) == nil) + } + + @Test func wrongKeyFailsToDecrypt() throws { + let sealed = try GroupCrypto.sealMessage( + content: "hi", + messageID: "msg-2", + senderNickname: "alice", + senderSigningKey: member.member.signingKey, + timestampMs: 1, + groupID: groupID, + epoch: 1, + key: key, + sign: member.sign + ) + let envelope = try #require(GroupMessageEnvelope.decode(sealed)) + #expect(throws: GroupCryptoError.decryptionFailed) { + _ = try GroupCrypto.openMessage(envelope, key: Data(repeating: 0x7F, count: 32)) + } + } + + @Test func epochIsBoundIntoTheCiphertext() throws { + // Re-labeling an epoch-1 envelope as epoch 2 must break the AEAD: + // a rotated-out member cannot replay old ciphertext into a new epoch. + let sealed = try GroupCrypto.sealMessage( + content: "hi", + messageID: "msg-3", + senderNickname: "alice", + senderSigningKey: member.member.signingKey, + timestampMs: 1, + groupID: groupID, + epoch: 1, + key: key, + sign: member.sign + ) + let envelope = try #require(GroupMessageEnvelope.decode(sealed)) + let relabeled = GroupMessageEnvelope( + groupID: envelope.groupID, + epoch: 2, + nonce: envelope.nonce, + ciphertext: envelope.ciphertext + ) + #expect(throws: GroupCryptoError.decryptionFailed) { + _ = try GroupCrypto.openMessage(relabeled, key: key) + } + } + + @Test func badSenderSignatureIsRejected() throws { + // A key-holder who signs with a key other than the one they claim + // (or garbage) is dropped even though decryption succeeds. + let sealed = try GroupCrypto.sealMessage( + content: "spoof", + messageID: "msg-4", + senderNickname: "mallory", + senderSigningKey: member.member.signingKey, // claims member's key… + timestampMs: 1, + groupID: groupID, + epoch: 1, + key: key, + sign: outsider.sign // …but signs with the outsider's + ) + let envelope = try #require(GroupMessageEnvelope.decode(sealed)) + #expect(throws: GroupCryptoError.badSenderSignature) { + _ = try GroupCrypto.openMessage(envelope, key: key) + } + } + + @Test func tamperedCiphertextFailsToOpen() throws { + let sealed = try GroupCrypto.sealMessage( + content: "hi", + messageID: "msg-5", + senderNickname: "alice", + senderSigningKey: member.member.signingKey, + timestampMs: 1, + groupID: groupID, + epoch: 1, + key: key, + sign: member.sign + ) + let envelope = try #require(GroupMessageEnvelope.decode(sealed)) + var flipped = envelope.ciphertext + flipped[flipped.startIndex] ^= 0x01 + let tampered = GroupMessageEnvelope( + groupID: envelope.groupID, + epoch: envelope.epoch, + nonce: envelope.nonce, + ciphertext: flipped + ) + #expect(throws: GroupCryptoError.decryptionFailed) { + _ = try GroupCrypto.openMessage(tampered, key: key) + } + } + + @Test func malformedEnvelopesAreRejected() { + #expect(GroupMessageEnvelope.decode(Data()) == nil) + #expect(GroupMessageEnvelope.decode(Data([0x01, 0x00])) == nil) + #expect(GroupStatePayload.decode(Data([0xFF, 0x00, 0x01])) == nil) + } + + // MARK: - Oversize / UTF-8 safety (Codex findings) + + @Test func oversizeMessageContentFailsToSealInsteadOfTruncating() { + // A content whose UTF-8 exceeds the 16-bit TLV length must fail to + // seal (surfacing send_failed) rather than silently truncate into a + // ciphertext recipients would drop. + let oversize = String(repeating: "a", count: 70_000) + #expect(throws: (any Error).self) { + _ = try GroupCrypto.sealMessage( + content: oversize, + messageID: "big", + senderNickname: "alice", + senderSigningKey: member.member.signingKey, + timestampMs: 1, + groupID: groupID, + epoch: 1, + key: key, + sign: member.sign + ) + } + } + + @Test func multiByteNicknameTruncatesOnScalarBoundary() throws { + // 40 euro signs = 120 UTF-8 bytes; a raw 64-byte prefix would split + // the 21st scalar and make the roster undecodable. Truncation must + // land on a Character boundary so the blob round-trips. + let euros = String(repeating: "€", count: 40) + let wide = GroupMember( + fingerprint: creator.fingerprint, + signingKey: creator.member.signingKey, + nickname: euros + ) + let blob = try #require(GroupRosterCoding.encode([wide])) + let decoded = try #require(GroupRosterCoding.decode(blob)) + #expect(decoded.count == 1) + #expect(Data(decoded[0].nickname.utf8).count <= 64) + #expect(decoded[0].nickname.allSatisfy { $0 == "€" }) + #expect(!decoded[0].nickname.isEmpty) + } + + // MARK: - Signable-bytes forward-proofing + + @Test func creatorSignatureCoversName() throws { + let group = makeGroup() + let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign)) + #expect(payload.verifyCreatorSignature()) + + // Swapping only the display name must invalidate the creator signature. + let renamed = GroupStatePayload( + groupID: payload.groupID, + name: "totally different name", + key: payload.key, + epoch: payload.epoch, + members: payload.members, + creatorFingerprint: payload.creatorFingerprint, + signature: payload.signature + ) + #expect(!renamed.verifyCreatorSignature()) + } + + @Test func messageSignatureCoversEpoch() { + // The signed bytes differ by epoch, so a signature captured at one + // epoch cannot verify when re-sealed under a later epoch key. + let atEpoch1 = GroupCrypto.messageSigningContent( + groupID: groupID, epoch: 1, messageID: "m", timestampMs: 1, content: "x" + ) + let atEpoch2 = GroupCrypto.messageSigningContent( + groupID: groupID, epoch: 2, messageID: "m", timestampMs: 1, content: "x" + ) + #expect(atEpoch1 != atEpoch2) + } +} diff --git a/bitchatTests/Services/GroupStoreTests.swift b/bitchatTests/Services/GroupStoreTests.swift new file mode 100644 index 00000000..adf04806 --- /dev/null +++ b/bitchatTests/Services/GroupStoreTests.swift @@ -0,0 +1,170 @@ +// +// GroupStoreTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +import BitFoundation +@testable import bitchat + +@MainActor +struct GroupStoreTests { + + private func makeMember(seed: UInt8, nickname: String = "peer") -> GroupMember { + GroupMember( + fingerprint: Data(repeating: seed, count: 32).hexEncodedString(), + signingKey: Data(repeating: seed &+ 1, count: 32), + nickname: nickname + ) + } + + private func tempFileURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("group-store-tests-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("groups.json") + } + + // MARK: - Create / read + + @Test func createGroupStoresMetadataAndKey() throws { + let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false) + let creator = makeMember(seed: 0xC1, nickname: "me") + + let group = try #require(store.createGroup(named: "ops", creator: creator)) + #expect(group.groupID.count == BitchatGroup.groupIDLength) + #expect(group.epoch == 1) + #expect(group.members == [creator]) + #expect(group.creatorFingerprint == creator.fingerprint) + + #expect(store.group(withID: group.groupID) == group) + #expect(store.group(for: group.peerID) == group) + let key = try #require(store.key(forGroupID: group.groupID)) + #expect(key.count == BitchatGroup.keyLength) + #expect(group.peerID.isGroup) + #expect(group.peerID.groupIDData == group.groupID) + } + + // MARK: - Roster cap + + @Test func rosterCapIsEnforced() throws { + let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false) + let creator = makeMember(seed: 0xC1) + let group = try #require(store.createGroup(named: "big", creator: creator)) + + // Filling to the cap works… + let fifteen = (1...15).map { makeMember(seed: UInt8($0)) } + #expect(store.updateRoster(groupID: group.groupID, members: [creator] + fifteen) != nil) + #expect(store.group(withID: group.groupID)?.members.count == BitchatGroup.maxMembers) + + // …one more is rejected. + let overflow = [creator] + fifteen + [makeMember(seed: 0x99)] + #expect(store.updateRoster(groupID: group.groupID, members: overflow) == nil) + #expect(store.group(withID: group.groupID)?.members.count == BitchatGroup.maxMembers) + + // Direct upsert past the cap is rejected too. + var oversized = group + oversized.members = overflow + #expect(!store.upsert(oversized, key: Data(repeating: 1, count: 32))) + } + + @Test func rosterMustRetainCreator() throws { + let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false) + let creator = makeMember(seed: 0xC1) + let other = makeMember(seed: 0xA1) + let group = try #require(store.createGroup(named: "crew", creator: creator)) + + #expect(store.updateRoster(groupID: group.groupID, members: [other]) == nil) + #expect(store.group(withID: group.groupID)?.members == [creator]) + } + + // MARK: - Rotation + + @Test func rotateKeyBumpsEpochAndReplacesKey() throws { + let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false) + let creator = makeMember(seed: 0xC1) + let removed = makeMember(seed: 0xA1) + let group = try #require(store.createGroup(named: "crew", creator: creator)) + #expect(store.updateRoster(groupID: group.groupID, members: [creator, removed]) != nil) + let oldKey = try #require(store.key(forGroupID: group.groupID)) + + let rotation = try #require(store.rotateKey(groupID: group.groupID, members: [creator])) + #expect(rotation.group.epoch == 2) + #expect(rotation.group.members == [creator]) + #expect(rotation.key != oldKey) + #expect(store.key(forGroupID: group.groupID) == rotation.key) + #expect(store.group(withID: group.groupID)?.epoch == 2) + } + + // MARK: - Persistence + + @Test func persistsAcrossInstances() throws { + let keychain = MockKeychain() + let fileURL = tempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let creator = makeMember(seed: 0xC1, nickname: "me") + let group: BitchatGroup + do { + let store = GroupStore(keychain: keychain, fileURL: fileURL) + group = try #require(store.createGroup(named: "hike", creator: creator)) + } + + let reloaded = GroupStore(keychain: keychain, fileURL: fileURL) + #expect(reloaded.groups == [group]) + #expect(reloaded.key(forGroupID: group.groupID) != nil) + } + + @Test func groupsWithoutKeysAreDroppedOnLoad() throws { + let keychain = MockKeychain() + let fileURL = tempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let group: BitchatGroup + do { + let store = GroupStore(keychain: keychain, fileURL: fileURL) + group = try #require(store.createGroup(named: "stale", creator: makeMember(seed: 0xC1))) + } + // Simulate a keychain wipe without the metadata file being removed. + _ = keychain.deleteAllKeychainData() + + let reloaded = GroupStore(keychain: keychain, fileURL: fileURL) + #expect(reloaded.groups.isEmpty) + #expect(reloaded.group(withID: group.groupID) == nil) + } + + // MARK: - Panic wipe + + @Test func wipeRemovesMetadataAndKeys() throws { + let keychain = MockKeychain() + let fileURL = tempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let store = GroupStore(keychain: keychain, fileURL: fileURL) + let group = try #require(store.createGroup(named: "gone", creator: makeMember(seed: 0xC1))) + #expect(FileManager.default.fileExists(atPath: fileURL.path)) + + store.wipe() + + #expect(store.groups.isEmpty) + #expect(store.key(forGroupID: group.groupID) == nil) + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + + // A fresh instance sees nothing either. + let reloaded = GroupStore(keychain: keychain, fileURL: fileURL) + #expect(reloaded.groups.isEmpty) + } + + @Test func removeGroupDeletesItsKey() throws { + let keychain = MockKeychain() + let store = GroupStore(keychain: keychain, persistsToDisk: false) + let group = try #require(store.createGroup(named: "bye", creator: makeMember(seed: 0xC1))) + + store.removeGroup(withID: group.groupID) + #expect(store.groups.isEmpty) + #expect(store.key(forGroupID: group.groupID) == nil) + } +} diff --git a/bitchatTests/Services/MeshDiagnosticsTests.swift b/bitchatTests/Services/MeshDiagnosticsTests.swift index ced8a92e..b565840d 100644 --- a/bitchatTests/Services/MeshDiagnosticsTests.swift +++ b/bitchatTests/Services/MeshDiagnosticsTests.swift @@ -277,6 +277,11 @@ private final class DiagnosticsMockContext: CommandContextProvider { func clearPrivateChat(_ peerID: PeerID) {} func sendPublicRaw(_ content: String) {} func sendPublicMessage(_ content: String) {} + func groupCreate(named name: String) -> CommandResult { .handled } + func groupInvite(nickname: String) -> CommandResult { .handled } + func groupRemove(nickname: String) -> CommandResult { .handled } + func groupLeave() -> CommandResult { .handled } + func groupList() -> CommandResult { .handled } func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {} func addPublicSystemMessage(_ content: String) {} func toggleFavorite(peerID: PeerID) {} diff --git a/bitchatTests/Sync/SyncTypeFlagsBoardTests.swift b/bitchatTests/Sync/SyncTypeFlagsBoardTests.swift index 75cbbd54..829bfd4d 100644 --- a/bitchatTests/Sync/SyncTypeFlagsBoardTests.swift +++ b/bitchatTests/Sync/SyncTypeFlagsBoardTests.swift @@ -38,19 +38,19 @@ struct SyncTypeFlagsBoardTests { /// decode path accepts the bytes and simply maps unknown bits to no /// message type, so a board-only request reads as "nothing I can serve". @Test func unknownBitsDecodeToNoTypes() throws { - // Bits 10-15 are unassigned (bit 8 = board, bit 9 = prekeyBundle); a - // future (or unknown) two-byte bitfield must decode without error and - // yield no known types. - let decoded = try #require(SyncTypeFlags.decode(Data([0x00, 0xFC]))) + // Bits 11-15 are unassigned (bit 8 = board, bit 9 = prekeyBundle, + // bit 10 = groupMessage); a future (or unknown) two-byte bitfield must + // decode without error and yield no known types. + let decoded = try #require(SyncTypeFlags.decode(Data([0x00, 0xF8]))) #expect(decoded.toMessageTypes().isEmpty) - for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost, .prekeyBundle] { + for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost, .prekeyBundle, .groupMessage] { #expect(!decoded.contains(type)) } } @Test func mixedKnownAndUnknownBitsKeepKnownTypes() throws { - // Known low-byte flags survive alongside unknown high bits (10-15). - let decoded = try #require(SyncTypeFlags.decode(Data([0x03, 0xFC]))) + // Known low-byte flags survive alongside unknown high bits (11-15). + let decoded = try #require(SyncTypeFlags.decode(Data([0x03, 0xF8]))) #expect(decoded.contains(.announce)) #expect(decoded.contains(.message)) #expect(Set(decoded.toMessageTypes()) == Set([.announce, .message])) diff --git a/bitchatTests/Sync/SyncTypeFlagsGroupTests.swift b/bitchatTests/Sync/SyncTypeFlagsGroupTests.swift new file mode 100644 index 00000000..2bd9d53b --- /dev/null +++ b/bitchatTests/Sync/SyncTypeFlagsGroupTests.swift @@ -0,0 +1,62 @@ +// +// SyncTypeFlagsGroupTests.swift +// bitchat +// +// Wire-compat proof for the groupMessage sync bit (bit 10): the types +// bitfield widens from 1 to 2 bytes, and clients that don't know the bit +// simply ignore it. +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +import BitFoundation +@testable import bitchat + +struct SyncTypeFlagsGroupTests { + + @Test func groupMessageOccupiesBitTen() { + #expect(SyncTypeFlags.groupMessage.rawValue == 1 << 10) + #expect(SyncTypeFlags.groupMessage.contains(.groupMessage)) + #expect(!SyncTypeFlags.publicMessages.contains(.groupMessage)) + } + + @Test func extendedBitfieldWidensToTwoBytes() throws { + // Legacy flags fit one byte… + #expect(SyncTypeFlags.publicMessages.toData() == Data([0x03])) + + // …the group bit widens the little-endian encoding to two bytes. + let combined = SyncTypeFlags.publicMessages.union(.groupMessage) + let encoded = try #require(combined.toData()) + #expect(encoded == Data([0x03, 0x04])) + + let decoded = try #require(SyncTypeFlags.decode(encoded)) + #expect(decoded == combined) + #expect(Set(decoded.toMessageTypes()) == Set([.announce, .message, .groupMessage])) + } + + @Test func unknownBitsAreIgnoredNotRejected() throws { + // An "old client" reading a 2-byte field keeps the raw bits but maps + // unknown bit indices to no message type — it answers with the types + // it knows instead of dropping the request. + let futuristic = try #require(SyncTypeFlags.decode(Data([0x03, 0xFC]))) + #expect(Set(futuristic.toMessageTypes()) == Set([.announce, .message, .groupMessage])) + #expect(futuristic.contains(.announce)) + #expect(futuristic.contains(.message)) + } + + @Test func requestSyncPacketRoundTripsGroupFlag() throws { + let types = SyncTypeFlags.publicMessages.union(.groupMessage) + let packet = RequestSyncPacket(p: 8, m: 1024, data: Data([0xAB, 0xCD]), types: types) + let encoded = packet.encode() + + let decoded = try #require(RequestSyncPacket.decode(from: encoded)) + #expect(decoded.types == types) + #expect(decoded.types?.contains(.groupMessage) == true) + #expect(decoded.p == 8) + #expect(decoded.m == 1024) + #expect(decoded.data == Data([0xAB, 0xCD])) + } +} diff --git a/bitchatTests/Sync/SyncTypeFlagsTests.swift b/bitchatTests/Sync/SyncTypeFlagsTests.swift index 45dee06a..8c400119 100644 --- a/bitchatTests/Sync/SyncTypeFlagsTests.swift +++ b/bitchatTests/Sync/SyncTypeFlagsTests.swift @@ -13,9 +13,10 @@ struct SyncTypeFlagsTests { } @Test func decodeDropsPhantomBits() { - // Bits 10+ map to no message type (bit 8 = boardPost, bit 9 = - // prekeyBundle). They must not survive decode as phantom membership. - let phantom = Data([0x00, 0xFC]) // bits 10..15 set, no known type + // Bits 11+ map to no message type (bit 8 = boardPost, bit 9 = + // prekeyBundle, bit 10 = groupMessage). They must not survive decode + // as phantom membership. + let phantom = Data([0x00, 0xF8]) // bits 11..15 set, no known type let decoded = SyncTypeFlags.decode(phantom) #expect(decoded?.rawValue == 0) #expect(decoded?.toMessageTypes().isEmpty == true) @@ -23,17 +24,18 @@ struct SyncTypeFlagsTests { @Test func boardBitSurvivesDecode() { // Bit 8 maps to boardPost and spills the field into a second byte; - // it must survive decode while the phantom high bits (10+) are - // stripped. Bit 9 (prekeyBundle) is cleared to isolate the board bit. - let mixed = Data([0x00, 0xFD]) // bit 8 (board) known, bits 10..15 phantom + // it must survive decode while the phantom high bits (11+) are + // stripped. Bits 9 (prekeyBundle) and 10 (groupMessage) are cleared + // to isolate the board bit. + let mixed = Data([0x00, 0xF9]) // bit 8 (board) known, bits 11..15 phantom let decoded = SyncTypeFlags.decode(mixed) #expect(decoded?.contains(.board) == true) #expect(decoded?.rawValue == 0b1_0000_0000) } @Test func phantomBitsAreStrippedButKnownBitsSurvive() { - // Low byte = announce(0) + message(1); high byte bits 10+ are phantom. - let mixed = Data([0b0000_0011, 0xFC]) + // Low byte = announce(0) + message(1); high byte bits 11+ are phantom. + let mixed = Data([0b0000_0011, 0xF8]) let decoded = SyncTypeFlags.decode(mixed) #expect(decoded?.contains(.announce) == true) #expect(decoded?.contains(.message) == true) diff --git a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift index 47f46b85..4ddf5ab1 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift @@ -26,6 +26,7 @@ public enum MessageType: UInt8 { case fileTransfer = 0x22 // Binary file/audio/image payloads case boardPost = 0x23 // Signed geohash bulletin-board post or tombstone case prekeyBundle = 0x24 // Signed batch of one-time prekeys (gossiped) + case groupMessage = 0x25 // Group-encrypted broadcast (cleartext group ID, ChaChaPoly body) // Mesh diagnostics case ping = 0x26 // Directed echo request (nonce + origin TTL) @@ -48,6 +49,7 @@ public enum MessageType: UInt8 { case .fileTransfer: return "fileTransfer" case .boardPost: return "boardPost" case .prekeyBundle: return "prekeyBundle" + case .groupMessage: return "groupMessage" case .ping: return "ping" case .pong: return "pong" case .nostrCarrier: return "nostrCarrier" diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift b/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift index 0b2721c2..f22fac18 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift @@ -34,6 +34,9 @@ public struct PeerID: Equatable, Hashable, Sendable { case geoDM = "nostr_" /// `"nostr:"` (+ 8 characters hex) case geoChat = "nostr:" + /// `"group_"` (+ 32 characters hex) — virtual conversation ID for a + /// private group (16-byte group ID). Never routed to a single peer. + case group = "group_" } public let prefix: Prefix @@ -96,6 +99,22 @@ public extension PeerID { } } +// MARK: - Group Conversation Helpers + +public extension PeerID { + /// Convenience init to create a virtual group conversation PeerID from a + /// 16-byte group ID ("group_" + 32 hex characters). + init(groupID: Data) { + self.init(str: Prefix.group.rawValue + groupID.hexEncodedString()) + } + + /// The 16-byte group ID behind a "group_" PeerID, if this is one. + var groupIDData: Data? { + guard isGroup, bare.count == 32 else { return nil } + return Data(hexString: bare) + } +} + // MARK: - Noise Public Key Helpers public extension PeerID { @@ -143,6 +162,11 @@ public extension PeerID { prefix == .geoDM } + /// Returns true if `id` starts with "`group_`" + var isGroup: Bool { + prefix == .group + } + func toPercentEncoded() -> String { id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id } From a0c517c018d1d33b330c090b6b2886caf5e14fe4 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:16:35 +0200 Subject: [PATCH 17/18] i18n: machine-translate feature strings into all 28 non-English locales (#1391) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fills the translation gaps for the strings the feature program added (capability UI, /ping /trace, board, vouch, prekeys, gateway, groups, Cashu, Wi-Fi bulk, Tor-offline). 3300 (key,language) pairs added across 28 locales; Spanish was already fully translated, the rest land as `needs_review` for native-speaker review before shipping. Purely additive: main's key set (336) and existing translations are authoritative and untouched. Verified programmatically — 0 removed, 0 changed, exactly 3300 added. (The git diff-stat shows large deletion counts, but that's line-alignment churn from inserting into a 38k-line JSON; no content is removed.) Machine translation only — every `needs_review` entry needs a native speaker before it can be trusted. Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/Localizable.xcstrings | 20012 ++++++++++++++++++++++++++++++++ 1 file changed, 20012 insertions(+) diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index f7979b8d..fdabb822 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -5024,6 +5024,150 @@ "state" : "translated", "value" : "blocked" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "محظور" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ব্লক করা" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "blockiert" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "bloqueado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bloqué" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "חסום" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ब्लॉक किया गया" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "diblokir" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bloccato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ブロック中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "차단됨" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "diblokir" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ब्लक" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "geblokkeerd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "zablokowany" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bloqueado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "заблокирован" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "blockerad" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "தடுக்கப்பட்டது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ถูกบล็อก" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "engellendi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "заблоковано" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "بلاک شدہ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đã chặn" + } } } }, @@ -5036,6 +5180,150 @@ "state" : "translated", "value" : "end-to-end encrypted session" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "جلسة مشفرة من طرف إلى طرف" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "এন্ড-টু-এন্ড এনক্রিপ্টেড সেশন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "end-to-end-verschlüsselte sitzung" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "sesión cifrada de extremo a extremo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "session chiffrée de bout en bout" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "חיבור מוצפן מקצה לקצה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "एंड-टू-एंड एन्क्रिप्टेड सत्र" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sesi terenkripsi ujung ke ujung" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sessione cifrata end-to-end" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "エンドツーエンド暗号化されたセッション" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "종단간 암호화된 세션" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sesi terenkripsi ujung ke ujung" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "एन्ड-टु-एन्ड सङ्केत सत्र" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "end-to-end-versleutelde sessie" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sesja szyfrowana end-to-end" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sessão encriptada ponta a ponta" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "сессия со сквозным шифрованием" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "end-to-end-krypterad session" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "முனை-முதல்-முனை குறியாக்கம் செய்யப்பட்ட அமர்வு" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เซสชันที่เข้ารหัสแบบครบวงจร" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "uçtan uca şifreli oturum" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "наскрізно зашифрована сесія" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "اینڈ ٹو اینڈ خفیہ کردہ سیشن" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "phiên mã hóa đầu cuối" + } } } }, @@ -5048,6 +5336,150 @@ "state" : "translated", "value" : "encryption failed — messages not secured" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "فشل التشفير — الرسائل غير مؤمَّنة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "এনক্রিপশন ব্যর্থ — বার্তা সুরক্ষিত নয়" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verschlüsselung fehlgeschlagen — nachrichten nicht gesichert" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "cifrado fallido — mensajes no protegidos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "échec du chiffrement — messages non sécurisés" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ההצפנה נכשלה — ההודעות אינן מאובטחות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "एन्क्रिप्शन विफल — संदेश सुरक्षित नहीं" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "enkripsi gagal — pesan tidak aman" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cifratura fallita — messaggi non protetti" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "暗号化に失敗 — メッセージは保護されていません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "암호화 실패 — 메시지가 보호되지 않음" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "enkripsi gagal — pesan tidak selamat" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "सङ्केत असफल — सन्देश सुरक्षित छैनन्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "versleuteling mislukt — berichten niet beveiligd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "szyfrowanie nie powiodło się — wiadomości nie są zabezpieczone" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "falha na encriptação — mensagens não protegidas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "шифрование не удалось — сообщения не защищены" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kryptering misslyckades — meddelanden inte skyddade" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குறியாக்கம் தோல்வியடைந்தது — செய்திகள் பாதுகாக்கப்படவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เข้ารหัสไม่สำเร็จ — ข้อความไม่ปลอดภัย" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "şifreleme başarısız — mesajlar güvende değil" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "шифрування не вдалося — повідомлення не захищені" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "انکرپشن ناکام — پیغامات محفوظ نہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mã hóa thất bại — tin nhắn không được bảo mật" + } } } }, @@ -5060,6 +5492,150 @@ "state" : "translated", "value" : "favorite — enables offline messages via nostr when mutual" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "مفضّل — يتيح الرسائل بدون اتصال عبر nostr عند التفضيل المتبادل" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "প্রিয় — পারস্পরিক হলে nostr দিয়ে অফলাইন বার্তা সক্রিয় করে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favorit — ermöglicht offline-nachrichten über nostr, wenn beidseitig" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorito — habilita mensajes sin conexión vía Nostr cuando es mutuo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favori — active les messages hors ligne via nostr quand c'est mutuel" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מועדף — מאפשר הודעות לא מקוונות דרך nostr כשההעדפה הדדית" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "पसंदीदा — आपसी होने पर नोस्ट्र के ज़रिए ऑफ़लाइन संदेश सक्षम करता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favorit — mengaktifkan pesan offline via nostr saat saling favorit" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "preferito — abilita i messaggi offline via nostr quando reciproco" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "お気に入り — 相互になるとnostr経由でオフラインメッセージが可能に" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "즐겨찾기 — 상호 등록 시 nostr를 통해 오프라인 메시지 가능" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favorit — membolehkan pesan offline melalui nostr apabila saling favorit" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "मनपर्ने — दुवैतर्फ भएमा nostr मार्फत अफलाइन सन्देश सक्षम गर्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favoriet — schakelt offline berichten via nostr in bij wederzijds" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ulubiony — umożliwia wiadomości offline przez Nostr, gdy wzajemny" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favorito — ativa mensagens offline via nostr quando for mútuo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "избранное — включает офлайн-сообщения через nostr при взаимности" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favorit — möjliggör offline-meddelanden via Nostr när ömsesidig" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "சிறப்பு — பரஸ்பரமாக இருக்கும்போது nostr வழியாக ஆஃப்லைன் செய்திகளை இயக்கும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "คนโปรด — เปิดใช้ข้อความออฟไลน์ผ่าน nostr เมื่อเป็นคนโปรดของกันและกัน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favori — karşılıklı olduğunda Nostr üzerinden çevrimdışı mesajları etkinleştirir" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "улюблений — вмикає офлайн-повідомлення через nostr, коли взаємний" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "پسندیدہ — باہمی ہونے پر nostr کے ذریعے آف لائن پیغامات فعال کرتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "yêu thích — bật tin nhắn ngoại tuyến qua nostr khi cả hai cùng thích" + } } } }, @@ -5072,6 +5648,150 @@ "state" : "translated", "value" : "physically in this location channel's area" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "موجود فعليًا في منطقة قناة الموقع هذه" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "সশরীরে এই লোকেশন চ্যানেলের এলাকায়" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "physisch im gebiet dieses standortkanals" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "físicamente en el área de este canal de ubicación" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "physiquement dans la zone de ce canal de localisation" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "נמצא פיזית באזור של ערוץ המיקום הזה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "इस लोकेशन चैनल के क्षेत्र में मौजूद" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "secara fisik berada di area kanal lokasi ini" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "fisicamente nell'area di questo canale di posizione" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "この位置チャンネルのエリア内にいます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이 위치 채널 영역 안에 있음" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "secara fizikal berada di kawasan kanal lokasi ini" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "यो स्थान च्यानलको क्षेत्रमा भौतिक रूपमा" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "fysiek in het gebied van dit locatiekanaal" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "fizycznie w obszarze tego kanału lokalizacji" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "fisicamente na área deste canal de localização" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "физически в зоне этого локального канала" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "fysiskt i den här platskanalens område" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "இந்த இருப்பிட சேனலின் பகுதியில் நேரடியாக உள்ளார்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "อยู่ในพื้นที่ของช่องตามตำแหน่งนี้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "fiziksel olarak bu konum kanalının bölgesinde" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "фізично в зоні цього каналу локації" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "اس لوکیشن چینل کے علاقے میں جسمانی طور پر موجود" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hiện đang ở trong khu vực của kênh vị trí này" + } } } }, @@ -5084,6 +5804,150 @@ "state" : "translated", "value" : "connected directly over bluetooth" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "متصل مباشرة عبر bluetooth" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "সরাসরি ব্লুটুথে সংযুক্ত" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "direkt über bluetooth verbunden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "conectado directamente por Bluetooth" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "connecté directement en bluetooth" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מחובר ישירות דרך bluetooth" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ब्लूटूथ से सीधे जुड़ा" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "terhubung langsung lewat bluetooth" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "connesso direttamente via bluetooth" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bluetoothで直接接続中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bluetooth로 직접 연결됨" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bersambung terus melalui bluetooth" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bluetooth मार्फत सिधै जडान" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "rechtstreeks verbonden via bluetooth" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "połączono bezpośrednio przez Bluetooth" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ligado diretamente por bluetooth" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "подключён напрямую через bluetooth" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ansluten direkt via Bluetooth" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bluetooth மூலம் நேரடியாக இணைக்கப்பட்டுள்ளது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เชื่อมต่อโดยตรงผ่าน bluetooth" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "doğrudan Bluetooth üzerinden bağlı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "з'єднано напряму через bluetooth" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "براہ راست Bluetooth کے ذریعے جڑا ہوا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kết nối trực tiếp qua Bluetooth" + } } } }, @@ -5096,6 +5960,150 @@ "state" : "translated", "value" : "reachable through the mesh, relayed by others" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "يمكن الوصول إليه عبر mesh، بإعادة تمرير من الآخرين" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "মেশের মাধ্যমে পৌঁছানো যায়, অন্যরা রিলে করে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "über das mesh erreichbar, von anderen weitergeleitet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "alcanzable a través del mesh, retransmitido por otros" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "joignable via le mesh, relayé par d'autres" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "נגיש דרך ה-mesh, בשידור חוזר על ידי אחרים" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "मेश के ज़रिए पहुँच योग्य, दूसरों द्वारा रिले किया गया" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bisa dijangkau lewat mesh, diteruskan oleh yang lain" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "raggiungibile tramite la mesh, inoltrato da altri" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh経由で到達可能、他のピアがリレー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh를 통해 도달 가능, 다른 피어가 중계" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "boleh dicapai melalui mesh, diteruskan oleh orang lain" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh मार्फत पुग्न सकिने, अरूले रिले गरेको" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bereikbaar via de mesh, doorgestuurd door anderen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "osiągalny przez mesh, przekazywany przez innych" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "acessível através da mesh, retransmitido por outros" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "доступен через mesh, ретранслируется другими" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nåbar via mesh, vidarebefordrad av andra" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh வழியாக அணுகக்கூடியது, மற்றவர்களால் ரிலே செய்யப்படுகிறது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เข้าถึงได้ผ่าน mesh โดยมีผู้อื่นส่งต่อ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh üzerinden ulaşılabilir, başkaları tarafından aktarılır" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "досяжно через mesh, ретрансльовано іншими" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh کے ذریعے قابل رسائی، دوسروں کے ذریعے ریلے شدہ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "có thể tiếp cận qua mesh, được người khác chuyển tiếp" + } } } }, @@ -5108,6 +6116,150 @@ "state" : "translated", "value" : "reachable over the internet (nostr) — mutual favorites only" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "يمكن الوصول إليه عبر الإنترنت (nostr) — المفضّلون المتبادلون فقط" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ইন্টারনেটে পৌঁছানো যায় (nostr) — শুধু পারস্পরিক প্রিয়" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "über das internet erreichbar (nostr) — nur bei beidseitigen favoriten" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "alcanzable por internet (Nostr) — solo favoritos mutuos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "joignable via internet (nostr) — favoris mutuels uniquement" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "נגיש דרך האינטרנט (nostr) — מועדפים הדדיים בלבד" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "इंटरनेट (नोस्ट्र) पर पहुँच योग्य — केवल आपसी पसंदीदा" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bisa dijangkau lewat internet (nostr) — hanya untuk saling favorit" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "raggiungibile via internet (nostr) — solo preferiti reciproci" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "インターネット(nostr)経由で到達可能 — 相互お気に入りのみ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "인터넷(nostr)을 통해 도달 가능 — 상호 즐겨찾기만" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "boleh dicapai melalui internet (nostr) — hanya untuk saling favorit" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "इन्टरनेट (nostr) मार्फत पुग्न सकिने — दुवैतर्फका मनपर्ने मात्र" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bereikbaar via internet (nostr) — alleen wederzijdse favorieten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "osiągalny przez internet (Nostr) — tylko wzajemni ulubieni" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "acessível pela internet (nostr) — apenas favoritos mútuos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "доступен через интернет (nostr) — только для взаимного избранного" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nåbar via internet (Nostr) — endast ömsesidiga favoriter" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "இணையம் வழியாக அணுகக்கூடியது (nostr) — பரஸ்பர சிறப்பினர் மட்டுமே" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เข้าถึงได้ผ่านอินเทอร์เน็ต (nostr) — เฉพาะคนโปรดของกันและกัน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "internet üzerinden ulaşılabilir (Nostr) — yalnızca karşılıklı favoriler" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "досяжно через інтернет (nostr) — лише взаємні улюблені" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "انٹرنیٹ (nostr) کے ذریعے قابل رسائی — صرف باہمی پسندیدہ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "có thể tiếp cận qua internet (nostr) — chỉ khi cả hai cùng thích" + } } } }, @@ -5120,6 +6272,150 @@ "state" : "translated", "value" : "offline — not currently reachable" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "غير متصل — لا يمكن الوصول إليه حاليًا" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "অফলাইন — এখন পৌঁছানো যাচ্ছে না" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline — derzeit nicht erreichbar" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "sin conexión — no alcanzable ahora" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hors ligne — actuellement injoignable" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "לא מקוון — לא נגיש כרגע" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ऑफ़लाइन — फ़िलहाल पहुँच योग्य नहीं" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline — saat ini tidak bisa dijangkau" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline — attualmente non raggiungibile" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "オフライン — 現在到達できません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "오프라인 — 현재 도달할 수 없음" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline — kini tidak boleh dicapai" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "अफलाइन — अहिले पुग्न सकिँदैन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline — momenteel niet bereikbaar" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline — obecnie nieosiągalny" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline — atualmente inacessível" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "офлайн — сейчас недоступен" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline — inte nåbar just nu" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ஆஃப்லைன் — தற்போது அணுக முடியாது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ออฟไลน์ — ไม่สามารถเข้าถึงได้ในขณะนี้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "çevrimdışı — şu anda ulaşılamıyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "офлайн — зараз недосяжно" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "آف لائن — فی الحال قابل رسائی نہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ngoại tuyến — hiện không thể tiếp cận" + } } } }, @@ -5132,6 +6428,150 @@ "state" : "translated", "value" : "teleported — joined the channel from somewhere else" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "منتقل فوريًا — انضم إلى القناة من مكان آخر" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "টেলিপোর্টেড — অন্য কোথাও থেকে চ্যানেলে যোগ দিয়েছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teleportiert — dem kanal von woanders beigetreten" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "teletransportado — se unió al canal desde otro lugar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "téléporté — a rejoint le canal depuis un autre endroit" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "טלפורט — הצטרף לערוץ ממקום אחר" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "टेलीपोर्टेड — कहीं और से चैनल में शामिल हुआ" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teleport — bergabung ke kanal dari tempat lain" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teletrasportato — è entrato nel canale da un altro luogo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "テレポート — 別の場所からチャンネルに参加" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "텔레포트 — 다른 곳에서 채널에 참여함" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teleport — menyertai kanal dari tempat lain" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "टेलिपोर्ट भएको — अन्त कतैबाट च्यानलमा जोडिएको" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "geteleporteerd — het kanaal van elders binnengekomen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teleportowany — dołączył do kanału skądinąd" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teletransportado — entrou no canal a partir de outro sítio" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "телепортирован — присоединился к каналу из другого места" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teleporterad — gick med i kanalen från en annan plats" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "டெலிபோர்ட் செய்யப்பட்டது — வேறு இடத்திலிருந்து சேனலில் சேர்ந்தார்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เทเลพอร์ต — เข้าร่วมช่องจากที่อื่น" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ışınlandı — kanala başka bir yerden katıldı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "телепортований — приєднався до каналу з іншого місця" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ٹیلی پورٹ شدہ — کسی اور جگہ سے چینل میں شامل ہوا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đã dịch chuyển — tham gia kênh từ nơi khác" + } } } }, @@ -5144,6 +6584,150 @@ "state" : "translated", "value" : "SYMBOLS" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "الرموز" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "প্রতীকসমূহ" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "SYMBOLE" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "SÍMBOLOS" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "SYMBOLES" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "סמלים" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "प्रतीक" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "SIMBOL" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "SIMBOLI" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "記号" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "기호" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "SIMBOL" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "चिन्हहरू" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "SYMBOLEN" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "SYMBOLE" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "SÍMBOLOS" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "СИМВОЛЫ" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "SYMBOLER" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "சின்னங்கள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "สัญลักษณ์" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "SEMBOLLER" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "СИМВОЛИ" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "علامات" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "KÝ HIỆU" + } } } }, @@ -5156,6 +6740,150 @@ "state" : "translated", "value" : "unread private messages" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "رسائل خاصة غير مقروءة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "অপঠিত ব্যক্তিগত বার্তা" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ungelesene private nachrichten" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensajes privados sin leer" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "messages privés non lus" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הודעות פרטיות שלא נקראו" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "अपठित निजी संदेश" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan pribadi belum dibaca" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "messaggi privati non letti" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "未読のプライベートメッセージ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "읽지 않은 개인 메시지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan peribadi belum dibaca" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "नपढेका व्यक्तिगत सन्देश" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ongelezen privéberichten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nieprzeczytane wiadomości prywatne" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mensagens privadas não lidas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "непрочитанные личные сообщения" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "olästa privata meddelanden" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "படிக்காத தனிப்பட்ட செய்திகள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ข้อความส่วนตัวที่ยังไม่ได้อ่าน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "okunmamış özel mesajlar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "непрочитані приватні повідомлення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "نہ پڑھے گئے نجی پیغامات" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tin nhắn riêng tư chưa đọc" + } } } }, @@ -5168,6 +6896,150 @@ "state" : "translated", "value" : "identity verified" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تم التحقق من الهوية" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "পরিচয় যাচাই করা হয়েছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "identität verifiziert" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "identidad verificada" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "identité vérifiée" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הזהות אומתה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "पहचान सत्यापित" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "identitas terverifikasi" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "identità verificata" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "本人確認済み" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "신원 확인됨" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "identiti disahkan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "पहिचान प्रमाणित" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "identiteit geverifieerd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tożsamość zweryfikowana" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "identidade verificada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "личность подтверждена" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "identitet verifierad" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "அடையாளம் சரிபார்க்கப்பட்டது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ยืนยันตัวตนแล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kimlik doğrulandı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "особу підтверджено" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "شناخت کی تصدیق ہو گئی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "danh tính đã xác minh" + } } } }, @@ -5180,6 +7052,150 @@ "state" : "translated", "value" : "NETWORK" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "الشبكة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "নেটওয়ার্ক" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "NETZWERK" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "RED" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "RÉSEAU" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "רשת" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "नेटवर्क" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "JARINGAN" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "RETE" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ネットワーク" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "네트워크" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "RANGKAIAN" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "नेटवर्क" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "NETWERK" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "SIEĆ" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "REDE" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "СЕТЬ" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "NÄTVERK" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "நெட்வொர்க்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เครือข่าย" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "AĞ" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "МЕРЕЖА" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "نیٹ ورک" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "MẠNG" + } } } }, @@ -5192,6 +7208,150 @@ "state" : "translated", "value" : "map of peers and links learned from mesh announces" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "خريطة الأقران والروابط المُستنتَجة من إعلانات mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "মেশ অ্যানাউন্স থেকে জানা পিয়ার ও লিঙ্কের মানচিত্র" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "karte der peers und verbindungen, aus mesh-announces gelernt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mapa de peers y enlaces obtenidos de los anuncios del mesh" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "carte des pairs et des liens issue des annonces mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מפה של עמיתים וקישורים שנלמדו מהכרזות mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "मेश अनाउंस से सीखे गए पीयरों और लिंकों का नक्शा" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "peta peer dan tautan yang dipelajari dari announce mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mappa dei peer e dei collegamenti appresa dagli announce mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh announceから学習したピアとリンクのマップ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh announce에서 학습한 피어와 링크의 지도" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "peta peer dan pautan yang dipelajari daripada announce mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh घोषणाबाट थाहा भएका सहकर्मी र लिंकहरूको नक्सा" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kaart van peers en verbindingen, geleerd uit mesh-announces" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mapa peerów i połączeń poznanych z ogłoszeń mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mapa de pares e ligações obtido dos announces mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "карта пиров и связей, полученная из mesh-анонсов" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "karta över peers och länkar från mesh-announces" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh அறிவிப்புகளிலிருந்து அறியப்பட்ட peer-கள் மற்றும் இணைப்புகளின் வரைபடம்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "แผนที่ของเพียร์และการเชื่อมต่อที่เรียนรู้จาก mesh announce" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh duyurularından öğrenilen eşlerin ve bağlantıların haritası" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "карта пірів і зв'язків, отриманих з оголошень mesh" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh اعلانات سے سیکھے گئے ہم منصبوں اور روابط کا نقشہ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bản đồ các nút ngang hàng và liên kết học được từ các announce mesh" + } } } }, @@ -5204,6 +7364,150 @@ "state" : "translated", "value" : "opens the mesh topology map" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "يفتح خريطة طوبولوجيا mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "মেশ টপোলজি মানচিত্র খোলে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "öffnet die mesh-topologie-karte" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "abre el mapa de topología del mesh" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ouvre la carte de topologie mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "פותח את מפת טופולוגיית ה-mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "मेश टोपोलॉजी नक्शा खोलता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "membuka peta topologi mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "apre la mappa della topologia mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh トポロジーマップを開きます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh 토폴로지 지도를 엽니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "membuka peta topologi mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh टोपोलोजी नक्सा खोल्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "opent de mesh-topologiekaart" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "otwiera mapę topologii mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "abre o mapa de topologia mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "открывает карту топологии mesh" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "öppnar mesh-topologikartan" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh டோபாலஜி வரைபடத்தைத் திறக்கும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เปิดแผนที่โทโพโลยี mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh topolojisi haritasını açar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "відкриває карту топології mesh" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh ٹوپولوجی نقشہ کھولتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mở bản đồ cấu trúc mesh" + } } } }, @@ -5216,6 +7520,150 @@ "state" : "translated", "value" : "mesh topology" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "طوبولوجيا mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "মেশ টপোলজি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh-topologie" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "topología del mesh" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologie mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "טופולוגיית mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "मेश टोपोलॉजी" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologi mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologia mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh トポロジー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh 토폴로지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologi mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh टोपोलोजी" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh-topologie" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologia mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologia mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "топология mesh" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh-topologi" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh டோபாலஜி" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "โทโพโลยี mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh topolojisi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "топологія mesh" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh ٹوپولوجی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cấu trúc mesh" + } } } }, @@ -8629,6 +11077,150 @@ "state" : "translated", "value" : "shows app info" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "يعرض معلومات التطبيق" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "অ্যাপের তথ্য দেখায়" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "zeigt app-infos" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "muestra la información de la app" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "affiche les infos de l'app" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מציג את פרטי האפליקציה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ऐप जानकारी दिखाता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "menampilkan info aplikasi" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mostra le info dell'app" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "アプリ情報を表示します" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "앱 정보를 표시합니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "menunjukkan maklumat aplikasi" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "एप जानकारी देखाउँछ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "toont app-info" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pokazuje informacje o aplikacji" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mostra as informações da app" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "показывает информацию о приложении" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "visar appinfo" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "செயலி தகவலைக் காட்டும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "แสดงข้อมูลแอป" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "uygulama bilgisini gösterir" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "показує інформацію про застосунок" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ایپ کی معلومات دکھاتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hiển thị thông tin ứng dụng" + } } } }, @@ -8641,6 +11233,150 @@ "state" : "translated", "value" : "attach photo" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "إرفاق صورة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ছবি যুক্ত করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "foto anhängen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "adjuntar foto" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "joindre une photo" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "צירוף תמונה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "फ़ोटो संलग्न करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "lampirkan foto" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "allega foto" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "写真を添付" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "사진 첨부" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "lampirkan foto" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तस्बिर संलग्न गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "foto bijvoegen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dołącz zdjęcie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "anexar foto" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "прикрепить фото" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bifoga foto" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "புகைப்படத்தை இணைக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "แนบรูปภาพ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "fotoğraf ekle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "додати фото" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تصویر منسلک کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đính kèm ảnh" + } } } }, @@ -8653,6 +11389,150 @@ "state" : "translated", "value" : "opens the photo library; use the take photo action for the camera" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "يفتح مكتبة الصور؛ استخدم إجراء التقاط صورة للكاميرا" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ছবির লাইব্রেরি খোলে; ক্যামেরার জন্য ছবি তোলার অ্যাকশন ব্যবহার করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "öffnet die fotobibliothek; nutze die aktion foto aufnehmen für die kamera" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "abre la fototeca; usa la acción tomar foto para la cámara" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ouvre la photothèque ; utilise l'action prendre une photo pour l'appareil photo" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "פותח את ספריית התמונות; לשימוש במצלמה השתמש בפעולת צילום תמונה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "फ़ोटो लाइब्रेरी खोलता है; कैमरे के लिए फ़ोटो लें क्रिया का उपयोग करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "membuka galeri foto; gunakan aksi ambil foto untuk kamera" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "apre la libreria foto; usa l'azione scatta foto per la fotocamera" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "写真ライブラリを開きます。カメラには写真を撮るを使用してください" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "사진 보관함을 엽니다. 카메라는 사진 촬영 동작을 사용하세요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "membuka galeri foto; guna tindakan ambil foto untuk kamera" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तस्बिर लाइब्रेरी खोल्छ; क्यामेराका लागि तस्बिर खिच्ने कार्य प्रयोग गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "opent de fotobibliotheek; gebruik de actie foto maken voor de camera" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "otwiera bibliotekę zdjęć; użyj akcji zrób zdjęcie, aby użyć aparatu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "abre a biblioteca de fotos; usa a ação tirar foto para a câmara" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "открывает библиотеку фото; для камеры используй действие «сделать фото»" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "öppnar fotobiblioteket; använd ta foto för kameran" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "புகைப்படத் தொகுப்பைத் திறக்கும்; கேமராவுக்கு புகைப்படம் எடுக்கும் செயலைப் பயன்படுத்தவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เปิดคลังรูปภาพ ใช้การถ่ายรูปสำหรับกล้อง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "fotoğraf kitaplığını açar; kamera için fotoğraf çek eylemini kullanın" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "відкриває бібліотеку фото; для камери скористайся дією зробити фото" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تصویری لائبریری کھولتا ہے؛ کیمرے کیلئے تصویر لینے کا عمل استعمال کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mở thư viện ảnh; dùng thao tác chụp ảnh cho máy ảnh" + } } } }, @@ -9023,6 +11903,150 @@ "state" : "translated", "value" : "choose photo" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "اختيار صورة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ছবি বেছে নিন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "foto auswählen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "elegir foto" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "choisir une photo" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "בחירת תמונה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "फ़ोटो चुनें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pilih foto" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "scegli foto" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "写真を選択" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "사진 선택" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pilih foto" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तस्बिर छान" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "foto kiezen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wybierz zdjęcie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "escolher foto" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "выбрать фото" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "välj foto" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "புகைப்படத்தைத் தேர்ந்தெடுக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เลือกรูปภาพ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "fotoğraf seç" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "вибрати фото" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تصویر منتخب کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chọn ảnh" + } } } }, @@ -9214,6 +12238,150 @@ "state" : "translated", "value" : "tap to show delivery details" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "اضغط لعرض تفاصيل التسليم" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ডেলিভারির বিবরণ দেখতে ট্যাপ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tippe, um zustelldetails anzuzeigen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "toca para ver los detalles de entrega" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "touche pour afficher les détails de livraison" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הקש להצגת פרטי מסירה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "डिलीवरी विवरण दिखाने के लिए टैप करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ketuk untuk menampilkan detail pengiriman" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tocca per mostrare i dettagli di consegna" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "タップして配信の詳細を表示" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "탭하여 전송 세부정보 표시" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ketuk untuk menunjukkan butiran penghantaran" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "डेलिभरी विवरण देखाउन ट्याप गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tik om bezorgdetails te tonen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "stuknij, aby pokazać szczegóły dostarczenia" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "toca para mostrar os detalhes de entrega" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "нажми, чтобы показать детали доставки" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tryck för att visa leveransdetaljer" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "வழங்கல் விவரங்களைக் காண தட்டவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "แตะเพื่อแสดงรายละเอียดการส่ง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teslimat ayrıntılarını göstermek için dokunun" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "торкнися, щоб показати деталі доставки" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ترسیل کی تفصیلات دیکھنے کیلئے ٹیپ کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chạm để xem chi tiết gửi" + } } } }, @@ -9405,6 +12573,150 @@ "state" : "translated", "value" : "Internet gateway active, sharing your connection with the mesh" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "بوابة الإنترنت نشطة، تشارك اتصالك مع mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ইন্টারনেট গেটওয়ে সক্রিয়, মেশের সঙ্গে আপনার সংযোগ ভাগ করছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Internet-gateway aktiv, teilt deine verbindung mit dem mesh" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Puerta de enlace a internet activa, compartiendo tu conexión con el mesh" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "passerelle internet active, partage ta connexion avec le mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "שער אינטרנט פעיל, משתף את החיבור שלך עם ה-mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "इंटरनेट गेटवे सक्रिय, आपका कनेक्शन मेश के साथ साझा किया जा रहा है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gateway internet aktif, membagikan koneksimu dengan mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gateway internet attivo, condivide la tua connessione con la mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "インターネットゲートウェイが有効、接続をmeshと共有中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "인터넷 게이트웨이 활성화됨, 연결을 mesh와 공유 중" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gateway internet aktif, berkongsi sambunganmu dengan mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "इन्टरनेट गेटवे सक्रिय, तिम्रो जडान mesh सँग साझेदारी गरिँदै" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "internetgateway actief, deelt je verbinding met de mesh" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "brama internetowa aktywna, udostępniasz swoje połączenie sieci mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gateway de internet ativo, a partilhar a tua ligação com a mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "интернет-шлюз активен, соединение раздаётся в mesh" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "internetgateway aktiv, delar din anslutning med mesh" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "இணைய நுழைவாயில் செயலில் உள்ளது, உங்கள் இணைப்பை mesh உடன் பகிர்கிறது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เกตเวย์อินเทอร์เน็ตทำงานอยู่ กำลังแชร์การเชื่อมต่อของคุณกับ mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "internet ağ geçidi etkin, bağlantınız mesh ile paylaşılıyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "інтернет-шлюз активний, ділишся своїм з'єднанням з mesh" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "انٹرنیٹ گیٹ وے فعال، آپ کا کنکشن mesh کے ساتھ شیئر کر رہا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cổng internet đang hoạt động, chia sẻ kết nối của bạn với mesh" + } } } }, @@ -9416,6 +12728,150 @@ "state" : "translated", "value" : "Group chat" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "دردشة جماعية" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "গ্রুপ চ্যাট" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Gruppenchat" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chat de grupo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Discussion de groupe" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "צ'אט קבוצתי" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह चैट" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "obrolan grup" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Chat di gruppo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "グループチャット" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "그룹 채팅" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sembang kumpulan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह च्याट" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Groepschat" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "czat grupowy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Conversa de grupo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "групповой чат" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gruppchatt" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குழு உரையாடல்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "แชทกลุ่ม" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grup sohbeti" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "груповий чат" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "گروپ چیٹ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "trò chuyện nhóm" + } } } }, @@ -9428,6 +12884,150 @@ "state" : "translated", "value" : "jump to latest messages" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "الانتقال إلى أحدث الرسائل" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "সর্বশেষ বার্তায় যান" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "zu den neuesten nachrichten springen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ir a los mensajes más recientes" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "aller aux derniers messages" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "קפיצה להודעות האחרונות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "नवीनतम संदेशों पर जाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "lompat ke pesan terbaru" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "vai ai messaggi più recenti" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "最新のメッセージへ移動" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "최신 메시지로 이동" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "lompat ke pesan terbaru" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "पछिल्ला सन्देशमा जाऊ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "naar de nieuwste berichten springen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "przejdź do najnowszych wiadomości" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ir para as mensagens mais recentes" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "перейти к последним сообщениям" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hoppa till senaste meddelanden" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "சமீபத்திய செய்திகளுக்குச் செல்லவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไปยังข้อความล่าสุด" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "en son mesajlara atla" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "перейти до останніх повідомлень" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تازہ ترین پیغامات پر جائیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nhảy đến tin nhắn mới nhất" + } } } }, @@ -9977,6 +13577,150 @@ "state" : "translated", "value" : "connected" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "متصل" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "সংযুক্ত" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verbunden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "conectado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "connecté" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מחובר" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "जुड़ा हुआ" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "terhubung" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "connesso" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "接続中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "연결됨" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bersambung" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "जडान भयो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verbonden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "połączono" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ligado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "подключено" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ansluten" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "இணைக்கப்பட்டது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เชื่อมต่อแล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bağlı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "з'єднано" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "جڑا ہوا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đã kết nối" + } } } }, @@ -9989,6 +13733,150 @@ "state" : "translated", "value" : "no one reachable" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "لا أحد يمكن الوصول إليه" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "কেউ পৌঁছানোর মতো নেই" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "niemand erreichbar" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "nadie alcanzable" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "personne n'est joignable" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "אף אחד לא נגיש" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "कोई पहुँच योग्य नहीं" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak ada yang bisa dijangkau" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nessuno raggiungibile" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "到達可能な相手がいません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "도달 가능한 사람 없음" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tiada siapa boleh dicapai" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "कोही पुग्न सकिँदैन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "niemand bereikbaar" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nikt nieosiągalny" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ninguém acessível" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "никого не достать" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ingen nåbar" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "யாரையும் அணுக முடியாது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไม่มีใครที่เข้าถึงได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kimseye ulaşılamıyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "нікого не досяжно" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "کوئی قابل رسائی نہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "không ai có thể tiếp cận" + } } } }, @@ -10922,6 +14810,150 @@ "state" : "translated", "value" : "double-tap to start recording, double-tap again to send" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "اضغط ضغطة مزدوجة لبدء التسجيل، واضغط مرة أخرى للإرسال" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "রেকর্ডিং শুরু করতে দুইবার ট্যাপ করুন, পাঠাতে আবার দুইবার ট্যাপ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "doppeltippen zum aufnehmen, erneut doppeltippen zum senden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "toca dos veces para empezar a grabar, toca dos veces de nuevo para enviar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "double-touche pour démarrer l'enregistrement, double-touche à nouveau pour envoyer" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הקש הקשה כפולה כדי להתחיל הקלטה, הקש שוב כדי לשלוח" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "रिकॉर्डिंग शुरू करने के लिए दो बार टैप करें, भेजने के लिए फिर दो बार टैप करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ketuk dua kali untuk mulai merekam, ketuk dua kali lagi untuk mengirim" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tocca due volte per iniziare a registrare, tocca di nuovo due volte per inviare" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ダブルタップで録音開始、もう一度ダブルタップで送信" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "두 번 탭하여 녹음 시작, 다시 두 번 탭하여 전송" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ketuk dua kali untuk mula merakam, ketuk dua kali lagi untuk menghantar" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "रेकर्ड सुरु गर्न दुई पटक ट्याप गर, पठाउन फेरि दुई पटक ट्याप गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dubbeltik om op te nemen, dubbeltik opnieuw om te verzenden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "stuknij dwukrotnie, aby rozpocząć nagrywanie, stuknij ponownie, aby wysłać" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "toca duas vezes para começar a gravar, toca duas vezes de novo para enviar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "двойной тап — начать запись, ещё один двойной тап — отправить" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dubbeltryck för att börja spela in, dubbeltryck igen för att skicka" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "பதிவைத் தொடங்க இரட்டைத் தட்டவும், அனுப்ப மீண்டும் இரட்டைத் தட்டவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "แตะสองครั้งเพื่อเริ่มบันทึก แตะสองครั้งอีกครั้งเพื่อส่ง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kaydı başlatmak için çift dokunun, göndermek için tekrar çift dokunun" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "двічі торкнися, щоб почати запис, торкнися ще раз, щоб надіслати" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ریکارڈنگ شروع کرنے کیلئے ڈبل ٹیپ کریں، بھیجنے کیلئے دوبارہ ڈبل ٹیپ کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chạm hai lần để bắt đầu ghi, chạm hai lần nữa để gửi" + } } } }, @@ -10934,6 +14966,150 @@ "state" : "translated", "value" : "record voice note" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تسجيل ملاحظة صوتية" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ভয়েস নোট রেকর্ড করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sprachnachricht aufnehmen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "grabar nota de voz" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "enregistrer une note vocale" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הקלטת הערה קולית" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "वॉइस नोट रिकॉर्ड करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "rekam catatan suara" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "registra nota vocale" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ボイスメモを録音" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "음성 메모 녹음" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "rakam nota suara" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "भ्वाइस नोट रेकर्ड गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "spraakbericht opnemen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nagraj notatkę głosową" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gravar nota de voz" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "записать голосовое сообщение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "spela in röstmeddelande" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குரல் குறிப்பைப் பதிவு செய்யவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "บันทึกข้อความเสียง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sesli not kaydet" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "записати голосову нотатку" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "وائس نوٹ ریکارڈ کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ghi chú giọng nói" + } } } }, @@ -10946,6 +15122,150 @@ "state" : "translated", "value" : "recording" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "جارٍ التسجيل" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "রেকর্ড হচ্ছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "aufnahme" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "grabando" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "enregistrement" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מקליט" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "रिकॉर्डिंग हो रही है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "merekam" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "registrazione" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "録音中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "녹음 중" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "merakam" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "रेकर्ड हुँदै" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "opnemen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nagrywanie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "a gravar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "запись" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "spelar in" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "பதிவு செய்கிறது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "กำลังบันทึก" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kaydediliyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "запис" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ریکارڈنگ جاری" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đang ghi" + } } } }, @@ -11674,6 +15994,150 @@ "state" : "translated", "value" : "take photo with camera" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "التقاط صورة بالكاميرا" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ক্যামেরা দিয়ে ছবি তুলুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "foto mit kamera aufnehmen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "tomar foto con la cámara" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "prendre une photo avec l'appareil" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "צילום תמונה במצלמה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "कैमरे से फ़ोटो लें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ambil foto dengan kamera" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "scatta foto con la fotocamera" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "カメラで写真を撮る" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "카메라로 사진 촬영" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ambil foto dengan kamera" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "क्यामेराले तस्बिर खिच" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "foto maken met camera" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "zrób zdjęcie aparatem" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tirar foto com a câmara" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "сделать фото камерой" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ta foto med kameran" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "கேமராவால் புகைப்படம் எடுக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ถ่ายรูปด้วยกล้อง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kamerayla fotoğraf çek" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "зробити фото камерою" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "کیمرے سے تصویر لیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chụp ảnh bằng máy ảnh" + } } } }, @@ -12044,6 +16508,150 @@ "state" : "translated", "value" : "verify encryption" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "التحقق من التشفير" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "এনক্রিপশন যাচাই করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verschlüsselung verifizieren" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "verificar cifrado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "vérifier le chiffrement" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "אימות הצפנה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "एन्क्रिप्शन सत्यापित करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verifikasi enkripsi" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verifica la cifratura" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "暗号化を確認" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "암호화 확인" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sahkan enkripsi" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "सङ्केत प्रमाणित गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "versleuteling verifiëren" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "zweryfikuj szyfrowanie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verificar a encriptação" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "проверить шифрование" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verifiera kryptering" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குறியாக்கத்தைச் சரிபார்க்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ยืนยันการเข้ารหัส" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "şifrelemeyi doğrula" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "перевірити шифрування" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "انکرپشن کی توثیق کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "xác minh mã hóa" + } } } }, @@ -12951,6 +17559,150 @@ "state" : "translated", "value" : "resend" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "إعادة الإرسال" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আবার পাঠান" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "erneut senden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "reenviar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "renvoyer" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "שלח שוב" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "फिर भेजें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kirim ulang" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "reinvia" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "再送信" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "다시 전송" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hantar semula" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "पुनः पठाऊ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "opnieuw verzenden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wyślij ponownie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "reenviar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "отправить снова" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "skicka igen" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "மீண்டும் அனுப்பு" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ส่งอีกครั้ง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "yeniden gönder" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "надіслати знову" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "دوبارہ بھیجیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gửi lại" + } } } }, @@ -14574,6 +19326,150 @@ "state" : "translated", "value" : "clear chat" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "مسح الدردشة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "চ্যাট মুছুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chat leeren" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "limpiar chat" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "effacer la discussion" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "נקה צ'אט" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "चैट साफ़ करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hapus obrolan" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "svuota chat" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "チャットをクリア" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "채팅 지우기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kosongkan sembang" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "च्याट खाली गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chat wissen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wyczyść czat" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "limpar conversa" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "очистить чат" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "rensa chatt" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "உரையாடலை அழி" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ล้างแชท" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sohbeti temizle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "очистити чат" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "چیٹ صاف کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "xóa cuộc trò chuyện" + } } } }, @@ -14586,6 +19482,150 @@ "state" : "translated", "value" : "clear this chat?" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "مسح هذه الدردشة؟" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "এই চ্যাট মুছবেন?" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "diesen chat leeren?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿limpiar este chat?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "effacer cette discussion ?" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "לנקות את הצ'אט הזה?" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "यह चैट साफ़ करें?" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hapus obrolan ini?" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "svuotare questa chat?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "このチャットをクリアしますか?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이 채팅을 지울까요?" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kosongkan sembang ini?" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "यो च्याट खाली गर्ने?" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "deze chat wissen?" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wyczyścić ten czat?" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "limpar esta conversa?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "очистить этот чат?" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "rensa den här chatten?" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "இந்த உரையாடலை அழிக்கவா?" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ล้างแชทนี้หรือไม่?" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bu sohbet temizlensin mi?" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "очистити цей чат?" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "یہ چیٹ صاف کریں؟" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "xóa cuộc trò chuyện này?" + } } } }, @@ -15134,6 +20174,150 @@ "state" : "translated", "value" : "create or manage private groups" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "إنشاء أو إدارة المجموعات الخاصة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ব্যক্তিগত গ্রুপ তৈরি বা পরিচালনা করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "private gruppen erstellen oder verwalten" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "crear o gestionar grupos privados" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "créer ou gérer des groupes privés" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "יצירה או ניהול של קבוצות פרטיות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निजी समूह बनाएँ या प्रबंधित करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "buat atau kelola grup pribadi" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "crea o gestisci gruppi privati" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "プライベートグループを作成または管理" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "비공개 그룹 생성 또는 관리" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cipta atau urus kumpulan peribadi" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निजी समूह सिर्जना वा व्यवस्थापन गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privégroepen maken of beheren" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "twórz lub zarządzaj prywatnymi grupami" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "criar ou gerir grupos privados" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "создать частные группы или управлять ими" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "skapa eller hantera privata grupper" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "தனிப்பட்ட குழுக்களை உருவாக்கவும் அல்லது நிர்வகிக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "สร้างหรือจัดการกลุ่มส่วนตัว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "özel gruplar oluştur veya yönet" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "створюй або керуй приватними групами" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "نجی گروپ بنائیں یا ان کا نظم کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tạo hoặc quản lý nhóm riêng tư" + } } } }, @@ -15146,6 +20330,150 @@ "state" : "translated", "value" : "show available commands" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "عرض الأوامر المتاحة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "উপলব্ধ কমান্ড দেখান" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verfügbare befehle anzeigen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mostrar los comandos disponibles" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "afficher les commandes disponibles" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הצג פקודות זמינות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "उपलब्ध कमांड दिखाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tampilkan perintah yang tersedia" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mostra i comandi disponibili" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "利用可能なコマンドを表示" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "사용 가능한 명령어 표시" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tunjukkan perintah yang tersedia" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "उपलब्ध आदेशहरू देखाऊ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "beschikbare commando's tonen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pokaż dostępne komendy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mostrar os comandos disponíveis" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "показать доступные команды" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "visa tillgängliga kommandon" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "கிடைக்கும் கட்டளைகளைக் காட்டவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "แสดงคำสั่งที่ใช้ได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kullanılabilir komutları göster" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "показати доступні команди" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "دستیاب کمانڈز دکھائیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hiển thị các lệnh khả dụng" + } } } }, @@ -15516,6 +20844,150 @@ "state" : "translated", "value" : "send a cashu ecash token in this chat" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "إرسال رمز cashu ecash في هذه الدردشة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "এই চ্যাটে একটি ক্যাশু ইক্যাশ টোকেন পাঠান" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "einen cashu-ecash-token in diesem chat senden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "enviar un token de ecash Cashu en este chat" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "envoyer un token ecash cashu dans cette discussion" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "שליחת אסימון cashu ecash בצ'אט הזה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "इस चैट में कैशु ईकैश टोकन भेजें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kirim token cashu ecash di obrolan ini" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "invia un token ecash cashu in questa chat" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "このチャットでcashuのecashトークンを送る" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이 채팅에서 cashu ecash 토큰 보내기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hantar token cashu ecash dalam sembang ini" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "यो च्याटमा cashu इ-क्यास टोकन पठाऊ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "een cashu-ecash-token in deze chat sturen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wyślij token ecash Cashu na tym czacie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "enviar um token ecash cashu nesta conversa" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "отправить токен cashu ecash в этот чат" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "skicka en Cashu ecash-token i den här chatten" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "இந்த உரையாடலில் Cashu ecash டோக்கனை அனுப்பவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ส่งโทเคน ecash ของ cashu ในแชทนี้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bu sohbette cashu ecash tokenı gönder" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "надіслати токен cashu ecash у цьому чаті" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "اس چیٹ میں Cashu ecash ٹوکن بھیجیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gửi token cashu ecash trong cuộc trò chuyện này" + } } } }, @@ -15528,6 +21000,150 @@ "state" : "translated", "value" : "measure round-trip time to a mesh peer" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "قياس زمن الذهاب والإياب إلى قرين mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "কোনো মেশ পিয়ারে রাউন্ড-ট্রিপ সময় মাপুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "die roundtrip-zeit zu einem mesh-peer messen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "medir el tiempo de ida y vuelta a un peer del mesh" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesurer le temps d'aller-retour vers un pair mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מדידת זמן הלוך ושוב לעמית mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "किसी मेश पीयर तक राउंड-ट्रिप समय मापें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ukur waktu bolak-balik ke peer mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "misura il tempo di andata e ritorno verso un peer mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "meshピアへの往復時間を測定" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh 피어까지의 왕복 시간 측정" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ukur masa pergi-balik ke peer mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh सहकर्मीसम्मको राउन्ड-ट्रिप समय नाप" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "de retourtijd naar een mesh-peer meten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "zmierz czas do peera mesh i z powrotem" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "medir o tempo de ida e volta até um par mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "измерить время отклика до mesh-пира" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mät tur-och-retur-tid till en mesh-peer" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh peer க்கு செல்லவந்த நேரத்தை அளவிடவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "วัดเวลาไป-กลับไปยังเพียร์ mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bir mesh eşine gidiş-dönüş süresini ölç" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "виміряти час туди-назад до піра mesh" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh ہم منصب تک راؤنڈ ٹرپ وقت ناپیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đo thời gian khứ hồi đến một nút mesh" + } } } }, @@ -15719,6 +21335,150 @@ "state" : "translated", "value" : "estimate the mesh path to a peer" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تقدير مسار mesh إلى قرين" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "কোনো পিয়ার পর্যন্ত মেশ পথ অনুমান করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "den mesh-pfad zu einem peer schätzen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "estimar la ruta del mesh hasta un peer" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "estimer le chemin mesh vers un pair" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הערכת נתיב ה-mesh לעמית" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "किसी पीयर तक मेश पथ का अनुमान लगाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "perkirakan jalur mesh ke peer" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "stima il percorso mesh verso un peer" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ピアへのmeshの経路を推定" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "피어까지의 mesh 경로 추정" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "anggarkan laluan mesh ke peer" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "सहकर्मीसम्मको mesh मार्ग अनुमान गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "het mesh-pad naar een peer schatten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "oszacuj ścieżkę mesh do peera" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "estimar o caminho mesh até um par" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "оценить mesh-путь до пира" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "uppskatta mesh-vägen till en peer" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "peer க்கான mesh பாதையை மதிப்பிடவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ประมาณเส้นทาง mesh ไปยังเพียร์" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bir eşe giden mesh yolunu tahmin et" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "оцінити шлях mesh до піра" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "کسی ہم منصب تک mesh راستے کا تخمینہ لگائیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ước tính đường đi mesh đến một nút" + } } } }, @@ -17163,6 +22923,150 @@ "state" : "translated", "value" : "not delivered" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "لم يُسلَّم" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "পৌঁছায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nicht zugestellt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no entregado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "non livré" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "לא נמסר" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "डिलीवर नहीं हुआ" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak terkirim" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "non consegnato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "未配信" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "전송되지 않음" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak dihantar" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "डेलिभर भएन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "niet bezorgd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie dostarczono" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "não entregue" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не доставлено" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "inte levererat" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "வழங்கப்படவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ยังไม่ได้ส่ง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teslim edilmedi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не доставлено" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "نہیں پہنچا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chưa gửi được" + } } } }, @@ -17175,6 +23079,150 @@ "state" : "translated", "value" : "encryption failed" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "فشل التشفير" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "এনক্রিপশন ব্যর্থ" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verschlüsselung fehlgeschlagen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "cifrado fallido" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "échec du chiffrement" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ההצפנה נכשלה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "एन्क्रिप्शन विफल" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "enkripsi gagal" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cifratura fallita" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "暗号化に失敗" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "암호화 실패" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "enkripsi gagal" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "सङ्केत असफल" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "versleuteling mislukt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "szyfrowanie nie powiodło się" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "falha na encriptação" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "шифрование не удалось" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kryptering misslyckades" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குறியாக்கம் தோல்வியடைந்தது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เข้ารหัสไม่สำเร็จ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "şifreleme başarısız" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "шифрування не вдалося" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "انکرپشن ناکام" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mã hóa thất bại" + } } } }, @@ -17187,6 +23235,150 @@ "state" : "translated", "value" : "voice note too large" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "الملاحظة الصوتية كبيرة جدًا" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ভয়েস নোট খুব বড়" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sprachnachricht zu groß" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "nota de voz demasiado grande" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "note vocale trop volumineuse" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ההערה הקולית גדולה מדי" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "वॉइस नोट बहुत बड़ा" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "catatan suara terlalu besar" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nota vocale troppo grande" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ボイスメモが大きすぎます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "음성 메모가 너무 큼" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nota suara terlalu besar" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "भ्वाइस नोट धेरै ठूलो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "spraakbericht te groot" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "notatka głosowa zbyt duża" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nota de voz demasiado grande" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "голосовое сообщение слишком большое" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "röstmeddelandet är för stort" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குரல் குறிப்பு மிகப் பெரியது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ข้อความเสียงใหญ่เกินไป" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sesli not çok büyük" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "голосова нотатка завелика" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "وائس نوٹ بہت بڑا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ghi chú giọng nói quá lớn" + } } } }, @@ -17199,6 +23391,150 @@ "state" : "translated", "value" : "voice note failed to send" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "فشل إرسال الملاحظة الصوتية" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ভয়েস নোট পাঠানো যায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sprachnachricht konnte nicht gesendet werden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se pudo enviar la nota de voz" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "échec de l'envoi de la note vocale" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "שליחת ההערה הקולית נכשלה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "वॉइस नोट भेजने में विफल" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "catatan suara gagal dikirim" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "invio della nota vocale non riuscito" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ボイスメモの送信に失敗" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "음성 메모 전송 실패" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nota suara gagal dihantar" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "भ्वाइस नोट पठाउन असफल" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "spraakbericht kon niet worden verzonden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie udało się wysłać notatki głosowej" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "falha ao enviar a nota de voz" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не удалось отправить голосовое сообщение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "röstmeddelandet kunde inte skickas" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குரல் குறிப்பு அனுப்ப முடியவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ส่งข้อความเสียงไม่สำเร็จ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sesli not gönderilemedi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не вдалося надіслати голосову нотатку" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "وائس نوٹ بھیجنے میں ناکام" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gửi ghi chú giọng nói thất bại" + } } } }, @@ -17927,6 +24263,150 @@ "state" : "translated", "value" : "sending..." } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "جارٍ الإرسال..." + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "পাঠানো হচ্ছে..." + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wird gesendet ..." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "enviando..." + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "envoi..." + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "שולח..." + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "भेजा जा रहा है..." + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mengirim..." + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "invio in corso..." + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "送信中..." + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "전송 중..." + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "menghantar..." + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "पठाइँदै..." + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verzenden..." + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wysyłanie..." + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "a enviar..." + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "отправка..." + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "skickar..." + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "அனுப்புகிறது..." + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "กำลังส่ง..." + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gönderiliyor..." + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "надсилання..." + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "بھیجا جا رہا ہے..." + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đang gửi..." + } } } }, @@ -17939,6 +24419,150 @@ "state" : "translated", "value" : "sent — no delivery confirmation yet" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "أُرسِل — لا يوجد تأكيد تسليم بعد" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "পাঠানো হয়েছে — এখনো ডেলিভারি নিশ্চিতকরণ নেই" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gesendet — noch keine zustellbestätigung" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "enviado — aún sin confirmación de entrega" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "envoyé — aucune confirmation de livraison pour l'instant" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "נשלח — אין עדיין אישור מסירה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "भेजा गया — अभी तक डिलीवरी की पुष्टि नहीं" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "terkirim — belum ada konfirmasi pengiriman" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "inviato — ancora nessuna conferma di consegna" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "送信済み — まだ配信確認がありません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "전송됨 — 아직 전송 확인 없음" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dihantar — belum ada pengesahan penghantaran" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "पठाइयो — अझै डेलिभरी पुष्टि छैन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verzonden — nog geen bezorgbevestiging" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wysłano — brak potwierdzenia dostarczenia" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "enviado — ainda sem confirmação de entrega" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "отправлено — подтверждения доставки пока нет" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "skickat — ingen leveransbekräftelse än" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "அனுப்பப்பட்டது — இன்னும் வழங்கல் உறுதிப்படுத்தல் இல்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ส่งแล้ว — ยังไม่มีการยืนยันการส่ง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gönderildi — henüz teslim onayı yok" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "надіслано — ще немає підтвердження доставки" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "بھیج دیا گیا — ابھی ترسیل کی تصدیق نہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đã gửi — chưa có xác nhận nhận được" + } } } }, @@ -17951,6 +24575,150 @@ "state" : "translated", "value" : "you're in #%@ — a public location channel over the internet" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "أنت في #%@ — قناة موقع عامة عبر الإنترنت" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আপনি #%@-এ আছেন — ইন্টারনেটের ওপর একটি পাবলিক লোকেশন চ্যানেল" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "du bist in #%@ — ein öffentlicher standortkanal über das internet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "estás en #%@ — un canal de ubicación público por internet" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tu es dans #%@ — un canal de localisation public sur internet" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "אתה ב-#%@ — ערוץ מיקום ציבורי דרך האינטרנט" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "आप #%@ में हैं — इंटरनेट पर एक सार्वजनिक लोकेशन चैनल" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kamu di #%@ — kanal lokasi publik lewat internet" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sei in #%@ — un canale di posizione pubblico su internet" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#%@ にいます — インターネット経由の公開位置チャンネルです" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#%@ 에 있습니다 — 인터넷을 통한 공개 위치 채널입니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "anda di #%@ — kanal lokasi awam melalui internet" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तिमी #%@ मा छौ — इन्टरनेटमाथिको सार्वजनिक स्थान च्यानल" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "je bent in #%@ — een openbaar locatiekanaal via internet" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "jesteś w #%@ — publiczny kanał lokalizacji przez internet" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "estás em #%@ — um canal de localização público pela internet" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ты в #%@ — публичном локальном канале через интернет" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "du är i #%@ — en publik platskanal över internet" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "நீங்கள் #%@ இல் இருக்கிறீர்கள் — இணையம் வழியாக ஒரு பொது இருப்பிட சேனல்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "คุณอยู่ใน #%@ — ช่องตามตำแหน่งสาธารณะผ่านอินเทอร์เน็ต" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#%@ kanalındasın — internet üzerinden herkese açık bir konum kanalı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ти в #%@ — публічний канал локації через інтернет" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "آپ #%@ میں ہیں — انٹرنیٹ پر ایک عوامی لوکیشن چینل" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bạn đang ở #%@ — một kênh vị trí công khai qua internet" + } } } }, @@ -17963,6 +24731,150 @@ "state" : "translated", "value" : "you're on #mesh — reaches people within bluetooth range" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "أنت في #mesh — تصل إلى الأشخاص ضمن نطاق bluetooth" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আপনি #mesh-এ আছেন — ব্লুটুথ পরিসরের মধ্যে মানুষের কাছে পৌঁছায়" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "du bist auf #mesh — erreicht menschen in bluetooth-reichweite" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "estás en #mesh — llega a personas dentro del alcance de Bluetooth" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tu es sur #mesh — atteint les personnes à portée bluetooth" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "אתה ב-#mesh — מגיע לאנשים בטווח bluetooth" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "आप #mesh पर हैं — ब्लूटूथ रेंज के भीतर लोगों तक पहुँचता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kamu di #mesh — menjangkau orang dalam jangkauan bluetooth" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sei su #mesh — raggiunge le persone a portata bluetooth" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#mesh にいます — bluetooth圏内の人に届きます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#mesh 에 있습니다 — bluetooth 범위 내의 사람에게 도달합니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "anda di #mesh — mencapai orang dalam jangkauan bluetooth" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तिमी #mesh मा छौ — bluetooth दायराभित्रका मानिससम्म पुग्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "je bent op #mesh — bereikt mensen binnen bluetoothbereik" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "jesteś na #mesh — dociera do osób w zasięgu Bluetooth" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "estás em #mesh — alcança pessoas dentro do alcance bluetooth" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ты в #mesh — достаёт людей в радиусе bluetooth" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "du är på #mesh — når personer inom Bluetooth-räckvidd" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "நீங்கள் #mesh இல் இருக்கிறீர்கள் — bluetooth வரம்பிற்குள் உள்ளவர்களை அடையும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "คุณอยู่ใน #mesh — เข้าถึงคนในระยะ bluetooth" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#mesh kanalındasın — Bluetooth menzilindeki kişilere ulaşır" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ти на #mesh — досягає людей у радіусі bluetooth" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "آپ #mesh پر ہیں — Bluetooth رینج میں لوگوں تک پہنچتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bạn đang ở #mesh — tiếp cận mọi người trong phạm vi Bluetooth" + } } } }, @@ -17975,6 +24887,150 @@ "state" : "translated", "value" : "nobody in range yet... messages appear here" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "لا أحد في النطاق بعد... ستظهر الرسائل هنا" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "এখনো কেউ পরিসরে নেই... বার্তা এখানে দেখা যাবে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "noch niemand in reichweite ... nachrichten erscheinen hier" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "nadie a tu alcance todavía... los mensajes aparecerán aquí" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "personne à portée pour l'instant... les messages apparaîtront ici" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "אף אחד לא בטווח עדיין... הודעות יופיעו כאן" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "अभी तक कोई रेंज में नहीं... संदेश यहाँ दिखाई देंगे" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "belum ada siapa pun dalam jangkauan... pesan akan muncul di sini" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ancora nessuno a portata... i messaggi appariranno qui" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "まだ圏内に誰もいません... メッセージはここに表示されます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "아직 범위 내에 아무도 없습니다... 메시지가 여기에 표시됩니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "belum ada sesiapa dalam jangkauan... pesan akan muncul di sini" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "अझै दायरामा कोही छैन... सन्देश यहाँ देखिन्छन्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nog niemand in bereik... berichten verschijnen hier" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nikogo w zasięgu... wiadomości pojawią się tutaj" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ainda ninguém ao alcance... as mensagens aparecem aqui" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "пока никого рядом... сообщения появятся здесь" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ingen inom räckvidd än... meddelanden visas här" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "இன்னும் வரம்பில் யாரும் இல்லை... செய்திகள் இங்கே தோன்றும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ยังไม่มีใครอยู่ในระยะ... ข้อความจะปรากฏที่นี่" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "menzilde henüz kimse yok... mesajlar burada görünür" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "поки нікого в радіусі... повідомлення з'являться тут" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ابھی رینج میں کوئی نہیں... پیغامات یہاں ظاہر ہوں گے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chưa có ai trong phạm vi... tin nhắn sẽ xuất hiện ở đây" + } } } }, @@ -17987,6 +25043,150 @@ "state" : "translated", "value" : "tap the channel name above to switch · tap bitchat/ for help" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "اضغط اسم القناة بالأعلى للتبديل · اضغط bitchat/ للمساعدة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "বদলাতে উপরের চ্যানেলের নাম ট্যাপ করুন · সাহায্যের জন্য bitchat/ ট্যাপ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tippe oben auf den kanalnamen zum wechseln · tippe bitchat/ für hilfe" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "toca el nombre del canal de arriba para cambiar · toca bitchat/ para ayuda" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "touche le nom du canal ci-dessus pour changer · touche bitchat/ pour l'aide" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הקש על שם הערוץ למעלה כדי להחליף · הקש על bitchat/ לעזרה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "स्विच करने के लिए ऊपर चैनल का नाम टैप करें · मदद के लिए bitchat/ टैप करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ketuk nama kanal di atas untuk beralih · ketuk bitchat/ untuk bantuan" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tocca il nome del canale in alto per cambiare · tocca bitchat/ per l'aiuto" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "上のチャンネル名をタップして切り替え · bitchat/ をタップしてヘルプ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "위의 채널 이름을 탭하여 전환 · bitchat/ 를 탭하여 도움말" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ketuk nama kanal di atas untuk beralih · ketuk bitchat/ untuk bantuan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "बदल्न माथिको च्यानल नाम ट्याप गर · मद्दतका लागि bitchat/ ट्याप गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tik op de kanaalnaam hierboven om te wisselen · tik op bitchat/ voor hulp" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "stuknij nazwę kanału powyżej, aby przełączyć · stuknij bitchat/ po pomoc" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "toca no nome do canal acima para trocar · toca em bitchat/ para ajuda" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "нажми на имя канала выше, чтобы переключиться · нажми bitchat/ для справки" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tryck på kanalnamnet ovan för att byta · tryck på bitchat/ för hjälp" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "மாற்ற மேலே உள்ள சேனல் பெயரைத் தட்டவும் · உதவிக்கு bitchat/ ஐத் தட்டவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "แตะชื่อช่องด้านบนเพื่อสลับ · แตะ bitchat/ เพื่อดูวิธีใช้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "değiştirmek için yukarıdaki kanal adına dokunun · yardım için bitchat/ dokunun" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "торкнися назви каналу вгорі, щоб перемкнути · торкнися bitchat/ для довідки" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تبدیل کرنے کیلئے اوپر چینل کے نام پر ٹیپ کریں · مدد کیلئے bitchat/ پر ٹیپ کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chạm tên kênh phía trên để chuyển · chạm bitchat/ để được trợ giúp" + } } } }, @@ -17999,6 +25199,150 @@ "state" : "translated", "value" : "Sharing your internet connection with nearby mesh peers" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "مشاركة اتصال الإنترنت مع أقران mesh القريبين" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "কাছাকাছি মেশ পিয়ারদের সঙ্গে আপনার ইন্টারনেট সংযোগ ভাগ করছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teilt deine internetverbindung mit nahen mesh-peers" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compartiendo tu conexión a internet con peers cercanos del mesh" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "partage ta connexion internet avec les pairs mesh à proximité" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "משתף את חיבור האינטרנט שלך עם עמיתי mesh קרובים" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "आपका इंटरनेट कनेक्शन आसपास के मेश पीयरों के साथ साझा किया जा रहा है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "membagikan koneksi internetmu dengan peer mesh terdekat" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "condivide la tua connessione internet con i peer mesh vicini" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "近くのmeshピアとインターネット接続を共有中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "근처 mesh 피어와 인터넷 연결을 공유 중" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "berkongsi sambungan internetmu dengan peer mesh berdekatan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तिम्रो इन्टरनेट जडान नजिकका mesh सहकर्मीसँग साझेदारी गरिँदै" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "deelt je internetverbinding met mesh-peers in de buurt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Udostępniasz swoje połączenie internetowe pobliskim peerom mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "a partilhar a tua ligação à internet com pares mesh próximos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "раздаёт твоё интернет-соединение ближайшим mesh-пирам" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Delar din internetanslutning med mesh-peers i närheten" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "உங்கள் இணைய இணைப்பை அருகிலுள்ள mesh peer-களுடன் பகிர்கிறது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "กำลังแชร์การเชื่อมต่ออินเทอร์เน็ตของคุณกับเพียร์ mesh ที่อยู่ใกล้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "İnternet bağlantınızı yakındaki mesh eşleriyle paylaşıyorsunuz" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ділишся своїм інтернет-з'єднанням з ближніми пірами mesh" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "قریبی mesh ہم منصبوں کے ساتھ اپنا انٹرنیٹ کنکشن شیئر کر رہا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chia sẻ kết nối internet của bạn với các nút mesh gần đó" + } } } }, @@ -18368,6 +25712,150 @@ "state" : "translated", "value" : "create|invite|leave|list" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "create|invite|leave|list" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "create|invite|leave|list" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "create|invite|leave|list" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "create|invite|leave|list" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "create|invite|leave|list" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "create|invite|leave|list" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "create|invite|leave|list" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "create|invite|leave|list" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "create|invite|leave|list" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "create|invite|leave|list" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "create|invite|leave|list" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "create|invite|leave|list" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "create|invite|leave|list" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "create|invite|leave|list" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "create|invite|leave|list" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "create|invite|leave|list" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "create|invite|leave|list" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "create|invite|leave|list" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "create|invite|leave|list" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "create|invite|leave|list" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "create|invite|leave|list" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "create|invite|leave|list" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "create|invite|leave|list" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "create|invite|leave|list" + } } } }, @@ -18380,6 +25868,150 @@ "state" : "translated", "value" : "message #%@ — public" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "رسالة #%@ — عام" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#%@-এ বার্তা — পাবলিক" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nachricht an #%@ — öffentlich" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensaje #%@ — público" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "message vers #%@ — public" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הודעה ל-#%@ — ציבורי" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "संदेश #%@ — सार्वजनिक" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan #%@ — publik" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "messaggio a #%@ — pubblico" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#%@ にメッセージ — 公開" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#%@ 에 메시지 — 공개" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan #%@ — awam" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#%@ मा सन्देश — सार्वजनिक" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bericht naar #%@ — openbaar" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wiadomość #%@ — publiczna" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mensagem para #%@ — público" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "сообщение в #%@ — публичное" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "meddelande #%@ — publikt" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#%@ க்கு செய்தி — பொது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ส่งข้อความถึง #%@ — สาธารณะ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesaj #%@ — herkese açık" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "повідомлення #%@ — публічне" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "پیغام #%@ — عوامی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nhắn #%@ — công khai" + } } } }, @@ -18392,6 +26024,150 @@ "state" : "translated", "value" : "message #mesh — public, nearby" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "رسالة #mesh — عام، قريب" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#mesh-এ বার্তা — পাবলিক, কাছাকাছি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nachricht an #mesh — öffentlich, in der nähe" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensaje #mesh — público, cerca" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "message vers #mesh — public, à proximité" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הודעה ל-#mesh — ציבורי, קרוב" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "संदेश #mesh — सार्वजनिक, आसपास" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan #mesh — publik, terdekat" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "messaggio a #mesh — pubblico, nelle vicinanze" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#mesh にメッセージ — 公開、近くの人へ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#mesh 에 메시지 — 공개, 근처" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan #mesh — awam, berdekatan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#mesh मा सन्देश — सार्वजनिक, नजिकको" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bericht naar #mesh — openbaar, in de buurt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wiadomość #mesh — publiczna, w pobliżu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mensagem para #mesh — público, próximo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "сообщение в #mesh — публичное, рядом" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "meddelande #mesh — publikt, i närheten" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#mesh க்கு செய்தி — பொது, அருகில்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ส่งข้อความถึง #mesh — สาธารณะ, ใกล้เคียง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesaj #mesh — herkese açık, yakında" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "повідомлення #mesh — публічне, поблизу" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "پیغام #mesh — عوامی، قریبی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nhắn #mesh — công khai, gần đây" + } } } }, @@ -18404,6 +26180,150 @@ "state" : "translated", "value" : "message %@ — private" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "رسالة %@ — خاص" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@-কে বার্তা — ব্যক্তিগত" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nachricht an %@ — privat" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensaje %@ — privado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "message à %@ — privé" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הודעה ל-%@ — פרטי" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "संदेश %@ — निजी" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan %@ — pribadi" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "messaggio a %@ — privato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ にメッセージ — プライベート" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ 에게 메시지 — 비공개" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan %@ — peribadi" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ लाई सन्देश — निजी" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bericht naar %@ — privé" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wiadomość do %@ — prywatna" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mensagem para %@ — privado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "сообщение для %@ — личное" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "meddelande %@ — privat" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ க்கு செய்தி — தனிப்பட்டது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ส่งข้อความถึง %@ — ส่วนตัว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesaj %@ — özel" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "повідомлення %@ — приватне" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "پیغام %@ — نجی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nhắn %@ — riêng tư" + } } } }, @@ -18416,6 +26336,150 @@ "state" : "translated", "value" : "private conversation" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "محادثة خاصة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ব্যক্তিগত কথোপকথন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privates gespräch" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "conversación privada" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "conversation privée" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "שיחה פרטית" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निजी बातचीत" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "percakapan pribadi" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "conversazione privata" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "プライベートな会話" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "비공개 대화" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "perbualan peribadi" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निजी कुराकानी" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privégesprek" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "rozmowa prywatna" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "conversa privada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "личный разговор" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privat konversation" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "தனிப்பட்ட உரையாடல்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "การสนทนาส่วนตัว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "özel konuşma" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "приватна розмова" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "نجی گفتگو" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cuộc trò chuyện riêng tư" + } } } }, @@ -18428,6 +26492,150 @@ "state" : "translated", "value" : "private · end-to-end encrypted" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "خاص · مشفّر من طرف إلى طرف" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ব্যক্তিগত · এন্ড-টু-এন্ড এনক্রিপ্টেড" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privat · end-to-end-verschlüsselt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "privado · cifrado de extremo a extremo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privé · chiffré de bout en bout" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "פרטי · מוצפן מקצה לקצה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निजी · एंड-टू-एंड एन्क्रिप्टेड" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pribadi · terenkripsi ujung ke ujung" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privato · cifrato end-to-end" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "プライベート · エンドツーエンド暗号化" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "비공개 · 종단간 암호화" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "peribadi · terenkripsi ujung ke ujung" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निजी · एन्ड-टु-एन्ड सङ्केत" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privé · end-to-end-versleuteld" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "prywatna · szyfrowana end-to-end" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privado · encriptado ponta a ponta" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "лично · сквозное шифрование" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privat · end-to-end-krypterad" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "தனிப்பட்டது · முனை-முதல்-முனை குறியாக்கம்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ส่วนตัว · เข้ารหัสแบบครบวงจร" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "özel · uçtan uca şifreli" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "приватна · наскрізно зашифрована" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "نجی · اینڈ ٹو اینڈ خفیہ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "riêng tư · mã hóa đầu cuối" + } } } }, @@ -18619,6 +26827,150 @@ "state" : "translated", "value" : "token" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "رمز" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "টোকেন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "token" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "טוקן" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "टोकन" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "トークン" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "토큰" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "टोकन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "токен" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "டோக்கன்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "โทเคน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "токен" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ٹوکن" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token" + } } } }, @@ -18631,6 +26983,150 @@ "state" : "translated", "value" : "%lld new" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld جديدة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld নতুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld neu" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld nuevos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld nouveaux" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld חדשות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld नए" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld baru" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld nuovi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld 件の新着" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "새 메시지 %lld개" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld baru" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld नयाँ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld nieuw" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld nowych" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld novas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld новых" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld nya" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld புதியது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ใหม่ %lld รายการ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld yeni" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld нових" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld نئی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%lld mới" + } } } }, @@ -19896,6 +28392,150 @@ "state" : "translated", "value" : "copy token" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "نسخ الرمز" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "টোকেন কপি করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token kopieren" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "copiar token" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "copier le token" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "העתק טוקן" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "टोकन कॉपी करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "salin token" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "copia token" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "トークンをコピー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "토큰 복사" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "salin token" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "टोकन कपी गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "token kopiëren" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kopiuj token" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "copiar token" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "скопировать токен" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kopiera token" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "டோக்கனை நகலெடு" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "คัดลอกโทเคน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tokenı kopyala" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "скопіювати токен" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ٹوکن کاپی کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sao chép token" + } } } }, @@ -20087,6 +28727,150 @@ "state" : "translated", "value" : "redeem in wallet" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "الاسترداد في المحفظة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ওয়ালেটে রিডিম করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "in wallet einlösen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "canjear en la cartera" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "utiliser dans le wallet" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מימוש בארנק" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "वॉलेट में रिडीम करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tukarkan di dompet" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "riscatta nel wallet" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ウォレットで受け取る" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "지갑에서 받기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tebus dalam dompet" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "वालेटमा भजाऊ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "inwisselen in wallet" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "zrealizuj w portfelu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "resgatar na wallet" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "обменять в кошельке" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "lös in i plånbok" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "வாலெட்டில் மீட்டெடு" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "แลกในกระเป๋าเงิน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cüzdanda kullan" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "погасити в гаманці" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "والیٹ میں ریڈیم کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đổi trong ví" + } } } }, @@ -20099,6 +28883,150 @@ "state" : "translated", "value" : "redeem on web" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "الاسترداد على الويب" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ওয়েবে রিডিম করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "im web einlösen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "canjear en la web" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "utiliser sur le web" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מימוש באינטרנט" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "वेब पर रिडीम करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tukarkan di web" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "riscatta sul web" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ウェブで受け取る" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "웹에서 받기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tebus di web" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "वेबमा भजाऊ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "inwisselen op web" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "zrealizuj w sieci" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "resgatar na web" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "обменять в вебе" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "lös in på webben" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "வெப்பில் மீட்டெடு" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "แลกบนเว็บ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "webde kullan" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "погасити у вебі" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ویب پر ریڈیم کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đổi trên web" + } } } }, @@ -20110,6 +29038,150 @@ "state" : "translated", "value" : "encrypted group · members only" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "مجموعة مشفّرة · للأعضاء فقط" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "এনক্রিপ্টেড গ্রুপ · শুধু সদস্য" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verschlüsselte gruppe · nur mitglieder" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "grupo cifrado · solo miembros" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "groupe chiffré · membres uniquement" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "קבוצה מוצפנת · לחברים בלבד" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "एन्क्रिप्टेड समूह · केवल सदस्य" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grup terenkripsi · hanya anggota" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gruppo cifrato · solo membri" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "暗号化グループ · メンバー限定" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "암호화된 그룹 · 멤버 전용" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kumpulan terenkripsi · ahli sahaja" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "सङ्केतित समूह · सदस्य मात्र" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "versleutelde groep · alleen leden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "szyfrowana grupa · tylko członkowie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grupo encriptado · apenas membros" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "зашифрованная группа · только участники" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "krypterad grupp · endast medlemmar" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குறியாக்கம் செய்யப்பட்ட குழு · உறுப்பினர்கள் மட்டுமே" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "กลุ่มที่เข้ารหัส · เฉพาะสมาชิก" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "şifreli grup · yalnızca üyeler" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "зашифрована група · лише учасники" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "خفیہ گروپ · صرف اراکین" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nhóm mã hóa · chỉ thành viên" + } } } }, @@ -22628,6 +31700,150 @@ "state" : "translated", "value" : "✓ VOUCHED" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ موثوق" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ সমর্থিত" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ VERBÜRGT" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ AVALADO" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ ATTESTÉ" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ בערבות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ अनुशंसित" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ DIJAMIN" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ GARANTITO" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ 保証済み" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ 보증됨" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ DIJAMIN" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ जमानत" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ INGESTAAN" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ PORĘCZONY" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ ATESTADO" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ ПОРУЧЕНО" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ INTYGAD" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ உத்தரவாதம்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ รับรองแล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ KEFİL OLUNDU" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ ЗАСВІДЧЕНО" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ ضمانت شدہ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "✓ ĐÃ BẢO CHỨNG" + } } } }, @@ -23199,6 +32415,290 @@ } } } + }, + "bn" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আপনি যাচাই করেছেন এমন %d জনের দ্বারা সমর্থিত" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আপনি যাচাই করেছেন এমন %d জনের দ্বারা সমর্থিত" + } + } + } + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "avalado por %#@people@ que verificaste" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d persona" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d personas" + } + } + } + } + } + } + }, + "hi" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "आपके सत्यापित %d व्यक्ति द्वारा अनुशंसित" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "आपके सत्यापित %d लोगों द्वारा अनुशंसित" + } + } + } + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dijamin oleh %#@people@ yang kamu verifikasi" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d orang" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d orang" + } + } + } + } + } + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%#@people@ が保証しています(あなたが確認済み)" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d人" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d人" + } + } + } + } + } + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "당신이 확인한 %#@people@이(가) 보증함" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d명" + } + } + } + } + } + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dijamin oleh %#@people@ yang anda sahkan" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d orang" + } + } + } + } + } + } + }, + "ne" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तिमीले प्रमाणित गरेका %d व्यक्तिले जमानत गरेका" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तिमीले प्रमाणित गरेका %d व्यक्तिले जमानत गरेका" + } + } + } + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "за него поручились %#@people@, кого ты проверил" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d человек" + } + }, + "few" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d человека" + } + }, + "many" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d человек" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d человека" + } + } + } + } + } + } + }, + "ta" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "நீங்கள் சரிபார்த்த %d நபரால் உத்தரவாதம் அளிக்கப்பட்டது" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "நீங்கள் சரிபார்த்த %d நபர்களால் உத்தரவாதம் அளிக்கப்பட்டது" + } + } + } + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "รับรองโดย %#@people@ ที่คุณยืนยันแล้ว" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d คน" + } + } + } + } + } + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "được bảo chứng bởi %#@people@ mà bạn đã xác minh" + }, + "substitutions" : { + "people" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d người" + } + } + } + } + } + } } } }, @@ -24285,6 +33785,150 @@ "state" : "translated", "value" : "in this area" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "في هذه المنطقة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "এই এলাকায়" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "in diesem gebiet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "en esta zona" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dans cette zone" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "באזור הזה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "इस क्षेत्र में" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "di area ini" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "in questa zona" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "このエリア内" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이 지역 내" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "di kawasan ini" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "यही क्षेत्रमा" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "in dit gebied" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "w tym obszarze" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nesta área" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "в этой зоне" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "i det här området" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "இந்தப் பகுதியில்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ในพื้นที่นี้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bu bölgede" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "у цій зоні" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "اس علاقے میں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "trong khu vực này" + } } } }, @@ -24297,6 +33941,150 @@ "state" : "translated", "value" : "teleported from elsewhere" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "منتقل فوريًا من مكان آخر" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "অন্য কোথাও থেকে টেলিপোর্টেড" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "von woanders teleportiert" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "teletransportado desde otro lugar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "téléporté d'ailleurs" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "עבר בטלפורט ממקום אחר" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "कहीं और से टेलीपोर्टेड" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teleport dari tempat lain" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teletrasportato da altrove" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "別の場所からテレポート" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "다른 곳에서 텔레포트" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teleport dari tempat lain" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "अन्त कतैबाट टेलिपोर्ट" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "van elders geteleporteerd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teleportowany skądinąd" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teletransportado de outro lugar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "телепортирован из другого места" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teleporterad från annan plats" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "வேறு இடத்திலிருந்து டெலிபோர்ட் செய்யப்பட்டது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เทเลพอร์ตจากที่อื่น" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "başka bir yerden ışınlandı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "телепортований звідкись" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "کہیں اور سے ٹیلی پورٹ ہوا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dịch chuyển từ nơi khác" + } } } }, @@ -24309,6 +34097,150 @@ "state" : "translated", "value" : "you" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "أنت" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আপনি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "du" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "tú" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "toi" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "אתה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "आप" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kamu" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tu" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "あなた" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "나" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "anda" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तिमी" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "jij" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ty" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tu" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ты" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "du" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "நீங்கள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "คุณ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sen" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ти" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "آپ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bạn" + } } } }, @@ -24678,6 +34610,150 @@ "state" : "translated", "value" : "Opens the group chat" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "يفتح الدردشة الجماعية" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "গ্রুপ চ্যাট খোলে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Öffnet den gruppenchat" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abre el chat de grupo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ouvre la discussion de groupe" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "פותח את הצ'אט הקבוצתי" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह चैट खोलता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "membuka obrolan grup" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Apre la chat di gruppo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "グループチャットを開きます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "그룹 채팅을 엽니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "membuka sembang kumpulan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह च्याट खोल्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Opent de groepschat" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Otwiera czat grupowy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Abre a conversa de grupo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "открывает групповой чат" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Öppnar gruppchatten" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குழு உரையாடலைத் திறக்கும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เปิดแชทกลุ่ม" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Grup sohbetini açar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Відкриває груповий чат" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "گروپ چیٹ کھولتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mở cuộc trò chuyện nhóm" + } } } }, @@ -24689,6 +34765,150 @@ "state" : "translated", "value" : "(%@)" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "(%@)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(%@)" + } } } }, @@ -24700,6 +34920,150 @@ "state" : "translated", "value" : "groups" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "المجموعات" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "গ্রুপ" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gruppen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "grupos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "groupes" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "קבוצות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grup" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gruppi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "グループ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "그룹" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kumpulan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूहहरू" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "groepen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grupy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grupos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "группы" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grupper" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குழுக்கள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "กลุ่ม" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gruplar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "групи" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "گروپس" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nhóm" + } } } }, @@ -24711,6 +35075,150 @@ "state" : "translated", "value" : "Creator" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "المُنشئ" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "নির্মাতা" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ersteller" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Creador" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Créateur" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "יוצר" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निर्माता" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pembuat" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Creatore" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "作成者" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "생성자" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pencipta" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "सिर्जनाकर्ता" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Maker" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Twórca" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Criador" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "создатель" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Skapare" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "உருவாக்கியவர்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ผู้สร้าง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Oluşturan" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Створювач" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "بنانے والا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "người tạo" + } } } }, @@ -24723,6 +35231,150 @@ "state" : "translated", "value" : "bookmark channel" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "إضافة إشارة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "চ্যানেল বুকমার্ক করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kanal mit lesezeichen versehen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "marcar canal" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mettre le canal en signet" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הוסף סימנייה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "चैनल बुकमार्क करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tandai kanal" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "aggiungi il canale ai segnalibri" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "チャンネルをブックマーク" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "채널 북마크" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tanda buku kanal" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "च्यानल बुकमार्क गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kanaal toevoegen aan bladwijzers" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dodaj kanał do zakładek" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "marcar canal" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "добавить канал в закладки" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bokmärk kanal" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "சேனலைப் புக்மார்க் செய்யவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "บุ๊กมาร์กช่อง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kanalı yer imlerine ekle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "додати канал у закладки" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "چینل کو بُک مارک کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đánh dấu kênh" + } } } }, @@ -24735,6 +35387,150 @@ "state" : "translated", "value" : "remove bookmark" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "إزالة الإشارة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "বুকমার্ক সরান" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "lesezeichen entfernen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "quitar marcador" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "supprimer le signet" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הסר סימנייה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "बुकमार्क हटाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hapus tanda" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "rimuovi segnalibro" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ブックマークを削除" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "북마크 제거" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "buang tanda buku" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "बुकमार्क हटाऊ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bladwijzer verwijderen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "usuń zakładkę" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "remover marcador" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "удалить закладку" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ta bort bokmärke" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "புக்மார்க்கை நீக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ลบบุ๊กมาร์ก" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "yer imini kaldır" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "видалити закладку" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "بُک مارک ہٹائیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bỏ đánh dấu" + } } } }, @@ -24747,6 +35543,150 @@ "state" : "translated", "value" : "switches to this channel" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "التبديل إلى هذه القناة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "এই চ্যানেলে বদলে যায়" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wechselt zu diesem kanal" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "cambia a este canal" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bascule vers ce canal" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מחליף לערוץ הזה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "इस चैनल पर स्विच करता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "beralih ke kanal ini" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "passa a questo canale" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "このチャンネルに切り替えます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이 채널로 전환합니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "beralih ke kanal ini" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "यो च्यानलमा बदल्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "schakelt naar dit kanaal" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "przełącza na ten kanał" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "muda para este canal" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "переключает на этот канал" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "byter till den här kanalen" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "இந்த சேனலுக்கு மாறும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "สลับไปยังช่องนี้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bu kanala geçer" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "перемикає на цей канал" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "اس چینل پر منتقل ہوتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chuyển sang kênh này" + } } } }, @@ -26012,6 +36952,150 @@ "state" : "translated", "value" : "share your internet with nearby mesh peers so their geohash messages reach the network" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "شارك إنترنتك مع أقران mesh القريبين لتصل رسائل geohash الخاصة بهم إلى الشبكة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "কাছাকাছি মেশ পিয়ারদের সঙ্গে আপনার ইন্টারনেট ভাগ করুন যাতে তাদের জিওহ্যাশ বার্তা নেটওয়ার্কে পৌঁছায়" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "teile dein internet mit nahen mesh-peers, damit ihre geohash-nachrichten das netzwerk erreichen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "comparte tu internet con peers cercanos del mesh para que sus mensajes geohash lleguen a la red" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "partage ton internet avec les pairs mesh à proximité pour que leurs messages geohash atteignent le réseau" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "שתף את האינטרנט שלך עם עמיתי mesh קרובים כדי שהודעות ה-geohash שלהם יגיעו לרשת" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "अपना इंटरनेट आसपास के मेश पीयरों के साथ साझा करें ताकि उनके जियोहैश संदेश नेटवर्क तक पहुँचें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bagikan internetmu ke peer mesh terdekat agar pesan geohash mereka sampai ke jaringan" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "condividi il tuo internet con i peer mesh vicini così i loro messaggi geohash raggiungono la rete" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "近くのmeshピアとインターネットを共有して、そのgeohashメッセージをネットワークに届けます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "근처 mesh 피어와 인터넷을 공유하여 그들의 geohash 메시지가 네트워크에 도달하도록 합니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kongsi internetmu dengan peer mesh berdekatan supaya pesan geohash mereka sampai ke rangkaian" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "नजिकका mesh सहकर्मीसँग तिम्रो इन्टरनेट साझेदारी गर ताकि उनीहरूका geohash सन्देश नेटवर्कसम्म पुगून्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "deel je internet met mesh-peers in de buurt zodat hun geohash-berichten het netwerk bereiken" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "udostępnij internet pobliskim peerom mesh, aby ich wiadomości geohash docierały do sieci" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "partilha a tua internet com pares mesh próximos para que as mensagens geohash deles cheguem à rede" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "раздай интернет ближайшим mesh-пирам, чтобы их geohash-сообщения дошли до сети" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dela ditt internet med mesh-peers i närheten så att deras geohash-meddelanden når nätverket" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "உங்கள் இணையத்தை அருகிலுள்ள mesh peer-களுடன் பகிர்ந்து அவர்களின் geohash செய்திகள் நெட்வொர்க்கை அடையச் செய்யவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "แชร์อินเทอร์เน็ตของคุณกับเพียร์ mesh ที่อยู่ใกล้เพื่อให้ข้อความ geohash ของพวกเขาไปถึงเครือข่าย" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "geohash mesajlarının ağa ulaşması için internetini yakındaki mesh eşleriyle paylaş" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "поділися інтернетом з ближніми пірами mesh, щоб їхні повідомлення geohash досягали мережі" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "قریبی mesh ہم منصبوں کے ساتھ اپنا انٹرنیٹ شیئر کریں تاکہ ان کے geohash پیغامات نیٹ ورک تک پہنچیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chia sẻ internet của bạn với các nút mesh gần đó để tin nhắn geohash của họ đến được mạng" + } } } }, @@ -26024,6 +37108,150 @@ "state" : "translated", "value" : "internet gateway" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "بوابة الإنترنت" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ইন্টারনেট গেটওয়ে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "internet-gateway" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "puerta de enlace a internet" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "passerelle internet" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "שער אינטרנט" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "इंटरनेट गेटवे" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gateway internet" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gateway internet" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "インターネットゲートウェイ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "인터넷 게이트웨이" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gateway internet" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "इन्टरनेट गेटवे" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "internetgateway" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "brama internetowa" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gateway de internet" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "интернет-шлюз" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "internetgateway" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "இணைய நுழைவாயில்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เกตเวย์อินเทอร์เน็ต" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "internet ağ geçidi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "інтернет-шлюз" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "انٹرنیٹ گیٹ وے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cổng internet" + } } } }, @@ -31995,6 +43223,150 @@ "state" : "translated", "value" : "cancel sending" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "إلغاء الإرسال" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "পাঠানো বাতিল করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "senden abbrechen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "cancelar envío" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "annuler l'envoi" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ביטול שליחה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "भेजना रद्द करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "batalkan pengiriman" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "annulla invio" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "送信をキャンセル" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "전송 취소" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "batalkan penghantaran" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "पठाउने रद्द गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verzenden annuleren" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "anuluj wysyłanie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cancelar envio" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "отменить отправку" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "avbryt sändning" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "அனுப்புவதை ரத்து செய்யவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ยกเลิกการส่ง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "göndermeyi iptal et" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "скасувати надсилання" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "بھیجنا منسوخ کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hủy gửi" + } } } }, @@ -32007,6 +43379,150 @@ "state" : "translated", "value" : "hidden image" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "صورة مخفية" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "লুকানো ছবি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verstecktes bild" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "imagen oculta" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "image masquée" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "תמונה מוסתרת" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "छिपा हुआ चित्र" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gambar tersembunyi" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "immagine nascosta" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "非表示の画像" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "숨겨진 이미지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "imej tersembunyi" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "लुकाइएको तस्बिर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verborgen afbeelding" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ukryty obraz" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "imagem oculta" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "скрытое изображение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dold bild" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "மறைக்கப்பட்ட படம்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "รูปภาพที่ซ่อนอยู่" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gizli görsel" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "приховане зображення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "چھپی ہوئی تصویر" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hình ảnh ẩn" + } } } }, @@ -32019,6 +43535,150 @@ "state" : "translated", "value" : "opens the image full screen" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "يفتح الصورة بملء الشاشة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ছবিটি পূর্ণ স্ক্রিনে খোলে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "öffnet das bild im vollbild" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "abre la imagen a pantalla completa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ouvre l'image en plein écran" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "פותח את התמונה במסך מלא" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "चित्र को पूरी स्क्रीन पर खोलता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "membuka gambar layar penuh" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "apre l'immagine a schermo intero" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "画像を全画面で開きます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이미지를 전체 화면으로 엽니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "membuka imej skrin penuh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तस्बिर पूरा स्क्रिनमा खोल्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "opent de afbeelding op volledig scherm" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "otwiera obraz na pełnym ekranie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "abre a imagem em ecrã inteiro" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "открывает изображение на весь экран" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "öppnar bilden i helskärm" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "படத்தை முழுத் திரையில் திறக்கும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เปิดรูปภาพแบบเต็มหน้าจอ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "görseli tam ekran açar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "відкриває зображення на весь екран" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تصویر کو پوری اسکرین پر کھولتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mở hình ảnh toàn màn hình" + } } } }, @@ -32031,6 +43691,150 @@ "state" : "translated", "value" : "reveals the image" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "يكشف الصورة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ছবিটি প্রকাশ করে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "zeigt das bild" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "revela la imagen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "révèle l'image" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "חושף את התמונה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "चित्र दिखाता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "menampilkan gambar" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "rivela l'immagine" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "画像を表示します" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이미지를 표시합니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "menunjukkan imej" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तस्बिर देखाउँछ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "toont de afbeelding" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "odsłania obraz" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "revela a imagem" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "показывает изображение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "visar bilden" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "படத்தை வெளிப்படுத்தும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "แสดงรูปภาพ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "görseli gösterir" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "показує зображення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تصویر ظاہر کرتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hiện hình ảnh" + } } } }, @@ -32043,6 +43847,150 @@ "state" : "translated", "value" : "image" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "صورة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ছবি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bild" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "imagen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "image" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "תמונה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "चित्र" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gambar" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "immagine" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "画像" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이미지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "imej" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तस्बिर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "afbeelding" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "obraz" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "imagem" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "изображение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bild" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "படம்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "รูปภาพ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "görsel" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "зображення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تصویر" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hình ảnh" + } } } }, @@ -32055,6 +44003,150 @@ "state" : "translated", "value" : "sending image" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "جارٍ إرسال الصورة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ছবি পাঠানো হচ্ছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bild wird gesendet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "enviando imagen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "envoi de l'image" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "שולח תמונה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "चित्र भेजा जा रहा है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mengirim gambar" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "invio immagine" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "画像を送信中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이미지 전송 중" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "menghantar imej" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तस्बिर पठाइँदै" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "afbeelding verzenden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wysyłanie obrazu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "a enviar imagem" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "отправка изображения" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "skickar bild" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "படத்தை அனுப்புகிறது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "กำลังส่งรูปภาพ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "görsel gönderiliyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "надсилання зображення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تصویر بھیجی جا رہی ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đang gửi hình ảnh" + } } } }, @@ -32067,6 +44159,150 @@ "state" : "translated", "value" : "delete image" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "حذف الصورة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ছবি মুছুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bild löschen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "eliminar imagen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "supprimer l'image" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מחק תמונה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "चित्र हटाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hapus gambar" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "elimina immagine" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "画像を削除" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이미지 삭제" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "padam imej" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तस्बिर मेटाऊ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "afbeelding verwijderen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "usuń obraz" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "eliminar imagem" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "удалить изображение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ta bort bild" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "படத்தை நீக்கு" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ลบรูปภาพ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "görseli sil" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "видалити зображення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تصویر حذف کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "xóa hình ảnh" + } } } }, @@ -32079,6 +44315,150 @@ "state" : "translated", "value" : "hide image" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "إخفاء الصورة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ছবি লুকান" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bild verbergen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ocultar imagen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "masquer l'image" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הסתר תמונה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "चित्र छिपाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sembunyikan gambar" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nascondi immagine" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "画像を非表示" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이미지 숨기기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sembunyikan imej" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तस्बिर लुकाऊ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "afbeelding verbergen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ukryj obraz" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ocultar imagem" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "скрыть изображение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dölj bild" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "படத்தை மறை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ซ่อนรูปภาพ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "görseli gizle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "приховати зображення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تصویر چھپائیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ẩn hình ảnh" + } } } }, @@ -32091,6 +44471,150 @@ "state" : "translated", "value" : "open image" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "فتح الصورة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ছবি খুলুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bild öffnen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "abrir imagen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ouvrir l'image" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "פתח תמונה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "चित्र खोलें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "buka gambar" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "apri immagine" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "画像を開く" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이미지 열기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "buka imej" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तस्बिर खोल" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "afbeelding openen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "otwórz obraz" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "abrir imagem" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "открыть изображение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "öppna bild" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "படத்தைத் திற" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เปิดรูปภาพ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "görseli aç" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "відкрити зображення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تصویر کھولیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mở hình ảnh" + } } } }, @@ -32103,6 +44627,150 @@ "state" : "translated", "value" : "reveal image" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "كشف الصورة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ছবি প্রকাশ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bild anzeigen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "revelar imagen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "révéler l'image" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "חשוף תמונה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "चित्र दिखाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tampilkan gambar" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "rivela immagine" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "画像を表示" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이미지 표시" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tunjukkan imej" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तस्बिर देखाऊ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "afbeelding tonen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "odsłoń obraz" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "revelar imagem" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "показать изображение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "visa bild" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "படத்தை வெளிப்படுத்து" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "แสดงรูปภาพ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "görseli göster" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "показати зображення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تصویر ظاہر کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hiện hình ảnh" + } } } }, @@ -32115,6 +44783,150 @@ "state" : "translated", "value" : "this cannot be undone — the sender may not be in range to send it again." } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "لا يمكن التراجع عن هذا — قد لا يكون المرسِل في النطاق لإرسالها مرة أخرى." + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "এটি ফেরানো যাবে না — প্রেরক আবার পাঠানোর মতো পরিসরে নাও থাকতে পারেন।" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "das kann nicht rückgängig gemacht werden — der absender ist möglicherweise nicht in reichweite, um es erneut zu senden." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "esto no se puede deshacer — puede que el remitente no esté a tu alcance para enviarla de nuevo." + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cette action est irréversible — l'expéditeur n'est peut-être pas à portée pour la renvoyer." + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "אי אפשר לבטל את זה — ייתכן שהשולח לא בטווח כדי לשלוח אותה שוב." + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "इसे पूर्ववत नहीं किया जा सकता — भेजने वाला शायद इसे दोबारा भेजने की रेंज में न हो।" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ini tidak bisa dibatalkan — pengirim mungkin tidak dalam jangkauan untuk mengirimnya lagi." + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "questa azione non può essere annullata — il mittente potrebbe non essere a portata per inviarla di nuovo." + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "この操作は取り消せません — 送信者が圏内におらず、再送信できない場合があります。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이 작업은 취소할 수 없습니다 — 보낸 사람이 범위 내에 없어 다시 보내지 못할 수 있습니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ini tidak boleh dibatalkan — penghantar mungkin tiada dalam jangkauan untuk menghantarnya semula." + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "यो फिर्ता गर्न सकिँदैन — पठाउने फेरि पठाउन दायरामा नहुन सक्छ।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dit kan niet ongedaan worden gemaakt — de afzender is mogelijk niet in bereik om het opnieuw te sturen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tej operacji nie można cofnąć — nadawca może być poza zasięgiem, aby wysłać go ponownie." + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "isto não pode ser anulado — o remetente pode não estar ao alcance para a enviar de novo." + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "это нельзя отменить — отправитель может быть вне зоны, чтобы отправить снова." + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "detta kan inte ångras — avsändaren kanske inte är inom räckhåll för att skicka den igen." + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "இதை மீட்டெடுக்க முடியாது — அனுப்பியவர் மீண்டும் அனுப்பும் வரம்பில் இல்லாமல் இருக்கலாம்." + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "การกระทำนี้ไม่สามารถยกเลิกได้ — ผู้ส่งอาจไม่อยู่ในระยะที่จะส่งอีกครั้ง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bu geri alınamaz — gönderen tekrar göndermek için menzilde olmayabilir." + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "це не можна скасувати — відправник може бути поза радіусом, щоб надіслати його знову." + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "اسے واپس نہیں کیا جا سکتا — ممکن ہے بھیجنے والا اسے دوبارہ بھیجنے کیلئے رینج میں نہ ہو۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "không thể hoàn tác — người gửi có thể không trong phạm vi để gửi lại." + } } } }, @@ -32127,6 +44939,150 @@ "state" : "translated", "value" : "delete this image?" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "حذف هذه الصورة؟" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "এই ছবি মুছবেন?" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dieses bild löschen?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿eliminar esta imagen?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "supprimer cette image ?" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "למחוק את התמונה הזו?" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "यह चित्र हटाएँ?" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hapus gambar ini?" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "eliminare questa immagine?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "この画像を削除しますか?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이 이미지를 삭제할까요?" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "padam imej ini?" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "यो तस्बिर मेटाउने?" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "deze afbeelding verwijderen?" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "usunąć ten obraz?" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "eliminar esta imagem?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "удалить это изображение?" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ta bort den här bilden?" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "இந்தப் படத்தை நீக்கவா?" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ลบรูปภาพนี้หรือไม่?" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bu görsel silinsin mi?" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "видалити це зображення?" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "یہ تصویر حذف کریں؟" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "xóa hình ảnh này?" + } } } }, @@ -32139,6 +45095,150 @@ "state" : "translated", "value" : "tap to reveal" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "اضغط للكشف" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "প্রকাশ করতে ট্যাপ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "zum anzeigen tippen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "toca para revelar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "touche pour révéler" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הקש לחשיפה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "दिखाने के लिए टैप करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ketuk untuk menampilkan" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tocca per rivelare" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "タップして表示" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "탭하여 표시" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ketuk untuk menunjukkan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "देखाउन ट्याप गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tik om te tonen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "stuknij, aby odsłonić" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "toca para revelar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "нажми, чтобы показать" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tryck för att visa" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "வெளிப்படுத்த தட்டவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "แตะเพื่อแสดง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "göstermek için dokunun" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "торкнися, щоб показати" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ظاہر کرنے کیلئے ٹیپ کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chạm để hiện" + } } } }, @@ -32151,6 +45251,150 @@ "state" : "translated", "value" : "pause voice note" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "إيقاف الملاحظة الصوتية مؤقتًا" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ভয়েস নোট থামান" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sprachnachricht pausieren" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "pausar nota de voz" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mettre la note vocale en pause" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "השהיית הערה קולית" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "वॉइस नोट रोकें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "jeda catatan suara" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "metti in pausa la nota vocale" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ボイスメモを一時停止" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "음성 메모 일시정지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "jeda nota suara" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "भ्वाइस नोट रोक" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "spraakbericht pauzeren" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wstrzymaj notatkę głosową" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "colocar a nota de voz em pausa" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "приостановить голосовое сообщение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pausa röstmeddelande" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குரல் குறிப்பை இடைநிறுத்தவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "หยุดข้อความเสียงชั่วคราว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sesli notu duraklat" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "призупинити голосову нотатку" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "وائس نوٹ روکیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tạm dừng ghi chú giọng nói" + } } } }, @@ -32163,6 +45407,150 @@ "state" : "translated", "value" : "play voice note" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تشغيل الملاحظة الصوتية" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ভয়েস নোট চালান" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sprachnachricht abspielen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "reproducir nota de voz" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "lire la note vocale" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "השמעת הערה קולית" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "वॉइस नोट चलाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "putar catatan suara" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "riproduci la nota vocale" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ボイスメモを再生" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "음성 메모 재생" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mainkan nota suara" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "भ्वाइस नोट बजाऊ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "spraakbericht afspelen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "odtwórz notatkę głosową" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "reproduzir a nota de voz" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "воспроизвести голосовое сообщение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "spela upp röstmeddelande" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குரல் குறிப்பை இயக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เล่นข้อความเสียง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sesli notu oynat" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "відтворити голосову нотатку" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "وائس نوٹ چلائیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "phát ghi chú giọng nói" + } } } }, @@ -32175,6 +45563,150 @@ "state" : "translated", "value" : "opens a private chat" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "يفتح دردشة خاصة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "একটি ব্যক্তিগত চ্যাট খোলে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "öffnet einen privaten chat" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "abre un chat privado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ouvre une discussion privée" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "פותח צ'אט פרטי" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निजी चैट खोलता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "membuka obrolan pribadi" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "apre una chat privata" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "プライベートチャットを開きます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "비공개 채팅을 엽니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "membuka sembang peribadi" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निजी च्याट खोल्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "opent een privéchat" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "otwiera czat prywatny" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "abre uma conversa privada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "открывает личный чат" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "öppnar en privat chatt" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "தனிப்பட்ட உரையாடலைத் திறக்கும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เปิดแชทส่วนตัว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "özel bir sohbet açar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "відкриває приватний чат" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "نجی چیٹ کھولتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mở cuộc trò chuyện riêng tư" + } } } }, @@ -32187,6 +45719,150 @@ "state" : "translated", "value" : "show fingerprint" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "عرض البصمة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ফিঙ্গারপ্রিন্ট দেখান" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "fingerabdruck anzeigen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mostrar huella" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "afficher l'empreinte" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הצג טביעת אצבע" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "फ़िंगरप्रिंट दिखाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tampilkan sidik" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mostra impronta" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "フィンガープリントを表示" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "지문 표시" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tunjukkan sidik" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "फिंगरप्रिन्ट देखाऊ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "vingerafdruk tonen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pokaż odcisk" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mostrar impressão digital" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "показать отпечаток" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "visa fingeravtryck" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "கைரேகையைக் காட்டு" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "แสดงลายนิ้วมือ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "parmak izini göster" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "показати відбиток" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "فنگرپرنٹ دکھائیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hiện vân tay" + } } } }, @@ -32199,6 +45875,150 @@ "state" : "translated", "value" : "blocked" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "محظور" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ব্লক করা" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "blockiert" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "bloqueado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bloqué" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "חסום" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ब्लॉक किया गया" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "diblokir" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bloccato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ブロック中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "차단됨" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "diblokir" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ब्लक" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "geblokkeerd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "zablokowany" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bloqueado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "заблокирован" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "blockerad" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "தடுக்கப்பட்டது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ถูกบล็อก" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "engellendi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "заблоковано" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "بلاک شدہ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đã chặn" + } } } }, @@ -32211,6 +46031,150 @@ "state" : "translated", "value" : "favorite" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "مفضّل" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "প্রিয়" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favorit" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorito" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favori" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מועדף" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "पसंदीदा" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favorit" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "preferito" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "お気に入り" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "즐겨찾기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favorit" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "मनपर्ने" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favoriet" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ulubiony" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favorito" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "избранное" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favorit" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "சிறப்பு" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "คนโปรด" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "favori" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "улюблений" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "پسندیدہ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "yêu thích" + } } } }, @@ -32223,6 +46187,150 @@ "state" : "translated", "value" : "offline" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "غير متصل" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "অফলাইন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "sin conexión" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hors ligne" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "לא מקוון" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ऑफ़लाइन" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "オフライン" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "오프라인" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "अफलाइन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "офлайн" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "offline" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ஆஃப்லைன்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ออฟไลน์" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "çevrimdışı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "офлайн" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "آف لائن" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ngoại tuyến" + } } } }, @@ -32235,6 +46343,150 @@ "state" : "translated", "value" : "new messages" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "رسائل جديدة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "নতুন বার্তা" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "neue nachrichten" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensajes nuevos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nouveaux messages" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הודעות חדשות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "नए संदेश" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan baru" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nuovi messaggi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "新着メッセージ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "새 메시지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan baru" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "नयाँ सन्देश" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nieuwe berichten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nowe wiadomości" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "novas mensagens" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "новые сообщения" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nya meddelanden" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "புதிய செய்திகள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ข้อความใหม่" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "yeni mesajlar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "нові повідомлення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "نئے پیغامات" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tin nhắn mới" + } } } }, @@ -32247,6 +46499,150 @@ "state" : "translated", "value" : "vouched" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "موثوق" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "সমর্থিত" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verbürgt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "avalado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "attesté" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "בערבות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "अनुशंसित" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dijamin" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "garantito" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "保証済み" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "보증됨" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dijamin" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "जमानत" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ingestaan" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "poręczony" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "atestado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "поручились" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "intygad" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "உத்தரவாதம்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "รับรองแล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kefil olundu" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "засвідчено" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ضمانت شدہ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đã bảo chứng" + } } } }, @@ -32438,6 +46834,150 @@ "state" : "translated", "value" : "vouched for by someone you verified" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "موثوق من قِبل شخص تحقّقت منه" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আপনি যাচাই করেছেন এমন কারো দ্বারা সমর্থিত" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verbürgt von jemandem, den du verifiziert hast" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "avalado por alguien que verificaste" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "attesté par une personne que tu as vérifiée" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מישהו שאימתת ערב לו" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "आपके किसी सत्यापित व्यक्ति द्वारा अनुशंसित" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dijamin oleh seseorang yang kamu verifikasi" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "garantito da qualcuno che hai verificato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "あなたが確認した人が保証しています" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "당신이 확인한 사람이 보증함" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dijamin oleh seseorang yang anda sahkan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तिमीले प्रमाणित गरेका कसैले जमानत गरेको" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ingestaan door iemand die je hebt geverifieerd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "poręczony przez kogoś, kogo zweryfikowałeś" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "atestado por alguém que verificaste" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "за него поручился тот, кого ты проверил" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "intygad av någon du verifierat" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "நீங்கள் சரிபார்த்த ஒருவரால் உத்தரவாதம் அளிக்கப்பட்டது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "รับรองโดยคนที่คุณยืนยันแล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "doğruladığın biri tarafından kefil olundu" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "засвідчено кимось, кого ти підтвердив" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "کسی ایسے شخص کی ضمانت جس کی آپ نے توثیق کی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "được bảo chứng bởi người bạn đã xác minh" + } } } }, @@ -33882,6 +48422,150 @@ "state" : "translated", "value" : "sent via mesh gateway" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "أُرسل عبر بوابة mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "মেশ গেটওয়ের মাধ্যমে পাঠানো হয়েছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "über mesh-gateway gesendet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "enviado por la puerta de enlace del mesh" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "envoyé via la passerelle mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "נשלח דרך שער mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "मेश गेटवे के ज़रिए भेजा गया" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dikirim via gateway mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "inviato tramite gateway mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "meshゲートウェイ経由で送信" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh 게이트웨이를 통해 전송됨" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dihantar via gateway mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh गेटवे मार्फत पठाइयो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verzonden via mesh-gateway" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wysłano przez bramę mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "enviado através do gateway mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "отправлено через mesh-шлюз" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "skickat via mesh-gateway" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh நுழைவாயில் வழியாக அனுப்பப்பட்டது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ส่งผ่านเกตเวย์ mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh ağ geçidi üzerinden gönderildi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "надіслано через шлюз mesh" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh گیٹ وے کے ذریعے بھیجا گیا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đã gửi qua cổng mesh" + } } } }, @@ -34251,6 +48935,150 @@ "state" : "translated", "value" : "%@ is already a member" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ عضو بالفعل" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ ইতিমধ্যে একজন সদস্য" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ ist bereits mitglied" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ya es miembro" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ est déjà membre" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ כבר חבר" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ पहले से ही सदस्य है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ sudah menjadi anggota" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ è già un membro" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ はすでにメンバーです" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ 님은 이미 멤버입니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ sudah menjadi ahli" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ पहिले नै सदस्य हो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ is al lid" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ jest już członkiem" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ já é membro" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ уже участник" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ är redan medlem" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ ஏற்கனவே ஒரு உறுப்பினர்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ เป็นสมาชิกอยู่แล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ zaten üye" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ уже учасник" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ پہلے ہی رکن ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ đã là thành viên" + } } } }, @@ -34262,6 +49090,150 @@ "state" : "translated", "value" : "the creator cannot be removed" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "لا يمكن إزالة المُنشئ" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "নির্মাতাকে সরানো যায় না" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "der ersteller kann nicht entfernt werden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se puede eliminar al creador" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "le créateur ne peut pas être retiré" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "אי אפשר להסיר את היוצר" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निर्माता को हटाया नहीं जा सकता" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pembuat tidak bisa dihapus" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "il creatore non può essere rimosso" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "作成者は削除できません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "생성자는 제거할 수 없습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pencipta tidak boleh dibuang" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "सिर्जनाकर्तालाई हटाउन सकिँदैन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "de maker kan niet worden verwijderd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie można usunąć twórcy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "o criador não pode ser removido" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "создателя нельзя удалить" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "skaparen kan inte tas bort" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "உருவாக்கியவரை நீக்க முடியாது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไม่สามารถลบผู้สร้างได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "oluşturan kişi kaldırılamaz" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "створювача не можна видалити" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "بنانے والے کو ہٹایا نہیں جا سکتا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "không thể xóa người tạo" + } } } }, @@ -34273,6 +49245,150 @@ "state" : "translated", "value" : "could not create the group" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تعذّر إنشاء المجموعة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "গ্রুপ তৈরি করা যায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "die gruppe konnte nicht erstellt werden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se pudo crear el grupo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "impossible de créer le groupe" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "לא ניתן ליצור את הקבוצה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह नहीं बनाया जा सका" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak bisa membuat grup" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "impossibile creare il gruppo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "グループを作成できませんでした" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "그룹을 생성할 수 없습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak dapat mencipta kumpulan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह सिर्जना गर्न सकिएन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kon de groep niet aanmaken" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie udało się utworzyć grupy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "não foi possível criar o grupo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не удалось создать группу" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kunde inte skapa gruppen" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குழுவை உருவாக்க முடியவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไม่สามารถสร้างกลุ่มได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grup oluşturulamadı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не вдалося створити групу" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "گروپ نہیں بنایا جا سکا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "không thể tạo nhóm" + } } } }, @@ -34284,6 +49400,150 @@ "state" : "translated", "value" : "created group '%@' — use /group invite @name to add people" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "أُنشئت المجموعة '%@' — استخدم /group invite @name لإضافة أشخاص" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' গ্রুপ তৈরি হয়েছে — মানুষ যোগ করতে /group invite @name ব্যবহার করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gruppe '%@' erstellt — nutze /group invite @name, um leute hinzuzufügen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "grupo '%@' creado — usa /group invite @name para añadir personas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "groupe '%@' créé — utilise /group invite @name pour ajouter des personnes" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "נוצרה הקבוצה '%@' — השתמש ב-/group invite @name כדי להוסיף אנשים" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह '%@' बनाया गया — लोगों को जोड़ने के लिए /group invite @name का उपयोग करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grup '%@' dibuat — gunakan /group invite @name untuk menambah orang" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gruppo '%@' creato — usa /group invite @name per aggiungere persone" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "グループ '%@' を作成しました — /group invite @name で人を追加できます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "그룹 '%@' 을(를) 생성했습니다 — /group invite @name 으로 사람을 추가하세요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kumpulan '%@' dicipta — guna /group invite @name untuk menambah orang" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह '%@' सिर्जना भयो — मानिस थप्न /group invite @name प्रयोग गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "groep '%@' aangemaakt — gebruik /group invite @name om mensen toe te voegen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "utworzono grupę '%@' — użyj /group invite @name, aby dodać osoby" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grupo '%@' criado — usa /group invite @name para adicionar pessoas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "создана группа «%@» — используй /group invite @name, чтобы добавить людей" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "skapade grupp '%@' — använd /group invite @name för att lägga till personer" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' குழு உருவாக்கப்பட்டது — நபர்களைச் சேர்க்க /group invite @name ஐப் பயன்படுத்தவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "สร้างกลุ่ม '%@' แล้ว — ใช้ /group invite @name เพื่อเพิ่มคน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' grubu oluşturuldu — kişi eklemek için /group invite @name kullanın" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "створено групу '%@' — використай /group invite @name, щоб додати людей" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "گروپ '%@' بنایا گیا — لوگوں کو شامل کرنے کیلئے /group invite @name استعمال کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đã tạo nhóm '%@' — dùng /group invite @name để thêm người" + } } } }, @@ -34295,6 +49555,150 @@ "state" : "translated", "value" : "only the group creator can do that" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "منشئ المجموعة فقط يمكنه فعل ذلك" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "শুধু গ্রুপের নির্মাতা এটি করতে পারেন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nur der gruppenersteller kann das tun" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "solo el creador del grupo puede hacer eso" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "seul le créateur du groupe peut faire cela" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "רק יוצר הקבוצה יכול לעשות זאת" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "केवल समूह निर्माता ही ऐसा कर सकता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hanya pembuat grup yang bisa melakukan itu" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "solo il creatore del gruppo può farlo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "グループの作成者のみが実行できます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "그룹 생성자만 할 수 있습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hanya pencipta kumpulan boleh berbuat demikian" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह सिर्जनाकर्ताले मात्र त्यो गर्न सक्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "alleen de maker van de groep kan dat doen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tylko twórca grupy może to zrobić" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "só o criador do grupo pode fazer isso" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "это может сделать только создатель группы" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "endast gruppens skapare kan göra det" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குழுவை உருவாக்கியவர் மட்டுமே அதைச் செய்ய முடியும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เฉพาะผู้สร้างกลุ่มเท่านั้นที่ทำได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bunu yalnızca grubu oluşturan yapabilir" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "це може лише створювач групи" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "صرف گروپ بنانے والا یہ کر سکتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chỉ người tạo nhóm mới có thể làm điều đó" + } } } }, @@ -34306,6 +49710,150 @@ "state" : "translated", "value" : "group is full (max %@ members)" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "المجموعة ممتلئة (الحد الأقصى %@ عضو)" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "গ্রুপ পূর্ণ (সর্বোচ্চ %@ সদস্য)" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gruppe ist voll (max. %@ mitglieder)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "el grupo está lleno (máx. %@ miembros)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "le groupe est plein (max %@ membres)" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הקבוצה מלאה (מקסימום %@ חברים)" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह भरा हुआ है (अधिकतम %@ सदस्य)" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grup penuh (maks %@ anggota)" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "il gruppo è pieno (max %@ membri)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "グループが満員です(最大 %@ 人)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "그룹이 가득 찼습니다 (최대 %@명)" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kumpulan penuh (maks %@ ahli)" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह भरिएको छ (बढीमा %@ सदस्य)" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "groep is vol (max %@ leden)" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grupa jest pełna (maks. %@ członków)" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "o grupo está cheio (máx. %@ membros)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "группа заполнена (макс. %@ участников)" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gruppen är full (max %@ medlemmar)" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குழு நிரம்பிவிட்டது (அதிகபட்சம் %@ உறுப்பினர்கள்)" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "กลุ่มเต็มแล้ว (สูงสุด %@ คน)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grup dolu (en fazla %@ üye)" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "група заповнена (макс. %@ учасників)" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "گروپ بھر گیا ہے (زیادہ سے زیادہ %@ اراکین)" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nhóm đã đầy (tối đa %@ thành viên)" + } } } }, @@ -34317,6 +49865,150 @@ "state" : "translated", "value" : "your identity keys are not ready yet" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "مفاتيح هويتك ليست جاهزة بعد" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আপনার পরিচয় কী এখনো প্রস্তুত নয়" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "deine identitätsschlüssel sind noch nicht bereit" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "tus claves de identidad aún no están listas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tes clés d'identité ne sont pas encore prêtes" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מפתחות הזהות שלך עדיין לא מוכנים" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "आपकी पहचान कुंजियाँ अभी तैयार नहीं हैं" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kunci identitasmu belum siap" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "le tue chiavi di identità non sono ancora pronte" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "身元確認用のキーがまだ準備できていません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "신원 키가 아직 준비되지 않았습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kunci identitimu belum sedia" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तिम्रा पहिचान कुञ्जीहरू अझै तयार छैनन्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "je identiteitssleutels zijn nog niet klaar" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "twoje klucze tożsamości nie są jeszcze gotowe" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "as tuas chaves de identidade ainda não estão prontas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "твои ключи личности ещё не готовы" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dina identitetsnycklar är inte redo än" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "உங்கள் அடையாள விசைகள் இன்னும் தயாராகவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "คีย์ยืนยันตัวตนของคุณยังไม่พร้อม" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kimlik anahtarların henüz hazır değil" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "твої ключі особи ще не готові" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "آپ کی شناختی کلیدیں ابھی تیار نہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "khóa danh tính của bạn chưa sẵn sàng" + } } } }, @@ -34328,6 +50020,150 @@ "state" : "translated", "value" : "could not build the group invite" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تعذّر إنشاء دعوة المجموعة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "গ্রুপ আমন্ত্রণ তৈরি করা যায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "die gruppeneinladung konnte nicht erstellt werden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se pudo generar la invitación al grupo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "impossible de créer l'invitation au groupe" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "לא ניתן לבנות את הזמנת הקבוצה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह आमंत्रण नहीं बनाया जा सका" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak bisa membuat undangan grup" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "impossibile creare l'invito al gruppo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "グループ招待を作成できませんでした" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "그룹 초대를 만들 수 없습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak dapat membina jemputan kumpulan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह निमन्त्रणा बनाउन सकिएन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kon de groepsuitnodiging niet aanmaken" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie udało się utworzyć zaproszenia do grupy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "não foi possível criar o convite do grupo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не удалось создать приглашение в группу" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kunde inte skapa gruppinbjudan" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குழு அழைப்பை உருவாக்க முடியவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไม่สามารถสร้างคำเชิญกลุ่มได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grup daveti oluşturulamadı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не вдалося створити запрошення до групи" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "گروپ کی دعوت نہیں بنائی جا سکی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "không thể tạo lời mời nhóm" + } } } }, @@ -34339,6 +50175,150 @@ "state" : "translated", "value" : "invited %1$@ to '%2$@'" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تمت دعوة %1$@ إلى '%2$@'" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@-কে '%2$@'-এ আমন্ত্রণ জানানো হয়েছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ zu '%2$@' eingeladen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "invitaste a %1$@ a '%2$@'" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ invité(e) dans '%2$@'" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ הוזמן ל-'%2$@'" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ को '%2$@' में आमंत्रित किया" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mengundang %1$@ ke '%2$@'" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ invitato in '%2$@'" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ を '%2$@' に招待しました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ 님을 '%2$@' 에 초대했습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "menjemput %1$@ ke '%2$@'" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ लाई '%2$@' मा निमन्त्रणा गरियो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ uitgenodigd voor '%2$@'" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "zaproszono %1$@ do '%2$@'" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ convidado para '%2$@'" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ приглашён в «%2$@»" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bjöd in %1$@ till '%2$@'" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ ஐ '%2$@' க்கு அழைத்தது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เชิญ %1$@ เข้า '%2$@' แล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ '%2$@' grubuna davet edildi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "запрошено %1$@ до '%2$@'" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$@ کو '%2$@' میں مدعو کیا گیا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đã mời %1$@ vào '%2$@'" + } } } }, @@ -34350,6 +50330,150 @@ "state" : "translated", "value" : "you were added to group '%1$@' by %2$@ — it now appears in your people list" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "أضافك %2$@ إلى المجموعة '%1$@' — تظهر الآن في قائمة الأشخاص" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%2$@ আপনাকে '%1$@' গ্রুপে যুক্ত করেছেন — এটি এখন আপনার মানুষের তালিকায় দেখা যাবে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "du wurdest von %2$@ zur gruppe '%1$@' hinzugefügt — sie erscheint jetzt in deiner personenliste" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%2$@ te añadió al grupo '%1$@' — ahora aparece en tu lista de personas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tu as été ajouté(e) au groupe '%1$@' par %2$@ — il apparaît maintenant dans ta liste de personnes" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%2$@ הוסיף אותך לקבוצה '%1$@' — היא מופיעה כעת ברשימת האנשים שלך" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "आपको %2$@ द्वारा समूह '%1$@' में जोड़ा गया — यह अब आपकी लोगों की सूची में दिखाई देता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kamu ditambahkan ke grup '%1$@' oleh %2$@ — kini muncul di daftar orangmu" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sei stato aggiunto al gruppo '%1$@' da %2$@ — ora appare nella tua lista di persone" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%2$@ によってグループ '%1$@' に追加されました — ピープルリストに表示されます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%2$@ 님이 그룹 '%1$@' 에 추가했습니다 — 이제 피플 목록에 표시됩니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "anda ditambah ke kumpulan '%1$@' oleh %2$@ — kini ia muncul dalam senarai orangmu" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%2$@ ले तिमीलाई समूह '%1$@' मा थप्यो — अब यो तिम्रो मानिस सूचीमा देखिन्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "je bent door %2$@ toegevoegd aan groep '%1$@' — die verschijnt nu in je personenlijst" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%2$@ dodał cię do grupy '%1$@' — pojawia się teraz na liście osób" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "foste adicionado ao grupo '%1$@' por %2$@ — agora aparece na tua lista de pessoas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%2$@ добавил тебя в группу «%1$@» — теперь она в твоём списке людей" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%2$@ lade till dig i gruppen '%1$@' — den visas nu i din personlista" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%2$@ உங்களை '%1$@' குழுவில் சேர்த்தார் — அது இப்போது உங்கள் நபர்கள் பட்டியலில் தோன்றுகிறது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%2$@ เพิ่มคุณเข้ากลุ่ม '%1$@' — ตอนนี้จะปรากฏในรายชื่อคนของคุณ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%2$@ seni '%1$@' grubuna ekledi — artık kişi listende görünüyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%2$@ додав тебе до групи '%1$@' — вона тепер у твоєму списку людей" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%2$@ نے آپ کو گروپ '%1$@' میں شامل کیا — یہ اب آپ کی لوگوں کی فہرست میں ظاہر ہوتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bạn đã được %2$@ thêm vào nhóm '%1$@' — nó giờ xuất hiện trong danh sách người của bạn" + } } } }, @@ -34361,6 +50485,150 @@ "state" : "translated", "value" : "left group '%@'" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "غادرت المجموعة '%@'" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' গ্রুপ ছেড়ে দিয়েছেন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gruppe '%@' verlassen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "saliste del grupo '%@'" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "groupe '%@' quitté" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "עזבת את הקבוצה '%@'" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह '%@' छोड़ दिया" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "keluar dari grup '%@'" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "uscito dal gruppo '%@'" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "グループ '%@' から退出しました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "그룹 '%@' 에서 나갔습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "keluar dari kumpulan '%@'" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह '%@' छोडियो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "groep '%@' verlaten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "opuszczono grupę '%@'" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "saíste do grupo '%@'" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ты покинул группу «%@»" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "lämnade gruppen '%@'" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' குழுவிலிருந்து வெளியேறியது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ออกจากกลุ่ม '%@' แล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' grubundan ayrıldın" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "залишено групу '%@'" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "گروپ '%@' چھوڑ دیا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đã rời nhóm '%@'" + } } } }, @@ -34372,6 +50640,150 @@ "state" : "translated", "value" : "your groups:" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "مجموعاتك:" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আপনার গ্রুপসমূহ:" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "deine gruppen:" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "tus grupos:" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tes groupes :" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הקבוצות שלך:" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "आपके समूह:" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grupmu:" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "i tuoi gruppi:" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "あなたのグループ:" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "내 그룹:" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kumpulanmu:" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तिम्रा समूहहरू:" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "je groepen:" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "twoje grupy:" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "os teus grupos:" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "твои группы:" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dina grupper:" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "உங்கள் குழுக்கள்:" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "กลุ่มของคุณ:" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grupların:" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "твої групи:" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "آپ کے گروپس:" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nhóm của bạn:" + } } } }, @@ -34383,6 +50795,150 @@ "state" : "translated", "value" : "'%@' is not a member of this group" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' ليس عضوًا في هذه المجموعة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' এই গ্রুপের সদস্য নন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' ist kein mitglied dieser gruppe" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' no es miembro de este grupo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' n'est pas membre de ce groupe" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' אינו חבר בקבוצה הזו" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' इस समूह का सदस्य नहीं है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' bukan anggota grup ini" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' non è un membro di questo gruppo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' はこのグループのメンバーではありません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' 님은 이 그룹의 멤버가 아닙니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' bukan ahli kumpulan ini" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' यो समूहको सदस्य होइन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' is geen lid van deze groep" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' nie jest członkiem tej grupy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' não é membro deste grupo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "«%@» не является участником этой группы" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' är inte medlem i den här gruppen" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' இந்தக் குழுவின் உறுப்பினர் அல்ல" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' ไม่ใช่สมาชิกของกลุ่มนี้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' bu grubun üyesi değil" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' не учасник цієї групи" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' اس گروپ کا رکن نہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' không phải là thành viên của nhóm này" + } } } }, @@ -34394,6 +50950,150 @@ "state" : "translated", "value" : "group names are limited to 40 characters" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "أسماء المجموعات محدودة بـ 40 حرفًا" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "গ্রুপের নাম ৪০ অক্ষরে সীমাবদ্ধ" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gruppennamen sind auf 40 zeichen begrenzt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "los nombres de grupo están limitados a 40 caracteres" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "les noms de groupe sont limités à 40 caractères" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "שמות קבוצות מוגבלים ל-40 תווים" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह के नाम 40 अक्षरों तक सीमित हैं" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nama grup dibatasi 40 karakter" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "i nomi dei gruppi sono limitati a 40 caratteri" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "グループ名は40文字までです" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "그룹 이름은 40자로 제한됩니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nama kumpulan terhad kepada 40 aksara" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह नाम ४० अक्षरमा सीमित छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "groepsnamen zijn beperkt tot 40 tekens" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nazwy grup są ograniczone do 40 znaków" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "os nomes de grupo estão limitados a 40 caracteres" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "имена групп ограничены 40 символами" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gruppnamn är begränsade till 40 tecken" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குழுப் பெயர்கள் 40 எழுத்துகளுக்கு வரம்பிடப்பட்டுள்ளன" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ชื่อกลุ่มจำกัดไม่เกิน 40 ตัวอักษร" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grup adları en fazla 40 karakter olabilir" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "назви груп обмежені 40 символами" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "گروپ کے نام 40 حروف تک محدود ہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tên nhóm giới hạn 40 ký tự" + } } } }, @@ -34405,6 +51105,150 @@ "state" : "translated", "value" : "you are not in any groups — /group create to start one" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "لست في أي مجموعة — /group create لبدء واحدة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আপনি কোনো গ্রুপে নেই — একটি শুরু করতে /group create " + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "du bist in keiner gruppe — /group create , um eine zu starten" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no estás en ningún grupo — /group create para crear uno" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tu n'es dans aucun groupe — /group create pour en démarrer un" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "אתה לא באף קבוצה — /group create כדי להתחיל אחת" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "आप किसी समूह में नहीं हैं — शुरू करने के लिए /group create " + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kamu tidak ada di grup mana pun — /group create untuk memulai" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "non sei in nessun gruppo — /group create per crearne uno" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "どのグループにも参加していません — /group create で作成できます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "참여 중인 그룹이 없습니다 — /group create 으로 시작하세요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "anda tiada dalam mana-mana kumpulan — /group create untuk memulakan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तिमी कुनै समूहमा छैनौ — सुरु गर्न /group create " + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "je zit in geen enkele groep — /group create om er een te starten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie należysz do żadnej grupy — /group create , aby utworzyć" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "não estás em nenhum grupo — /group create para começar um" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ты не состоишь ни в одной группе — /group create , чтобы создать" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "du är inte med i någon grupp — /group create för att skapa en" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "நீங்கள் எந்தக் குழுவிலும் இல்லை — ஒன்றைத் தொடங்க /group create " + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "คุณยังไม่ได้อยู่ในกลุ่มใด — /group create เพื่อเริ่มกลุ่ม" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hiçbir grupta değilsin — bir grup oluşturmak için /group create " + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ти не в жодній групі — /group create , щоб створити" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "آپ کسی گروپ میں نہیں ہیں — ایک شروع کرنے کیلئے /group create " + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bạn không ở trong nhóm nào — /group create để tạo một nhóm" + } } } }, @@ -34416,6 +51260,150 @@ "state" : "translated", "value" : "open a group chat first" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "افتح دردشة جماعية أولاً" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "প্রথমে একটি গ্রুপ চ্যাট খুলুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "öffne zuerst einen gruppenchat" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "abre primero un chat de grupo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ouvre d'abord une discussion de groupe" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "פתח קודם צ'אט קבוצתי" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "पहले कोई समूह चैट खोलें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "buka obrolan grup dulu" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "apri prima una chat di gruppo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "先にグループチャットを開いてください" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "먼저 그룹 채팅을 여세요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "buka sembang kumpulan dahulu" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "पहिले समूह च्याट खोल" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "open eerst een groepschat" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "najpierw otwórz czat grupowy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "abre primeiro uma conversa de grupo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "сначала открой групповой чат" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "öppna en gruppchatt först" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "முதலில் ஒரு குழு உரையாடலைத் திறக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เปิดแชทกลุ่มก่อน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "önce bir grup sohbeti aç" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "спочатку відкрий груповий чат" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "پہلے کوئی گروپ چیٹ کھولیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mở một cuộc trò chuyện nhóm trước" + } } } }, @@ -34427,6 +51415,150 @@ "state" : "translated", "value" : "cannot verify %@'s identity yet — wait for their announce" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "لا يمكن التحقق من هوية %@ بعد — انتظر إعلانه" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@-এর পরিচয় এখনো যাচাই করা যাচ্ছে না — তাদের অ্যানাউন্সের জন্য অপেক্ষা করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@s identität kann noch nicht verifiziert werden — warte auf deren announce" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "aún no se puede verificar la identidad de %@ — espera su anuncio" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "impossible de vérifier l'identité de %@ pour l'instant — attends son announce" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "לא ניתן לאמת את הזהות של %@ עדיין — המתן להכרזה שלו" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ की पहचान अभी सत्यापित नहीं की जा सकती — उनके अनाउंस की प्रतीक्षा करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "belum bisa memverifikasi identitas %@ — tunggu announce mereka" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "impossibile verificare ancora l'identità di %@ — attendi il suo announce" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ の身元をまだ確認できません — announceを待ってください" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ 님의 신원을 아직 확인할 수 없습니다 — announce를 기다리세요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "belum boleh mengesahkan identiti %@ — tunggu announce mereka" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ को पहिचान अझै प्रमाणित गर्न सकिँदैन — उनको घोषणा पर्ख" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kan de identiteit van %@ nog niet verifiëren — wacht op hun announce" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie można jeszcze zweryfikować tożsamości %@ — poczekaj na ich ogłoszenie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ainda não é possível verificar a identidade de %@ — aguarda o announce dele" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "пока нельзя проверить личность %@ — дождись его анонса" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kan inte verifiera %@:s identitet än — vänta på deras announce" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ இன் அடையாளத்தை இன்னும் சரிபார்க்க முடியவில்லை — அவர்களின் அறிவிப்புக்காகக் காத்திருக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ยังไม่สามารถยืนยันตัวตนของ %@ — รอ announce ของพวกเขา" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ kimliği henüz doğrulanamıyor — duyurusunu bekle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "поки не можна підтвердити особу %@ — почекай на їхнє оголошення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ کی شناخت ابھی تصدیق نہیں ہو سکتی — ان کے اعلان کا انتظار کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chưa thể xác minh danh tính của %@ — chờ announce của họ" + } } } }, @@ -34438,6 +51570,150 @@ "state" : "translated", "value" : "%@ must be connected over mesh to be invited" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "يجب أن يكون %@ متصلاً عبر mesh لدعوته" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আমন্ত্রণ জানাতে %@-কে মেশে সংযুক্ত থাকতে হবে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ muss über mesh verbunden sein, um eingeladen zu werden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ debe estar conectado por mesh para ser invitado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ doit être connecté en mesh pour être invité" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ חייב להיות מחובר דרך mesh כדי להזמינו" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ को आमंत्रित करने के लिए मेश पर जुड़ा होना ज़रूरी है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ harus terhubung lewat mesh untuk diundang" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ deve essere connesso via mesh per essere invitato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ は招待するためにmeshで接続している必要があります" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ 님을 초대하려면 mesh로 연결되어 있어야 합니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ mesti bersambung melalui mesh untuk dijemput" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निमन्त्रणा गर्न %@ mesh मार्फत जडान भएको हुनुपर्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ moet via mesh verbonden zijn om uitgenodigd te worden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ musi być połączony przez mesh, aby otrzymać zaproszenie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ tem de estar ligado por mesh para ser convidado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ должен быть подключён по mesh, чтобы получить приглашение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ måste vara ansluten via mesh för att bjudas in" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ அழைக்கப்படுவதற்கு mesh மூலம் இணைக்கப்பட்டிருக்க வேண்டும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ ต้องเชื่อมต่อผ่าน mesh จึงจะเชิญได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "davet edilebilmesi için %@ mesh üzerinden bağlı olmalı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ має бути з'єднаний через mesh, щоб отримати запрошення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "دعوت دینے کیلئے %@ کا mesh پر جڑا ہونا ضروری ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ phải được kết nối qua mesh để được mời" + } } } }, @@ -34449,6 +51725,150 @@ "state" : "translated", "value" : "'%@' not found" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' غير موجود" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' পাওয়া যায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' nicht gefunden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' no encontrado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' introuvable" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' לא נמצא" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' नहीं मिला" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' tidak ditemukan" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' non trovato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' が見つかりません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' 을(를) 찾을 수 없습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' tidak dijumpai" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' भेटिएन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' niet gevonden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie znaleziono '%@'" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' não encontrado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "«%@» не найден" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' hittades inte" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' கண்டறியப்படவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไม่พบ '%@'" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' bulunamadı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' не знайдено" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' نہیں ملا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "không tìm thấy '%@'" + } } } }, @@ -34460,6 +51880,150 @@ "state" : "translated", "value" : "you were removed from group '%@'" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تمت إزالتك من المجموعة '%@'" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আপনাকে '%@' গ্রুপ থেকে সরানো হয়েছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "du wurdest aus der gruppe '%@' entfernt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "fuiste eliminado del grupo '%@'" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tu as été retiré(e) du groupe '%@'" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הוסרת מהקבוצה '%@'" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "आपको समूह '%@' से हटा दिया गया" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kamu dikeluarkan dari grup '%@'" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sei stato rimosso dal gruppo '%@'" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "グループ '%@' から削除されました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "그룹 '%@' 에서 제거되었습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "anda dikeluarkan dari kumpulan '%@'" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तिमीलाई समूह '%@' बाट हटाइयो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "je bent verwijderd uit groep '%@'" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "usunięto cię z grupy '%@'" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "foste removido do grupo '%@'" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "тебя удалили из группы «%@»" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "du togs bort från gruppen '%@'" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' குழுவிலிருந்து நீங்கள் நீக்கப்பட்டீர்கள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "คุณถูกนำออกจากกลุ่ม '%@'" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "'%@' grubundan çıkarıldın" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "тебе видалено з групи '%@'" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "آپ کو گروپ '%@' سے ہٹا دیا گیا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bạn đã bị xóa khỏi nhóm '%@'" + } } } }, @@ -34471,6 +52035,150 @@ "state" : "translated", "value" : "removed %@ and rotated the group key" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تمت إزالة %@ وتدوير مفتاح المجموعة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@-কে সরানো হয়েছে এবং গ্রুপ কী রোটেট করা হয়েছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ entfernt und den gruppenschlüssel rotiert" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "se eliminó a %@ y se rotó la clave del grupo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ retiré(e) et clé du groupe renouvelée" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ הוסר ומפתח הקבוצה סובב" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ को हटाया और समूह कुंजी घुमाई" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "menghapus %@ dan memutar ulang kunci grup" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ rimosso e chiave del gruppo ruotata" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ を削除しグループキーをローテーションしました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ 님을 제거하고 그룹 키를 교체했습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "membuang %@ dan memutar kunci kumpulan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ लाई हटाइयो र समूह कुञ्जी घुमाइयो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ verwijderd en de groepssleutel geroteerd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "usunięto %@ i wymieniono klucz grupy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ removido e chave do grupo rodada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ удалён, ключ группы обновлён" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tog bort %@ och roterade gruppnyckeln" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ ஐ நீக்கி குழு விசையைச் சுழற்றியது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "นำ %@ ออกและเปลี่ยนคีย์กลุ่มแล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ çıkarıldı ve grup anahtarı döndürüldü" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "видалено %@ і замінено ключ групи" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ کو ہٹا دیا اور گروپ کی کلید تبدیل کر دی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đã xóa %@ và xoay khóa nhóm" + } } } }, @@ -34482,6 +52190,150 @@ "state" : "translated", "value" : "could not rotate the group key" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تعذّر تدوير مفتاح المجموعة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "গ্রুপ কী রোটেট করা যায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "der gruppenschlüssel konnte nicht rotiert werden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se pudo rotar la clave del grupo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "impossible de renouveler la clé du groupe" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "לא ניתן לסובב את מפתח הקבוצה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह कुंजी नहीं घुमाई जा सकी" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak bisa memutar ulang kunci grup" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "impossibile ruotare la chiave del gruppo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "グループキーをローテーションできませんでした" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "그룹 키를 교체할 수 없습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak dapat memutar kunci kumpulan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "समूह कुञ्जी घुमाउन सकिएन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kon de groepssleutel niet roteren" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie udało się wymienić klucza grupy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "não foi possível rodar a chave do grupo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не удалось обновить ключ группы" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kunde inte rotera gruppnyckeln" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குழு விசையைச் சுழற்ற முடியவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไม่สามารถเปลี่ยนคีย์กลุ่มได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "grup anahtarı döndürülemedi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не вдалося замінити ключ групи" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "گروپ کی کلید تبدیل نہیں کی جا سکی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "không thể xoay khóa nhóm" + } } } }, @@ -34493,6 +52345,150 @@ "state" : "translated", "value" : "could not encrypt the message" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تعذّر تشفير الرسالة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "বার্তা এনক্রিপ্ট করা যায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "die nachricht konnte nicht verschlüsselt werden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se pudo cifrar el mensaje" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "impossible de chiffrer le message" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "לא ניתן להצפין את ההודעה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "संदेश एन्क्रिप्ट नहीं किया जा सका" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak bisa mengenkripsi pesan" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "impossibile cifrare il messaggio" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "メッセージを暗号化できませんでした" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "메시지를 암호화할 수 없습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak dapat mengenkripsi pesan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "सन्देश सङ्केत गर्न सकिएन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kon het bericht niet versleutelen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie udało się zaszyfrować wiadomości" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "não foi possível encriptar a mensagem" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не удалось зашифровать сообщение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kunde inte kryptera meddelandet" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "செய்தியைக் குறியாக்கம் செய்ய முடியவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไม่สามารถเข้ารหัสข้อความได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesaj şifrelenemedi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не вдалося зашифрувати повідомлення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "پیغام کو خفیہ نہیں کیا جا سکا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "không thể mã hóa tin nhắn" + } } } }, @@ -34504,6 +52500,150 @@ "state" : "translated", "value" : "you are no longer in this group" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "لم تعد في هذه المجموعة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আপনি আর এই গ্রুপে নেই" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "du bist nicht mehr in dieser gruppe" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ya no estás en este grupo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tu n'es plus dans ce groupe" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "אתה כבר לא בקבוצה הזו" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "आप अब इस समूह में नहीं हैं" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kamu tidak lagi ada di grup ini" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "non sei più in questo gruppo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "このグループには参加していません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "더 이상 이 그룹에 속해 있지 않습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "anda tidak lagi dalam kumpulan ini" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तिमी अब यो समूहमा छैनौ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "je zit niet meer in deze groep" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie należysz już do tej grupy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "já não estás neste grupo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ты больше не в этой группе" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "du är inte längre med i den här gruppen" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "நீங்கள் இனி இந்தக் குழுவில் இல்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "คุณไม่ได้อยู่ในกลุ่มนี้แล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "artık bu grupta değilsin" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ти більше не в цій групі" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "آپ اب اس گروپ میں نہیں ہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bạn không còn ở trong nhóm này" + } } } }, @@ -34515,6 +52655,150 @@ "state" : "translated", "value" : "usage: /group create " } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "الاستخدام: /group create " + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ব্যবহার: /group create " + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verwendung: /group create " + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /group create " + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "utilisation : /group create " + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "שימוש: /group create " + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "उपयोग: /group create " + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "penggunaan: /group create " + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "uso: /group create " + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "使い方: /group create " + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "사용법: /group create " + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "penggunaan: /group create " + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "प्रयोग: /group create " + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gebruik: /group create " + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "użycie: /group create " + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "utilização: /group create " + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "использование: /group create " + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "användning: /group create " + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "பயன்பாடு: /group create " + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "วิธีใช้: /group create " + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kullanım: /group create " + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "використання: /group create " + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "استعمال: /group create " + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cách dùng: /group create " + } } } }, @@ -34526,6 +52810,150 @@ "state" : "translated", "value" : "usage: /group invite @name" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "الاستخدام: /group invite @name" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ব্যবহার: /group invite @name" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verwendung: /group invite @name" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /group invite @name" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "utilisation : /group invite @name" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "שימוש: /group invite @name" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "उपयोग: /group invite @name" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "penggunaan: /group invite @name" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "uso: /group invite @name" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "使い方: /group invite @name" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "사용법: /group invite @name" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "penggunaan: /group invite @name" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "प्रयोग: /group invite @name" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gebruik: /group invite @name" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "użycie: /group invite @name" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "utilização: /group invite @name" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "использование: /group invite @name" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "användning: /group invite @name" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "பயன்பாடு: /group invite @name" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "วิธีใช้: /group invite @name" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kullanım: /group invite @name" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "використання: /group invite @name" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "استعمال: /group invite @name" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cách dùng: /group invite @name" + } } } }, @@ -34537,6 +52965,150 @@ "state" : "translated", "value" : "usage: /group remove @name" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "الاستخدام: /group remove @name" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ব্যবহার: /group remove @name" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "verwendung: /group remove @name" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /group remove @name" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "utilisation : /group remove @name" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "שימוש: /group remove @name" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "उपयोग: /group remove @name" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "penggunaan: /group remove @name" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "uso: /group remove @name" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "使い方: /group remove @name" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "사용법: /group remove @name" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "penggunaan: /group remove @name" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "प्रयोग: /group remove @name" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gebruik: /group remove @name" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "użycie: /group remove @name" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "utilização: /group remove @name" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "использование: /group remove @name" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "användning: /group remove @name" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "பயன்பாடு: /group remove @name" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "วิธีใช้: /group remove @name" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kullanım: /group remove @name" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "використання: /group remove @name" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "استعمال: /group remove @name" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cách dùng: /group remove @name" + } } } }, @@ -34907,6 +53479,150 @@ "state" : "translated", "value" : "cannot block %@: not found or unable to verify identity" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تعذّر حظر %@: غير موجود أو تعذّر التحقق من الهوية" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@-কে ব্লক করা যায় না: পাওয়া যায়নি বা পরিচয় যাচাই করা যায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ kann nicht blockiert werden: nicht gefunden oder identität nicht verifizierbar" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se puede bloquear a %@: no encontrado o no se pudo verificar la identidad" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "impossible de bloquer %@ : introuvable ou identité invérifiable" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "לא ניתן לחסום את %@: לא נמצא או שלא ניתן לאמת זהות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ को ब्लॉक नहीं कर सकते: नहीं मिला या पहचान सत्यापित नहीं हो सकी" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak bisa memblokir %@: tidak ditemukan atau tidak bisa memverifikasi identitas" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "impossibile bloccare %@: non trovato o identità non verificabile" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ をブロックできません: 見つからないか身元を確認できません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ 님을 차단할 수 없습니다: 찾을 수 없거나 신원을 확인할 수 없습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak dapat memblokir %@: tidak dijumpai atau tidak dapat mengesahkan identiti" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ लाई ब्लक गर्न सकिएन: भेटिएन वा पहिचान प्रमाणित गर्न सकिएन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kan %@ niet blokkeren: niet gevonden of identiteit niet te verifiëren" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie można zablokować %@: nie znaleziono lub nie można zweryfikować tożsamości" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "não é possível bloquear %@: não encontrado ou identidade não verificável" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не удалось заблокировать %@: не найден или нельзя проверить личность" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kan inte blockera %@: hittades inte eller kan inte verifiera identitet" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ ஐத் தடுக்க முடியாது: கண்டறியப்படவில்லை அல்லது அடையாளத்தைச் சரிபார்க்க முடியவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไม่สามารถบล็อก %@: ไม่พบหรือไม่สามารถยืนยันตัวตน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ engellenemiyor: bulunamadı veya kimlik doğrulanamıyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не можна заблокувати %@: не знайдено або не вдалося підтвердити особу" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ کو بلاک نہیں کیا جا سکتا: نہیں ملا یا شناخت کی تصدیق ممکن نہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "không thể chặn %@: không tìm thấy hoặc không thể xác minh danh tính" + } } } }, @@ -34919,6 +53635,150 @@ "state" : "translated", "value" : "blocked %@. you will no longer receive messages from them" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تم حظر %@. لن تتلقى رسائل منه بعد الآن" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@-কে ব্লক করা হয়েছে। আপনি আর তাদের বার্তা পাবেন না" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ blockiert. du erhältst keine nachrichten mehr von ihnen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "se bloqueó a %@. ya no recibirás mensajes suyos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ bloqué. tu ne recevras plus ses messages" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ נחסם. לא תקבל ממנו יותר הודעות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ को ब्लॉक किया। अब आपको उनसे संदेश नहीं मिलेंगे" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "memblokir %@. kamu tidak akan menerima pesan dari mereka lagi" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ bloccato. non riceverai più messaggi da questa persona" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ をブロックしました。今後この相手からのメッセージは受信しません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ 님을 차단했습니다. 이제 이 사용자의 메시지를 받지 않습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "memblokir %@. anda tidak akan menerima pesan daripada mereka lagi" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ लाई ब्लक गरियो। अब उनीबाट सन्देश पाउने छैनौ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ geblokkeerd. je ontvangt geen berichten meer van deze persoon" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "zablokowano %@. nie będziesz już otrzymywać od nich wiadomości" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ bloqueado. deixarás de receber mensagens desta pessoa" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ заблокирован. ты больше не будешь получать от него сообщения" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "blockerade %@. du får inte längre meddelanden från dem" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ தடுக்கப்பட்டது. அவர்களிடமிருந்து இனி செய்திகளைப் பெறமாட்டீர்கள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "บล็อก %@ แล้ว คุณจะไม่ได้รับข้อความจากพวกเขาอีก" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ engellendi. artık ondan mesaj almayacaksın" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "заблоковано %@. ти більше не отримуватимеш від них повідомлень" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ کو بلاک کر دیا۔ اب آپ کو ان سے پیغامات نہیں ملیں گے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đã chặn %@. bạn sẽ không còn nhận tin nhắn từ họ nữa" + } } } }, @@ -34931,6 +53791,150 @@ "state" : "translated", "value" : "cannot unblock %@: not found" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تعذّر إلغاء حظر %@: غير موجود" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@-কে আনব্লক করা যায় না: পাওয়া যায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ kann nicht entsperrt werden: nicht gefunden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se puede desbloquear a %@: no encontrado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "impossible de débloquer %@ : introuvable" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "לא ניתן לבטל חסימה של %@: לא נמצא" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ को अनब्लॉक नहीं कर सकते: नहीं मिला" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak bisa membuka blokir %@: tidak ditemukan" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "impossibile sbloccare %@: non trovato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ のブロックを解除できません: 見つかりません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ 님의 차단을 해제할 수 없습니다: 찾을 수 없습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "tidak dapat membuka blokir %@: tidak dijumpai" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ लाई अनब्लक गर्न सकिएन: भेटिएन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kan %@ niet deblokkeren: niet gevonden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie można odblokować %@: nie znaleziono" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "não é possível desbloquear %@: não encontrado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не удалось разблокировать %@: не найден" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "kan inte avblockera %@: hittades inte" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ இன் தடையை நீக்க முடியாது: கண்டறியப்படவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไม่สามารถเลิกบล็อก %@: ไม่พบ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ engeli kaldırılamıyor: bulunamadı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не можна розблокувати %@: не знайдено" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ کو ان بلاک نہیں کیا جا سکتا: نہیں ملا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "không thể bỏ chặn %@: không tìm thấy" + } } } }, @@ -34943,6 +53947,150 @@ "state" : "translated", "value" : "unblocked %@" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "أُلغي حظر %@" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@-কে আনব্লক করা হয়েছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ entsperrt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "se desbloqueó a %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ débloqué" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "החסימה של %@ בוטלה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ को अनब्लॉक किया" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "membuka blokir %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ sbloccato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ のブロックを解除しました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ 님의 차단을 해제했습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "membuka blokir %@" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ लाई अनब्लक गरियो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ gedeblokkeerd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "odblokowano %@" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ desbloqueado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ разблокирован" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "avblockerade %@" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ இன் தடை நீக்கப்பட்டது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "เลิกบล็อก %@ แล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ engeli kaldırıldı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "розблоковано %@" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ کو ان بلاک کر دیا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "đã bỏ chặn %@" + } } } }, @@ -35850,6 +54998,150 @@ "state" : "translated", "value" : "estimated from gossiped neighbor lists (up to 10 per peer) — your device is highlighted" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "مُقدَّرة من قوائم الجيران المتناقلة (حتى 10 لكل قرين) — جهازك مميَّز" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "গসিপ করা প্রতিবেশী তালিকা থেকে অনুমান করা (প্রতি পিয়ারে সর্বোচ্চ ১০) — আপনার ডিভাইস হাইলাইট করা" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "geschätzt aus verbreiteten nachbarlisten (bis zu 10 pro peer) — dein gerät ist hervorgehoben" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "estimado a partir de listas de vecinos difundidas (hasta 10 por peer) — tu dispositivo está resaltado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "estimé à partir des listes de voisins diffusées (jusqu'à 10 par pair) — ton appareil est mis en évidence" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "מוערך מרשימות שכנים שהופצו (עד 10 לכל עמית) — המכשיר שלך מודגש" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "गॉसिप की गई पड़ोसी सूचियों से अनुमानित (प्रति पीयर 10 तक) — आपका डिवाइस हाइलाइट किया गया है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "diperkirakan dari daftar tetangga yang di-gossip (hingga 10 per peer) — perangkatmu disorot" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "stimato dalle liste di vicini diffuse (fino a 10 per peer) — il tuo dispositivo è evidenziato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ゴシップされた近隣リスト(ピアあたり最大10件)から推定 — お使いのデバイスをハイライト" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "가십으로 전파된 이웃 목록(피어당 최대 10개)에서 추정 — 사용 중인 기기가 강조 표시됨" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "dianggarkan dari senarai jiran yang di-gossip (sehingga 10 setiap peer) — perantimu diserlahkan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "गसिप गरिएका छिमेकी सूचीबाट अनुमानित (प्रति सहकर्मी बढीमा १०) — तिम्रो यन्त्र हाइलाइट गरिएको छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "geschat op basis van verspreide buurlijsten (tot 10 per peer) — je apparaat is gemarkeerd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "oszacowane na podstawie rozgłaszanych list sąsiadów (do 10 na peera) — twoje urządzenie jest wyróżnione" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "estimado a partir de listas de vizinhos difundidas (até 10 por par) — o teu dispositivo está destacado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "оценено по gossip-спискам соседей (до 10 на пира) — твоё устройство выделено" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "uppskattat från skvallrade grannlistor (upp till 10 per peer) — din enhet är markerad" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "வதந்தியாகப் பகிரப்பட்ட அண்டை பட்டியல்களிலிருந்து மதிப்பிடப்பட்டது (ஒரு peer க்கு 10 வரை) — உங்கள் சாதனம் தனிப்படுத்தப்பட்டுள்ளது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ประมาณจากรายชื่อเพื่อนบ้านที่ส่งต่อแบบ gossip (สูงสุด 10 รายต่อเพียร์) — อุปกรณ์ของคุณถูกไฮไลต์" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "yayılan komşu listelerinden tahmin edildi (eş başına en fazla 10) — cihazın vurgulanmış" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "оцінено на основі поширюваних списків сусідів (до 10 на піра) — твій пристрій виділено" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "گپ شپ کی گئی پڑوسیوں کی فہرستوں سے اندازہ (فی ہم منصب 10 تک) — آپ کی ڈیوائس نمایاں ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ước tính từ danh sách hàng xóm được gossip (tối đa 10 mỗi nút) — thiết bị của bạn được làm nổi bật" + } } } }, @@ -35862,6 +55154,150 @@ "state" : "translated", "value" : "no mesh links yet — the map fills in as peer announces arrive" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "لا توجد روابط mesh بعد — تمتلئ الخريطة مع وصول إعلانات الأقران" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "এখনো কোনো মেশ লিঙ্ক নেই — পিয়ার অ্যানাউন্স এলে মানচিত্র পূরণ হয়" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "noch keine mesh-verbindungen — die karte füllt sich, sobald peer-announces eintreffen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "aún no hay enlaces del mesh — el mapa se completa a medida que llegan los anuncios de peers" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "aucun lien mesh pour l'instant — la carte se remplit à mesure que les annonces des pairs arrivent" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "אין עדיין קישורי mesh — המפה מתמלאת כשהכרזות עמיתים מגיעות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "अभी तक कोई मेश लिंक नहीं — पीयर अनाउंस आने पर नक्शा भरता जाता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "belum ada tautan mesh — peta terisi saat announce peer berdatangan" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ancora nessun collegamento mesh — la mappa si riempie man mano che arrivano gli announce dei peer" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "まだmeshリンクがありません — ピアのannounceが届くとマップが埋まります" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "아직 mesh 링크가 없습니다 — 피어 announce가 도착하면 지도가 채워집니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "belum ada pautan mesh — peta terisi apabila announce peer tiba" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "अझै mesh लिंक छैन — सहकर्मी घोषणा आउँदै जाँदा नक्सा भरिन्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nog geen mesh-verbindingen — de kaart vult zich naarmate peer-announces binnenkomen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "brak połączeń mesh — mapa wypełnia się, gdy nadchodzą ogłoszenia peerów" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ainda sem ligações mesh — o mapa preenche-se à medida que chegam os announces dos pares" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "пока нет mesh-связей — карта заполнится по мере поступления анонсов пиров" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "inga mesh-länkar än — kartan fylls i när peer-announces kommer in" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "இன்னும் mesh இணைப்புகள் இல்லை — peer அறிவிப்புகள் வரும்போது வரைபடம் நிரப்பப்படும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ยังไม่มีการเชื่อมต่อ mesh — แผนที่จะเติมเต็มเมื่อ announce ของเพียร์มาถึง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "henüz mesh bağlantısı yok — eş duyuruları geldikçe harita dolar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "поки немає зв'язків mesh — карта заповнюється, коли надходять оголошення пірів" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ابھی کوئی mesh روابط نہیں — ہم منصبوں کے اعلانات آتے ہی نقشہ بھر جاتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "chưa có liên kết mesh nào — bản đồ sẽ được điền khi các announce của nút đến" + } } } }, @@ -35874,6 +55310,150 @@ "state" : "translated", "value" : "refresh topology" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تحديث الطوبولوجيا" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "টপোলজি রিফ্রেশ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologie aktualisieren" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "actualizar topología" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "actualiser la topologie" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "רענון טופולוגיה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "टोपोलॉजी रीफ़्रेश करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "segarkan topologi" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "aggiorna topologia" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "トポロジーを更新" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "토폴로지 새로고침" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "segarkan topologi" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "टोपोलोजी ताजा गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologie vernieuwen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "odśwież topologię" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "atualizar topologia" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "обновить топологию" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "uppdatera topologi" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "டோபாலஜியைப் புதுப்பிக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "รีเฟรชโทโพโลยี" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topolojiyi yenile" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "оновити топологію" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ٹوپولوجی ریفریش کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "làm mới cấu trúc" + } } } }, @@ -35886,6 +55466,150 @@ "state" : "translated", "value" : "%1$ld peers · %2$ld links" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld قرين · %2$ld رابط" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld পিয়ার · %2$ld লিঙ্ক" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld peers · %2$ld verbindungen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$ld peers · %2$ld enlaces" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld pairs · %2$ld liens" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld עמיתים · %2$ld קישורים" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld पीयर · %2$ld लिंक" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld peer · %2$ld tautan" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld peer · %2$ld collegamenti" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld ピア · %2$ld リンク" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "피어 %1$ld개 · 링크 %2$ld개" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld peer · %2$ld pautan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld सहकर्मी · %2$ld लिंक" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld peers · %2$ld verbindingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld peerów · %2$ld połączeń" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld pares · %2$ld ligações" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld пиров · %2$ld связей" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld peers · %2$ld länkar" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld peer-கள் · %2$ld இணைப்புகள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld เพียร์ · %2$ld การเชื่อมต่อ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld eş · %2$ld bağlantı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld пірів · %2$ld зв'язків" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld ہم منصب · %2$ld روابط" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%1$ld nút · %2$ld liên kết" + } } } }, @@ -35898,6 +55622,150 @@ "state" : "translated", "value" : "mesh topology" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "طوبولوجيا mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "মেশ টপোলজি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh-topologie" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "topología del mesh" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologie mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "טופולוגיית mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "मेश टोपोलॉजी" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologi mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologia mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh トポロジー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh 토폴로지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologi mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh टोपोलोजी" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh-topologie" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologia mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "topologia mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "топология mesh" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh-topologi" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh டோபாலஜி" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "โทโพโลยี mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh topolojisi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "топологія mesh" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesh ٹوپولوجی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cấu trúc mesh" + } } } }, @@ -38238,6 +58106,150 @@ "state" : "translated", "value" : "image unavailable" } + }, + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "الصورة غير متاحة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ছবি অনুপলব্ধ" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bild nicht verfügbar" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "imagen no disponible" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "image indisponible" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "התמונה לא זמינה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "चित्र उपलब्ध नहीं" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gambar tidak tersedia" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "immagine non disponibile" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "画像を利用できません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이미지를 사용할 수 없음" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "imej tidak tersedia" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तस्बिर उपलब्ध छैन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "afbeelding niet beschikbaar" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "obraz niedostępny" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "imagem indisponível" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "изображение недоступно" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bild otillgänglig" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "படம் கிடைக்கவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไม่สามารถใช้รูปภาพได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "görsel kullanılamıyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "зображення недоступне" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تصویر دستیاب نہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hình ảnh không khả dụng" + } } } } From 8415c529131441a6b44da4029d6375775623aa43 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:40:12 +0200 Subject: [PATCH 18/18] Unified notices: one pin, one sheet for board pins + location notes (#1392) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Unified notices: merge board pins and location notes into one sheet One pin icon in the header now opens a single Notices sheet with a geo/mesh scope toggle, replacing the separate board, location-notes, and mesh-only note buttons: - geo tab: current geohash's notices — mesh-synced board posts merged and deduped with Nostr kind-1 location notes, with per-item mesh/net source badges. Scope follows the selected location channel, or the device's building geohash when chatting on mesh. - mesh tab: mesh-local board only (fully offline). - One composer: geo posts go to the board and bridge to Nostr (existing bridge), so mesh and internet see the same notice. - Merged delete: tombstoning an own board post now also retracts the bridged Nostr copy via NIP-09 (new createDeleteEvent, bridged event ids tracked in BoardManager); own Nostr-only notes are deletable too. - LocationNotesManager accepts any channel-precision geohash (1-12 chars), not just building-level. BoardView and LocationNotesView are superseded by NoticesView. Co-Authored-By: Claude Fable 5 * Notices round 2: honest composer, friendlier copy, new-pin chat alerts - Urgent + expiry controls now appear on the mesh tab only: the bridged Nostr copy of a geo post carries neither, so relay-side readers would never see them. Geo posts default to non-urgent with 7-day expiry, and the bridged note now gets a NIP-40 expiration tag so honoring relays drop it in step with the board copy. - Geo tab explainer reuses the original location-notes description (keeps its 29 existing translations); mesh tab gets a new plain- language description. - New-pin chat alerts, fully local (no wire traffic): BoardStore fires postArrivals for posts newly accepted from the wire; BoardAlertsModel filters own posts, dedups by postID, and for urgent pins created within the last 30 minutes emits one system line into the matching timeline (geo pin -> that geohash's chat, mesh pin -> mesh chat), collapsing simultaneous arrivals into a count line. - Routine pins light up the header: the pin icon tints orange whenever the current scope has notices at all, and fills (pin.fill) while unseen new pins are waiting; opening the sheet clears them. Co-Authored-By: Claude Fable 5 * i18n: translate the unified-notices strings into all 28 non-English locales Adds the 13 new notices keys (sheet title, geo/mesh tabs, mesh description, source badges, urgent alert lines, button tooltip and accessibility strings) to the string catalog with translations for every locale the app ships. The geo tab already reuses the fully translated location_notes.description; this covers the rest. Insertion preserves the catalog's case-insensitive key order, so the diff is purely additive. Co-Authored-By: Claude Fable 5 * Address Codex review: panic-wipe reset, scoped badge clear, geohash-aware dedupe - BoardStore.wipe() now emits didWipe; BoardAlertsModel subscribes and resets, so a panic wipe drops pending urgent lines (which could otherwise re-append pre-wipe content into chat after the collapse flush), unseen badge scopes, and handled-post history. - Opening the notices sheet clears unseen badges only for the scopes it actually shows (mesh + current geo scope); pins for other geohash channels keep their badge until visited. - LocationNotesManager.Note now retains the matched g tag, and the bridged-copy dedupe requires the note's geohash to equal the board post's — a same-text note from a neighboring cell is no longer swallowed as a duplicate. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/App/AppRuntime.swift | 19 + bitchat/BitchatApp.swift | 1 + bitchat/Localizable.xcstrings | 7189 +++++++++++------ bitchat/Nostr/NostrProtocol.swift | 29 +- bitchat/Protocols/Geohash.swift | 8 + bitchat/Services/Board/BoardAlertsModel.swift | 160 + bitchat/Services/Board/BoardManager.swift | 50 +- bitchat/Services/Board/BoardStore.swift | 18 + bitchat/Services/Board/UnifiedNotices.swift | 88 + bitchat/Services/LocationNotesManager.swift | 56 +- bitchat/ViewModels/ChatViewModel.swift | 13 + bitchat/Views/BoardView.swift | 270 - bitchat/Views/ContentHeaderView.swift | 175 +- bitchat/Views/ContentView.swift | 4 - bitchat/Views/LocationNotesView.swift | 304 - bitchat/Views/NoticesView.swift | 560 ++ .../Services/BoardAlertsModelTests.swift | 215 + .../Services/UnifiedNoticesTests.swift | 142 + bitchatTests/ViewSmokeTests.swift | 48 +- 19 files changed, 6210 insertions(+), 3139 deletions(-) create mode 100644 bitchat/Services/Board/BoardAlertsModel.swift create mode 100644 bitchat/Services/Board/UnifiedNotices.swift delete mode 100644 bitchat/Views/BoardView.swift delete mode 100644 bitchat/Views/LocationNotesView.swift create mode 100644 bitchat/Views/NoticesView.swift create mode 100644 bitchatTests/Services/BoardAlertsModelTests.swift create mode 100644 bitchatTests/Services/UnifiedNoticesTests.swift diff --git a/bitchat/App/AppRuntime.swift b/bitchat/App/AppRuntime.swift index a706cfac..de096e8e 100644 --- a/bitchat/App/AppRuntime.swift +++ b/bitchat/App/AppRuntime.swift @@ -28,6 +28,7 @@ final class AppRuntime: ObservableObject { let locationChannelsModel: LocationChannelsModel let peerListModel: PeerListModel let appChromeModel: AppChromeModel + let boardAlertsModel: BoardAlertsModel private let idBridge: NostrIdentityBridge private var cancellables = Set() @@ -91,6 +92,24 @@ final class AppRuntime: ObservableObject { chatViewModel: self.chatViewModel, privateInboxModel: self.privateInboxModel ) + let chatViewModel = self.chatViewModel + self.boardAlertsModel = BoardAlertsModel( + arrivals: BoardStore.shared.postArrivals.eraseToAnyPublisher(), + wipes: BoardStore.shared.didWipe.eraseToAnyPublisher(), + dependencies: BoardAlertsModel.Dependencies( + isOwnPost: { post in + let key = chatViewModel.meshService.noiseSigningPublicKeyData() + return !key.isEmpty && key == post.authorSigningKey + }, + emitSystemLine: { content, geohash in + if geohash.isEmpty { + chatViewModel.addMeshOnlySystemMessage(content) + } else { + chatViewModel.addGeohashSystemMessage(content, geohash: geohash) + } + } + ) + ) GeoRelayDirectory.shared.prefetchIfNeeded() bindRuntimeObservers() diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index e606506f..41a01f6d 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -40,6 +40,7 @@ struct BitchatApp: App { .environmentObject(runtime.locationChannelsModel) .environmentObject(runtime.peerListModel) .environmentObject(runtime.appChromeModel) + .environmentObject(runtime.boardAlertsModel) .onAppear { appDelegate.runtime = runtime runtime.start() diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index fdabb822..c2ef1442 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -1,6 +1,9 @@ { "sourceLanguage" : "en", "strings" : { + "#%@" : { + + }, "%@" : { "comment" : "Non-localizable symbol used in code", "extractionState" : "manual", @@ -5019,12 +5022,6 @@ "comment" : "Legend entry for the nosign glyph", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "blocked" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -5043,6 +5040,12 @@ "value" : "blockiert" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "blocked" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -5175,12 +5178,6 @@ "comment" : "Legend entry for the lock glyph", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "end-to-end encrypted session" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -5199,6 +5196,12 @@ "value" : "end-to-end-verschlüsselte sitzung" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "end-to-end encrypted session" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -5331,12 +5334,6 @@ "comment" : "Legend entry for the failed-encryption lock-slash glyph", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "encryption failed — messages not secured" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -5355,6 +5352,12 @@ "value" : "verschlüsselung fehlgeschlagen — nachrichten nicht gesichert" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "encryption failed — messages not secured" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -5487,12 +5490,6 @@ "comment" : "Legend entry for the star glyph", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "favorite — enables offline messages via nostr when mutual" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -5511,6 +5508,12 @@ "value" : "favorit — ermöglicht offline-nachrichten über nostr, wenn beidseitig" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorite — enables offline messages via nostr when mutual" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -5643,12 +5646,6 @@ "comment" : "Legend entry for the map pin glyph", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "physically in this location channel's area" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -5667,6 +5664,12 @@ "value" : "physisch im gebiet dieses standortkanals" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "physically in this location channel's area" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -5799,12 +5802,6 @@ "comment" : "Legend entry for the antenna glyph", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "connected directly over bluetooth" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -5823,6 +5820,12 @@ "value" : "direkt über bluetooth verbunden" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "connected directly over bluetooth" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -5955,12 +5958,6 @@ "comment" : "Legend entry for the relayed-mesh glyph", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "reachable through the mesh, relayed by others" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -5979,6 +5976,12 @@ "value" : "über das mesh erreichbar, von anderen weitergeleitet" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "reachable through the mesh, relayed by others" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -6111,12 +6114,6 @@ "comment" : "Legend entry for the globe glyph", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "reachable over the internet (nostr) — mutual favorites only" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -6135,6 +6132,12 @@ "value" : "über das internet erreichbar (nostr) — nur bei beidseitigen favoriten" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "reachable over the internet (nostr) — mutual favorites only" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -6267,12 +6270,6 @@ "comment" : "Legend entry for the offline person glyph", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "offline — not currently reachable" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -6291,6 +6288,12 @@ "value" : "offline — derzeit nicht erreichbar" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "offline — not currently reachable" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -6423,12 +6426,6 @@ "comment" : "Legend entry for the teleported glyph", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "teleported — joined the channel from somewhere else" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -6447,6 +6444,12 @@ "value" : "teleportiert — dem kanal von woanders beigetreten" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "teleported — joined the channel from somewhere else" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -6579,12 +6582,6 @@ "comment" : "Section header for the symbols legend in app info", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "SYMBOLS" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -6603,6 +6600,12 @@ "value" : "SYMBOLE" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "SYMBOLS" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -6735,12 +6738,6 @@ "comment" : "Legend entry for the envelope glyph", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "unread private messages" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -6759,6 +6756,12 @@ "value" : "ungelesene private nachrichten" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "unread private messages" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -6891,12 +6894,6 @@ "comment" : "Legend entry for the verified seal glyph", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "identity verified" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -6915,6 +6912,12 @@ "value" : "identität verifiziert" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "identity verified" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -7047,12 +7050,6 @@ "comment" : "Section header for network diagnostics in the app info sheet", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "NETWORK" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -7071,6 +7068,12 @@ "value" : "NETZWERK" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "NETWORK" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -7203,12 +7206,6 @@ "comment" : "Row description for the mesh topology map", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "map of peers and links learned from mesh announces" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -7227,6 +7224,12 @@ "value" : "karte der peers und verbindungen, aus mesh-announces gelernt" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "map of peers and links learned from mesh announces" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -7359,12 +7362,6 @@ "comment" : "Accessibility hint for the mesh topology row", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "opens the mesh topology map" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -7383,6 +7380,12 @@ "value" : "öffnet die mesh-topologie-karte" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "opens the mesh topology map" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -7515,12 +7518,6 @@ "comment" : "Row title opening the mesh topology map", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh topology" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -7539,6 +7536,12 @@ "value" : "mesh-topologie" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh topology" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -9457,6 +9460,186 @@ } } }, + "Choose an image" : { + "comment" : "A label displayed above a button that allows the user to choose an image to send.", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "اختر صورة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "একটি ছবি নির্বাচন করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bild auswählen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose an image" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elige una imagen" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumili ng larawan" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisir une image" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בחר תמונה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "एक चित्र चुनें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pilih gambar" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scegli un’immagine" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "画像を選択" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이미지를 선택하세요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pilih imej" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "एउटा तस्वीर चयन गर्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kies een afbeelding" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wybierz obraz" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolher uma imagem" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolha uma imagem" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите изображение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Välj en bild" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "ஒரு படத்தைத் தேர்ந்தெடுக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เลือกภาพ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bir görüntü seç" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Виберіть зображення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ایک تصویر منتخب کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chọn một hình ảnh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择图像" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "選擇圖像" + } + } + } + }, "close" : { "comment" : "Button to dismiss fullscreen media viewer", "localizations" : { @@ -11072,12 +11255,6 @@ "comment" : "Accessibility hint on the bitchat/ logo explaining a tap opens app info", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "shows app info" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -11096,6 +11273,12 @@ "value" : "zeigt app-infos" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "shows app info" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -11228,12 +11411,6 @@ "comment" : "Accessibility label for the photo attachment button", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "attach photo" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -11252,6 +11429,12 @@ "value" : "foto anhängen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "attach photo" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -11384,12 +11567,6 @@ "comment" : "Accessibility hint explaining the attachment button opens the photo library", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "opens the photo library; use the take photo action for the camera" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -11408,6 +11585,12 @@ "value" : "öffnet die fotobibliothek; nutze die aktion foto aufnehmen für die kamera" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "opens the photo library; use the take photo action for the camera" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -11898,12 +12081,6 @@ "comment" : "Accessibility label for the macOS photo picker button", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "choose photo" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -11922,6 +12099,12 @@ "value" : "foto auswählen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "choose photo" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -12233,12 +12416,6 @@ "comment" : "Accessibility hint for the delivery status glyph explaining a tap reveals details", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "tap to show delivery details" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -12257,6 +12434,12 @@ "value" : "tippe, um zustelldetails anzuzeigen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "tap to show delivery details" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -12568,12 +12751,6 @@ "comment" : "Accessibility label for the internet gateway indicator", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Internet gateway active, sharing your connection with the mesh" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -12592,6 +12769,12 @@ "value" : "Internet-gateway aktiv, teilt deine verbindung mit dem mesh" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Internet gateway active, sharing your connection with the mesh" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -12723,12 +12906,6 @@ "content.accessibility.group_chat" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Group chat" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -12747,6 +12924,12 @@ "value" : "Gruppenchat" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group chat" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -12879,12 +13062,6 @@ "comment" : "Accessibility label for the jump to latest messages button", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "jump to latest messages" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -12903,6 +13080,12 @@ "value" : "zu den neuesten nachrichten springen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "jump to latest messages" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -13389,6 +13572,366 @@ } } }, + "content.accessibility.notices" : { + "comment" : "Accessibility label for the notices button", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إعلانات" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "নোটিশ" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hinweise" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notices" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avisos" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mga paunawa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annonces" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מודעות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचनाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pengumuman" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avvisi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お知らせ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pengumuman" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचनाहरू" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mededelingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ogłoszenia" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avisos" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avisos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Объявления" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anslag" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அறிவிப்புகள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ประกาศ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duyurular" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оголошення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اعلانات" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thông báo" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "公告" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "公告" + } + } + } + }, + "content.accessibility.notices_new" : { + "comment" : "Accessibility value for the notices button when unseen pins arrived", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld جديد" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldটি নতুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld neu" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld new" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld nuevos" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld bago" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld nouvelles" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld חדשות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld नई" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld baru" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld nuovi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld件の新着" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld개 새 항목" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld baharu" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld नयाँ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld nieuw" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld nowych" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld novos" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld novos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld новых" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld nya" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld புதியவை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ใหม่ %lld รายการ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld yeni" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld нових" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld نئے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld mới" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 条新公告" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 則新公告" + } + } + } + }, "content.accessibility.open_unread_private_chat" : { "extractionState" : "manual", "localizations" : { @@ -13572,12 +14115,6 @@ "comment" : "Accessibility value when peers are reachable", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "connected" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -13596,6 +14133,12 @@ "value" : "verbunden" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "connected" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -13728,12 +14271,6 @@ "comment" : "Accessibility value when no peers are reachable", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "no one reachable" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -13752,6 +14289,12 @@ "value" : "niemand erreichbar" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "no one reachable" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -14805,12 +15348,6 @@ "comment" : "Accessibility hint explaining double-tap toggles voice recording", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "double-tap to start recording, double-tap again to send" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -14829,6 +15366,12 @@ "value" : "doppeltippen zum aufnehmen, erneut doppeltippen zum senden" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "double-tap to start recording, double-tap again to send" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -14961,12 +15504,6 @@ "comment" : "Accessibility label for the voice note button", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "record voice note" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -14985,6 +15522,12 @@ "value" : "sprachnachricht aufnehmen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "record voice note" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -15117,12 +15660,6 @@ "comment" : "Accessibility value announced while a voice note is recording", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "recording" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -15141,6 +15678,12 @@ "value" : "aufnahme" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "recording" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -15989,12 +16532,6 @@ "comment" : "Accessibility action name for taking a photo with the camera", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "take photo with camera" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -16013,6 +16550,12 @@ "value" : "foto mit kamera aufnehmen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "take photo with camera" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -16503,12 +17046,6 @@ "comment" : "Accessibility label for the verification QR button", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "verify encryption" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -16527,6 +17064,12 @@ "value" : "verschlüsselung verifizieren" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "verify encryption" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -17554,12 +18097,6 @@ "comment" : "Context menu action that resends a failed private message", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "resend" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -17578,6 +18115,12 @@ "value" : "erneut senden" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "resend" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -19321,12 +19864,6 @@ "comment" : "Destructive confirmation button that clears the current chat", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "clear chat" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -19345,6 +19882,12 @@ "value" : "chat leeren" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "clear chat" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -19477,12 +20020,6 @@ "comment" : "Title of the confirmation dialog shown before clearing the current chat", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "clear this chat?" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -19501,6 +20038,12 @@ "value" : "diesen chat leeren?" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "clear this chat?" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -20169,12 +20712,6 @@ "content.commands.group" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "create or manage private groups" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -20193,6 +20730,12 @@ "value" : "private gruppen erstellen oder verwalten" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "create or manage private groups" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -20325,12 +20868,6 @@ "comment" : "Description of the /help command in the suggestions panel", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "show available commands" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -20349,6 +20886,12 @@ "value" : "verfügbare befehle anzeigen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "show available commands" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -20839,12 +21382,6 @@ "comment" : "Autocomplete description for the /pay command that sends a Cashu ecash token", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "send a cashu ecash token in this chat" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -20863,6 +21400,12 @@ "value" : "einen cashu-ecash-token in diesem chat senden" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "send a cashu ecash token in this chat" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -20995,12 +21538,6 @@ "comment" : "Description of the /ping command in the suggestions panel", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "measure round-trip time to a mesh peer" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -21019,6 +21556,12 @@ "value" : "die roundtrip-zeit zu einem mesh-peer messen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "measure round-trip time to a mesh peer" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -21330,12 +21873,6 @@ "comment" : "Description of the /trace command in the suggestions panel", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "estimate the mesh path to a peer" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -21354,6 +21891,12 @@ "value" : "den mesh-pfad zu einem peer schätzen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "estimate the mesh path to a peer" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -22914,172 +23457,10 @@ } } }, - "content.delivery.reason.not_delivered" : { - "comment" : "Failure reason shown when the router gave up delivering a message", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "not delivered" - } - }, - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "لم يُسلَّم" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "পৌঁছায়নি" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nicht zugestellt" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "no entregado" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "non livré" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "לא נמסר" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "डिलीवर नहीं हुआ" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tidak terkirim" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "non consegnato" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "未配信" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "전송되지 않음" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "tidak dihantar" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "डेलिभर भएन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "niet bezorgd" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nie dostarczono" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "não entregue" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "не доставлено" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "inte levererat" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "வழங்கப்படவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ยังไม่ได้ส่ง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "teslim edilmedi" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "не доставлено" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "نہیں پہنچا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "chưa gửi được" - } - } - } - }, "content.delivery.reason.encryption_failed" : { "comment" : "Failure reason shown when a message could not be encrypted for the peer", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "encryption failed" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -23098,6 +23479,12 @@ "value" : "verschlüsselung fehlgeschlagen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "encryption failed" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -23226,314 +23613,158 @@ } } }, - "content.delivery.reason.voice_too_large" : { - "comment" : "Failure reason shown when a voice note exceeds the size limit", + "content.delivery.reason.not_delivered" : { + "comment" : "Failure reason shown when the router gave up delivering a message", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "voice note too large" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", - "value" : "الملاحظة الصوتية كبيرة جدًا" + "value" : "لم يُسلَّم" } }, "bn" : { "stringUnit" : { "state" : "needs_review", - "value" : "ভয়েস নোট খুব বড়" + "value" : "পৌঁছায়নি" } }, "de" : { "stringUnit" : { "state" : "needs_review", - "value" : "sprachnachricht zu groß" + "value" : "nicht zugestellt" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "nota de voz demasiado grande" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "note vocale trop volumineuse" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ההערה הקולית גדולה מדי" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "वॉइस नोट बहुत बड़ा" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "catatan suara terlalu besar" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nota vocale troppo grande" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ボイスメモが大きすぎます" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "음성 메모가 너무 큼" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nota suara terlalu besar" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "भ्वाइस नोट धेरै ठूलो" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "spraakbericht te groot" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "notatka głosowa zbyt duża" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nota de voz demasiado grande" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "голосовое сообщение слишком большое" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "röstmeddelandet är för stort" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "குரல் குறிப்பு மிகப் பெரியது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ข้อความเสียงใหญ่เกินไป" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sesli not çok büyük" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "голосова нотатка завелика" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "وائس نوٹ بہت بڑا ہے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ghi chú giọng nói quá lớn" - } - } - } - }, - "content.delivery.reason.voice_send_failed" : { - "comment" : "Failure reason shown when a voice note could not be sent", - "extractionState" : "manual", - "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "voice note failed to send" - } - }, - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "فشل إرسال الملاحظة الصوتية" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ভয়েস নোট পাঠানো যায়নি" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "sprachnachricht konnte nicht gesendet werden" + "value" : "not delivered" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "no se pudo enviar la nota de voz" + "value" : "no entregado" } }, "fr" : { "stringUnit" : { "state" : "needs_review", - "value" : "échec de l'envoi de la note vocale" + "value" : "non livré" } }, "he" : { "stringUnit" : { "state" : "needs_review", - "value" : "שליחת ההערה הקולית נכשלה" + "value" : "לא נמסר" } }, "hi" : { "stringUnit" : { "state" : "needs_review", - "value" : "वॉइस नोट भेजने में विफल" + "value" : "डिलीवर नहीं हुआ" } }, "id" : { "stringUnit" : { "state" : "needs_review", - "value" : "catatan suara gagal dikirim" + "value" : "tidak terkirim" } }, "it" : { "stringUnit" : { "state" : "needs_review", - "value" : "invio della nota vocale non riuscito" + "value" : "non consegnato" } }, "ja" : { "stringUnit" : { "state" : "needs_review", - "value" : "ボイスメモの送信に失敗" + "value" : "未配信" } }, "ko" : { "stringUnit" : { "state" : "needs_review", - "value" : "음성 메모 전송 실패" + "value" : "전송되지 않음" } }, "ms" : { "stringUnit" : { "state" : "needs_review", - "value" : "nota suara gagal dihantar" + "value" : "tidak dihantar" } }, "ne" : { "stringUnit" : { "state" : "needs_review", - "value" : "भ्वाइस नोट पठाउन असफल" + "value" : "डेलिभर भएन" } }, "nl" : { "stringUnit" : { "state" : "needs_review", - "value" : "spraakbericht kon niet worden verzonden" + "value" : "niet bezorgd" } }, "pl" : { "stringUnit" : { "state" : "needs_review", - "value" : "nie udało się wysłać notatki głosowej" + "value" : "nie dostarczono" } }, "pt" : { "stringUnit" : { "state" : "needs_review", - "value" : "falha ao enviar a nota de voz" + "value" : "não entregue" } }, "ru" : { "stringUnit" : { "state" : "needs_review", - "value" : "не удалось отправить голосовое сообщение" + "value" : "не доставлено" } }, "sv" : { "stringUnit" : { "state" : "needs_review", - "value" : "röstmeddelandet kunde inte skickas" + "value" : "inte levererat" } }, "ta" : { "stringUnit" : { "state" : "needs_review", - "value" : "குரல் குறிப்பு அனுப்ப முடியவில்லை" + "value" : "வழங்கப்படவில்லை" } }, "th" : { "stringUnit" : { "state" : "needs_review", - "value" : "ส่งข้อความเสียงไม่สำเร็จ" + "value" : "ยังไม่ได้ส่ง" } }, "tr" : { "stringUnit" : { "state" : "needs_review", - "value" : "sesli not gönderilemedi" + "value" : "teslim edilmedi" } }, "uk" : { "stringUnit" : { "state" : "needs_review", - "value" : "не вдалося надіслати голосову нотатку" + "value" : "не доставлено" } }, "ur" : { "stringUnit" : { "state" : "needs_review", - "value" : "وائس نوٹ بھیجنے میں ناکام" + "value" : "نہیں پہنچا" } }, "vi" : { "stringUnit" : { "state" : "needs_review", - "value" : "gửi ghi chú giọng nói thất bại" + "value" : "chưa gửi được" } } } @@ -24254,16 +24485,322 @@ } } }, + "content.delivery.reason.voice_send_failed" : { + "comment" : "Failure reason shown when a voice note could not be sent", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "فشل إرسال الملاحظة الصوتية" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ভয়েস নোট পাঠানো যায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sprachnachricht konnte nicht gesendet werden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "voice note failed to send" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se pudo enviar la nota de voz" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "échec de l'envoi de la note vocale" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "שליחת ההערה הקולית נכשלה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "वॉइस नोट भेजने में विफल" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "catatan suara gagal dikirim" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "invio della nota vocale non riuscito" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ボイスメモの送信に失敗" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "음성 메모 전송 실패" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nota suara gagal dihantar" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "भ्वाइस नोट पठाउन असफल" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "spraakbericht kon niet worden verzonden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nie udało się wysłać notatki głosowej" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "falha ao enviar a nota de voz" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не удалось отправить голосовое сообщение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "röstmeddelandet kunde inte skickas" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குரல் குறிப்பு அனுப்ப முடியவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ส่งข้อความเสียงไม่สำเร็จ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sesli not gönderilemedi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "не вдалося надіслати голосову нотатку" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "وائس نوٹ بھیجنے میں ناکام" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gửi ghi chú giọng nói thất bại" + } + } + } + }, + "content.delivery.reason.voice_too_large" : { + "comment" : "Failure reason shown when a voice note exceeds the size limit", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "الملاحظة الصوتية كبيرة جدًا" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ভয়েস নোট খুব বড়" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sprachnachricht zu groß" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "voice note too large" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "nota de voz demasiado grande" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "note vocale trop volumineuse" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ההערה הקולית גדולה מדי" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "वॉइस नोट बहुत बड़ा" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "catatan suara terlalu besar" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nota vocale troppo grande" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ボイスメモが大きすぎます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "음성 메모가 너무 큼" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nota suara terlalu besar" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "भ्वाइस नोट धेरै ठूलो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "spraakbericht te groot" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "notatka głosowa zbyt duża" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nota de voz demasiado grande" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "голосовое сообщение слишком большое" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "röstmeddelandet är för stort" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "குரல் குறிப்பு மிகப் பெரியது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ข้อความเสียงใหญ่เกินไป" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "sesli not çok büyük" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "голосова нотатка завелика" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "وائس نوٹ بہت بڑا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ghi chú giọng nói quá lớn" + } + } + } + }, "content.delivery.sending" : { "comment" : "Delivery status description while a private message is being sent", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "sending..." - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -24282,6 +24819,12 @@ "value" : "wird gesendet ..." } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "sending..." + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -24414,12 +24957,6 @@ "comment" : "Delivery status description for a sent but not yet confirmed private message", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "sent — no delivery confirmation yet" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -24438,6 +24975,12 @@ "value" : "gesendet — noch keine zustellbestätigung" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "sent — no delivery confirmation yet" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -24570,12 +25113,6 @@ "comment" : "First line of an empty geohash timeline naming the channel", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "you're in #%@ — a public location channel over the internet" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -24594,6 +25131,12 @@ "value" : "du bist in #%@ — ein öffentlicher standortkanal über das internet" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you're in #%@ — a public location channel over the internet" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -24726,12 +25269,6 @@ "comment" : "First line of the empty mesh timeline explaining what the mesh channel is", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "you're on #mesh — reaches people within bluetooth range" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -24750,6 +25287,12 @@ "value" : "du bist auf #mesh — erreicht menschen in bluetooth-reichweite" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you're on #mesh — reaches people within bluetooth range" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -24882,12 +25425,6 @@ "comment" : "Second line of the empty mesh timeline saying no peers are in range yet", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "nobody in range yet... messages appear here" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -24906,6 +25443,12 @@ "value" : "noch niemand in reichweite ... nachrichten erscheinen hier" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "nobody in range yet... messages appear here" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -25038,12 +25581,6 @@ "comment" : "Empty timeline hint pointing at the channel switcher and the help screen", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "tap the channel name above to switch · tap bitchat/ for help" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -25062,6 +25599,12 @@ "value" : "tippe oben auf den kanalnamen zum wechseln · tippe bitchat/ für hilfe" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "tap the channel name above to switch · tap bitchat/ for help" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -25194,12 +25737,6 @@ "comment" : "Tooltip for the internet gateway indicator", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sharing your internet connection with nearby mesh peers" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -25218,6 +25755,12 @@ "value" : "teilt deine internetverbindung mit nahen mesh-peers" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sharing your internet connection with nearby mesh peers" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -25346,6 +25889,186 @@ } } }, + "content.header.notices" : { + "comment" : "Tooltip for the notices button", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إعلانات: منشورات مثبتة لهذه المنطقة وشبكة mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "নোটিশ: এই এলাকা ও মেশের জন্য পিন করা পোস্ট" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hinweise: angeheftete Beiträge für diese Gegend und das Mesh" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notices: pinned posts for this area and the mesh" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avisos: publicaciones fijadas para esta zona y el mesh" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mga paunawa: naka-pin na post para sa lugar na ito at sa mesh" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annonces : publications épinglées pour cette zone et le mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מודעות: פוסטים נעוצים לאזור הזה ול-mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचनाएँ: इस क्षेत्र और मेश के लिए पिन की गई पोस्ट" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pengumuman: kiriman yang disematkan untuk area ini dan mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avvisi: post appuntati per questa zona e il mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お知らせ: このエリアとmeshのピン留め投稿" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공지: 이 지역과 mesh에 고정된 게시물" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pengumuman: kiriman disemat untuk kawasan ini dan mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचनाहरू: यो क्षेत्र र मेशका लागि पिन गरिएका पोस्ट" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mededelingen: vastgeprikte berichten voor deze omgeving en het mesh" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ogłoszenia: przypięte wpisy dla tej okolicy i mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avisos: publicações afixadas para esta zona e o mesh" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avisos: publicações fixadas para esta área e o mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Объявления: закреплённые записи для этого места и mesh" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anslag: uppnålade inlägg för det här området och mesh" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அறிவிப்புகள்: இந்தப் பகுதி மற்றும் மெஷுக்கான பின் செய்யப்பட்ட பதிவுகள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ประกาศ: โพสต์ที่ปักหมุดสำหรับบริเวณนี้และ mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duyurular: bu bölge ve mesh için sabitlenmiş gönderiler" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оголошення: закріплені дописи для цієї місцевості та mesh" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اعلانات: اس علاقے اور mesh کیلئے پن شدہ پوسٹس" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thông báo: bài ghim cho khu vực này và mesh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "公告:此区域和 mesh 的置顶帖子" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "公告:此區域和 mesh 的置頂貼文" + } + } + } + }, "content.header.people" : { "extractionState" : "manual", "localizations" : { @@ -25707,12 +26430,6 @@ "content.input.group_placeholder" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "create|invite|leave|list" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -25731,6 +26448,12 @@ "value" : "create|invite|leave|list" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "create|invite|leave|list" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -25859,786 +26582,6 @@ } } }, - "content.input.placeholder.location" : { - "comment" : "Composer placeholder for a public geohash channel, naming it", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "message #%@ — public" - } - }, - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "رسالة #%@ — عام" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#%@-এ বার্তা — পাবলিক" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nachricht an #%@ — öffentlich" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "mensaje #%@ — público" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "message vers #%@ — public" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "הודעה ל-#%@ — ציבורי" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "संदेश #%@ — सार्वजनिक" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pesan #%@ — publik" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "messaggio a #%@ — pubblico" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#%@ にメッセージ — 公開" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#%@ 에 메시지 — 공개" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pesan #%@ — awam" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#%@ मा सन्देश — सार्वजनिक" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bericht naar #%@ — openbaar" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "wiadomość #%@ — publiczna" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mensagem para #%@ — público" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "сообщение в #%@ — публичное" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "meddelande #%@ — publikt" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#%@ க்கு செய்தி — பொது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ส่งข้อความถึง #%@ — สาธารณะ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesaj #%@ — herkese açık" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "повідомлення #%@ — публічне" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "پیغام #%@ — عوامی" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nhắn #%@ — công khai" - } - } - } - }, - "content.input.placeholder.mesh" : { - "comment" : "Composer placeholder for the public mesh channel", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "message #mesh — public, nearby" - } - }, - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "رسالة #mesh — عام، قريب" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#mesh-এ বার্তা — পাবলিক, কাছাকাছি" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nachricht an #mesh — öffentlich, in der nähe" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "mensaje #mesh — público, cerca" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "message vers #mesh — public, à proximité" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "הודעה ל-#mesh — ציבורי, קרוב" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "संदेश #mesh — सार्वजनिक, आसपास" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pesan #mesh — publik, terdekat" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "messaggio a #mesh — pubblico, nelle vicinanze" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#mesh にメッセージ — 公開、近くの人へ" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#mesh 에 메시지 — 공개, 근처" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pesan #mesh — awam, berdekatan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#mesh मा सन्देश — सार्वजनिक, नजिकको" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bericht naar #mesh — openbaar, in de buurt" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "wiadomość #mesh — publiczna, w pobliżu" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mensagem para #mesh — público, próximo" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "сообщение в #mesh — публичное, рядом" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "meddelande #mesh — publikt, i närheten" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "#mesh க்கு செய்தி — பொது, அருகில்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ส่งข้อความถึง #mesh — สาธารณะ, ใกล้เคียง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesaj #mesh — herkese açık, yakında" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "повідомлення #mesh — публічне, поблизу" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "پیغام #mesh — عوامی، قریبی" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nhắn #mesh — công khai, gần đây" - } - } - } - }, - "content.input.placeholder.private" : { - "comment" : "Composer placeholder inside a private chat, naming the conversation partner", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "message %@ — private" - } - }, - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "رسالة %@ — خاص" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@-কে বার্তা — ব্যক্তিগত" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nachricht an %@ — privat" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "mensaje %@ — privado" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "message à %@ — privé" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "הודעה ל-%@ — פרטי" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "संदेश %@ — निजी" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pesan %@ — pribadi" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "messaggio a %@ — privato" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ にメッセージ — プライベート" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ 에게 메시지 — 비공개" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pesan %@ — peribadi" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ लाई सन्देश — निजी" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bericht naar %@ — privé" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "wiadomość do %@ — prywatna" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mensagem para %@ — privado" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "сообщение для %@ — личное" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "meddelande %@ — privat" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%@ க்கு செய்தி — தனிப்பட்டது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ส่งข้อความถึง %@ — ส่วนตัว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "mesaj %@ — özel" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "повідомлення %@ — приватне" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "پیغام %@ — نجی" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nhắn %@ — riêng tư" - } - } - } - }, - "content.private.caption" : { - "comment" : "Caption above the private chat composer before encryption is established", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "private conversation" - } - }, - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "محادثة خاصة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ব্যক্তিগত কথোপকথন" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "privates gespräch" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "conversación privada" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "conversation privée" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "שיחה פרטית" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "निजी बातचीत" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "percakapan pribadi" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "conversazione privata" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "プライベートな会話" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "비공개 대화" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "perbualan peribadi" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "निजी कुराकानी" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "privégesprek" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "rozmowa prywatna" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "conversa privada" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "личный разговор" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "privat konversation" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "தனிப்பட்ட உரையாடல்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "การสนทนาส่วนตัว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "özel konuşma" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "приватна розмова" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "نجی گفتگو" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "cuộc trò chuyện riêng tư" - } - } - } - }, - "content.private.caption_encrypted" : { - "comment" : "Caption above the private chat composer once the session is end-to-end encrypted", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "private · end-to-end encrypted" - } - }, - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "خاص · مشفّر من طرف إلى طرف" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ব্যক্তিগত · এন্ড-টু-এন্ড এনক্রিপ্টেড" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "privat · end-to-end-verschlüsselt" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "privado · cifrado de extremo a extremo" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "privé · chiffré de bout en bout" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "פרטי · מוצפן מקצה לקצה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "निजी · एंड-टू-एंड एन्क्रिप्टेड" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "pribadi · terenkripsi ujung ke ujung" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "privato · cifrato end-to-end" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "プライベート · エンドツーエンド暗号化" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "비공개 · 종단간 암호화" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "peribadi · terenkripsi ujung ke ujung" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "निजी · एन्ड-टु-एन्ड सङ्केत" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "privé · end-to-end-versleuteld" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "prywatna · szyfrowana end-to-end" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "privado · encriptado ponta a ponta" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "лично · сквозное шифрование" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "privat · end-to-end-krypterad" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "தனிப்பட்டது · முனை-முதல்-முனை குறியாக்கம்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ส่วนตัว · เข้ารหัสแบบครบวงจร" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "özel · uçtan uca şifreli" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "приватна · наскрізно зашифрована" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "نجی · اینڈ ٹو اینڈ خفیہ" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "riêng tư · mã hóa đầu cuối" - } - } - } - }, "content.input.nickname_placeholder" : { "extractionState" : "manual", "localizations" : { @@ -26818,16 +26761,478 @@ } } }, + "content.input.placeholder.location" : { + "comment" : "Composer placeholder for a public geohash channel, naming it", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "رسالة #%@ — عام" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#%@-এ বার্তা — পাবলিক" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nachricht an #%@ — öffentlich" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "message #%@ — public" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensaje #%@ — público" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "message vers #%@ — public" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הודעה ל-#%@ — ציבורי" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "संदेश #%@ — सार्वजनिक" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan #%@ — publik" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "messaggio a #%@ — pubblico" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#%@ にメッセージ — 公開" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#%@ 에 메시지 — 공개" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan #%@ — awam" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#%@ मा सन्देश — सार्वजनिक" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bericht naar #%@ — openbaar" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wiadomość #%@ — publiczna" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mensagem para #%@ — público" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "сообщение в #%@ — публичное" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "meddelande #%@ — publikt" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#%@ க்கு செய்தி — பொது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ส่งข้อความถึง #%@ — สาธารณะ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesaj #%@ — herkese açık" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "повідомлення #%@ — публічне" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "پیغام #%@ — عوامی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nhắn #%@ — công khai" + } + } + } + }, + "content.input.placeholder.mesh" : { + "comment" : "Composer placeholder for the public mesh channel", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "رسالة #mesh — عام، قريب" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#mesh-এ বার্তা — পাবলিক, কাছাকাছি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nachricht an #mesh — öffentlich, in der nähe" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "message #mesh — public, nearby" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensaje #mesh — público, cerca" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "message vers #mesh — public, à proximité" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הודעה ל-#mesh — ציבורי, קרוב" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "संदेश #mesh — सार्वजनिक, आसपास" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan #mesh — publik, terdekat" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "messaggio a #mesh — pubblico, nelle vicinanze" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#mesh にメッセージ — 公開、近くの人へ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#mesh 에 메시지 — 공개, 근처" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan #mesh — awam, berdekatan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#mesh मा सन्देश — सार्वजनिक, नजिकको" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bericht naar #mesh — openbaar, in de buurt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wiadomość #mesh — publiczna, w pobliżu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mensagem para #mesh — público, próximo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "сообщение в #mesh — публичное, рядом" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "meddelande #mesh — publikt, i närheten" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "#mesh க்கு செய்தி — பொது, அருகில்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ส่งข้อความถึง #mesh — สาธารณะ, ใกล้เคียง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesaj #mesh — herkese açık, yakında" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "повідомлення #mesh — публічне, поблизу" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "پیغام #mesh — عوامی، قریبی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nhắn #mesh — công khai, gần đây" + } + } + } + }, + "content.input.placeholder.private" : { + "comment" : "Composer placeholder inside a private chat, naming the conversation partner", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "رسالة %@ — خاص" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@-কে বার্তা — ব্যক্তিগত" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nachricht an %@ — privat" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "message %@ — private" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensaje %@ — privado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "message à %@ — privé" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "הודעה ל-%@ — פרטי" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "संदेश %@ — निजी" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan %@ — pribadi" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "messaggio a %@ — privato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ にメッセージ — プライベート" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ 에게 메시지 — 비공개" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pesan %@ — peribadi" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ लाई सन्देश — निजी" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bericht naar %@ — privé" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "wiadomość do %@ — prywatna" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mensagem para %@ — privado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "сообщение для %@ — личное" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "meddelande %@ — privat" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%@ க்கு செய்தி — தனிப்பட்டது" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ส่งข้อความถึง %@ — ส่วนตัว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "mesaj %@ — özel" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "повідомлення %@ — приватне" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "پیغام %@ — نجی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "nhắn %@ — riêng tư" + } + } + } + }, "content.input.token_placeholder" : { "comment" : "Placeholder shown after /pay in the command suggestion panel", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "token" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -26846,6 +27251,12 @@ "value" : "token" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "token" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -26978,12 +27389,6 @@ "comment" : "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld new" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -27002,6 +27407,12 @@ "value" : "%lld neu" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld new" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -28387,12 +28798,6 @@ "comment" : "Context menu action copying a Cashu token to the pasteboard", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "copy token" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -28411,6 +28816,12 @@ "value" : "token kopieren" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "copy token" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -28722,12 +29133,6 @@ "comment" : "Context menu action opening a Cashu token in an ecash wallet app", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "redeem in wallet" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -28746,6 +29151,12 @@ "value" : "in wallet einlösen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "redeem in wallet" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -28878,12 +29289,6 @@ "comment" : "Context menu action opening a Cashu token in the web redemption page", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "redeem on web" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -28902,6 +29307,12 @@ "value" : "im web einlösen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "redeem on web" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -29030,15 +29441,321 @@ } } }, - "content.private.caption_group" : { + "content.private.caption" : { + "comment" : "Caption above the private chat composer before encryption is established", "extractionState" : "manual", "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "محادثة خاصة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ব্যক্তিগত কথোপকথন" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privates gespräch" + } + }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "encrypted group · members only" + "value" : "private conversation" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "conversación privada" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "conversation privée" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "שיחה פרטית" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निजी बातचीत" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "percakapan pribadi" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "conversazione privata" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "プライベートな会話" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "비공개 대화" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "perbualan peribadi" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निजी कुराकानी" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privégesprek" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "rozmowa prywatna" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "conversa privada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "личный разговор" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privat konversation" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "தனிப்பட்ட உரையாடல்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "การสนทนาส่วนตัว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "özel konuşma" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "приватна розмова" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "نجی گفتگو" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "cuộc trò chuyện riêng tư" + } + } + } + }, + "content.private.caption_encrypted" : { + "comment" : "Caption above the private chat composer once the session is end-to-end encrypted", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "خاص · مشفّر من طرف إلى طرف" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ব্যক্তিগত · এন্ড-টু-এন্ড এনক্রিপ্টেড" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privat · end-to-end-verschlüsselt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "private · end-to-end encrypted" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "privado · cifrado de extremo a extremo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privé · chiffré de bout en bout" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "פרטי · מוצפן מקצה לקצה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निजी · एंड-टू-एंड एन्क्रिप्टेड" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "pribadi · terenkripsi ujung ke ujung" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privato · cifrato end-to-end" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "プライベート · エンドツーエンド暗号化" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "비공개 · 종단간 암호화" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "peribadi · terenkripsi ujung ke ujung" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "निजी · एन्ड-टु-एन्ड सङ्केत" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privé · end-to-end-versleuteld" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "prywatna · szyfrowana end-to-end" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privado · encriptado ponta a ponta" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "лично · сквозное шифрование" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "privat · end-to-end-krypterad" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "தனிப்பட்டது · முனை-முதல்-முனை குறியாக்கம்" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ส่วนตัว · เข้ารหัสแบบครบวงจร" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "özel · uçtan uca şifreli" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "приватна · наскрізно зашифрована" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "نجی · اینڈ ٹو اینڈ خفیہ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "riêng tư · mã hóa đầu cuối" + } + } + } + }, + "content.private.caption_group" : { + "extractionState" : "manual", + "localizations" : { "ar" : { "stringUnit" : { "state" : "needs_review", @@ -29057,6 +29774,12 @@ "value" : "verschlüsselte gruppe · nur mitglieder" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "encrypted group · members only" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -31695,12 +32418,6 @@ "comment" : "Badge shown when a peer is vouched for by people the user verified but not directly verified", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ VOUCHED" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -31719,6 +32436,12 @@ "value" : "✓ VERBÜRGT" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "✓ VOUCHED" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -32388,6 +33111,24 @@ "comment" : "How many people the user verified have vouched for this peer", "extractionState" : "manual", "localizations" : { + "bn" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আপনি যাচাই করেছেন এমন %d জনের দ্বারা সমর্থিত" + } + }, + "other" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "আপনি যাচাই করেছেন এমন %d জনের দ্বারা সমর্থিত" + } + } + } + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -32416,24 +33157,6 @@ } } }, - "bn" : { - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "আপনি যাচাই করেছেন এমন %d জনের দ্বারা সমর্থিত" - } - }, - "other" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "আপনি যাচাই করেছেন এমন %d জনের দ্বারা সমর্থিত" - } - } - } - } - }, "es" : { "stringUnit" : { "state" : "translated", @@ -32609,12 +33332,6 @@ "formatSpecifier" : "lld", "variations" : { "plural" : { - "one" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d человек" - } - }, "few" : { "stringUnit" : { "state" : "needs_review", @@ -32627,6 +33344,12 @@ "value" : "%d человек" } }, + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d человек" + } + }, "other" : { "stringUnit" : { "state" : "needs_review", @@ -33780,12 +34503,6 @@ "comment" : "State label for someone physically in the location channel's area", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "in this area" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -33804,6 +34521,12 @@ "value" : "in diesem gebiet" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "in this area" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -33936,12 +34659,6 @@ "comment" : "State label for someone who joined the location channel from elsewhere", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "teleported from elsewhere" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -33960,6 +34677,12 @@ "value" : "von woanders teleportiert" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "teleported from elsewhere" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -34092,12 +34815,6 @@ "comment" : "State label marking your own row in the people list", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "you" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -34116,6 +34833,12 @@ "value" : "du" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -34605,12 +35328,6 @@ "groups.accessibility.open_group_hint" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Opens the group chat" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -34629,6 +35346,12 @@ "value" : "Öffnet den gruppenchat" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opens the group chat" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -34760,12 +35483,6 @@ "groups.member_count %@" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "(%@)" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -34784,6 +35501,12 @@ "value" : "(%@)" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "(%@)" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -34915,12 +35638,6 @@ "groups.section.header" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "groups" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -34939,6 +35656,12 @@ "value" : "gruppen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "groups" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -35070,12 +35793,6 @@ "groups.state.creator" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Creator" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -35094,6 +35811,12 @@ "value" : "Ersteller" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Creator" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -35222,16 +35945,189 @@ } } }, + "Images are only available in mesh chats." : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الصور متاحة فقط في محادثات الميش." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ছবি শুধু মেশ চ্যাটে উপলব্ধ।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bilder sind nur im Mesh-Chat verfügbar." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Images are only available in mesh chats." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Las imágenes solo están disponibles en los chats de mesh." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ang mga larawan ay available lamang sa mga mesh chat." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les images sont uniquement disponibles dans les discussions mesh." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "תמונות זמינות רק בצ׳אט של mesh." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "चित्र केवल मेश चैट में ही उपलब्ध हैं।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gambar hanya tersedia di obrolan mesh." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le immagini sono disponibili solo nelle chat mesh." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "画像はメッシュチャットでのみ利用できます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이미지는 메쉬 채팅에서만 사용할 수 있습니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Imej hanya tersedia dalam sembang mesh." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "तस्बिरहरू केवल मेष च्याटमा मात्र उपलब्ध छन्।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afbeeldingen zijn alleen beschikbaar in mesh-chats." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obrazy są dostępne tylko na czatach mesh." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "As imagens só estão disponíveis nos chats mesh." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "As imagens só estão disponíveis nos chats mesh." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Изображения доступны только в mesh-чатах." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bilder är bara tillgängliga i mesh-chattar." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "படங்கள் மெஷ் உரையாடல்களில் மட்டுமே கிடைக்கும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "รูปภาพใช้งานได้เฉพาะในแชต mesh เท่านั้น" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Görseller yalnızca mesh sohbetlerinde kullanılabilir." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Зображення доступні лише в mesh-чатах." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "تصاویر صرف میش چیٹس میں دستیاب ہیں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hình ảnh chỉ khả dụng trong các cuộc trò chuyện mesh." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "图片仅可在 mesh 聊天中使用。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "圖片僅能在 mesh 聊天中使用。" + } + } + } + }, "location_channels.accessibility.add_bookmark" : { "comment" : "Accessibility action name for bookmarking a channel", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "bookmark channel" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -35250,6 +36146,12 @@ "value" : "kanal mit lesezeichen versehen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "bookmark channel" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -35382,12 +36284,6 @@ "comment" : "Accessibility action name for removing a channel bookmark", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "remove bookmark" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -35406,6 +36302,12 @@ "value" : "lesezeichen entfernen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "remove bookmark" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -35538,12 +36440,6 @@ "comment" : "Accessibility hint on a channel row explaining activation switches to it", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "switches to this channel" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -35562,6 +36458,12 @@ "value" : "wechselt zu diesem kanal" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "switches to this channel" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -36947,12 +37849,6 @@ "comment" : "Explanation under the internet gateway toggle", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "share your internet with nearby mesh peers so their geohash messages reach the network" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -36971,6 +37867,12 @@ "value" : "teile dein internet mit nahen mesh-peers, damit ihre geohash-nachrichten das netzwerk erreichen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "share your internet with nearby mesh peers so their geohash messages reach the network" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -37103,12 +38005,6 @@ "comment" : "Title for the internet gateway toggle in the location channels sheet", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "internet gateway" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -37127,6 +38023,12 @@ "value" : "internet-gateway" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "internet gateway" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -43218,12 +44120,6 @@ "comment" : "Accessibility label for the cancel button on an in-flight media send", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "cancel sending" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -43242,6 +44138,12 @@ "value" : "senden abbrechen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "cancel sending" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -43374,12 +44276,6 @@ "comment" : "Accessibility label for a blurred incoming image", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "hidden image" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -43398,6 +44294,12 @@ "value" : "verstecktes bild" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "hidden image" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -43530,12 +44432,6 @@ "comment" : "Accessibility hint for a revealed image; activating it opens the image full screen", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "opens the image full screen" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -43554,6 +44450,12 @@ "value" : "öffnet das bild im vollbild" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "opens the image full screen" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -43686,12 +44588,6 @@ "comment" : "Accessibility hint for a blurred image; activating it reveals the image", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "reveals the image" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -43710,6 +44606,12 @@ "value" : "zeigt das bild" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "reveals the image" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -43842,12 +44744,6 @@ "comment" : "Accessibility label for a revealed image", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "image" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -43866,6 +44762,12 @@ "value" : "bild" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "image" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -43998,12 +44900,6 @@ "comment" : "Accessibility label for an image that is still sending", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "sending image" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -44022,6 +44918,12 @@ "value" : "bild wird gesendet" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "sending image" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -44150,16 +45052,166 @@ } } }, + "media.image.accessibility.unavailable" : { + "comment" : "Accessibility label for an image whose file could not be loaded", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "الصورة غير متاحة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ছবি অনুপলব্ধ" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bild nicht verfügbar" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "image unavailable" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "imagen no disponible" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "image indisponible" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "התמונה לא זמינה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "चित्र उपलब्ध नहीं" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "gambar tidak tersedia" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "immagine non disponibile" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "画像を利用できません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "이미지를 사용할 수 없음" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "imej tidak tersedia" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "तस्बिर उपलब्ध छैन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "afbeelding niet beschikbaar" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "obraz niedostępny" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "imagem indisponível" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "изображение недоступно" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "bild otillgänglig" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "படம் கிடைக்கவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไม่สามารถใช้รูปภาพได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "görsel kullanılamıyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "зображення недоступне" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تصویر دستیاب نہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "hình ảnh không khả dụng" + } + } + } + }, "media.image.action.delete" : { "comment" : "Context menu action that deletes a received image", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "delete image" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -44178,6 +45230,12 @@ "value" : "bild löschen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "delete image" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -44310,12 +45368,6 @@ "comment" : "Context menu action that re-blurs a revealed image", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "hide image" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -44334,6 +45386,12 @@ "value" : "bild verbergen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "hide image" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -44466,12 +45524,6 @@ "comment" : "Context menu action that opens an image full screen", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "open image" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -44490,6 +45542,12 @@ "value" : "bild öffnen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "open image" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -44622,12 +45680,6 @@ "comment" : "Context menu action that reveals a blurred image", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "reveal image" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -44646,6 +45698,12 @@ "value" : "bild anzeigen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "reveal image" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -44778,12 +45836,6 @@ "comment" : "Body of the confirmation dialog before deleting a received image", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "this cannot be undone — the sender may not be in range to send it again." - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -44802,6 +45854,12 @@ "value" : "das kann nicht rückgängig gemacht werden — der absender ist möglicherweise nicht in reichweite, um es erneut zu senden." } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "this cannot be undone — the sender may not be in range to send it again." + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -44934,12 +45992,6 @@ "comment" : "Title of the confirmation dialog before deleting a received image", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "delete this image?" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -44958,6 +46010,12 @@ "value" : "dieses bild löschen?" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "delete this image?" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -45090,12 +46148,6 @@ "comment" : "Caption on a blurred incoming image inviting a tap to reveal it", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "tap to reveal" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -45114,6 +46166,12 @@ "value" : "zum anzeigen tippen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "tap to reveal" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -45246,12 +46304,6 @@ "comment" : "Accessibility label for pausing voice note playback", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "pause voice note" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -45270,6 +46322,12 @@ "value" : "sprachnachricht pausieren" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "pause voice note" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -45402,12 +46460,6 @@ "comment" : "Accessibility label for playing a voice note", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "play voice note" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -45426,6 +46478,12 @@ "value" : "sprachnachricht abspielen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "play voice note" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -45558,12 +46616,6 @@ "comment" : "Accessibility hint on a peer row explaining activation opens a private chat", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "opens a private chat" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -45582,6 +46634,12 @@ "value" : "öffnet einen privaten chat" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "opens a private chat" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -45714,12 +46772,6 @@ "comment" : "Context menu action that shows a peer's fingerprint/verification screen", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "show fingerprint" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -45738,6 +46790,12 @@ "value" : "fingerabdruck anzeigen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "show fingerprint" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -45870,12 +46928,6 @@ "comment" : "State label for a blocked peer", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "blocked" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -45894,6 +46946,12 @@ "value" : "blockiert" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "blocked" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -46026,12 +47084,6 @@ "comment" : "State label for a favorited peer", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "favorite" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -46050,6 +47102,12 @@ "value" : "favorit" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorite" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -46182,12 +47240,6 @@ "comment" : "State label for a peer that is not currently reachable", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "offline" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -46206,6 +47258,12 @@ "value" : "offline" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "offline" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -46338,12 +47396,6 @@ "comment" : "State label for a peer with unread private messages", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "new messages" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -46362,6 +47414,12 @@ "value" : "neue nachrichten" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "new messages" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -46494,12 +47552,6 @@ "comment" : "State label for a peer vouched for by someone the user verified", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "vouched" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -46518,6 +47570,12 @@ "value" : "verbürgt" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "vouched" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -46829,12 +47887,6 @@ "comment" : "Tooltip for the vouched (unfilled seal) badge next to a peer", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "vouched for by someone you verified" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -46853,6 +47905,12 @@ "value" : "verbürgt von jemandem, den du verifiziert hast" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "vouched for by someone you verified" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -46981,6 +48039,1806 @@ } } }, + "notices.accessibility.close" : { + "comment" : "Accessibility label for the notices close button", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إغلاق الإعلانات" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "নোটিশ বন্ধ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hinweise schließen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close notices" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cerrar avisos" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Isara ang mga paunawa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fermer les annonces" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "סגור מודעות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचनाएँ बंद करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tutup pengumuman" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chiudi avvisi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お知らせを閉じる" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공지 닫기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tutup pengumuman" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचना बन्द गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mededelingen sluiten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamknij ogłoszenia" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fechar avisos" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fechar avisos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть объявления" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stäng anslag" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அறிவிப்புகளை மூடு" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ปิดประกาศ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duyuruları kapat" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрити оголошення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اعلانات بند کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đóng thông báo" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关闭公告" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "關閉公告" + } + } + } + }, + "notices.accessibility.scope" : { + "comment" : "Accessibility label for the geo/mesh scope toggle", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "نطاق الإعلانات" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "নোটিশের পরিধি" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bereich der Hinweise" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notices scope" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ámbito de los avisos" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saklaw ng mga paunawa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Portée des annonces" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "טווח המודעות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचनाओं का दायरा" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cakupan pengumuman" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ambito degli avvisi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お知らせの範囲" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공지 범위" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skop pengumuman" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचनाको दायरा" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bereik van mededelingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zakres ogłoszeń" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Âmbito dos avisos" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escopo dos avisos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Раздел объявлений" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anslagens omfattning" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அறிவிப்புகளின் வரம்பு" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ขอบเขตประกาศ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duyuru kapsamı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Розділ оголошень" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اعلانات کا دائرہ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Phạm vi thông báo" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "公告范围" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "公告範圍" + } + } + } + }, + "notices.alert.urgent_collapsed" : { + "comment" : "Local chat line when several urgent notices arrive together", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld إعلانات عاجلة جديدة — اضغط على الدبوس للعرض" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lldটি নতুন জরুরি নোটিশ — দেখতে পিনে ট্যাপ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld neue dringende hinweise — tippe zum ansehen auf den pin" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld new urgent notices — tap the pin to view" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld avisos urgentes nuevos — toca el pin para verlos" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld bagong agarang paunawa — i-tap ang pin para makita" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld nouvelles annonces urgentes — touche l'épingle pour voir" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld מודעות דחופות חדשות — הקש על הנעץ לצפייה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld नई ज़रूरी सूचनाएँ — देखने के लिए पिन टैप करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld pengumuman mendesak baru — ketuk pin untuk melihat" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld nuovi avvisi urgenti — tocca la puntina per vederli" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 新しい緊急のお知らせが%lld件 — ピンをタップして表示" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 새 긴급 공지 %lld개 — 핀을 탭하여 확인" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld pengumuman segera baharu — ketik pin untuk melihat" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld नयाँ जरुरी सूचना — हेर्न पिन ट्याप गर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld nieuwe dringende mededelingen — tik op de pin om te bekijken" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld nowych pilnych ogłoszeń — dotknij pinezki, aby zobaczyć" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld novos avisos urgentes — toca no pin para ver" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld avisos urgentes novos — toque no pin para ver" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld новых срочных объявлений — нажми на булавку, чтобы посмотреть" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld nya brådskande anslag — tryck på nålen för att visa" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld புதிய அவசர அறிவிப்புகள் — பார்க்க பின்னைத் தட்டவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 ประกาศด่วนใหม่ %lld รายการ — แตะหมุดเพื่อดู" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld yeni acil duyuru — görmek için raptiyeye dokun" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld нових термінових оголошень — натисни на шпильку, щоб переглянути" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld نئے فوری اعلانات — دیکھنے کیلئے پن پر ٹیپ کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld thông báo khẩn mới — chạm vào ghim để xem" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld 条新紧急公告 — 点按图钉查看" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 %lld 則新緊急公告 — 點按圖釘查看" + } + } + } + }, + "notices.alert.urgent_single" : { + "comment" : "Local chat line when one urgent notice is pinned nearby", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 إعلان عاجل من @%@: %@" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 @%@-এর জরুরি নোটিশ: %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 dringender hinweis von @%@: %@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 urgent notice from @%@: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 aviso urgente de @%@: %@" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 agarang paunawa mula kay @%@: %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 annonce urgente de @%@ : %@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 מודעה דחופה מאת @%@: %@" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 @%@ की ज़रूरी सूचना: %@" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 pengumuman mendesak dari @%@: %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 avviso urgente da @%@: %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 @%@からの緊急のお知らせ: %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 @%@님의 긴급 공지: %@" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 pengumuman segera daripada @%@: %@" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 @%@को जरुरी सूचना: %@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 dringende mededeling van @%@: %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 pilne ogłoszenie od @%@: %@" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 aviso urgente de @%@: %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 aviso urgente de @%@: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 срочное объявление от @%@: %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 brådskande anslag från @%@: %@" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 @%@ இன் அவசர அறிவிப்பு: %@" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 ประกาศด่วนจาก @%@: %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 @%@ kişisinden acil duyuru: %@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 термінове оголошення від @%@: %@" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 @%@ کا فوری اعلان: %@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 thông báo khẩn từ @%@: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 来自 @%@ 的紧急公告:%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "📌 來自 @%@ 的緊急公告:%@" + } + } + } + }, + "notices.description.mesh" : { + "comment" : "Explainer for the mesh tab of the notices sheet", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "ثبّت إعلانات قصيرة لمن حولك. تنتقل من هاتف إلى هاتف حتى دون اتصال، وتختفي وحدها بعد أيام قليلة." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "আশেপাশের মানুষের জন্য ছোট নোটিশ পিন করুন। অফলাইনেও ফোন থেকে ফোনে পৌঁছে যায় আর কয়েক দিন পরে নিজে থেকেই মুছে যায়।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "hefte kurze hinweise für leute in deiner nähe an. sie springen von handy zu handy, auch offline, und verschwinden nach ein paar tagen von selbst." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "pin short notices for people around you. they hop phone to phone, even offline, and disappear on their own after a few days." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "fija avisos cortos para la gente cercana. saltan de teléfono a teléfono, incluso sin conexión, y desaparecen solos después de unos días." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mag-pin ng maiikling paunawa para sa mga taong nasa paligid mo. lumilipat ito mula sa isang telepono patungo sa iba, kahit offline, at kusang nawawala pagkatapos ng ilang araw." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "épingle de courtes annonces pour les gens autour de toi. elles passent de téléphone en téléphone, même hors ligne, et disparaissent d'elles-mêmes après quelques jours." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הצמד מודעות קצרות לאנשים סביבך. הן עוברות מטלפון לטלפון, גם בלי אינטרנט, ונעלמות מעצמן אחרי כמה ימים." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "आस-पास के लोगों के लिए छोटी सूचनाएँ पिन करें। ये ऑफ़लाइन भी फ़ोन से फ़ोन तक पहुँचती हैं और कुछ दिनों बाद अपने आप मिट जाती हैं।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "sematkan pengumuman singkat untuk orang di sekitarmu. berpindah dari ponsel ke ponsel, bahkan saat offline, dan hilang sendiri setelah beberapa hari." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "appunta brevi avvisi per chi ti sta intorno. passano da telefono a telefono, anche offline, e spariscono da soli dopo qualche giorno." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "近くの人に向けて短いお知らせをピン留め。オフラインでもスマホからスマホへ伝わり、数日後に自動的に消えます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "주변 사람들을 위해 짧은 공지를 고정하세요. 오프라인에서도 휴대폰에서 휴대폰으로 전달되고 며칠 후 스스로 사라집니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "semat pengumuman ringkas untuk orang di sekeliling anda. ia berpindah dari telefon ke telefon, walaupun di luar talian, dan hilang sendiri selepas beberapa hari." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "वरपरका मानिसहरूका लागि छोटा सूचना पिन गर। अफलाइनमा पनि फोनबाट फोनमा पुग्छन् र केही दिनपछि आफैँ हराउँछन्।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "prik korte mededelingen voor mensen om je heen. ze springen van telefoon naar telefoon, ook offline, en verdwijnen vanzelf na een paar dagen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "przypinaj krótkie ogłoszenia dla ludzi w pobliżu. przeskakują z telefonu na telefon, nawet offline, i same znikają po kilku dniach." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "afixa avisos curtos para quem está por perto. saltam de telemóvel em telemóvel, mesmo offline, e desaparecem sozinhos após alguns dias." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "fixe avisos curtos para as pessoas por perto. eles pulam de celular em celular, mesmo offline, e somem sozinhos depois de alguns dias." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "закрепляй короткие объявления для людей рядом. они передаются с телефона на телефон, даже офлайн, и сами исчезают через несколько дней." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "nåla upp korta anslag för folk i närheten. de hoppar från telefon till telefon, även offline, och försvinner av sig själva efter några dagar." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அருகிலுள்ளவர்களுக்காக சிறிய அறிவிப்புகளைப் பின் செய்யவும். ஆஃப்லைனிலும் ஃபோனிலிருந்து ஃபோனுக்குப் பரவி, சில நாட்களில் தானாக மறைந்துவிடும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ปักประกาศสั้น ๆ ให้คนรอบตัว ส่งต่อจากมือถือสู่มือถือได้แม้ออฟไลน์ และหายไปเองหลังผ่านไปสองสามวัน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "çevrendekiler için kısa duyurular sabitle. çevrimdışıyken bile telefondan telefona geçer ve birkaç gün sonra kendiliğinden kaybolur." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "закріплюй короткі оголошення для людей поруч. вони передаються з телефона на телефон, навіть офлайн, і самі зникають за кілька днів." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "آس پاس کے لوگوں کیلئے مختصر اعلانات پن کریں۔ یہ آف لائن بھی فون سے فون تک پہنچتے ہیں اور کچھ دنوں بعد خود مٹ جاتے ہیں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ghim thông báo ngắn cho những người quanh bạn. chúng truyền từ điện thoại này sang điện thoại khác, kể cả khi ngoại tuyến, và tự biến mất sau vài ngày." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "为周围的人钉上简短公告。即使离线也能在手机间传递,几天后自动消失。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "為周圍的人釘上簡短公告。即使離線也能在手機間傳遞,幾天後自動消失。" + } + } + } + }, + "notices.source.mesh" : { + "comment" : "Source badge for notices carried by the mesh", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + } + } + }, + "notices.source.nostr" : { + "comment" : "Source badge for notices seen on internet relays", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "نت" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "নেট" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "netz" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "net" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "red" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "net" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "net" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "רשת" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "नेट" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "net" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "rete" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ネット" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "넷" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "net" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "नेट" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "net" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "sieć" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "rede" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "rede" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "сеть" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "nät" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "நெட்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เน็ต" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ağ" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "мережа" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "نیٹ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mạng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "网络" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "網路" + } + } + } + }, + "notices.tab.geo" : { + "comment" : "Segmented control label for geohash-scoped notices", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "جغرافي" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এলাকা" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "géo" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אזור" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "क्षेत्र" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "area" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "エリア" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지역" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "kawasan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "क्षेत्र" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "гео" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "geo" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பகுதி" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "พื้นที่" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bölge" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "гео" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "علاقہ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "khu vực" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "区域" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "區域" + } + } + } + }, + "notices.tab.mesh" : { + "comment" : "Segmented control label for mesh-local notices", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh" + } + } + } + }, + "notices.title" : { + "comment" : "Title prefix of the unified notices sheet", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إعلانات" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "নোটিশ" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "hinweise" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "notices" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "avisos" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mga paunawa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "annonces" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מודעות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचनाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "pengumuman" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "avvisi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お知らせ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "pengumuman" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचनाहरू" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mededelingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ogłoszenia" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "avisos" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "avisos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "объявления" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "anslag" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அறிவிப்புகள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ประกาศ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "duyurular" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "оголошення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اعلانات" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "thông báo" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "公告" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "公告" + } + } + } + }, "recording %@" : { "comment" : "Voice note recording duration indicator", "localizations" : { @@ -48417,12 +51275,6 @@ "comment" : "System message when a geohash message was handed to a mesh internet gateway because no relay is reachable", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "sent via mesh gateway" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -48441,6 +51293,12 @@ "value" : "über mesh-gateway gesendet" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "sent via mesh gateway" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -48930,12 +51788,6 @@ "system.group.already_member" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ is already a member" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -48954,6 +51806,12 @@ "value" : "%@ ist bereits mitglied" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ is already a member" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -49085,12 +51943,6 @@ "system.group.cannot_remove_creator" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "the creator cannot be removed" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -49109,6 +51961,12 @@ "value" : "der ersteller kann nicht entfernt werden" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "the creator cannot be removed" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -49240,12 +52098,6 @@ "system.group.create_failed" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "could not create the group" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -49264,6 +52116,12 @@ "value" : "die gruppe konnte nicht erstellt werden" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "could not create the group" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -49395,12 +52253,6 @@ "system.group.created" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "created group '%@' — use /group invite @name to add people" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -49419,6 +52271,12 @@ "value" : "gruppe '%@' erstellt — nutze /group invite @name, um leute hinzuzufügen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "created group '%@' — use /group invite @name to add people" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -49550,12 +52408,6 @@ "system.group.creator_only" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "only the group creator can do that" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -49574,6 +52426,12 @@ "value" : "nur der gruppenersteller kann das tun" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "only the group creator can do that" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -49705,12 +52563,6 @@ "system.group.full" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "group is full (max %@ members)" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -49729,6 +52581,12 @@ "value" : "gruppe ist voll (max. %@ mitglieder)" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "group is full (max %@ members)" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -49860,12 +52718,6 @@ "system.group.identity_unavailable" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "your identity keys are not ready yet" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -49884,6 +52736,12 @@ "value" : "deine identitätsschlüssel sind noch nicht bereit" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "your identity keys are not ready yet" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -50015,12 +52873,6 @@ "system.group.invite_failed" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "could not build the group invite" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -50039,6 +52891,12 @@ "value" : "die gruppeneinladung konnte nicht erstellt werden" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "could not build the group invite" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -50170,12 +53028,6 @@ "system.group.invited" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "invited %1$@ to '%2$@'" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -50194,6 +53046,12 @@ "value" : "%1$@ zu '%2$@' eingeladen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "invited %1$@ to '%2$@'" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -50325,12 +53183,6 @@ "system.group.joined" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "you were added to group '%1$@' by %2$@ — it now appears in your people list" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -50349,6 +53201,12 @@ "value" : "du wurdest von %2$@ zur gruppe '%1$@' hinzugefügt — sie erscheint jetzt in deiner personenliste" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you were added to group '%1$@' by %2$@ — it now appears in your people list" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -50480,12 +53338,6 @@ "system.group.left" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "left group '%@'" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -50504,6 +53356,12 @@ "value" : "gruppe '%@' verlassen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "left group '%@'" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -50635,12 +53493,6 @@ "system.group.list_header" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "your groups:" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -50659,6 +53511,12 @@ "value" : "deine gruppen:" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "your groups:" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -50790,12 +53648,6 @@ "system.group.member_not_found" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' is not a member of this group" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -50814,6 +53666,12 @@ "value" : "'%@' ist kein mitglied dieser gruppe" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' is not a member of this group" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -50945,12 +53803,6 @@ "system.group.name_too_long" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "group names are limited to 40 characters" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -50969,6 +53821,12 @@ "value" : "gruppennamen sind auf 40 zeichen begrenzt" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "group names are limited to 40 characters" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -51100,12 +53958,6 @@ "system.group.none" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "you are not in any groups — /group create to start one" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -51124,6 +53976,12 @@ "value" : "du bist in keiner gruppe — /group create , um eine zu starten" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you are not in any groups — /group create to start one" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -51255,12 +54113,6 @@ "system.group.not_in_group" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "open a group chat first" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -51279,6 +54131,12 @@ "value" : "öffne zuerst einen gruppenchat" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "open a group chat first" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -51410,12 +54268,6 @@ "system.group.peer_identity_unknown" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "cannot verify %@'s identity yet — wait for their announce" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -51434,6 +54286,12 @@ "value" : "%@s identität kann noch nicht verifiziert werden — warte auf deren announce" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "cannot verify %@'s identity yet — wait for their announce" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -51565,12 +54423,6 @@ "system.group.peer_not_connected" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ must be connected over mesh to be invited" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -51589,6 +54441,12 @@ "value" : "%@ muss über mesh verbunden sein, um eingeladen zu werden" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ must be connected over mesh to be invited" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -51720,12 +54578,6 @@ "system.group.peer_not_found" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "'%@' not found" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -51744,6 +54596,12 @@ "value" : "'%@' nicht gefunden" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' not found" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -51875,12 +54733,6 @@ "system.group.removed_from" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "you were removed from group '%@'" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -51899,6 +54751,12 @@ "value" : "du wurdest aus der gruppe '%@' entfernt" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you were removed from group '%@'" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -52030,12 +54888,6 @@ "system.group.removed_member" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "removed %@ and rotated the group key" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -52054,6 +54906,12 @@ "value" : "%@ entfernt und den gruppenschlüssel rotiert" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "removed %@ and rotated the group key" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -52185,12 +55043,6 @@ "system.group.rotate_failed" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "could not rotate the group key" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -52209,6 +55061,12 @@ "value" : "der gruppenschlüssel konnte nicht rotiert werden" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "could not rotate the group key" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -52340,12 +55198,6 @@ "system.group.send_failed" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "could not encrypt the message" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -52364,6 +55216,12 @@ "value" : "die nachricht konnte nicht verschlüsselt werden" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "could not encrypt the message" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -52495,12 +55353,6 @@ "system.group.unknown" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "you are no longer in this group" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -52519,6 +55371,12 @@ "value" : "du bist nicht mehr in dieser gruppe" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you are no longer in this group" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -52650,12 +55508,6 @@ "system.group.usage_create" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "usage: /group create " - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -52674,6 +55526,12 @@ "value" : "verwendung: /group create " } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage: /group create " + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -52805,12 +55663,6 @@ "system.group.usage_invite" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "usage: /group invite @name" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -52829,6 +55681,12 @@ "value" : "verwendung: /group invite @name" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage: /group invite @name" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -52960,12 +55818,6 @@ "system.group.usage_remove" : { "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "usage: /group remove @name" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -52984,6 +55836,12 @@ "value" : "verwendung: /group remove @name" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage: /group remove @name" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -53474,12 +56332,6 @@ "comment" : "System message shown when a mesh peer cannot be blocked", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "cannot block %@: not found or unable to verify identity" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -53498,6 +56350,12 @@ "value" : "%@ kann nicht blockiert werden: nicht gefunden oder identität nicht verifizierbar" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "cannot block %@: not found or unable to verify identity" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -53630,12 +56488,6 @@ "comment" : "System message shown when a mesh peer is blocked", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "blocked %@. you will no longer receive messages from them" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -53654,6 +56506,12 @@ "value" : "%@ blockiert. du erhältst keine nachrichten mehr von ihnen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "blocked %@. you will no longer receive messages from them" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -53786,12 +56644,6 @@ "comment" : "System message shown when a mesh peer cannot be unblocked", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "cannot unblock %@: not found" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -53810,6 +56662,12 @@ "value" : "%@ kann nicht entsperrt werden: nicht gefunden" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "cannot unblock %@: not found" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -53942,12 +56800,6 @@ "comment" : "System message shown when a mesh peer is unblocked", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "unblocked %@" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -53966,6 +56818,12 @@ "value" : "%@ entsperrt" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "unblocked %@" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -54993,12 +57851,6 @@ "comment" : "Caption under the mesh topology map", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "estimated from gossiped neighbor lists (up to 10 per peer) — your device is highlighted" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -55017,6 +57869,12 @@ "value" : "geschätzt aus verbreiteten nachbarlisten (bis zu 10 pro peer) — dein gerät ist hervorgehoben" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "estimated from gossiped neighbor lists (up to 10 per peer) — your device is highlighted" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -55149,12 +58007,6 @@ "comment" : "Empty state of the mesh topology map", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "no mesh links yet — the map fills in as peer announces arrive" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -55173,6 +58025,12 @@ "value" : "noch keine mesh-verbindungen — die karte füllt sich, sobald peer-announces eintreffen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "no mesh links yet — the map fills in as peer announces arrive" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -55305,12 +58163,6 @@ "comment" : "Accessibility label of the topology refresh button", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "refresh topology" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -55329,6 +58181,12 @@ "value" : "topologie aktualisieren" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "refresh topology" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -55461,12 +58319,6 @@ "comment" : "Topology map summary: number of peers and links", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$ld peers · %2$ld links" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -55485,6 +58337,12 @@ "value" : "%1$ld peers · %2$ld verbindungen" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$ld peers · %2$ld links" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -55617,12 +58475,6 @@ "comment" : "Title of the mesh topology map sheet", "extractionState" : "manual", "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "mesh topology" - } - }, "ar" : { "stringUnit" : { "state" : "needs_review", @@ -55641,6 +58493,12 @@ "value" : "mesh-topologie" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh topology" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -57737,522 +60595,7 @@ } } } - }, - "Images are only available in mesh chats." : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "الصور متاحة فقط في محادثات الميش." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ছবি শুধু মেশ চ্যাটে উপলব্ধ।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bilder sind nur im Mesh-Chat verfügbar." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Images are only available in mesh chats." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Las imágenes solo están disponibles en los chats de mesh." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ang mga larawan ay available lamang sa mga mesh chat." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Les images sont uniquement disponibles dans les discussions mesh." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "תמונות זמינות רק בצ׳אט של mesh." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "चित्र केवल मेश चैट में ही उपलब्ध हैं।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gambar hanya tersedia di obrolan mesh." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Le immagini sono disponibili solo nelle chat mesh." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "画像はメッシュチャットでのみ利用できます。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "이미지는 메쉬 채팅에서만 사용할 수 있습니다." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Imej hanya tersedia dalam sembang mesh." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "तस्बिरहरू केवल मेष च्याटमा मात्र उपलब्ध छन्।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Afbeeldingen zijn alleen beschikbaar in mesh-chats." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Obrazy są dostępne tylko na czatach mesh." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "As imagens só estão disponíveis nos chats mesh." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "As imagens só estão disponíveis nos chats mesh." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Изображения доступны только в mesh-чатах." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bilder är bara tillgängliga i mesh-chattar." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "படங்கள் மெஷ் உரையாடல்களில் மட்டுமே கிடைக்கும்." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "รูปภาพใช้งานได้เฉพาะในแชต mesh เท่านั้น" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Görseller yalnızca mesh sohbetlerinde kullanılabilir." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Зображення доступні лише в mesh-чатах." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "تصاویر صرف میش چیٹس میں دستیاب ہیں۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hình ảnh chỉ khả dụng trong các cuộc trò chuyện mesh." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "图片仅可在 mesh 聊天中使用。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "圖片僅能在 mesh 聊天中使用。" - } - } - } - }, - "Choose an image" : { - "comment" : "A label displayed above a button that allows the user to choose an image to send.", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "اختر صورة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "একটি ছবি নির্বাচন করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bild auswählen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Choose an image" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Elige una imagen" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pumili ng larawan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Choisir une image" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "בחר תמונה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "एक चित्र चुनें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pilih gambar" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Scegli un’immagine" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "画像を選択" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "이미지를 선택하세요" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pilih imej" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "एउटा तस्वीर चयन गर्नुहोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kies een afbeelding" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wybierz obraz" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Escolher uma imagem" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Escolha uma imagem" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Выберите изображение" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Välj en bild" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "ஒரு படத்தைத் தேர்ந்தெடுக்கவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เลือกภาพ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bir görüntü seç" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Виберіть зображення" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "ایک تصویر منتخب کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chọn một hình ảnh" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "选择图像" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "選擇圖像" - } - } - } - }, - "media.image.accessibility.unavailable" : { - "comment" : "Accessibility label for an image whose file could not be loaded", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "image unavailable" - } - }, - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "الصورة غير متاحة" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ছবি অনুপলব্ধ" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bild nicht verfügbar" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "imagen no disponible" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "image indisponible" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "התמונה לא זמינה" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "चित्र उपलब्ध नहीं" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gambar tidak tersedia" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "immagine non disponibile" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "画像を利用できません" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "이미지를 사용할 수 없음" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "imej tidak tersedia" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "तस्बिर उपलब्ध छैन" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "afbeelding niet beschikbaar" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "obraz niedostępny" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "imagem indisponível" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "изображение недоступно" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "bild otillgänglig" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "படம் கிடைக்கவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ไม่สามารถใช้รูปภาพได้" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "görsel kullanılamıyor" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "зображення недоступне" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "تصویر دستیاب نہیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hình ảnh không khả dụng" - } - } - } } }, "version" : "1.1" -} +} \ No newline at end of file diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index 83578e2b..7e39bdb4 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -19,6 +19,7 @@ struct NostrProtocol { case giftWrap = 1059 // NIP-59 gift wrap case ephemeralEvent = 20000 case geohashPresence = 20001 + case deletion = 5 // NIP-09 event deletion request } /// Create a NIP-17 private message @@ -256,16 +257,22 @@ struct NostrProtocol { } /// Create a persistent location note (kind 1: text note) tagged to a street-level geohash. + /// An optional `expiresAt` adds a NIP-40 expiration tag so honoring relays + /// drop the note in step with a bridged board post's expiry. static func createGeohashTextNote( content: String, geohash: String, senderIdentity: NostrIdentity, - nickname: String? = nil + nickname: String? = nil, + expiresAt: Date? = nil ) throws -> NostrEvent { var tags = [["g", geohash]] if let nickname = nickname?.trimmedOrNilIfEmpty { tags.append(["n", nickname]) } + if let expiresAt { + tags.append(["expiration", String(Int(expiresAt.timeIntervalSince1970))]) + } let event = NostrEvent( pubkey: senderIdentity.publicKeyHex, createdAt: Date(), @@ -276,7 +283,25 @@ struct NostrProtocol { let schnorrKey = try senderIdentity.schnorrSigningKey() return try event.sign(with: schnorrKey) } - + + /// Create a NIP-09 deletion request for one of our own events. Relays that + /// honor NIP-09 drop the referenced event; it must be signed by the same + /// key that signed the original. + static func createDeleteEvent( + ofEventID eventID: String, + senderIdentity: NostrIdentity + ) throws -> NostrEvent { + let event = NostrEvent( + pubkey: senderIdentity.publicKeyHex, + createdAt: Date(), + kind: .deletion, + tags: [["e", eventID]], + content: "" + ) + let schnorrKey = try senderIdentity.schnorrSigningKey() + return try event.sign(with: schnorrKey) + } + // MARK: - Private Methods private static func createSeal( diff --git a/bitchat/Protocols/Geohash.swift b/bitchat/Protocols/Geohash.swift index f436ea27..6fbb2b90 100644 --- a/bitchat/Protocols/Geohash.swift +++ b/bitchat/Protocols/Geohash.swift @@ -18,6 +18,14 @@ enum Geohash { return geohash.lowercased().allSatisfy { base32Map[$0] != nil } } + /// Validates a geohash string at any channel precision (1-12 characters). + /// - Parameter geohash: The geohash string to validate + /// - Returns: true if a non-empty base32 geohash of at most 12 characters + static func isValidGeohash(_ geohash: String) -> Bool { + guard (1...12).contains(geohash.count) else { return false } + return geohash.lowercased().allSatisfy { base32Map[$0] != nil } + } + /// Encodes the provided coordinates into a geohash string. /// - Parameters: /// - latitude: Latitude in degrees (-90...90) diff --git a/bitchat/Services/Board/BoardAlertsModel.swift b/bitchat/Services/Board/BoardAlertsModel.swift new file mode 100644 index 00000000..35daedfe --- /dev/null +++ b/bitchat/Services/Board/BoardAlertsModel.swift @@ -0,0 +1,160 @@ +// +// BoardAlertsModel.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Combine +import Foundation + +/// Turns newly arriving board posts into local, scope-matched chat alerts. +/// Everything here is derived from posts the mesh already synced — no extra +/// wire traffic, nothing another peer can't already see. +/// +/// - Urgent, recent pins get one system line in the matching chat (geo pin → +/// that geohash's timeline, mesh pin → mesh chat), collapsed when several +/// arrive together. +/// - Every other new pin just marks the header's pin icon until the notices +/// sheet is opened. +@MainActor +final class BoardAlertsModel: ObservableObject { + struct Dependencies { + /// Own posts never alert; the author already knows. + var isOwnPost: @MainActor (BoardPostPacket) -> Bool + /// Appends a local system line to a scope's chat timeline + /// (geohash, or "" for mesh chat). + var emitSystemLine: @MainActor (_ content: String, _ geohash: String) -> Void + var now: () -> Date = Date.init + /// Schedules the collapsed flush of pending urgent alerts; tests + /// inject a synchronous hook. + var scheduleFlush: (_ flush: @escaping @MainActor () -> Void) -> Void = { flush in + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(BoardAlertsModel.collapseDelaySeconds * 1_000_000_000)) + flush() + } + } + } + + /// Posts older than this at arrival are backfilled history carried in by + /// a peer, not something happening now; they badge but never line the chat. + static let inlineRecencyWindow: TimeInterval = 30 * 60 + /// Urgent arrivals within this window collapse into one line. + static let collapseDelaySeconds: TimeInterval = 4 + private static let alertContentMaxChars = 120 + + /// Unseen new pins by postID (hex) → geohash scope, cleared when the + /// notices sheet opens. + @Published private(set) var unseenPostScopes: [String: String] = [:] + + /// PostIDs already handled this session, so store eviction/re-sync churn + /// can't re-alert. Bounded by session wire volume (32-byte strings). + private var handledPostIDs = Set() + private var pendingUrgent: [String: [BoardPostPacket]] = [:] + private var flushScheduled = false + private let dependencies: Dependencies + private var cancellable: AnyCancellable? + private var wipeCancellable: AnyCancellable? + + private enum Strings { + static func urgentSingle(author: String, content: String) -> String { + String( + format: String(localized: "notices.alert.urgent_single", defaultValue: "📌 urgent notice from @%@: %@", comment: "Local chat line when one urgent notice is pinned nearby"), + locale: .current, + author, content + ) + } + + static func urgentCollapsed(_ count: Int) -> String { + String( + format: String(localized: "notices.alert.urgent_collapsed", defaultValue: "📌 %lld new urgent notices — tap the pin to view", comment: "Local chat line when several urgent notices arrive together"), + locale: .current, + count + ) + } + } + + init( + arrivals: AnyPublisher, + wipes: AnyPublisher = Empty(completeImmediately: false).eraseToAnyPublisher(), + dependencies: Dependencies + ) { + self.dependencies = dependencies + cancellable = arrivals + .receive(on: DispatchQueue.main) + .sink { [weak self] post in + self?.handleArrival(post) + } + wipeCancellable = wipes + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.reset() + } + } + + func unseenCount(forGeohash geohash: String) -> Int { + unseenPostScopes.values.reduce(0) { $0 + ($1 == geohash ? 1 : 0) } + } + + /// Marks pins in the given scopes as seen — only the scopes the notices + /// sheet actually shows, so unseen pins for other geohash channels keep + /// their badge until visited. + func markSeen(forScopes scopes: Set) { + guard unseenPostScopes.contains(where: { scopes.contains($0.value) }) else { return } + unseenPostScopes = unseenPostScopes.filter { !scopes.contains($0.value) } + } + + /// Panic wipe: drop everything derived from pre-wipe posts, including + /// urgent lines still waiting on the collapse flush. + func reset() { + pendingUrgent.removeAll() + handledPostIDs.removeAll() + guard !unseenPostScopes.isEmpty else { return } + unseenPostScopes.removeAll() + } + + func handleArrival(_ post: BoardPostPacket) { + let postID = post.postID.hexEncodedString() + guard !handledPostIDs.contains(postID) else { return } + handledPostIDs.insert(postID) + guard !dependencies.isOwnPost(post) else { return } + + unseenPostScopes[postID] = post.geohash + + let createdAt = Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000) + guard post.isUrgent, + dependencies.now().timeIntervalSince(createdAt) <= Self.inlineRecencyWindow else { + return + } + pendingUrgent[post.geohash, default: []].append(post) + if !flushScheduled { + flushScheduled = true + dependencies.scheduleFlush { [weak self] in + self?.flushPendingUrgent() + } + } + } + + private func flushPendingUrgent() { + flushScheduled = false + let pending = pendingUrgent + pendingUrgent.removeAll() + for (geohash, posts) in pending { + guard let first = posts.first else { continue } + let line: String + if posts.count == 1 { + let author = first.authorNickname.trimmedOrNilIfEmpty ?? "anon" + line = Strings.urgentSingle(author: author, content: Self.truncated(first.content)) + } else { + line = Strings.urgentCollapsed(posts.count) + } + dependencies.emitSystemLine(line, geohash) + } + } + + private static func truncated(_ content: String) -> String { + guard content.count > alertContentMaxChars else { return content } + return content.prefix(alertContentMaxChars) + "…" + } +} diff --git a/bitchat/Services/Board/BoardManager.swift b/bitchat/Services/Board/BoardManager.swift index 989fda34..1f307bd4 100644 --- a/bitchat/Services/Board/BoardManager.swift +++ b/bitchat/Services/Board/BoardManager.swift @@ -20,17 +20,28 @@ final class BoardManager: ObservableObject { private let transport: Transport private let store: BoardStore - private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String) -> Void + /// Publishes a bridged kind-1 note (expiring with the board post via + /// NIP-40) and returns its Nostr event id, or nil when bridging failed or + /// was skipped. + private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String, _ expiresAtMs: UInt64) -> String? + /// Requests NIP-09 deletion of a previously bridged note. + private let deleteFromNostr: (_ eventID: String, _ geohash: String) -> Void + /// Bridged Nostr event ids by postID, for merged deletes. In-memory only: + /// after a relaunch a delete still tombstones the board copy, but the + /// Nostr copy is left to expire with relay retention. + private var bridgedEventIDs: [Data: String] = [:] private var cancellable: AnyCancellable? init( transport: Transport, store: BoardStore = .shared, - publishToNostr: ((String, String, String) -> Void)? = nil + publishToNostr: ((String, String, String, UInt64) -> String?)? = nil, + deleteFromNostr: ((String, String) -> Void)? = nil ) { self.transport = transport self.store = store self.publishToNostr = publishToNostr ?? Self.livePublishToNostr + self.deleteFromNostr = deleteFromNostr ?? Self.liveDeleteFromNostr cancellable = store.$postsSnapshot .receive(on: DispatchQueue.main) .sink { [weak self] snapshot in @@ -115,10 +126,10 @@ final class BoardManager: ObservableObject { ) transport.sendBoardPayload(BoardWire.post(post).encode()) - // One-way Nostr bridge (v1): geohash posts also go out as kind-1 - // location notes so online users see them. No inbound merge yet. - if !geohash.isEmpty { - publishToNostr(trimmed, geohash, cleanNickname) + // Nostr bridge: geohash posts also go out as kind-1 location notes so + // online users see them. Remember the event id for merged deletes. + if !geohash.isEmpty, let eventID = publishToNostr(trimmed, geohash, cleanNickname, expiresAt) { + bridgedEventIDs[postID] = eventID } return true } @@ -140,14 +151,20 @@ final class BoardManager: ObservableObject { signature: signature ) transport.sendBoardPayload(BoardWire.tombstone(tombstone).encode()) + + // Merged delete: also retract the bridged Nostr copy when we still + // know its event id. + if !post.geohash.isEmpty, let eventID = bridgedEventIDs.removeValue(forKey: post.postID) { + deleteFromNostr(eventID, post.geohash) + } return true } - private static func livePublishToNostr(content: String, geohash: String, nickname: String) { + private static func livePublishToNostr(content: String, geohash: String, nickname: String, expiresAtMs: UInt64) -> String? { let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount) guard !relays.isEmpty else { SecureLogger.debug("Board: no geo relays for \(geohash); skipping Nostr bridge", category: .session) - return + return nil } do { let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash) @@ -155,11 +172,26 @@ final class BoardManager: ObservableObject { content: content, geohash: geohash, senderIdentity: identity, - nickname: nickname + nickname: nickname, + expiresAt: Date(timeIntervalSince1970: TimeInterval(expiresAtMs) / 1000) ) NostrRelayManager.shared.sendEvent(event, to: relays) + return event.id } catch { SecureLogger.error("Board: failed to bridge post to Nostr: \(error)", category: .session) + return nil + } + } + + private static func liveDeleteFromNostr(eventID: String, geohash: String) { + let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount) + guard !relays.isEmpty else { return } + do { + let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash) + let deletion = try NostrProtocol.createDeleteEvent(ofEventID: eventID, senderIdentity: identity) + NostrRelayManager.shared.sendEvent(deletion, to: relays) + } catch { + SecureLogger.error("Board: failed to delete bridged Nostr note: \(error)", category: .session) } } } diff --git a/bitchat/Services/Board/BoardStore.swift b/bitchat/Services/Board/BoardStore.swift index 8ffdf7f3..c96670d0 100644 --- a/bitchat/Services/Board/BoardStore.swift +++ b/bitchat/Services/Board/BoardStore.swift @@ -78,6 +78,16 @@ final class BoardStore { /// Live posts, published on the main thread for the board UI. @Published private(set) var postsSnapshot: [BoardPostPacket] = [] + /// Fires on the main thread for each post newly accepted from the wire + /// (radio, sync, or local echo) — not for disk restores. Drives the + /// local new-pin chat alerts; duplicates never fire twice because the + /// store rejects them. + let postArrivals = PassthroughSubject() + + /// Fires on the main thread after a panic wipe so derived state (pending + /// alerts, unseen badges) is dropped along with the posts themselves. + let didWipe = PassthroughSubject() + private var posts: [StoredPost] = [] private var tombstones: [StoredTombstone] = [] private let queue = DispatchQueue(label: "chat.bitchat.board.store") @@ -104,6 +114,11 @@ final class BoardStore { let result = ingestLocked(wire, packet: packet, rawPacket: rawPacket, nowMs: nowMs) if result == .accepted { persistLocked() + if case .post(let post) = wire { + DispatchQueue.main.async { [weak self] in + self?.postArrivals.send(post) + } + } } return result } @@ -149,6 +164,9 @@ final class BoardStore { } publishSnapshotLocked() } + DispatchQueue.main.async { [weak self] in + self?.didWipe.send() + } } // MARK: - Internals (call only on `queue`) diff --git a/bitchat/Services/Board/UnifiedNotices.swift b/bitchat/Services/Board/UnifiedNotices.swift new file mode 100644 index 00000000..16d88a97 --- /dev/null +++ b/bitchat/Services/Board/UnifiedNotices.swift @@ -0,0 +1,88 @@ +// +// UnifiedNotices.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// One row in the unified notices sheet: a mesh board post or a Nostr +/// location note, normalized for display. +struct NoticeItem: Identifiable, Equatable { + enum Source: Equatable { + /// Signed board post carried by the mesh. + case board(BoardPostPacket) + /// Kind-1 location note seen on geo relays. + case nostr(LocationNotesManager.Note) + } + + let id: String + let author: String + let content: String + let createdAt: Date + let isUrgent: Bool + let source: Source + + var isBoardPost: Bool { + if case .board = source { return true } + return false + } + + init(post: BoardPostPacket) { + id = post.postID.hexEncodedString() + author = post.authorNickname.trimmedOrNilIfEmpty ?? "anon" + content = post.content + createdAt = Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000) + isUrgent = post.isUrgent + source = .board(post) + } + + init(note: LocationNotesManager.Note) { + id = note.id + let display = note.displayName + author = display.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false) + .first.map(String.init) ?? display + content = note.content + createdAt = note.createdAt + isUrgent = false + source = .nostr(note) + } +} + +/// Merges mesh board posts and Nostr location notes into one deduplicated +/// list for the notices sheet's geo tab. +enum UnifiedNotices { + /// Board posts on geohash channels are bridged to Nostr as kind-1 notes at + /// post time, so the same notice arrives twice. The copies share content + /// and nickname but are signed by unlinkable keys; match them + /// heuristically by content + author within a time window. + static let bridgeDedupeWindow: TimeInterval = 15 * 60 + + /// Returns board posts and notes as one list, urgent posts first, then + /// newest first. Notes that look like bridged copies of a board post are + /// dropped — the board copy wins because it carries urgency and supports + /// merged deletion. The geohash must match exactly: the notes + /// subscription also surfaces neighboring cells, and a same-text note + /// from a neighbor is not the bridged copy. + static func merge(posts: [BoardPostPacket], notes: [LocationNotesManager.Note]) -> [NoticeItem] { + var items = posts.map(NoticeItem.init(post:)) + for note in notes { + let noteNickname = note.nickname?.trimmedOrNilIfEmpty ?? "anon" + let isBridgedCopy = posts.contains { post in + post.geohash == note.geohash + && post.content == note.content + && (post.authorNickname.trimmedOrNilIfEmpty ?? "anon") == noteNickname + && abs(Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000).timeIntervalSince(note.createdAt)) <= bridgeDedupeWindow + } + if !isBridgedCopy { + items.append(NoticeItem(note: note)) + } + } + return items.sorted { + if $0.isUrgent != $1.isUrgent { return $0.isUrgent } + return $0.createdAt > $1.createdAt + } + } +} diff --git a/bitchat/Services/LocationNotesManager.swift b/bitchat/Services/LocationNotesManager.swift index 910ecced..e95f536a 100644 --- a/bitchat/Services/LocationNotesManager.swift +++ b/bitchat/Services/LocationNotesManager.swift @@ -67,6 +67,9 @@ final class LocationNotesManager: ObservableObject { let content: String let createdAt: Date let nickname: String? + /// The matched `g` tag: the cell the note was posted to, which can be + /// a neighbor of the subscribed geohash. + let geohash: String var displayName: String { let suffix = String(pubkey.suffix(4)) @@ -82,6 +85,8 @@ final class LocationNotesManager: ObservableObject { @Published private(set) var initialLoadComplete: Bool = false @Published private(set) var state: State = .loading @Published private(set) var errorMessage: String? + /// Public key of our per-geohash Nostr identity; identifies our own notes. + private var ownPubkey: String? private var subscriptionID: String? private var noteIDs = Set() // O(1) duplicate detection private var directoryUpdateCancellable: AnyCancellable? @@ -104,10 +109,10 @@ final class LocationNotesManager: ObservableObject { let norm = geohash.lowercased() self.geohash = norm self.dependencies = dependencies - // Validate geohash (building-level precision: 8 chars) - if !Geohash.isValidBuildingGeohash(norm) { - SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session) + if !Geohash.isValidGeohash(norm) { + SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 1-12 valid base32 chars)", category: .session) } + ownPubkey = (try? dependencies.deriveIdentity(norm))?.publicKeyHex subscribe() // The relay directory may load after init (remote fetch over Tor); // retry automatically instead of staying stuck on "no relays". @@ -123,9 +128,8 @@ final class LocationNotesManager: ObservableObject { func setGeohash(_ newGeohash: String) { let norm = newGeohash.lowercased() guard norm != geohash else { return } - // Validate geohash (building-level precision: 8 chars) - guard Geohash.isValidBuildingGeohash(norm) else { - SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session) + guard Geohash.isValidGeohash(norm) else { + SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 1-12 valid base32 chars)", category: .session) return } if let sub = subscriptionID { @@ -137,6 +141,7 @@ final class LocationNotesManager: ObservableObject { initialLoadComplete = false errorMessage = nil geohash = norm + ownPubkey = (try? dependencies.deriveIdentity(norm))?.publicKeyHex notes.removeAll() noteIDs.removeAll() subscribe() @@ -193,14 +198,14 @@ final class LocationNotesManager: ObservableObject { guard let self = self else { return } guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return } // Ensure matching tag - accept any of our 9 geohashes - guard event.tags.contains(where: { tag in + guard let matchedGeohash = event.tags.first(where: { tag in tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased()) - }) else { return } + })?[1].lowercased() else { return } guard !self.noteIDs.contains(event.id) else { return } self.noteIDs.insert(event.id) let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick) + let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick, geohash: matchedGeohash) self.notes.append(note) self.notes.sort { $0.createdAt > $1.createdAt } self.enforceMemoryCap() @@ -239,7 +244,8 @@ final class LocationNotesManager: ObservableObject { pubkey: id.publicKeyHex, content: trimmed, createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)), - nickname: nickname + nickname: nickname, + geohash: geohash ) self.noteIDs.insert(event.id) self.notes.insert(echo, at: 0) @@ -252,6 +258,36 @@ final class LocationNotesManager: ObservableObject { } } + /// Whether the note was published by this device's identity for the + /// current geohash (and can therefore be deleted with NIP-09). + func isOwnNote(_ note: Note) -> Bool { + guard let ownPubkey else { return false } + return note.pubkey == ownPubkey + } + + /// Requests NIP-09 deletion of one of our own notes and removes it locally. + @discardableResult + func delete(note: Note) -> Bool { + guard isOwnNote(note) else { return false } + let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount) + guard !relays.isEmpty else { + state = .noRelays + errorMessage = Strings.noRelays + return false + } + do { + let identity = try dependencies.deriveIdentity(geohash) + let deletion = try NostrProtocol.createDeleteEvent(ofEventID: note.id, senderIdentity: identity) + dependencies.sendEvent(deletion, relays) + // Keep the id in noteIDs so a relay replay can't resurrect it. + notes.removeAll { $0.id == note.id } + return true + } catch { + SecureLogger.error("LocationNotesManager: failed to delete note: \(error)", category: .session) + return false + } + } + /// Enforces defensive memory cap on notes array (keeps newest). private func enforceMemoryCap() { if notes.count > maxNotesInMemory { diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index c0c0d8b1..29140383 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1744,6 +1744,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele func addGeohashOnlySystemMessage(_ content: String) { publicConversationCoordinator.addGeohashOnlySystemMessage(content) } + + /// Add a local system message to one specific geohash timeline, active or + /// not. Used by the board's new-pin alerts to scope-match the pin's channel. + @MainActor + func addGeohashSystemMessage(_ content: String, geohash: String) { + let systemMessage = BitchatMessage( + sender: "system", + content: content, + timestamp: Date(), + isRelay: false + ) + appendGeohashMessageIfAbsent(systemMessage, toGeohash: geohash) + } // Send a public message without adding a local user echo. // Used for emotes where we want a local system-style confirmation instead. @MainActor diff --git a/bitchat/Views/BoardView.swift b/bitchat/Views/BoardView.swift deleted file mode 100644 index 8c443847..00000000 --- a/bitchat/Views/BoardView.swift +++ /dev/null @@ -1,270 +0,0 @@ -// -// BoardView.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import SwiftUI - -/// The bulletin board for one context: a geohash channel, or the mesh-local -/// board when `geohash` is empty. Urgent posts pin to the top; own posts can -/// be swipe-deleted, which broadcasts a signed tombstone. -struct BoardView: View { - /// Empty string = mesh-local board. - let geohash: String - let senderNickname: String - @ObservedObject var board: BoardManager - - @ThemedPalette private var palette - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @Environment(\.dismiss) private var dismiss - @State private var draft: String = "" - @State private var urgent = false - @State private var expiryDays = 7 - - private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 } - private var posts: [BoardPostPacket] { board.posts(forGeohash: geohash) } - - private enum Strings { - static let boardName = String(localized: "board.title", defaultValue: "board", comment: "Title prefix of the bulletin board sheet") - static let description = String(localized: "board.description", defaultValue: "persistent notices carried by the mesh. posts are signed, spread device-to-device, and expire on their own.", comment: "Explainer under the board sheet title") - static let emptyTitle = String(localized: "board.empty_title", defaultValue: "no notices yet", comment: "Title shown when the board has no posts") - static let emptySubtitle = String(localized: "board.empty_subtitle", defaultValue: "pin the first notice for people around here.", comment: "Subtitle shown when the board has no posts") - static let urgentBadge = String(localized: "board.urgent_badge", defaultValue: "urgent", comment: "Badge shown on urgent board posts") - static let urgentToggle = String(localized: "board.compose.urgent", defaultValue: "urgent", comment: "Label for the urgent toggle in the board composer") - static let placeholder = String(localized: "board.compose.placeholder", defaultValue: "post a notice…", comment: "Placeholder for the board composer text field") - static let send = String(localized: "board.accessibility.post", defaultValue: "Post notice", comment: "Accessibility label for the board post button") - static let deleteAction = String(localized: "board.action.delete", defaultValue: "delete", comment: "Delete action for own board posts") - static let expiryLabel = String(localized: "board.compose.expiry", defaultValue: "expires in", comment: "Label for the board post expiry picker") - static let closeHint = String(localized: "board.accessibility.close", defaultValue: "Close board", comment: "Accessibility label for the board close button") - - static func expiryDaysOption(_ days: Int) -> String { - String( - format: String(localized: "board.compose.expiry_days", defaultValue: "%lldd", comment: "Expiry picker option, number of days abbreviated"), - locale: .current, - days - ) - } - - static func postAccessibilityLabel(author: String, content: String, urgent: Bool) -> String { - let base = String( - format: String(localized: "board.accessibility.post_row", defaultValue: "Notice from %@: %@", comment: "Accessibility label for a board post row"), - locale: .current, - author, content - ) - return urgent ? "\(urgentBadge), \(base)" : base - } - } - - var body: some View { - VStack(spacing: 0) { - headerSection - postList - composer - } - .themedSurface() - #if os(macOS) - .frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680) - #endif - .themedSheetBackground() - } - - private var headerSection: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 12) { - Text(verbatim: geohash.isEmpty ? "\(Strings.boardName) @ #mesh" : "\(Strings.boardName) @ #\(geohash)") - .bitchatFont(size: 18) - Spacer() - SheetCloseButton { dismiss() } - .accessibilityLabel(Strings.closeHint) - } - Text(Strings.description) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 12) - .themedSurface() - } - - private var postList: some View { - Group { - if posts.isEmpty { - ScrollView { - VStack(alignment: .leading, spacing: 4) { - Text(Strings.emptyTitle) - .bitchatFont(size: 13, weight: .semibold) - Text(Strings.emptySubtitle) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.vertical, 12) - } - } else { - List { - ForEach(posts, id: \.postID) { post in - postRow(post) - .listRowBackground(palette.background) - .listRowSeparatorTint(palette.divider) - } - } - .listStyle(.plain) - .scrollContentBackground(.hidden) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .themedSurface() - } - - private func postRow(_ post: BoardPostPacket) -> some View { - let isOwn = board.isOwnPost(post) - let author = post.authorNickname.trimmedOrNilIfEmpty ?? "anon" - return VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - if post.isUrgent { - Image(systemName: "exclamationmark.triangle.fill") - .font(.bitchatSystem(size: 11)) - .foregroundColor(palette.alertRed) - Text(Strings.urgentBadge) - .bitchatFont(size: 11, weight: .semibold) - .foregroundColor(palette.alertRed) - } - Text(verbatim: "@\(author)") - .bitchatFont(size: 12, weight: .semibold) - Text(timestampText(forMs: post.createdAt)) - .bitchatFont(size: 11) - .foregroundColor(palette.secondary) - Spacer() - if isOwn { - Button { - board.deletePost(post) - } label: { - Image(systemName: "trash") - .font(.bitchatSystem(size: 12)) - .foregroundColor(palette.secondary) - } - .buttonStyle(.plain) - .accessibilityLabel(Strings.deleteAction) - } - } - Text(post.content) - .bitchatFont(size: 14) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.vertical, 4) - .accessibilityElement(children: .ignore) - .accessibilityLabel(Strings.postAccessibilityLabel(author: author, content: post.content, urgent: post.isUrgent)) - .accessibilityActions { - if isOwn { - Button(Strings.deleteAction) { board.deletePost(post) } - } - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - if isOwn { - Button(role: .destructive) { - board.deletePost(post) - } label: { - Label(Strings.deleteAction, systemImage: "trash") - } - } - } - } - - private var composer: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .top, spacing: 10) { - TextField(Strings.placeholder, text: $draft, axis: .vertical) - .textFieldStyle(.plain) - .bitchatFont(size: 14) - .lineLimit(maxDraftLines, reservesSpace: true) - .padding(.vertical, 6) - Button(action: send) { - Image(systemName: "arrow.up.circle.fill") - .font(.bitchatSystem(size: 20)) - .foregroundColor(sendEnabled ? palette.accent : .secondary) - } - .padding(.top, 2) - .buttonStyle(.plain) - .disabled(!sendEnabled) - .accessibilityLabel(Strings.send) - } - HStack(spacing: 12) { - Toggle(isOn: $urgent) { - Text(Strings.urgentToggle) - .bitchatFont(size: 12) - .foregroundColor(urgent ? palette.alertRed : palette.secondary) - } - .toggleStyle(.switch) - .fixedSize() - .accessibilityLabel(Strings.urgentToggle) - Spacer() - Text(Strings.expiryLabel) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) - Picker(Strings.expiryLabel, selection: $expiryDays) { - ForEach([1, 3, 7], id: \.self) { days in - Text(Strings.expiryDaysOption(days)).tag(days) - } - } - .pickerStyle(.segmented) - .fixedSize() - .accessibilityLabel(Strings.expiryLabel) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 14) - .themedSurface() - .overlay(Divider(), alignment: .top) - } - - private var sendEnabled: Bool { - let trimmed = draft.trimmed - return !trimmed.isEmpty && trimmed.utf8.count <= BoardWireConstants.contentMaxBytes - } - - private func send() { - guard let content = draft.trimmedOrNilIfEmpty else { return } - let sent = board.createPost( - content: content, - geohash: geohash, - urgent: urgent, - expiryDays: expiryDays, - nickname: senderNickname - ) - if sent { - draft = "" - urgent = false - } - } - - private func timestampText(forMs ms: UInt64) -> String { - let date = Date(timeIntervalSince1970: TimeInterval(ms) / 1000) - let now = Date() - if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 { - let rel = Self.relativeFormatter.string(from: date, to: now) ?? "" - return rel.isEmpty ? "" : "\(rel) ago" - } - return Self.absDateFormatter.string(from: date) - } - - private static let relativeFormatter: DateComponentsFormatter = { - let f = DateComponentsFormatter() - f.allowedUnits = [.day, .hour, .minute] - f.maximumUnitCount = 1 - f.unitsStyle = .abbreviated - f.collapsesLargestUnit = true - return f - }() - - private static let absDateFormatter: DateFormatter = { - let f = DateFormatter() - f.setLocalizedDateFormatFromTemplate("MMM d") - return f - }() -} diff --git a/bitchat/Views/ContentHeaderView.swift b/bitchat/Views/ContentHeaderView.swift index 6a260b07..9bac1095 100644 --- a/bitchat/Views/ContentHeaderView.swift +++ b/bitchat/Views/ContentHeaderView.swift @@ -1,21 +1,17 @@ import SwiftUI -#if os(iOS) -import UIKit -#endif struct ContentHeaderView: View { @EnvironmentObject private var appChromeModel: AppChromeModel @EnvironmentObject private var verificationModel: VerificationModel @EnvironmentObject private var locationChannelsModel: LocationChannelsModel @EnvironmentObject private var peerListModel: PeerListModel + @EnvironmentObject private var boardAlertsModel: BoardAlertsModel @Environment(\.dynamicTypeSize) private var dynamicTypeSize @Environment(\.appTheme) private var theme @ThemedPalette private var palette @Binding var showSidebar: Bool @Binding var showVerifySheet: Bool - @Binding var showLocationNotes: Bool - @Binding var notesGeohash: String? var isNicknameFieldFocused: FocusState.Binding let headerHeight: CGFloat @@ -25,8 +21,13 @@ struct ContentHeaderView: View { /// Courier envelopes this device is carrying for offline third parties. @State private var carriedMailCount = 0 - /// Bulletin board sheet for the current channel context. - @State private var showBoard = false + /// Unified notices sheet (board posts + location notes) for the current + /// channel context. + @State private var showNotices = false + + /// Board posts mirrored from the store so the pin icon can show when the + /// current scope has notices. + @State private var boardPosts: [BoardPostPacket] = [] var body: some View { HStack(spacing: 0) { @@ -137,36 +138,40 @@ struct ContentHeaderView: View { ) } - if case .mesh = locationChannelsModel.selectedChannel, - locationChannelsModel.permissionState == .authorized { - Button(action: { - locationChannelsModel.enableAndRefresh() - notesGeohash = locationChannelsModel.currentBuildingGeohash - showLocationNotes = true - }) { - Image(systemName: "note.text") - .font(.bitchatSystem(size: 12)) - .foregroundColor(Color.orange.opacity(0.8)) - .headerTapTarget() + Button(action: { + var scopes: Set = [""] + if let geoScope = noticesGeoScope { + scopes.insert(geoScope) } - .buttonStyle(.plain) - .accessibilityLabel( - String(localized: "content.accessibility.location_notes", comment: "Accessibility label for location notes button") - ) - } - - Button(action: { showBoard = true }) { - Image(systemName: "pin") + boardAlertsModel.markSeen(forScopes: scopes) + showNotices = true + }) { + // Fill marks unseen new pins; the tint says the current + // scope has notices at all. + Image(systemName: unseenNoticesCount > 0 ? "pin.fill" : "pin") .font(.bitchatSystem(size: 12)) - .foregroundColor(palette.secondary.opacity(0.9)) + .foregroundColor( + scopeHasNotices || unseenNoticesCount > 0 + ? Color.orange.opacity(0.8) + : palette.secondary.opacity(0.9) + ) .headerTapTarget() } .buttonStyle(.plain) .accessibilityLabel( - String(localized: "content.accessibility.board", defaultValue: "Bulletin board", comment: "Accessibility label for the bulletin board button") + String(localized: "content.accessibility.notices", defaultValue: "Notices", comment: "Accessibility label for the notices button") + ) + .accessibilityValue( + unseenNoticesCount > 0 + ? String( + format: String(localized: "content.accessibility.notices_new", defaultValue: "%lld new", comment: "Accessibility value for the notices button when unseen pins arrived"), + locale: .current, + unseenNoticesCount + ) + : "" ) .help( - String(localized: "content.header.board", defaultValue: "Bulletin board: persistent notices for this channel", comment: "Tooltip for the bulletin board button") + String(localized: "content.header.notices", defaultValue: "Notices: pinned posts for this area and the mesh", comment: "Tooltip for the notices button") ) if case .location(let channel) = locationChannelsModel.selectedChannel { @@ -267,54 +272,21 @@ struct ContentHeaderView: View { .onReceive(CourierStore.shared.$carriedCount) { count in carriedMailCount = count } + .onReceive(BoardStore.shared.$postsSnapshot) { posts in + boardPosts = posts + } .sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) { LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) .environmentObject(locationChannelsModel) .environmentObject(peerListModel) } - .sheet(isPresented: $showBoard) { - BoardView( - geohash: boardGeohash, + .sheet(isPresented: $showNotices) { + NoticesView( senderNickname: appChromeModel.nickname, - board: appChromeModel.boardManager + board: appChromeModel.boardManager, + initialTab: initialNoticesTab ) - } - .sheet(isPresented: $showLocationNotes, onDismiss: { - notesGeohash = nil - }) { - Group { - if let geohash = notesGeohash ?? locationChannelsModel.currentBuildingGeohash { - LocationNotesView( - geohash: geohash, - senderNickname: appChromeModel.nickname - ) - .environmentObject(locationChannelsModel) - } else { - ContentLocationNotesUnavailableView( - showLocationNotes: $showLocationNotes, - headerHeight: headerHeight - ) - .environmentObject(locationChannelsModel) - } - } - .onAppear { - locationChannelsModel.enableLocationChannels() - locationChannelsModel.beginLiveRefresh() - } - .onDisappear { - locationChannelsModel.endLiveRefresh() - } - .onChange(of: locationChannelsModel.availableChannels) { channels in - if let current = channels.first(where: { $0.level == .building })?.geohash, - notesGeohash != current { - notesGeohash = current - #if os(iOS) - let generator = UIImpactFeedbackGenerator(style: .light) - generator.prepare() - generator.impactOccurred() - #endif - } - } + .environmentObject(locationChannelsModel) } .onAppear { locationChannelsModel.refreshMeshChannelsIfNeeded() @@ -348,13 +320,34 @@ private extension ContentHeaderView { dynamicTypeSize.isAccessibilitySize ? 2 : 1 } - /// The board scope for the current channel: the geohash channel's board, - /// or the mesh-local board ("") in mesh chat. - var boardGeohash: String { + /// Open the notices sheet on the tab matching the current channel: the + /// geohash channel's notices, or the mesh-local board in mesh chat. + var initialNoticesTab: NoticesView.Tab { + if case .location = locationChannelsModel.selectedChannel { + return .geo + } + return .mesh + } + + /// The geo scope the notices sheet would open on: the selected location + /// channel, or the device's building geohash when chatting on mesh. + var noticesGeoScope: String? { if case .location(let channel) = locationChannelsModel.selectedChannel { return channel.geohash } - return "" + return locationChannelsModel.currentBuildingGeohash + } + + /// Whether either tab of the notices sheet currently has content. + var scopeHasNotices: Bool { + boardPosts.contains { $0.geohash.isEmpty || $0.geohash == noticesGeoScope } + } + + /// New pins in either visible scope since the sheet was last opened. + var unseenNoticesCount: Int { + let meshCount = boardAlertsModel.unseenCount(forGeohash: "") + let geoCount = noticesGeoScope.map { boardAlertsModel.unseenCount(forGeohash: $0) } ?? 0 + return meshCount + geoCount } /// Whether anyone is actually reachable on the current channel — the @@ -380,37 +373,3 @@ private extension ContentHeaderView { } } } - -private struct ContentLocationNotesUnavailableView: View { - @EnvironmentObject private var locationChannelsModel: LocationChannelsModel - @ThemedPalette private var palette - - @Binding var showLocationNotes: Bool - - let headerHeight: CGFloat - - var body: some View { - VStack(spacing: 12) { - HStack { - Text("content.notes.title") - .bitchatFont(size: 16, weight: .bold) - Spacer() - SheetCloseButton { showLocationNotes = false } - .foregroundColor(palette.primary) - } - .frame(minHeight: headerHeight) - .padding(.horizontal, 12) - .themedChromePanel(edge: .top) - Text("content.notes.location_unavailable") - .bitchatFont(size: 14) - .foregroundColor(palette.secondary) - Button("content.location.enable") { - locationChannelsModel.enableAndRefresh() - } - .buttonStyle(.bordered) - Spacer() - } - .themedSheetBackground() - .foregroundColor(palette.primary) - } -} diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index bd434ad1..901c61b5 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -49,8 +49,6 @@ struct ContentView: View { @State private var isAtBottomPrivate = true @State private var autocompleteDebounceTimer: Timer? @State private var showVerifySheet = false - @State private var showLocationNotes = false - @State private var notesGeohash: String? @State private var imagePreviewURL: URL? #if os(iOS) @State private var showImagePicker = false @@ -269,8 +267,6 @@ struct ContentView: View { ContentHeaderView( showSidebar: $showSidebar, showVerifySheet: $showVerifySheet, - showLocationNotes: $showLocationNotes, - notesGeohash: $notesGeohash, isNicknameFieldFocused: $isNicknameFieldFocused, headerHeight: headerHeight, headerPeerIconSize: headerPeerIconSize, diff --git a/bitchat/Views/LocationNotesView.swift b/bitchat/Views/LocationNotesView.swift deleted file mode 100644 index d2cd3f16..00000000 --- a/bitchat/Views/LocationNotesView.swift +++ /dev/null @@ -1,304 +0,0 @@ -import SwiftUI - -struct LocationNotesView: View { - @StateObject private var manager: LocationNotesManager - let geohash: String - let senderNickname: String - let onNotesCountChanged: ((Int) -> Void)? - - @ThemedPalette private var palette - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @EnvironmentObject private var locationChannelsModel: LocationChannelsModel - @Environment(\.dismiss) private var dismiss - @State private var draft: String = "" - - init( - geohash: String, - senderNickname: String, - onNotesCountChanged: ((Int) -> Void)? = nil, - manager: LocationNotesManager? = nil - ) { - let gh = geohash.lowercased() - self.geohash = gh - self.senderNickname = senderNickname - self.onNotesCountChanged = onNotesCountChanged - _manager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh)) - } - - private var backgroundColor: Color { palette.background } - private var accentGreen: Color { palette.accent } - private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 } - - private enum Strings { - static let description: LocalizedStringKey = "location_notes.description" - static let loadingRecent: LocalizedStringKey = "location_notes.loading_recent" - static let relaysPaused: LocalizedStringKey = "location_notes.relays_paused" - static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby" - static let retry: LocalizedStringKey = "location_notes.action.retry" - static let relaysRetryHint: LocalizedStringKey = "location_notes.relays_retry_hint" - static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes" - static let emptyTitle: LocalizedStringKey = "location_notes.empty_title" - static let emptySubtitle: LocalizedStringKey = "location_notes.empty_subtitle" - static let dismissError: LocalizedStringKey = "location_notes.action.dismiss" - static let addPlaceholder: LocalizedStringKey = "location_notes.placeholder" - } - - var body: some View { -#if os(macOS) - VStack(spacing: 0) { - ScrollView { - VStack(spacing: 0) { - headerSection - notesContent - } - } - .themedSurface() - inputSection - } - .frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680) - .themedSheetBackground() - .onDisappear { manager.cancel() } - .onChange(of: geohash) { newValue in - manager.setGeohash(newValue) - } - .onAppear { onNotesCountChanged?(manager.notes.count) } - .onChange(of: manager.notes.count) { newValue in - onNotesCountChanged?(newValue) - } -#else - NavigationView { - VStack(spacing: 0) { - headerSection - ScrollView { - notesContent - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - inputSection - } - .themedSurface() - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - .navigationBarHidden(true) - #else - .navigationTitle("") - #endif - } - .themedSheetBackground() - .onDisappear { manager.cancel() } - .onChange(of: geohash) { newValue in - manager.setGeohash(newValue) - } - .onAppear { onNotesCountChanged?(manager.notes.count) } - .onChange(of: manager.notes.count) { newValue in - onNotesCountChanged?(newValue) - } -#endif - } - - private var closeButton: some View { - SheetCloseButton { dismiss() } - } - - private var headerSection: some View { - let count = manager.notes.count - return VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 12) { - Text(headerTitle(for: count)) - .bitchatFont(size: 18) - Spacer() - closeButton - } - if let building = locationChannelsModel.locationName(for: .building), !building.isEmpty { - Text(building) - .bitchatFont(size: 12) - .foregroundColor(accentGreen) - } else if let block = locationChannelsModel.locationName(for: .block), !block.isEmpty { - Text(block) - .bitchatFont(size: 12) - .foregroundColor(accentGreen) - } - Text(Strings.description) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) - .fixedSize(horizontal: false, vertical: true) - if manager.state == .noRelays { - Text(Strings.relaysPaused) - .bitchatFont(size: 11) - .foregroundColor(palette.secondary) - } - } - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 12) - .themedSurface() - } - - private func headerTitle(for count: Int) -> String { - String( - format: String(localized: "location_notes.header", comment: "Header displaying the geohash and localized note count"), - locale: .current, - "\(geohash) ± 1", count - ) - } - - private var notesContent: some View { - LazyVStack(alignment: .leading, spacing: 12) { - if manager.state == .noRelays { - noRelaysRow - } else if manager.state == .loading && !manager.initialLoadComplete { - loadingRow - } else if manager.notes.isEmpty { - emptyRow - } else { - ForEach(manager.notes) { note in - noteRow(note) - } - } - - if let error = manager.errorMessage, manager.state != .noRelays { - errorRow(message: error) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - } - - private func noteRow(_ note: LocationNotesManager.Note) -> some View { - let baseName = note.displayName.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? note.displayName - let ts = timestampText(for: note.createdAt) - return VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(verbatim: "@\(baseName)") - .bitchatFont(size: 12, weight: .semibold) - if !ts.isEmpty { - Text(ts) - .bitchatFont(size: 11) - .foregroundColor(palette.secondary) - } - Spacer() - } - Text(note.content) - .bitchatFont(size: 14) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.vertical, 4) - } - - private var noRelaysRow: some View { - VStack(alignment: .leading, spacing: 4) { - Text(Strings.noRelaysNearby) - .bitchatFont(size: 13, weight: .semibold) - Text(Strings.relaysRetryHint) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) - Button(Strings.retry) { manager.refresh() } - .bitchatFont(size: 12) - .buttonStyle(.plain) - } - .padding(.vertical, 6) - } - - private var loadingRow: some View { - HStack(spacing: 10) { - ProgressView() - Text(Strings.loadingNotes) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) - Spacer() - } - .padding(.vertical, 8) - } - - private var emptyRow: some View { - VStack(alignment: .leading, spacing: 4) { - Text(Strings.emptyTitle) - .bitchatFont(size: 13, weight: .semibold) - Text(Strings.emptySubtitle) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) - } - .padding(.vertical, 6) - } - - private func errorRow(message: String) -> some View { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 6) { - Image(systemName: "exclamationmark.triangle.fill") - .bitchatFont(size: 12) - Text(message) - .bitchatFont(size: 12) - Spacer() - } - Button(Strings.dismissError) { manager.clearError() } - .bitchatFont(size: 12) - .buttonStyle(.plain) - } - .padding(.vertical, 6) - } - - private var inputSection: some View { - HStack(alignment: .top, spacing: 10) { - TextField(Strings.addPlaceholder, text: $draft, axis: .vertical) - .textFieldStyle(.plain) - .bitchatFont(size: 14) - .lineLimit(maxDraftLines, reservesSpace: true) - .padding(.vertical, 6) - Button(action: send) { - Image(systemName: "arrow.up.circle.fill") - .font(.bitchatSystem(size: 20)) - .foregroundColor(sendButtonEnabled ? accentGreen : .secondary) - } - .padding(.top, 2) - .buttonStyle(.plain) - .disabled(!sendButtonEnabled) - } - .padding(.horizontal, 16) - .padding(.vertical, 14) - .themedSurface() - .overlay(Divider(), alignment: .top) - } - - private func send() { - guard let content = draft.trimmedOrNilIfEmpty else { return } - manager.send(content: content, nickname: senderNickname) - draft = "" - } - - private var sendButtonEnabled: Bool { - !draft.trimmed.isEmpty && manager.state != .noRelays - } - - // MARK: - Timestamp Formatting - private func timestampText(for date: Date) -> String { - let now = Date() - if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 { - let rel = Self.relativeFormatter.string(from: date, to: now) ?? "" - return rel.isEmpty ? "" : "\(rel) ago" - } else { - let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year) - let fmt = sameYear ? Self.absDateFormatter : Self.absDateYearFormatter - return fmt.string(from: date) - } - } - - private static let relativeFormatter: DateComponentsFormatter = { - let f = DateComponentsFormatter() - f.allowedUnits = [.day, .hour, .minute] - f.maximumUnitCount = 1 - f.unitsStyle = .abbreviated - f.collapsesLargestUnit = true - return f - }() - - private static let absDateFormatter: DateFormatter = { - let f = DateFormatter() - f.setLocalizedDateFormatFromTemplate("MMM d") - return f - }() - - private static let absDateYearFormatter: DateFormatter = { - let f = DateFormatter() - f.setLocalizedDateFormatFromTemplate("MMM d, y") - return f - }() -} diff --git a/bitchat/Views/NoticesView.swift b/bitchat/Views/NoticesView.swift new file mode 100644 index 00000000..692158f1 --- /dev/null +++ b/bitchat/Views/NoticesView.swift @@ -0,0 +1,560 @@ +// +// NoticesView.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import SwiftUI + +/// The unified notices sheet behind the header's pin icon: one place for +/// everything pinned around you, with a scope toggle. +/// +/// - geo: the current geohash's notices — mesh-synced board posts merged and +/// deduped with Nostr kind-1 location notes, so you also see notices from +/// people who aren't on your mesh. +/// - mesh: the mesh-local board only (empty geohash, fully offline). +struct NoticesView: View { + enum Tab: Hashable { + case geo + case mesh + } + + let senderNickname: String + @ObservedObject var board: BoardManager + + @ThemedPalette private var palette + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var locationChannelsModel: LocationChannelsModel + @State private var tab: Tab + @State private var draft: String = "" + @State private var urgent = false + @State private var expiryDays = 7 + + /// Injected notes manager for tests; live use derives one per geohash. + private let notesManager: LocationNotesManager? + + init( + senderNickname: String, + board: BoardManager, + initialTab: Tab, + notesManager: LocationNotesManager? = nil + ) { + self.senderNickname = senderNickname + self.board = board + self.notesManager = notesManager + _tab = State(initialValue: initialTab) + } + + private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 } + + /// The geohash the geo tab is scoped to: the selected location channel, + /// or the device's building geohash when chatting on mesh. + private var geoGeohash: String? { + if case .location(let channel) = locationChannelsModel.selectedChannel { + return channel.geohash + } + return locationChannelsModel.currentBuildingGeohash + } + + /// The geo scope comes from device location only when no location channel + /// is selected; that's the case that needs the location machinery. + private var geoTabNeedsDeviceLocation: Bool { + if case .location = locationChannelsModel.selectedChannel { return false } + return true + } + + private var activeGeohash: String? { + switch tab { + case .geo: return geoGeohash + case .mesh: return "" + } + } + + enum Strings { + static let title = String(localized: "notices.title", defaultValue: "notices", comment: "Title prefix of the unified notices sheet") + static let geoTab = String(localized: "notices.tab.geo", defaultValue: "geo", comment: "Segmented control label for geohash-scoped notices") + static let meshTab = String(localized: "notices.tab.mesh", defaultValue: "mesh", comment: "Segmented control label for mesh-local notices") + static let scopePicker = String(localized: "notices.accessibility.scope", defaultValue: "Notices scope", comment: "Accessibility label for the geo/mesh scope toggle") + // The pre-merge location-notes explainer, reused so its existing + // translations carry over. + static let geoDescription = String(localized: "location_notes.description", comment: "Explainer for the geo tab of the notices sheet") + static let meshDescription = String(localized: "notices.description.mesh", defaultValue: "pin short notices for people around you. they hop phone to phone, even offline, and disappear on their own after a few days.", comment: "Explainer for the mesh tab of the notices sheet") + static let emptyTitle = String(localized: "board.empty_title", defaultValue: "no notices yet", comment: "Title shown when the board has no posts") + static let emptySubtitle = String(localized: "board.empty_subtitle", defaultValue: "pin the first notice for people around here.", comment: "Subtitle shown when the board has no posts") + static let urgentBadge = String(localized: "board.urgent_badge", defaultValue: "urgent", comment: "Badge shown on urgent board posts") + static let urgentToggle = String(localized: "board.compose.urgent", defaultValue: "urgent", comment: "Label for the urgent toggle in the board composer") + static let placeholder = String(localized: "board.compose.placeholder", defaultValue: "post a notice…", comment: "Placeholder for the board composer text field") + static let send = String(localized: "board.accessibility.post", defaultValue: "Post notice", comment: "Accessibility label for the board post button") + static let deleteAction = String(localized: "board.action.delete", defaultValue: "delete", comment: "Delete action for own board posts") + static let expiryLabel = String(localized: "board.compose.expiry", defaultValue: "expires in", comment: "Label for the board post expiry picker") + static let closeHint = String(localized: "notices.accessibility.close", defaultValue: "Close notices", comment: "Accessibility label for the notices close button") + static let meshSource = String(localized: "notices.source.mesh", defaultValue: "mesh", comment: "Source badge for notices carried by the mesh") + static let nostrSource = String(localized: "notices.source.nostr", defaultValue: "net", comment: "Source badge for notices seen on internet relays") + static let locationUnavailable = String(localized: "content.notes.location_unavailable", comment: "Shown when the device location is unavailable for geo notices") + static let enableLocation = String(localized: "content.location.enable", comment: "Button enabling location for geo notices") + static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes" + static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby" + static let relaysRetryHint: LocalizedStringKey = "location_notes.relays_retry_hint" + static let retry: LocalizedStringKey = "location_notes.action.retry" + static let dismissError: LocalizedStringKey = "location_notes.action.dismiss" + + static func expiryDaysOption(_ days: Int) -> String { + String( + format: String(localized: "board.compose.expiry_days", defaultValue: "%lldd", comment: "Expiry picker option, number of days abbreviated"), + locale: .current, + days + ) + } + + static func rowAccessibilityLabel(author: String, content: String, urgent: Bool) -> String { + let base = String( + format: String(localized: "board.accessibility.post_row", defaultValue: "Notice from %@: %@", comment: "Accessibility label for a board post row"), + locale: .current, + author, content + ) + return urgent ? "\(urgentBadge), \(base)" : base + } + } + + var body: some View { + VStack(spacing: 0) { + headerSection + contentSection + if activeGeohash != nil { + composer + } + } + .themedSurface() + #if os(macOS) + .frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680) + #endif + .themedSheetBackground() + .onAppear { beginGeoLocationIfNeeded() } + .onChange(of: tab) { newTab in + if newTab == .geo { + beginGeoLocationIfNeeded() + } else { + locationChannelsModel.endLiveRefresh() + } + } + // Catches permission granted from the geo tab's enable button. + .onChange(of: locationChannelsModel.permissionState) { _ in + beginGeoLocationIfNeeded() + } + .onDisappear { locationChannelsModel.endLiveRefresh() } + } + + /// The geo tab tracks the device's building geohash while on mesh; keep + /// location fresh only in that case (a selected location channel already + /// fixes the scope). + private func beginGeoLocationIfNeeded() { + guard tab == .geo, geoTabNeedsDeviceLocation, + locationChannelsModel.permissionState == .authorized else { return } + locationChannelsModel.enableLocationChannels() + locationChannelsModel.beginLiveRefresh() + } + + private var headerSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + Text(verbatim: scopeTitle) + .bitchatFont(size: 18) + Spacer() + SheetCloseButton { dismiss() } + .accessibilityLabel(Strings.closeHint) + } + Picker(Strings.scopePicker, selection: $tab) { + Text(Strings.geoTab).tag(Tab.geo) + Text(Strings.meshTab).tag(Tab.mesh) + } + .pickerStyle(.segmented) + .accessibilityLabel(Strings.scopePicker) + Text(tab == .geo ? Strings.geoDescription : Strings.meshDescription) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 12) + .themedSurface() + } + + private var scopeTitle: String { + switch tab { + case .mesh: + return "\(Strings.title) @ #mesh" + case .geo: + if let geohash = geoGeohash { + return "\(Strings.title) @ #\(geohash)" + } + return Strings.title + } + } + + @ViewBuilder + private var contentSection: some View { + switch tab { + case .mesh: + NoticesList( + items: UnifiedNotices.merge(posts: board.posts(forGeohash: ""), notes: []), + showsSource: false, + board: board, + notesManager: nil + ) + case .geo: + if let geohash = geoGeohash { + GeoNoticesList(geohash: geohash, board: board, manager: notesManager) + } else { + locationUnavailableSection + } + } + } + + private var locationUnavailableSection: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text(Strings.locationUnavailable) + .bitchatFont(size: 14) + .foregroundColor(palette.secondary) + .fixedSize(horizontal: false, vertical: true) + Button(Strings.enableLocation) { + locationChannelsModel.enableAndRefresh() + } + .buttonStyle(.bordered) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .themedSurface() + } + + private var composer: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 10) { + TextField(Strings.placeholder, text: $draft, axis: .vertical) + .textFieldStyle(.plain) + .bitchatFont(size: 14) + .lineLimit(maxDraftLines, reservesSpace: true) + .padding(.vertical, 6) + Button(action: send) { + Image(systemName: "arrow.up.circle.fill") + .font(.bitchatSystem(size: 20)) + .foregroundColor(sendEnabled ? palette.accent : .secondary) + } + .padding(.top, 2) + .buttonStyle(.plain) + .disabled(!sendEnabled) + .accessibilityLabel(Strings.send) + } + // Urgency and expiry only travel with the mesh copy — the bridged + // Nostr note carries neither, so relay-side readers would never + // see them. Offer the controls only where they fully apply. + if tab == .mesh { + HStack(spacing: 12) { + Toggle(isOn: $urgent) { + Text(Strings.urgentToggle) + .bitchatFont(size: 12) + .foregroundColor(urgent ? palette.alertRed : palette.secondary) + } + .toggleStyle(.switch) + .fixedSize() + .accessibilityLabel(Strings.urgentToggle) + Spacer() + Text(Strings.expiryLabel) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + Picker(Strings.expiryLabel, selection: $expiryDays) { + ForEach([1, 3, 7], id: \.self) { days in + Text(Strings.expiryDaysOption(days)).tag(days) + } + } + .pickerStyle(.segmented) + .fixedSize() + .accessibilityLabel(Strings.expiryLabel) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .themedSurface() + .overlay(Divider(), alignment: .top) + } + + private var sendEnabled: Bool { + let trimmed = draft.trimmed + return !trimmed.isEmpty && trimmed.utf8.count <= BoardWireConstants.contentMaxBytes + } + + private func send() { + guard let geohash = activeGeohash, let content = draft.trimmedOrNilIfEmpty else { return } + // Geo posts go to the board and are bridged to Nostr by BoardManager, + // so mesh and internet see the same notice. They always use the + // defaults: non-urgent, 7-day expiry (NIP-40 on the bridged copy). + let sent = board.createPost( + content: content, + geohash: geohash, + urgent: tab == .mesh && urgent, + expiryDays: tab == .mesh ? expiryDays : 7, + nickname: senderNickname + ) + if sent { + draft = "" + urgent = false + } + } +} + +/// The geo tab's list: owns the Nostr notes subscription for the scope +/// geohash and merges it with the board posts for the same geohash. +private struct GeoNoticesList: View { + let geohash: String + @ObservedObject var board: BoardManager + @StateObject private var notesManager: LocationNotesManager + + init(geohash: String, board: BoardManager, manager: LocationNotesManager? = nil) { + let gh = geohash.lowercased() + self.geohash = gh + self.board = board + _notesManager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh)) + } + + var body: some View { + NoticesList( + items: UnifiedNotices.merge( + posts: board.posts(forGeohash: geohash), + notes: notesManager.notes + ), + showsSource: true, + board: board, + notesManager: notesManager + ) + .onChange(of: geohash) { newValue in + notesManager.setGeohash(newValue) + } + .onDisappear { notesManager.cancel() } + } +} + +/// Renders merged notices with per-source affordances: swipe-delete for own +/// items and a mesh/net badge when sources mix. +private struct NoticesList: View { + let items: [NoticeItem] + let showsSource: Bool + let board: BoardManager + let notesManager: LocationNotesManager? + + @ThemedPalette private var palette + + private typealias Strings = NoticesView.Strings + + var body: some View { + Group { + if items.isEmpty { + ScrollView { + VStack(alignment: .leading, spacing: 4) { + statusRows + if showEmptyState { + Text(Strings.emptyTitle) + .bitchatFont(size: 13, weight: .semibold) + Text(Strings.emptySubtitle) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + } else { + List { + statusRows + .listRowBackground(palette.background) + .listRowSeparatorTint(palette.divider) + ForEach(items) { item in + row(item) + .listRowBackground(palette.background) + .listRowSeparatorTint(palette.divider) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .themedSurface() + } + + /// Notes may still be loading or unreachable; only claim "no notices yet" + /// once the sources settled. + private var showEmptyState: Bool { + guard let notesManager else { return true } + return notesManager.initialLoadComplete && notesManager.state != .loading + } + + @ViewBuilder + private var statusRows: some View { + if let notesManager { + if notesManager.state == .loading && !notesManager.initialLoadComplete { + HStack(spacing: 10) { + ProgressView() + Text(Strings.loadingNotes) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + Spacer() + } + .padding(.vertical, 8) + } else if notesManager.state == .noRelays { + VStack(alignment: .leading, spacing: 4) { + Text(Strings.noRelaysNearby) + .bitchatFont(size: 13, weight: .semibold) + Text(Strings.relaysRetryHint) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + Button(Strings.retry) { notesManager.refresh() } + .bitchatFont(size: 12) + .buttonStyle(.plain) + } + .padding(.bottom, 8) + } else if let error = notesManager.errorMessage { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .bitchatFont(size: 12) + Text(error) + .bitchatFont(size: 12) + Spacer() + } + Button(Strings.dismissError) { notesManager.clearError() } + .bitchatFont(size: 12) + .buttonStyle(.plain) + } + .padding(.bottom, 8) + } + } + } + + private func canDelete(_ item: NoticeItem) -> Bool { + switch item.source { + case .board(let post): + return board.isOwnPost(post) + case .nostr(let note): + return notesManager?.isOwnNote(note) ?? false + } + } + + private func delete(_ item: NoticeItem) { + switch item.source { + case .board(let post): + // Tombstones the board post and retracts the bridged Nostr copy. + board.deletePost(post) + case .nostr(let note): + notesManager?.delete(note: note) + } + } + + private func row(_ item: NoticeItem) -> some View { + let isOwn = canDelete(item) + return VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + if item.isUrgent { + Image(systemName: "exclamationmark.triangle.fill") + .font(.bitchatSystem(size: 11)) + .foregroundColor(palette.alertRed) + Text(Strings.urgentBadge) + .bitchatFont(size: 11, weight: .semibold) + .foregroundColor(palette.alertRed) + } + Text(verbatim: "@\(item.author)") + .bitchatFont(size: 12, weight: .semibold) + Text(Self.timestampText(for: item.createdAt)) + .bitchatFont(size: 11) + .foregroundColor(palette.secondary) + Spacer() + if showsSource { + sourceBadge(item) + } + if isOwn { + Button { + delete(item) + } label: { + Image(systemName: "trash") + .font(.bitchatSystem(size: 12)) + .foregroundColor(palette.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel(Strings.deleteAction) + } + } + Text(item.content) + .bitchatFont(size: 14) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 4) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Strings.rowAccessibilityLabel(author: item.author, content: item.content, urgent: item.isUrgent)) + .accessibilityActions { + if isOwn { + Button(Strings.deleteAction) { delete(item) } + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if isOwn { + Button(role: .destructive) { + delete(item) + } label: { + Label(Strings.deleteAction, systemImage: "trash") + } + } + } + } + + private func sourceBadge(_ item: NoticeItem) -> some View { + HStack(spacing: 3) { + Image(systemName: item.isBoardPost ? "antenna.radiowaves.left.and.right" : "globe") + .font(.bitchatSystem(size: 10)) + Text(item.isBoardPost ? Strings.meshSource : Strings.nostrSource) + .bitchatFont(size: 10) + } + .foregroundColor(palette.secondary.opacity(0.8)) + .accessibilityLabel(item.isBoardPost ? Strings.meshSource : Strings.nostrSource) + } + + // MARK: - Timestamp Formatting + + private static func timestampText(for date: Date) -> String { + let now = Date() + if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 { + let rel = relativeFormatter.string(from: date, to: now) ?? "" + return rel.isEmpty ? "" : "\(rel) ago" + } + let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year) + return (sameYear ? absDateFormatter : absDateYearFormatter).string(from: date) + } + + private static let relativeFormatter: DateComponentsFormatter = { + let f = DateComponentsFormatter() + f.allowedUnits = [.day, .hour, .minute] + f.maximumUnitCount = 1 + f.unitsStyle = .abbreviated + f.collapsesLargestUnit = true + return f + }() + + private static let absDateFormatter: DateFormatter = { + let f = DateFormatter() + f.setLocalizedDateFormatFromTemplate("MMM d") + return f + }() + + private static let absDateYearFormatter: DateFormatter = { + let f = DateFormatter() + f.setLocalizedDateFormatFromTemplate("MMM d, y") + return f + }() +} diff --git a/bitchatTests/Services/BoardAlertsModelTests.swift b/bitchatTests/Services/BoardAlertsModelTests.swift new file mode 100644 index 00000000..df891cea --- /dev/null +++ b/bitchatTests/Services/BoardAlertsModelTests.swift @@ -0,0 +1,215 @@ +// +// BoardAlertsModelTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Combine +import Foundation +import Testing +@testable import bitchat + +@MainActor +struct BoardAlertsModelTests { + + private let baseDate = Date(timeIntervalSince1970: 1_700_000_000) + private var baseMs: UInt64 { UInt64(baseDate.timeIntervalSince1970 * 1000) } + private let ownKey = Data(repeating: 7, count: 32) + + private final class Harness { + var lines: [(content: String, geohash: String)] = [] + var pendingFlushes: [@MainActor () -> Void] = [] + + @MainActor + func flushAll() { + let flushes = pendingFlushes + pendingFlushes = [] + for flush in flushes { flush() } + } + } + + private func makeModel(harness: Harness, now: Date? = nil) -> BoardAlertsModel { + let fixedNow = now ?? baseDate + return BoardAlertsModel( + arrivals: Empty(completeImmediately: false).eraseToAnyPublisher(), + dependencies: BoardAlertsModel.Dependencies( + isOwnPost: { [ownKey] in $0.authorSigningKey == ownKey }, + emitSystemLine: { content, geohash in + harness.lines.append((content, geohash)) + }, + now: { fixedNow }, + scheduleFlush: { flush in + harness.pendingFlushes.append(flush) + } + ) + ) + } + + private func makePost( + content: String = "hello", + geohash: String = "9q8yy", + nickname: String = "alice", + createdAt: UInt64? = nil, + urgent: Bool = false, + authorKey: Data = Data(repeating: 1, count: 32), + postID: Data? = nil + ) -> BoardPostPacket { + BoardPostPacket( + postID: postID ?? Data((0..<16).map { _ in UInt8.random(in: 0...255) }), + geohash: geohash, + content: content, + authorSigningKey: authorKey, + authorNickname: nickname, + createdAt: createdAt ?? baseMs, + expiresAt: (createdAt ?? baseMs) + 24 * 60 * 60 * 1000, + flags: urgent ? BoardPostPacket.urgentFlag : 0, + signature: Data(repeating: 2, count: 64) + ) + } + + @Test + func ownPosts_neverBadgeOrAlert() { + let harness = Harness() + let model = makeModel(harness: harness) + + model.handleArrival(makePost(urgent: true, authorKey: ownKey)) + harness.flushAll() + + #expect(model.unseenCount(forGeohash: "9q8yy") == 0) + #expect(harness.lines.isEmpty) + } + + @Test + func routinePost_badgesWithoutChatLine() { + let harness = Harness() + let model = makeModel(harness: harness) + + model.handleArrival(makePost(geohash: "")) + harness.flushAll() + + #expect(model.unseenCount(forGeohash: "") == 1) + #expect(model.unseenCount(forGeohash: "9q8yy") == 0) + #expect(harness.lines.isEmpty) + } + + @Test + func urgentRecentPost_emitsLineInMatchingScope() { + let harness = Harness() + let model = makeModel(harness: harness) + + model.handleArrival(makePost(content: "road closed", geohash: "9q8yy", urgent: true)) + #expect(harness.lines.isEmpty) + harness.flushAll() + + #expect(harness.lines.count == 1) + #expect(harness.lines[0].geohash == "9q8yy") + #expect(harness.lines[0].content.contains("road closed")) + #expect(harness.lines[0].content.contains("@alice")) + } + + @Test + func urgentBackfilledPost_badgesOnly() { + let harness = Harness() + let arrivalTime = baseDate.addingTimeInterval(BoardAlertsModel.inlineRecencyWindow + 120) + let model = makeModel(harness: harness, now: arrivalTime) + + model.handleArrival(makePost(createdAt: baseMs, urgent: true)) + harness.flushAll() + + #expect(model.unseenCount(forGeohash: "9q8yy") == 1) + #expect(harness.lines.isEmpty) + } + + @Test + func simultaneousUrgentPosts_collapseIntoOneLine() { + let harness = Harness() + let model = makeModel(harness: harness) + + model.handleArrival(makePost(content: "one", urgent: true)) + model.handleArrival(makePost(content: "two", urgent: true)) + model.handleArrival(makePost(content: "three", urgent: true)) + harness.flushAll() + + #expect(harness.lines.count == 1) + #expect(harness.lines[0].content.contains("3")) + #expect(harness.pendingFlushes.isEmpty) + } + + @Test + func urgentPostsInDifferentScopes_alertEachScope() { + let harness = Harness() + let model = makeModel(harness: harness) + + model.handleArrival(makePost(content: "geo pin", geohash: "9q8yy", urgent: true)) + model.handleArrival(makePost(content: "mesh pin", geohash: "", urgent: true)) + harness.flushAll() + + #expect(harness.lines.count == 2) + #expect(Set(harness.lines.map(\.geohash)) == ["9q8yy", ""]) + } + + @Test + func duplicateArrival_isHandledOnce() { + let harness = Harness() + let model = makeModel(harness: harness) + let id = Data(repeating: 3, count: 16) + + model.handleArrival(makePost(urgent: true, postID: id)) + model.handleArrival(makePost(urgent: true, postID: id)) + harness.flushAll() + + #expect(model.unseenCount(forGeohash: "9q8yy") == 1) + #expect(harness.lines.count == 1) + #expect(!harness.lines[0].content.contains("2")) + } + + @Test + func markSeen_clearsOnlyVisibleScopes() { + let harness = Harness() + let model = makeModel(harness: harness) + + model.handleArrival(makePost(geohash: "")) + model.handleArrival(makePost(geohash: "9q8yy")) + model.handleArrival(makePost(geohash: "u4pruyd")) + + // Opening the sheet on mesh + 9q8yy must not eat the badge for the + // never-shown u4pruyd channel. + model.markSeen(forScopes: ["", "9q8yy"]) + + #expect(model.unseenCount(forGeohash: "") == 0) + #expect(model.unseenCount(forGeohash: "9q8yy") == 0) + #expect(model.unseenCount(forGeohash: "u4pruyd") == 1) + } + + @Test + func reset_dropsPendingUrgentLinesAndBadges() { + let harness = Harness() + let model = makeModel(harness: harness) + + model.handleArrival(makePost(content: "pre-wipe secret", urgent: true)) + #expect(harness.pendingFlushes.count == 1) + + // Panic wipe lands before the collapse flush fires. + model.reset() + harness.flushAll() + + #expect(harness.lines.isEmpty) + #expect(model.unseenCount(forGeohash: "9q8yy") == 0) + } + + @Test + func longUrgentContent_isTruncatedInLine() { + let harness = Harness() + let model = makeModel(harness: harness) + let long = String(repeating: "a", count: 400) + + model.handleArrival(makePost(content: long, urgent: true)) + harness.flushAll() + + #expect(harness.lines.count == 1) + #expect(harness.lines[0].content.count < 200) + #expect(harness.lines[0].content.contains("…")) + } +} diff --git a/bitchatTests/Services/UnifiedNoticesTests.swift b/bitchatTests/Services/UnifiedNoticesTests.swift new file mode 100644 index 00000000..3dd369b4 --- /dev/null +++ b/bitchatTests/Services/UnifiedNoticesTests.swift @@ -0,0 +1,142 @@ +// +// UnifiedNoticesTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +@testable import bitchat + +struct UnifiedNoticesTests { + + private let baseDate = Date(timeIntervalSince1970: 1_700_000_000) + private var baseMs: UInt64 { UInt64(baseDate.timeIntervalSince1970 * 1000) } + + private func makePost( + content: String, + nickname: String = "alice", + createdAt: UInt64? = nil, + urgent: Bool = false + ) -> BoardPostPacket { + BoardPostPacket( + postID: Data((0..<16).map { _ in UInt8.random(in: 0...255) }), + geohash: "9q8yy", + content: content, + authorSigningKey: Data(repeating: 1, count: 32), + authorNickname: nickname, + createdAt: createdAt ?? baseMs, + expiresAt: (createdAt ?? baseMs) + 24 * 60 * 60 * 1000, + flags: urgent ? BoardPostPacket.urgentFlag : 0, + signature: Data(repeating: 2, count: 64) + ) + } + + private func makeNote( + content: String, + nickname: String? = "alice", + createdAt: Date? = nil, + geohash: String = "9q8yy" + ) -> LocationNotesManager.Note { + LocationNotesManager.Note( + id: UUID().uuidString, + pubkey: "ab" + UUID().uuidString.replacingOccurrences(of: "-", with: ""), + content: content, + createdAt: createdAt ?? baseDate, + nickname: nickname, + geohash: geohash + ) + } + + @Test + func merge_dropsBridgedCopyOfBoardPost() { + let post = makePost(content: "free couch on 5th") + let bridged = makeNote(content: "free couch on 5th", createdAt: baseDate.addingTimeInterval(30)) + + let merged = UnifiedNotices.merge(posts: [post], notes: [bridged]) + + #expect(merged.count == 1) + #expect(merged[0].isBoardPost) + } + + @Test + func merge_keepsNoteWithSameContentOutsideWindow() { + let post = makePost(content: "water station here") + let oldNote = makeNote( + content: "water station here", + createdAt: baseDate.addingTimeInterval(-UnifiedNotices.bridgeDedupeWindow - 60) + ) + + let merged = UnifiedNotices.merge(posts: [post], notes: [oldNote]) + + #expect(merged.count == 2) + } + + @Test + func merge_keepsSameTextNoteFromNeighborCell() { + // The notes subscription covers the center cell plus 8 neighbors; a + // matching note posted to a *neighbor* is not the bridged copy. + let post = makePost(content: "free couch on 5th") + let neighborNote = makeNote(content: "free couch on 5th", createdAt: baseDate.addingTimeInterval(30), geohash: "9q8yz") + + let merged = UnifiedNotices.merge(posts: [post], notes: [neighborNote]) + + #expect(merged.count == 2) + } + + @Test + func merge_keepsNoteFromDifferentAuthor() { + let post = makePost(content: "meetup at 6", nickname: "alice") + let note = makeNote(content: "meetup at 6", nickname: "bob") + + let merged = UnifiedNotices.merge(posts: [post], notes: [note]) + + #expect(merged.count == 2) + } + + @Test + func merge_sortsUrgentFirstThenNewest() { + let urgent = makePost(content: "road closed", createdAt: baseMs - 60_000, urgent: true) + let newerPost = makePost(content: "later post", createdAt: baseMs) + let note = makeNote(content: "a note", nickname: "carol", createdAt: baseDate.addingTimeInterval(30)) + + let merged = UnifiedNotices.merge(posts: [newerPost, urgent], notes: [note]) + + #expect(merged.map(\.content) == ["road closed", "a note", "later post"]) + #expect(merged[0].isUrgent) + } + + @Test + func merge_anonNicknamesMatchForDedupe() { + // Bridged posts from an empty nickname arrive as anon notes with no + // "n" tag; they must still dedupe against the anon board copy. + let post = makePost(content: "hello", nickname: "") + let bridged = makeNote(content: "hello", nickname: nil) + + let merged = UnifiedNotices.merge(posts: [post], notes: [bridged]) + + #expect(merged.count == 1) + #expect(merged[0].isBoardPost) + #expect(merged[0].author == "anon") + } + + @Test + func noticeItem_normalizesNoteDisplayName() { + let note = LocationNotesManager.Note( + id: "e1", + pubkey: "deadbeef", + content: "hi", + createdAt: baseDate, + nickname: "dave", + geohash: "9q8yy" + ) + + let item = NoticeItem(note: note) + + #expect(item.author == "dave") + #expect(!item.isBoardPost) + #expect(!item.isUrgent) + } +} diff --git a/bitchatTests/ViewSmokeTests.swift b/bitchatTests/ViewSmokeTests.swift index 786b481e..debb41e5 100644 --- a/bitchatTests/ViewSmokeTests.swift +++ b/bitchatTests/ViewSmokeTests.swift @@ -1,3 +1,4 @@ +import Combine import Testing import Foundation import SwiftUI @@ -39,6 +40,7 @@ private struct SmokeFeatureModels { let verificationModel: VerificationModel let conversationUIModel: ConversationUIModel let peerListModel: PeerListModel + let boardAlertsModel: BoardAlertsModel } @MainActor @@ -80,6 +82,14 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur locationChannelsModel: locationChannelsModel ) + let boardAlertsModel = BoardAlertsModel( + arrivals: Empty(completeImmediately: false).eraseToAnyPublisher(), + dependencies: BoardAlertsModel.Dependencies( + isOwnPost: { _ in false }, + emitSystemLine: { _, _ in } + ) + ) + return SmokeFeatureModels( publicChatModel: publicChatModel, appChromeModel: appChromeModel, @@ -88,7 +98,8 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur privateConversationModel: privateConversationModel, verificationModel: verificationModel, conversationUIModel: conversationUIModel, - peerListModel: peerListModel + peerListModel: peerListModel, + boardAlertsModel: boardAlertsModel ) } @@ -106,6 +117,7 @@ private func installSmokeEnvironment( .environmentObject(featureModels.verificationModel) .environmentObject(featureModels.conversationUIModel) .environmentObject(featureModels.peerListModel) + .environmentObject(featureModels.boardAlertsModel) } @MainActor @@ -395,9 +407,17 @@ struct ViewSmokeTests { } @Test - func locationNotesView_rendersNoRelayAndLoadedStates() throws { - let (viewModel, _, _) = makeSmokeViewModel() + func noticesView_rendersNoRelayAndLoadedStates() throws { + let (viewModel, transport, _) = makeSmokeViewModel() let featureModels = makeSmokeFeatureModels(for: viewModel) + featureModels.locationChannelsModel.select(.location(GeohashChannel(level: .building, geohash: "u4pruydq"))) + defer { featureModels.locationChannelsModel.select(.mesh) } + let board = BoardManager( + transport: transport, + store: BoardStore(persistsToDisk: false, fileURL: nil, now: { Date() }), + publishToNostr: { _, _, _, _ in nil }, + deleteFromNostr: { _, _ in } + ) let noRelayManager = LocationNotesManager( geohash: "u4pruydq", @@ -440,18 +460,28 @@ struct ViewSmokeTests { eose?() _ = mount( - LocationNotesView( - geohash: "u4pruydq", + NoticesView( senderNickname: viewModel.nickname, - manager: noRelayManager + board: board, + initialTab: .geo, + notesManager: noRelayManager ) .environmentObject(featureModels.locationChannelsModel) ) _ = mount( - LocationNotesView( - geohash: "u4pruydq", + NoticesView( senderNickname: viewModel.nickname, - manager: loadedManager + board: board, + initialTab: .geo, + notesManager: loadedManager + ) + .environmentObject(featureModels.locationChannelsModel) + ) + _ = mount( + NoticesView( + senderNickname: viewModel.nickname, + board: board, + initialTab: .mesh ) .environmentObject(featureModels.locationChannelsModel) )