Compare commits

...
Author SHA1 Message Date
islam fa45ee3aea Extract Nostr into a dedicated module 2026-04-14 20:23:16 +01:00
islam e8fb4a6333 Extract Noise into a dedicated module 2026-04-14 20:16:45 +01:00
IslamandGitHub 4cfcefcda6 BitFoundation module to centralize shared components (#1089)
* Run local packages’ tests as well on CI

* BitFoundation module to centralize shared components
2026-04-14 14:10:03 -05:00
GitHub Action 3e137d784c Automated update of relay data - Sun Apr 12 06:32:24 UTC 2026 2026-04-12 06:32:24 +00:00
islam 452e9e74fd Run CI build on all PRs (not just to the main) 2026-04-10 22:31:49 +01:00
jack 3afd74ffc8 Ignore .claude directory 2026-04-05 19:04:18 -05:00
951e056a55 Extract message list into dedicated MessageListView (#1080)
* Extract message list into dedicated MessageListView

* Fix broken smoke test

Mounts two distinct fixtures to cover separate render branches: a truncatable message (>2000 chars, no payment tokens) and a payment message (lightning + cashu, private, partial delivery). Expectations guard that each fixture actually reaches its intended branch.

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-04-05 19:01:57 -05:00
GitHub Action d265cfc765 Automated update of relay data - Sun Apr 5 06:26:16 UTC 2026 2026-04-05 06:26:16 +00:00
IslamandGitHub 473f5c7cce String trimming in a centralized manner (#1079)
* String trimming in a centralized manner

* Fix empty-nickname case
2026-04-04 15:56:39 -05:00
b5834cfc76 Cache CI build artifacts (#1085)
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-04-02 20:37:20 -05:00
IslamandGitHub 097d916da7 Refactor and Extract Media-related code (#1077)
* Extract media-related code from ContentView

* Refactor hard-coded prefix and directory names
2026-04-02 20:31:51 -05:00
IslamandGitHub 4b000b0785 Refactor & Extract image viewers (#1074)
* Extract `ImagePreviewView` + add `#Preview`

* Extract image pickers + dedupe and bg processing of images

* Include a dummy photo in the preview assets vs live url

* Fix development assets + url percent encoding

* Fix iOS vs macOS build issue

* Fix SPM-related issues
2026-04-02 18:49:17 -05:00
19437a0bc6 fix: pass environmentObject to people sheet to prevent PM reopen crash (#1069)
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-04-02 11:37:08 -05:00
34bae4a89d Refactor Voice Messages (#1075)
* Extract voice-related code to a dedicated VM

* Minor optimization of permission request + fix ui bug

When isPreparing is being set to true before permission is granted, the UI is shown for a fraction of a second and it’s even worse if the permission is being asked constantly if the user drags the finger even when the alert is shown

* Remove dead code

* Remove unnecessary delegate conformance

* `actor VoiceRecorder` + other minor improvements

* Centralized opening system settings + open for mic access

* Better voice-recording state handling with Enum

* Remove manual timer management

* Simplify formatting + avoid jumping UI w/ countdown

* Add `requestingPermission` state to avoid repetitive calls

* Improve guarding of the states after awaits

* Fix potential “actor reentrancy across awaits” issue

* Remove short-circuiting on .idle + guard before error

* Fix playbackLabel’s formatting for duration vs remainder

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-04-02 11:26:13 -05:00
0331871980 fix: missing file broadcast and leave message signatures + allow local development (#1078)
* add signatures to file transfers and LEAVE messages

* chore: setup project for local development and signing

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-03-31 14:12:09 -05:00
islam f9f6ac92b8 Remove dead code 2026-03-30 10:12:30 +01:00
GitHub Action ca374fd823 Automated update of relay data - Sun Mar 29 06:24:11 UTC 2026 2026-03-29 06:24:11 +00:00
GitHub Action 96f2986d65 Automated update of relay data - Sun Mar 22 06:16:48 UTC 2026 2026-03-22 06:16:48 +00:00
GitHub Action b6e45e3228 Automated update of relay data - Sun Mar 15 06:20:36 UTC 2026 2026-03-15 06:20:36 +00:00
c88ab3dd05 Move GeoRelay fetch work off the main actor (#1060)
* Run GeoRelay fetch pipeline off main actor

* Capture GeoRelay session before detached fetch

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-03-12 20:24:47 -10:00
7d83310bc2 Expand Nostr and identity coverage (#1059)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-03-12 19:41:05 -10:00
264a95b61a Raise protocol coverage to 99 percent (#1058)
* Expand protocol coverage with edge-case tests

* Stabilize read receipt transport test

* Stabilize BLE duplicate packet test

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-03-12 17:55:41 -10:00
8562a76367 Raise Noise coverage to 99 percent (#1057)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-03-12 17:16:09 -10:00
7e86d2061f Expand coverage for transport, chat, and media flows (#1056)
* Expand coverage for transport, chat, and media flows

* Stabilize transport and media coverage tests

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-03-12 16:20:19 -10:00
a136b5b7e9 Expand coverage for relay, identity, and location flows (#1055)
* Expand coverage for relay, identity, and location flows

* Fix macOS SwiftPM CI failures

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-03-12 12:50:46 -10:00
c043cf6354 Fix BLEService test local echo timing (#1054)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-03-12 09:53:58 -10:00
72093f9648 fix: disable macOS focus ring on chat input TextField (#905) (#1045)
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-03-12 08:56:14 -10:00
187a5c3195 fix: allow verification sheet in private chat via sidebar navigation (#988) (#1046)
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-03-12 08:44:43 -10:00
GitHub Action 02b2a006c5 Automated update of relay data - Sun Mar 8 06:12:28 UTC 2026 2026-03-08 06:12:28 +00:00
GitHub Action 2403a6eb90 Automated update of relay data - Sun Mar 1 06:14:25 UTC 2026 2026-03-01 06:14:25 +00:00
GitHub Action e47c5ae7b3 Automated update of relay data - Sun Feb 22 06:15:08 UTC 2026 2026-02-22 06:15:08 +00:00
GitHub Action c1f5868d2d Automated update of relay data - Sun Feb 15 06:17:47 UTC 2026 2026-02-15 06:17:47 +00:00
GitHub Action 0515f4c6e8 Automated update of relay data - Sun Feb 8 06:17:26 UTC 2026 2026-02-08 06:17:26 +00:00
8c7e3e7b9b Harden Nostr validation and BLE announce tests (#1012)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-02-02 18:07:19 -10:00
90a5e8ba9d fix: Rate limit iOS peer notifications to prevent flood (#972)
* fix: Rate limit iOS peer notifications to prevent flood

- Remove aggressive formIntersection that cleared recentlySeenPeers
  when peers temporarily dropped, causing them to be treated as "new"
- Add 5-minute cooldown between notifications (aligns with Android)
- Use fixed notification identifier so iOS updates existing notification
  instead of creating new ones
- Only mark peers as seen when notification is sent, so peers arriving
  during cooldown are included in the next notification

Fixes notification spam every 10-30 seconds when peers fluctuate.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: Bump version to 1.5.1

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 09:51:26 -10:00
GitHub Action 6206184862 Automated update of relay data - Sun Feb 1 06:16:49 UTC 2026 2026-02-01 06:16:49 +00:00
74cf0d89cc Fix iOS BLE mesh authentication issues in BLEService (#998)
* Fix iOS BLE mesh authentication bypass chain in BLEService

- Bind sender IDs to BLE connection UUIDs for peripherals and centrals to prevent spoofing
- Enforce explicit RSR request/response validation and remove legacy TTL==0 RSR path
- Remove TTL==0 unconditional acceptance for messages and file transfers
- Ensure gossip sync caching only occurs after a packet is accepted
- Preserve self‑sync TTL==0 dedup exception without weakening authentication

* fix: toctou in boundPeerID identified by codex

* fix: Remove unused variable and bump version to 1.5.1

- Remove unused messageType variable (compiler warning fix)
- Bump marketing version to 1.5.1

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 12:07:24 -10:00
GitHub Action b4b6aa5ca6 Automated update of relay data - Sun Jan 25 06:05:14 UTC 2026 2026-01-25 06:05:14 +00:00
GitHub Action 1e5a52f39f Automated update of relay data - Sun Jan 18 06:05:08 UTC 2026 2026-01-18 06:05:08 +00:00
3fc64f6168 feat: Implement Request Sync Manager (V2 Sync) (#965)
* feat: Implement Request Sync Manager (V2 Sync)

- Add RequestSyncManager to track and attribute sync requests
- Update BitchatPacket and BinaryProtocol to support IS_RSR flag (0x10)
- Update RequestSyncPacket with new TLV fields (sinceTimestamp, fragmentIdFilter)
- Update GossipSyncManager to use unicast sync requests and mark responses as RSR
- Update BLEService to enforce timestamp validation for normal packets and exempt valid RSRs
- Add documentation for the new sync manager mechanism

* fix: Resolve compilation errors in V2 Sync implementation

- Remove duplicate restartGossipManager in BLEService
- Add missing TransportConfig constants for sync
- Add 'sync' log category to BitLogger
- Add missing BitLogger import in GossipSyncManager

* fix: Update tests for V2 Sync changes

- Add requestSyncManager parameter to GossipSyncManager init in tests
- Implement getConnectedPeers stub in RecordingDelegate
- Remove unused variable warning in SubscriptionRateLimitTests

---------

Co-authored-by: a1denvalu3 <>
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-01-17 07:43:02 -10:00
9964710de2 Improve BLE mesh reliability for large transfers (#964)
* Improve BLE mesh reliability for large transfers

Major reliability improvements for fragment-based transfers (photos, files):

**Notification Queue Fixes**
- Fix silent packet loss when notification queue is full - now queues for retry
- Fix retry queue bug where remaining items were lost when one retry failed
- Add periodic drain mechanism as backup (every 5 seconds)

**Write Queue Fixes**
- Fix drainPendingWrites to use atomic take-send-requeue pattern
- Add logging when peripheral is ready for more writes
- Add periodic drain for pending writes as backup

**Fragment Pacing**
- Increase fragment spacing from 4-5ms to 25-30ms to prevent buffer overflow
- Conservative pacing prevents packet loss on congested BLE connections

**Thread Safety**
- Fix race conditions in stopServices() and emergencyDisconnectAll()
- Synchronize access to peripherals/centrals dictionaries during cleanup
- Clear pending message queues in emergencyDisconnectAll()

**Error Recovery**
- Add handlers for all BLE state transitions (poweredOff, unauthorized, etc.)
- Clear Noise session and re-initiate handshake on decryption failure
- Queue ACKs/receipts for delivery after handshake instead of dropping
- Re-queue failed pending messages for retry

**Other Improvements**
- Add route freshness validation in MeshTopologyTracker (60s threshold)
- Add TTL (24h) and size limits (100 per peer) for MessageRouter outbox
- Fix connection timeout to check peripheral.state before canceling
- Add NoiseEncryptionService.clearSession(for:) method

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix race condition and increase test timeouts

- Fix race in sendNoisePayload: use sync barrier instead of async
  to ensure payload is queued before initiating handshake
  (addresses Codex review feedback)

- Increase BLEServiceTests sleep from 0.5s to 1.0s for CI reliability

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 07:39:31 -10:00
806c420313 Add comprehensive test coverage for ChatViewModel and BLEService (#962)
* Add comprehensive test coverage for ChatViewModel and BLEService

This commit adds 14 new tests to improve the safety net for future
refactoring of ChatViewModel and BLEService:

New test files:
- ChatViewModelTorTests: Tor lifecycle notification handlers (8 tests)
- ChatViewModelDeliveryStatusTests: Delivery status state machine (6 tests)
- ChatViewModelRefactoringTests: Command routing and message handling (4 tests)
- BLEServiceCoreTests: Packet deduplication and stale broadcast filtering (2 tests)
- PublicMessagePipelineTests: Message ordering and deduplication (4 tests)
- MessageRouterTests: Transport selection and outbox behavior (4 tests)
- PrivateChatManagerTests: Chat selection and read receipts (2 tests)
- UnifiedPeerServiceTests: Fingerprint resolution and blocking (2 tests)
- RelayControllerTests: TTL, handshake, and fragment relay logic (4 tests)

Modified files:
- MockTransport: Added peer snapshot publishing on connect/disconnect
- TestHelpers: Added waitUntil polling helper for async tests
- MockIdentityManager: Extended for new test scenarios

Total: 454 tests across 64 suites (was 440)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix flaky test by using waitUntil instead of fixed sleep

Replace fixed 200ms sleep with waitUntil helper for more reliable
async assertion in routing tests. This prevents timing issues on CI.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 16:02:13 -10:00
jackandGitHub 194dedac43 Merge pull request #961 from permissionlesstech/chore/cleanup-dead-code
Remove dead code and stabilize tests
2026-01-16 08:42:10 -10:00
jack 9af46a9ff8 Normalize Cashu chip URLs 2026-01-15 09:57:40 -10:00
jack da3fcd5a21 Remove dead code and stabilize tests 2026-01-15 09:46:35 -10:00
jackandGitHub b282536080 Merge pull request #960 from permissionlesstech/cleanup/dead-code-and-helpers
Remove dead code and extract helper methods
2026-01-15 07:18:06 -10:00
jackandGitHub e156356c71 Update README.md 2026-01-14 14:39:39 -10:00
jackandClaude Opus 4.5 81a6e18d04 Remove dead code and extract helper methods
- Delete LocalizationCatalogTests.swift (530 lines entirely commented out)
- Remove unused nilIfEmpty String extension
- Remove backward-compat aliases from CommandProcessor
- Extract reachableTransport/connectedTransport helpers in MessageRouter
- Extract npubToHex/sendWrappedMessage helpers in NostrTransport
- Refactor 7 message sending methods to use shared helpers

Net reduction: ~590 lines

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:09:58 -10:00
jackandGitHub 4fbbd24021 Merge pull request #959 from permissionlesstech/fix/tor-foreground-restart-delay
Remove dormant wake attempt on Tor foreground restart
2026-01-14 12:04:10 -10:00
jackandClaude Opus 4.5 ec54877140 Remove dormant wake attempt on Tor foreground restart
Arti's dormant/wake FFI functions are no-op stubs that don't actually
implement dormant mode. The app was wasting 2.5-12 seconds trying to
wake from a dormant state that doesn't exist before falling back to
a full restart.

This change:
- Removes wakeFromDormant() call and function
- Simplifies goDormantOnBackground() to just mark state as not ready
- Goes straight to restartArti() on foreground, eliminating the delay

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:59:24 -10:00
jackandClaude Opus 4.5 293d627c28 Fix compiler warning: use let for reference type array
BitchatMessage is a class, so modifying its properties through an array
reference doesn't mutate the array itself. Changed var to let binding.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:18:16 -10:00
jackandGitHub 0c3f84224c Merge pull request #958 from permissionlesstech/feature/arti-tor-replacement
Replace C Tor with Rust Arti
2026-01-14 10:15:16 -10:00
jackandClaude Opus 4.5 7323c0b96c Fix SPM package path for Arti
Update root Package.swift to reference localPackages/Arti instead of
the removed localPackages/Tor directory.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 16:39:46 -10:00
jack 7bb835ffc9 Merge main into feature/arti-tor-replacement 2026-01-13 16:27:30 -10:00
jackandClaude Opus 4.5 23d63ab4df Replace C Tor with Rust Arti for Tor integration
- Replace C Tor (0.4.8.21) with Rust Arti (1.9.0/arti-client 0.38)
- 70% smaller binary: 21MB xcframework vs 67MB (6.9MB vs 14MB per slice)
- Memory-safe Rust implementation with modern async (tokio)
- Same SOCKS5 proxy interface at 127.0.0.1:39050 for drop-in compatibility
- FFI wrapper (arti-bitchat crate) with C-compatible exports
- Swift TorManager maintains identical public API
- Aggressive size optimization: opt-level=z, lto=fat, panic=abort, strip
- Supports iOS device, iOS simulator (Apple Silicon), and macOS

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 16:18:00 -10:00
jackandGitHub fa3c74f941 Merge pull request #957 from permissionlesstech/fix/noise-protocol-compatibility 2026-01-13 13:07:49 -10:00
callebtc 9bac52051a Revert Noise nonce size to 4 bytes for backward compatibility
This change reverts the nonce size increase introduced in f41a390 ('BCH-01-010: Noise Protocol spec compliance improvements').

While 8-byte nonces are recommended by the Noise specification, increasing the size from 4 bytes broke wire compatibility with existing clients. This caused all encrypted DMs to fail between updated and non-updated clients.

Changes:
- Revert NONCE_SIZE_BYTES to 4.
- Restore 4-byte nonce extraction logic in extractNonceFromCiphertextPayload.
- Restore 4-byte nonce serialization in nonceToBytes.
- Restore UInt32.max overflow check in encrypt.

Note: Other improvements from BCH-01-010 (constant-time comparisons, safe arithmetic in replay window, sensitive data clearing) are preserved.
2026-01-14 05:41:26 +07:00
jackandClaude Opus 4.5 7b9ffe464a Add Tor build script to repository for reproducibility
Include the build-minimal.sh script alongside BITCHAT_TOR.md so future
rebuilds can be done directly from the bitchat repo.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:46:48 -10:00
jackandGitHub 7b940485d9 Merge pull request #956 from permissionlesstech/tor-size-optimization
Update Tor to 0.4.8.21 with size optimization
2026-01-12 21:44:23 -10:00
jackandClaude Opus 4.5 5a87ee3e62 Update Tor to 0.4.8.21 with aggressive size optimization
- Update Tor from 0.4.8.17 to 0.4.8.21
- Update OpenSSL from 3.5.1 to 3.6.0
- Aggressive OpenSSL trimming to reduce binary size:
  - Remove post-quantum crypto (ML-DSA, ML-KEM, SLH-DSA, LMS)
  - Remove legacy ciphers (DES, RC2, RC4, RC5, IDEA, SEED, etc.)
  - Remove unused hashes (MD4, MDC2, Whirlpool, RIPEMD160)
  - Remove Chinese standards (SM2, SM3, SM4)
  - Remove certificate features (CMP, CT, RFC3779)
  - Remove GOST, binary EC curves, and other unused features
- Add --disable-module-pow to Tor configure
- Add -Wl,-dead_strip linker flag

Binary size reduction:
- iOS arm64: 17 MB → 14.2 MB (-16%)
- iOS simulator: 16 MB → 13.8 MB (-14%)
- macOS arm64: 16 MB → 13.8 MB (-14%)

Build script at ~/Documents/vibe/Tor.framework-build/build-minimal.sh

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:31:10 -10:00
jackandGitHub 342eabbc00 Merge pull request #955 from permissionlesstech/fix/topology-update-after-verification
Fix topology update running before announce verification
2026-01-12 18:39:56 -10:00
jackandClaude Opus 4.5 241ce2d52c Fix topology update running before announce verification
Move updateNeighbors call to after signature verification passes,
preventing spoofed or stale announces from injecting bad routing data.
Also reset isNewPeer/isReconnectedPeer flags on verification failure
to prevent spurious peer connection notifications.

Addresses review feedback from PR #938.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 18:34:59 -10:00
jackandGitHub 18b56e7393 Merge pull request #954 from permissionlesstech/fix/read-receipt-status-update
Fix READ receipt status not updating to blue checks
2026-01-12 18:28:20 -10:00
jackandClaude Opus 4.5 beb04fc887 Fix READ receipt status not updating to blue checks
- Add findMessageIndex helper to handle peer ID format mismatch
  (short 16-hex vs long 64-hex noise key)
- Prevent DELIVERED acks from downgrading .read status back to .delivered
  (fixes race condition where late-arriving delivery acks overwrote read status)
- Use incoming peerID for READ receipts instead of creating new ID from noise key
- Explicitly re-assign messages array to trigger @Published setter

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 18:23:56 -10:00
jackandGitHub 5fcffefa28 Merge pull request #953 from permissionlesstech/remove-security-warning-appinfo
Remove security warning from AppInfo view
2026-01-12 16:25:34 -10:00
jackandClaude Opus 4.5 46ae039587 Remove security warning from AppInfo view
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 16:24:51 -10:00
jackandGitHub 917f7ebe5f Merge pull request #952 from permissionlesstech/fix/file-transfer-peerid-normalization
Fix asymmetric voice/media delivery in private chats
2026-01-12 16:23:12 -10:00
jackandClaude Opus 4.5 b47fb736f4 Fix asymmetric voice/media delivery in private chats
When selectedPrivateChatPeer was migrated to a 64-hex stable Noise key
after session establishment, file transfers would silently fail because:

1. Sender used raw 64-hex key, truncated to first 8 bytes by BinaryProtocol
2. Receiver's myPeerID is SHA256-fingerprint-derived (different value)
3. Recipient check failed, causing silent packet drop

The fix normalizes peerID to short form (SHA256-derived 16-hex) in
sendFilePrivate before constructing recipientData, ensuring the wire
format matches what receivers expect.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 16:17:50 -10:00
jackandGitHub ebb5bb6558 Merge pull request #951 from permissionlesstech/fix/main-actor-isolation-snapshots
Fix main actor isolation error in clearAppSwitcherSnapshots
2026-01-12 16:11:40 -10:00
jackandClaude Opus 4.5 7cfdcfe174 Fix main actor isolation error in clearAppSwitcherSnapshots
Add `nonisolated` to the static method since it only performs
thread-safe FileManager operations and is called from Task.detached.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 16:04:12 -10:00
jackandGitHub 21e0cbc607 Merge pull request #950 from permissionlesstech/fix/var-to-let-warnings
Fix compiler warnings for unmutated variables in BLEService
2026-01-12 15:53:25 -10:00
jackandClaude Opus 4.5 1b439a543e Fix compiler warnings for unmutated variables in BLEService
Change var to let for packet variables that are never mutated.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:44:24 -10:00
jackandGitHub d30a3b14cf Merge pull request #949 from a1denvalu3/main
fix: update georelays weekly workflow
2026-01-12 15:37:14 -10:00
jackandGitHub d03128612d Merge pull request #948 from permissionlesstech/fix/BCH-01-010-noise-protocol-spec-compliance
BCH-01-010: Noise Protocol spec compliance improvements
2026-01-12 15:36:45 -10:00
jackandGitHub ae294aab6d Merge pull request #947 from permissionlesstech/fix/BCH-01-013-clear-snapshots-on-reset
fix(security): clear iOS app switcher snapshots on panic reset [BCH-01-013]
2026-01-12 15:36:01 -10:00
jackandGitHub 07b0e146f2 Merge pull request #946 from permissionlesstech/fix/BCH-01-012-notification-blocking-bypass
fix(security): prevent notifications from blocked users [BCH-01-012]
2026-01-12 15:35:29 -10:00
jackandGitHub cc5939cb13 Merge pull request #945 from permissionlesstech/fix/BCH-01-009-keychain-error-handling
fix(security): improve keychain error handling [BCH-01-009]
2026-01-12 15:34:47 -10:00
jackandGitHub cada784844 Merge pull request #944 from permissionlesstech/fix/BCH-01-011-timestamp-validation
fix(security): reduce timestamp validation window to ±5 minutes [BCH-01-011]
2026-01-12 15:33:38 -10:00
jackandGitHub b1aeb931bc Merge pull request #943 from permissionlesstech/fix/BCH-01-004-fingerprinting-disclosure
fix BCH-01-004: rate-limit subscription-triggered announces
2026-01-12 15:30:15 -10:00
jackandGitHub b675738664 Merge pull request #942 from permissionlesstech/fix/BCH-01-002-file-storage-dos
fix BCH-01-002: prevent DoS via file storage exhaustion
2026-01-12 15:27:51 -10:00
jackandClaude Opus 4.5 4091a30f10 BCH-01-002: Switch from pending files to disk quota management
The original PR introduced a PendingFileManager that held incoming files
in memory until user acceptance. However, this approach had issues:
1. No UI was implemented for users to accept/decline files
2. Files would expire after 5 minutes, losing legitimate transfers
3. The app only allows specific media types (photos, audio) that users
   explicitly choose to send, so manual acceptance adds friction

This commit replaces the pending file system with disk quota management:
- Auto-save files immediately (preserving original UX)
- Enforce 100 MB storage quota for incoming files
- Auto-delete oldest files when quota is exceeded
- Logs cleanup activity for visibility

This directly addresses the DoS vulnerability (disk exhaustion from
file spam) while maintaining good UX for legitimate transfers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:17:09 -10:00
a1denvalu3anda1denvalu3 238311aefb fix typo 2026-01-13 00:57:20 +01:00
a1denvalu3anda1denvalu3 6defae71c6 fetch georelays working workflow 2026-01-13 00:27:10 +01:00
jackandClaude Opus 4.5 b533d9560d Fix: Capture handshake hash before split() clears symmetric state
Addresses Codex review feedback on PR #948: the getTransportCiphers()
method now returns the handshake hash as part of its return tuple,
capturing it BEFORE split() calls clearSensitiveData().

Previously, calling getHandshakeHash() after getTransportCiphers()
would return an all-zero value since split() clears the symmetric
state. This broke channel binding functionality.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 11:32:17 -10:00
jackandClaude Opus 4.5 f41a390a94 BCH-01-010: Noise Protocol spec compliance improvements
This addresses the Cure53 audit finding BCH-01-010 which identified several
implementation deviations from the Noise Protocol Framework specification.

Changes:
- Expand nonce from 4-byte to 8-byte transmission per spec guidance
  This provides 2^64 nonce space instead of the limited 2^32 space
- Fix integer overflow in replay window check using safe arithmetic
- Add constant-time comparison functions for key validation to prevent
  timing side-channel attacks
- Add clearSensitiveData() method to NoiseSymmetricState that clears
  chaining key and hash after split() operation
- Document atomic nonce state updates in decryption

Security improvements:
- Constant-time operations prevent information leakage via timing analysis
- Proper cleanup of symmetric state after handshake completion
- Safer arithmetic prevents potential integer overflow issues

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 11:23:31 -10:00
jackandClaude Opus 4.5 f83316bd1f fix(security): clear iOS app switcher snapshots on panic reset [BCH-01-013]
Add code to clear iOS app switcher snapshot cache during panic mode.
iOS automatically captures screenshots for the app switcher, which could
reveal sensitive message content visible at the time.

Changes:
- Add clearAppSwitcherSnapshots() helper function (iOS only)
- Call it from panicClearAllData() during file cleanup phase
- Clears all files in Library/Caches/Snapshots/ directory

Security: Sensitive information visible in the app when it entered
background will no longer be recoverable from snapshot cache after
panic reset.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 11:10:34 -10:00
jackandClaude Opus 4.5 09818a02ed fix(security): prevent notifications from blocked users [BCH-01-012]
Block notifications from bypassing the user blocking feature on iOS.

Vulnerabilities fixed:
1. Nostr public messages: checkForMentions() was called regardless of
   blocking status, allowing blocked users to trigger mention notifications
2. Noise-encrypted DMs: didReceiveNoisePayload() didn't check blocking
   before calling handlePrivateMessage(), allowing DM notifications

Changes:
- ChatViewModel+Nostr.swift: Add blocking check before checkForMentions()
  and sendHapticFeedback() in subscribeNostrEvent
- ChatViewModel.swift: Add isPeerBlocked() check in didReceiveNoisePayload
  before processing private messages
- Add NotificationBlockingTests.swift with 5 tests for blocking behavior

Security: Blocked users can no longer trigger notifications through any
message path (Nostr public, Nostr DM, or BLE Noise DM).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 11:08:09 -10:00
jackandClaude Opus 4.5 9cd955ae2f fix: extend backoff window on blocked attempts (Codex P2 feedback)
Update lastAnnounceTime to 'now' when rate-limiting subscription attempts.
This ensures each blocked attempt extends the suppression window, preventing
attackers from waiting out the backoff while continuously spamming attempts.

Previously, the backoff timer was only based on the last successful announce,
allowing attackers to receive an announce as soon as the initial backoff
expired regardless of how many blocked attempts occurred in between.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 11:00:36 -10:00
jackandClaude Opus 4.5 49b1413d85 fix: address Codex review feedback for BCH-01-002
P1: Wire didReceivePendingFileTransfer into ChatViewModel
- Add implementation that creates system message for pending files
- Route to appropriate chat (public/private)
- Send local notification for incoming files
- Auto-decline files from blocked users

P2: Keep pending files in queue if save fails
- Only remove file from pendingFiles after successful save
- Allows user to retry accept if initial save failed

Also adds test for save failure scenario.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:58:02 -10:00
jackandClaude Opus 4.5 31be6b83a7 fix(security): improve keychain error handling [BCH-01-009]
Add proper error classification to distinguish expected states (item not
found) from critical failures (access denied, storage full) and
recoverable errors (device locked, auth failed).

Changes:
- Add KeychainReadResult and KeychainSaveResult enums with error types
- Add getIdentityKeyWithResult/saveIdentityKeyWithResult protocol methods
- Implement retry logic with exponential backoff for transient errors
- Add consistent SecureLogger logging for all keychain operations
- Update NoiseEncryptionService identity loading to handle errors gracefully
- Update MockKeychain and TrackingMockKeychain with error simulation
- Add comprehensive KeychainErrorHandlingTests (18 new tests)

Security: Applications can now properly detect and respond to critical
keychain failures, improving resilience during device lock states and
providing actionable diagnostics for access issues.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:53:02 -10:00
jackandClaude Opus 4.5 1563209797 fix(security): reduce timestamp validation window to ±5 minutes
BCH-01-011: Reduces replay attack window from ±1 hour to ±5 minutes.

The previous 1-hour window allowed extended replay attacks. A 5-minute
window is industry standard for replay protection while accommodating
reasonable clock drift.

- Updated InputValidator.validateTimestamp to use 300-second window
- Updated tests to verify new boundary conditions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:46:34 -10:00
jackandClaude Opus 4.5 99896bcded fix BCH-01-004: rate-limit subscription-triggered announces
Adds rate-limiting to prevent device enumeration attacks via rapid
subscription/disconnect cycles. Without this fix, an attacker could
enumerate ~120 bitchat devices per minute by connecting, subscribing,
capturing the announce packet, then disconnecting and moving to the next.

Key changes:
- Add per-central rate-limit tracking with exponential backoff
- Minimum 2-second interval between announces per central
- Exponential backoff (2x) up to 30 seconds for rapid reconnects
- After 5 rapid attempts, suppress announces entirely (likely attack)
- Clean up stale rate-limit entries after 60-second window
- Clear rate-limit state during panic mode

Configuration:
- bleSubscriptionRateLimitMinSeconds: 2.0
- bleSubscriptionRateLimitBackoffFactor: 2.0
- bleSubscriptionRateLimitMaxBackoffSeconds: 30.0
- bleSubscriptionRateLimitWindowSeconds: 60.0
- bleSubscriptionRateLimitMaxAttempts: 5

Security audit reference: Cure53 BCH-01-004 (Medium severity)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:43:36 -10:00
jackandClaude Opus 4.5 4b3077169a fix BCH-01-002: prevent DoS via file storage exhaustion
Incoming files are now held in memory via PendingFileManager instead of
being auto-saved to disk. Users must explicitly accept files before they
are written. This prevents attackers from exhausting device storage.

Key changes:
- Add PendingFileManager with configurable limits (max 10 files, 5MB total)
- Files auto-expire after 5 minutes if not accepted
- LRU eviction when limits are exceeded
- Pending files cleared during panic mode (emergencyDisconnectAll)
- Add didReceivePendingFileTransfer delegate method
- Add acceptPendingFile/declinePendingFile to Transport protocol

Security audit reference: Cure53 BCH-01-002 (Medium severity)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:39:19 -10:00
jackandGitHub b84c36c6fa Merge pull request #934 from 21-DOT-DEV/P256K-VERSION-LOCK
chore: pin swift-secp256k1 to exact version 0.21.1
2026-01-12 10:24:02 -10:00
jackandGitHub 3eac5858e4 Merge pull request #938 from permissionlesstech/source-routing-packet-format
feat: Source routing v2
2026-01-12 10:21:28 -10:00
jackandGitHub 10b7c1fd80 Merge branch 'main' into source-routing-packet-format 2026-01-12 10:09:29 -10:00
jackandGitHub bf3249aef7 Merge pull request #940 from permissionlesstech/geohash-presence-events
Implement Geohash Presence (Heartbeats)
2026-01-12 10:05:51 -10:00
jackandClaude Opus 4.5 95a6ec7315 add tests for geohash presence feature
34 tests covering:
- NostrProtocol presence event creation
- NostrFilter kind filtering
- ChatViewModel presence handling
- Privacy precision restrictions
- Display logic for "? people"
- Participant tracker integration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:56:51 -10:00
callebtc 74864472c7 fix relay 2026-01-12 17:04:17 +07:00
callebtc 9404c03477 fix doc 2026-01-12 16:56:45 +07:00
callebtc 6faa46a22f increase limit to 1000 2026-01-12 16:41:37 +07:00
callebtc e02f4327c0 move block up 2026-01-12 14:57:25 +07:00
callebtc ce31d85323 record participant count before self-suppression 2026-01-12 14:33:21 +07:00
callebtc b6d8a5b758 update UI on new data 2026-01-12 14:14:34 +07:00
callebtc 6630f5a792 start the service 2026-01-12 14:13:24 +07:00
callebtc 0f5299a0f5 announce and read presence 2026-01-12 14:12:49 +07:00
callebtc eb3bbfd861 source routing v2 2026-01-12 10:18:04 +07:00
callebtc a37243e780 min chunk size 2026-01-10 17:23:52 +07:00
callebtc 90b134186b dynamic fragmentation 2026-01-09 22:54:03 +07:00
callebtc 3c7e14f49d fixes 2026-01-09 16:03:17 +07:00
callebtc 31275856dd resign packet 2026-01-08 16:04:40 +07:00
callebtc b6cf44a824 centralize apply route 2026-01-07 19:16:18 +07:00
callebtc b6ae08be60 route length is part of header length now 2026-01-07 16:08:02 +07:00
callebtc d469704c34 wip: SBR only for v2 2026-01-07 15:11:46 +07:00
csjones c975abf2ff chore: pin swift-secp256k1 to exact version 0.21.1 2026-01-05 15:28:26 -08:00
jackandGitHub aff700a15e Merge pull request #931 from permissionlesstech/fix/flaky-fragmentation-tests
fix: eliminate flaky FragmentationTests with proper async synchronization
2026-01-04 14:25:07 -10:00
jackandClaude Opus 4.5 869d766f8d fix: address race condition in continuation-based waiting
Add recheck of message count after acquiring lock and before storing
continuation to prevent race where message arrives between initial
count check and continuation install.

Addresses Codex review feedback on PR #931.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 14:18:40 -10:00
jackandClaude Opus 4.5 1b4f120014 fix: eliminate flaky FragmentationTests with proper async synchronization
Replace timing-based sleep() synchronization with continuation-based waiting
in FragmentationTests to fix intermittent failures.

Changes:
- Add thread-safe CaptureDelegate with NSLock for array access
- Add waitForPublicMessages/waitForReceivedMessages using CheckedContinuation
- Update reassemblyFromFragmentsDeliversPublicMessage to use proper waiting
- Update duplicateFragmentDoesNotBreakReassembly to use proper waiting
- Remove fire-and-forget Task blocks that caused race conditions

Root cause: Tests used fire-and-forget Tasks with sleep(0.5) but delegate
callbacks go through notifyUI() which spawns another MainActor Task.
The sleep didn't guarantee callback completion.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 14:13:32 -10:00
jackandGitHub bc312e4aef Merge pull request #929 from permissionlesstech/fix/nostr-transport-thread-safety
fix: add thread safety to NostrTransport read receipt queue
2026-01-04 13:56:17 -10:00
jackandGitHub 6e7509f2be Merge branch 'main' into fix/nostr-transport-thread-safety 2026-01-04 13:52:31 -10:00
jackandGitHub ca06f4d51d Merge pull request #928 from permissionlesstech/fix/noise-dh-secret-clearing
fix: clear DH shared secrets after Noise handshake operations
2026-01-04 13:52:14 -10:00
jackandGitHub 04f57e8713 Merge branch 'main' into fix/nostr-transport-thread-safety 2026-01-04 13:41:53 -10:00
jackandGitHub 59c3c4e236 Merge branch 'main' into fix/noise-dh-secret-clearing 2026-01-04 13:41:38 -10:00
jackandClaude Opus 4.5 e887e04f40 fix: add thread safety to NostrTransport read receipt queue
Synchronized access to readQueue and isSendingReadAcks using the
existing concurrent DispatchQueue with barrier flags:

- sendReadReceipt(): wrap enqueue in barrier async
- processReadQueueIfNeeded(): extract item within barrier context
- scheduleNextReadAck(): wrap callback in barrier async

This fixes race conditions where concurrent calls could corrupt the
read queue or cause check-then-act bugs on isSendingReadAcks.

Also adds thread safety tests:
- concurrentReadReceiptEnqueue: 100 concurrent enqueue operations
- readQueueProcessingUnderLoad: concurrent enqueue during processing
- isPeerReachableThreadSafety: concurrent read access test

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:29:20 -10:00
jackandClaude Opus 4.5 151b68a497 fix: clear DH shared secrets after Noise handshake operations
Added secureClear() calls for all 6 DH operations in NoiseProtocol.swift
to properly clear sensitive shared secrets from memory after use:

- writeMessage(): .es initiator/responder, .se initiator/responder
- performDHOperation(): .ee and .ss operations

This fixes a forward secrecy vulnerability where shared secrets could
persist in memory after handshake completion.

Also adds:
- TrackingMockKeychain to count secureClear calls in tests
- 3 new tests verifying secureClear is called during handshake

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:28:55 -10:00
jackandClaude Opus 4.5 b4a3ee5777 fix: add @MainActor to MessageDeduplicationService for thread safety
Added @MainActor annotation to both LRUDeduplicationCache and
MessageDeduplicationService classes. This provides compile-time
enforcement of thread safety since all callers (ChatViewModel,
NostrRelayManager) are already on MainActor.

Benefits:
- Compile-time enforcement prevents future misuse
- Simpler than internal locking mechanisms
- Consistent with existing patterns in codebase

Also adds:
- @MainActor annotation to existing test suites
- 5 new concurrency tests verifying thread safety behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:25:43 -10:00
jackandClaude Opus 4.5 8d19d6d62c fix: add thread safety to NostrTransport read receipt queue
Synchronized access to readQueue and isSendingReadAcks using the
existing concurrent DispatchQueue with barrier flags:

- sendReadReceipt(): wrap enqueue in barrier async
- processReadQueueIfNeeded(): extract item within barrier context
- scheduleNextReadAck(): wrap callback in barrier async

This fixes race conditions where concurrent calls could corrupt the
read queue or cause check-then-act bugs on isSendingReadAcks.

Also adds thread safety tests:
- concurrentReadReceiptEnqueue: 100 concurrent enqueue operations
- readQueueProcessingUnderLoad: concurrent enqueue during processing
- isPeerReachableThreadSafety: concurrent read access test

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:23:32 -10:00
jackandClaude Opus 4.5 84fd92ef4b fix: clear DH shared secrets after Noise handshake operations
Added secureClear() calls for all 6 DH operations in NoiseProtocol.swift
to properly clear sensitive shared secrets from memory after use:

- writeMessage(): .es initiator/responder, .se initiator/responder
- performDHOperation(): .ee and .ss operations

This fixes a forward secrecy vulnerability where shared secrets could
persist in memory after handshake completion.

Also adds:
- TrackingMockKeychain to count secureClear calls in tests
- 3 new tests verifying secureClear is called during handshake

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:21:01 -10:00
jackandGitHub f71bd506fd Merge pull request #920 from malkovitc/feat/472-optimize-message-deduplicator
perf: optimize MessageDeduplicator performance
2026-01-04 12:41:48 -10:00
jackandGitHub ddd7ef5668 Merge branch 'main' into feat/472-optimize-message-deduplicator 2026-01-04 12:37:01 -10:00
jackandGitHub 548a20e77d Merge pull request #919 from malkovitc/fix/646-harden-hex-parsing
fix: harden hex string parsing
2026-01-04 12:33:16 -10:00
jackandGitHub 6e231d10c5 Merge branch 'main' into fix/646-harden-hex-parsing 2026-01-04 12:28:39 -10:00
jackandGitHub 4c9f6e689e Merge pull request #918 from malkovitc/fix/voice-recorder-simulator-build
fix(build): exclude allowBluetoothHFP on iOS Simulator
2026-01-04 12:25:11 -10:00
jackandGitHub 7e73b65240 Merge branch 'main' into fix/voice-recorder-simulator-build 2026-01-04 12:19:52 -10:00
jackandGitHub f5e5f7b98e Merge pull request #916 from malkovitc/fix/797-consolidate-keychain
refactor: consolidate KeychainHelper into KeychainManager
2026-01-04 12:13:15 -10:00
evgeniy.chernomortsev b15d92ebb5 perf: optimize MessageDeduplicator performance (#472)
- Make Entry struct Equatable for better testability
- Remove down to 75% of maxCount instead of fixed 100 items for better
  amortization of cleanup cost
- Reuse Date instance to reduce allocations in isDuplicate()
- Add documentation comments for public methods
- Improve memory capacity management in cleanup()
2025-12-09 18:48:14 +04:00
evgeniy.chernomortsev 6efe9d02fb fix: harden hex string parsing (#646)
Improve Data(hexString:) to handle edge cases:
- Reject odd-length strings (return nil)
- Support optional 0x/0X prefix
- Trim leading/trailing whitespace
- Handle empty strings correctly

Add comprehensive unit tests for hex parsing.
2025-12-09 15:01:59 +04:00
evgeniy.chernomortsev 5a66f03400 fix(build): exclude allowBluetoothHFP on iOS Simulator
allowBluetoothHFP is not available on iOS Simulator, causing build
failures. Use conditional compilation to exclude this option when
building for simulator while keeping it for device builds.
2025-12-09 14:50:34 +04:00
evgeniy.chernomortsevandClaude Opus 4.5 a221b22691 refactor: consolidate KeychainHelper into KeychainManager (#797)
Merge KeychainHelper functionality into KeychainManager to provide
a single, unified API for all keychain operations.

- Remove KeychainHelper.swift and KeychainHelperProtocol
- Add generic save/load/delete methods to KeychainManagerProtocol
- Update NostrIdentityBridge to use KeychainManagerProtocol
- Update FavoritesPersistenceService to use KeychainManagerProtocol
- Update PreviewKeychainManager with new methods
- Update MockKeychain and add MockKeychainHelper typealias for backwards compatibility

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 14:42:55 +04:00
2474 changed files with 24856 additions and 359715 deletions
+12 -41
View File
@@ -25,47 +25,18 @@ jobs:
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
mv nostr_relays.csv ./relays/online_relays_gps.csv
- name: Configure git
- name: Check for changes
id: git-check
run: |
git config user.email "action@github.com"
git config user.name "GitHub Action"
- name: Create update branch if changes
id: create_branch
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
- name: Commit and push changes
if: steps.git-check.outputs.changes == 'true'
run: |
# exit early if no changes
if git diff --quiet --relays/online_relays_gps.csv; then
echo "changed=false" >> $GITHUB_OUTPUT
exit 0
fi
# branch name with timestamp
BRANCH="update-georelays-$(date -u +%Y%m%dT%H%M%SZ)"
git checkout -b "$BRANCH"
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add relays/online_relays_gps.csv
git commit -m "Automated update of relay data - $(date -u --rfc-3339=seconds)"
echo "changed=true" >> $GITHUB_OUTPUT
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
- name: Push branch
if: steps.create_branch.outputs.changed == 'true'
run: |
git push --set-upstream origin "${{ steps.create_branch.outputs.branch }}"
- name: Create pull request
if: steps.create_branch.outputs.changed == 'true'
uses: peter-evans/create-pull-request@v5
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: Automated update of relay data
branch: ${{ steps.create_branch.outputs.branch }}
base: main
title: Automated update of relay data
body: |
This PR was created automatically by the scheduled workflow. It updates relays/online_relays_gps.csv from the GeoRelays source.
labels: automated, georelays
- name: No changes
if: steps.create_branch.outputs.changed != 'true'
run: echo "No changes to relays/online_relays_gps.csv"
git commit -m "Automated update of relay data - $(date -u)"
git push
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+25 -6
View File
@@ -5,14 +5,27 @@ on:
branches:
- main
pull_request:
branches:
- main
jobs:
test:
name: Run Swift Tests
name: Run Swift Tests (${{ matrix.name }})
runs-on: macos-latest
strategy:
fail-fast: false # Don't cancel other matrix jobs when one fails
matrix:
include:
- name: app
path: .
- name: BitLogger
path: localPackages/BitLogger
- name: BitFoundation
path: localPackages/BitFoundation
- name: Noise
path: localPackages/Noise
- name: Nostr
path: localPackages/Nostr
steps:
- name: Checkout code
uses: actions/checkout@v5
@@ -20,8 +33,14 @@ jobs:
- name: Set up Swift
uses: swift-actions/setup-swift@v2
- name: Build the package
run: swift build
- name: Cache build artifacts
uses: actions/cache@v4
with:
path: ${{ matrix.path }}/.build
key: ${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
restore-keys: |
${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
${{ runner.os }}-${{ matrix.name }}-
- name: Run Tests
run: swift test --parallel
run: swift test --parallel --quiet --package-path ${{ matrix.path }}
+1
View File
@@ -8,6 +8,7 @@ plans/
## AI
CLAUDE.md
AGENTS.md
.claude/
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
*.xcscmblueprint
+2 -1
View File
@@ -1,4 +1,4 @@
MARKETING_VERSION = 1.5.0
MARKETING_VERSION = 1.5.1
CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 16.0
@@ -9,3 +9,4 @@ DEVELOPMENT_TEAM = L3N5LHJD5Y
CODE_SIGN_STYLE = Automatic
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat
APP_GROUP_ID = group.chat.bitchat
+15 -5
View File
@@ -16,8 +16,11 @@ let package = Package(
),
],
dependencies:[
.package(path: "localPackages/Tor"),
.package(path: "localPackages/Arti"),
.package(path: "localPackages/Noise"),
.package(path: "localPackages/BitFoundation"),
.package(path: "localPackages/BitLogger"),
.package(path: "localPackages/Nostr"),
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
],
targets: [
@@ -25,13 +28,17 @@ let package = Package(
name: "bitchat",
dependencies: [
.product(name: "P256K", package: "swift-secp256k1"),
.product(name: "BitFoundation", package: "BitFoundation"),
.product(name: "BitLogger", package: "BitLogger"),
.product(name: "Tor", package: "Tor")
.product(name: "Noise", package: "Noise"),
.product(name: "Nostr", package: "Nostr"),
.product(name: "Tor", package: "Arti")
],
path: "bitchat",
exclude: [
"Info.plist",
"Assets.xcassets",
"_PreviewHelpers/PreviewAssets.xcassets",
"bitchat.entitlements",
"bitchat-macOS.entitlements",
"LaunchScreen.storyboard",
@@ -43,15 +50,18 @@ let package = Package(
),
.testTarget(
name: "bitchatTests",
dependencies: ["bitchat"],
dependencies: [
"bitchat",
.product(name: "BitFoundation", package: "BitFoundation"),
.product(name: "Nostr", package: "Nostr")
],
path: "bitchatTests",
exclude: [
"Info.plist",
"README.md"
],
resources: [
.process("Localization"),
.process("Noise")
.process("Localization")
]
)
]
-3
View File
@@ -8,9 +8,6 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
> [!WARNING]
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](https://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
## License
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
+71 -11
View File
@@ -10,11 +10,16 @@
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
A63163B62F80CB2500B8B128 /* Noise in Frameworks */ = {isa = PBXBuildFile; productRef = A63163B52F80CB2500B8B128 /* Noise */; };
A63163B82F80CB2D00B8B128 /* Noise in Frameworks */ = {isa = PBXBuildFile; productRef = A63163B72F80CB2D00B8B128 /* Noise */; };
A63180832F8103AB00B8B128 /* Nostr in Frameworks */ = {isa = PBXBuildFile; productRef = A63180822F8103AB00B8B128 /* Nostr */; };
A63180852F8103B600B8B128 /* Nostr in Frameworks */ = {isa = PBXBuildFile; productRef = A63180842F8103B600B8B128 /* Nostr */; };
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9472F80953E001CF9B9 /* BitFoundation */; };
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9492F809550001CF9B9 /* BitFoundation */; };
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; };
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; };
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA802E7706A80032EA8A /* Tor */; };
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */; };
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
/* End PBXBuildFile section */
@@ -155,17 +160,22 @@
isa = PBXFrameworksBuildPhase;
files = (
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
A63163B82F80CB2D00B8B128 /* Noise in Frameworks */,
3EE336D150427F736F32B56C /* P256K in Frameworks */,
A63180852F8103B600B8B128 /* Nostr in Frameworks */,
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */,
);
};
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
files = (
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */,
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
A63163B62F80CB2500B8B128 /* Noise in Frameworks */,
885BBED78092484A5B069461 /* P256K in Frameworks */,
A63180832F8103AB00B8B128 /* Nostr in Frameworks */,
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */,
);
};
/* End PBXFrameworksBuildPhase section */
@@ -225,6 +235,9 @@
B1D9136AA0083366353BFA2F /* P256K */,
A6E3E5712E7703760032EA8A /* BitLogger */,
A6E3EA802E7706A80032EA8A /* Tor */,
A6BCF9492F809550001CF9B9 /* BitFoundation */,
A63163B72F80CB2D00B8B128 /* Noise */,
A63180842F8103B600B8B128 /* Nostr */,
);
productName = bitchat_macOS;
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
@@ -305,6 +318,9 @@
4EB6BA1B8464F1EA38F4E286 /* P256K */,
A6E3E56F2E77036A0032EA8A /* BitLogger */,
A6E3EA7E2E7706720032EA8A /* Tor */,
A6BCF9472F80953E001CF9B9 /* BitFoundation */,
A63163B52F80CB2500B8B128 /* Noise */,
A63180822F8103AB00B8B128 /* Nostr */,
);
productName = bitchat_iOS;
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
@@ -345,7 +361,10 @@
packageReferences = (
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */,
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */,
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */,
A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */,
A63180812F8103AB00B8B128 /* XCLocalSwiftPackageReference "localPackages/Nostr" */,
);
preferredProjectObjectVersion = 90;
projectDirPath = "";
@@ -550,6 +569,7 @@
CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = NO;
INFOPLIST_FILE = bitchat/Info.plist;
@@ -560,7 +580,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = "$(MARKETING_VERSION)";
MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat;
SDKROOT = iphoneos;
@@ -610,6 +630,7 @@
CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = bitchat/Info.plist;
@@ -620,7 +641,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = "$(MARKETING_VERSION)";
MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat;
SDKROOT = iphoneos;
@@ -656,7 +677,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = "$(MARKETING_VERSION)";
MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES;
@@ -748,7 +769,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = "$(MARKETING_VERSION)";
MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES;
@@ -909,13 +930,25 @@
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = localPackages/Noise;
};
A63180812F8103AB00B8B128 /* XCLocalSwiftPackageReference "localPackages/Nostr" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = localPackages/Nostr;
};
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = localPackages/BitFoundation;
};
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = localPackages/BitLogger;
};
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */ = {
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = localPackages/Tor;
relativePath = localPackages/Arti;
};
/* End XCLocalSwiftPackageReference section */
@@ -924,8 +957,8 @@
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/21-DOT-DEV/swift-secp256k1";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.21.1;
kind = exactVersion;
version = 0.21.1;
};
};
/* End XCRemoteSwiftPackageReference section */
@@ -936,6 +969,33 @@
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
productName = P256K;
};
A63163B52F80CB2500B8B128 /* Noise */ = {
isa = XCSwiftPackageProductDependency;
productName = Noise;
};
A63163B72F80CB2D00B8B128 /* Noise */ = {
isa = XCSwiftPackageProductDependency;
package = A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */;
productName = Noise;
};
A63180822F8103AB00B8B128 /* Nostr */ = {
isa = XCSwiftPackageProductDependency;
productName = Nostr;
};
A63180842F8103B600B8B128 /* Nostr */ = {
isa = XCSwiftPackageProductDependency;
package = A63180812F8103AB00B8B128 /* XCLocalSwiftPackageReference "localPackages/Nostr" */;
productName = Nostr;
};
A6BCF9472F80953E001CF9B9 /* BitFoundation */ = {
isa = XCSwiftPackageProductDependency;
productName = BitFoundation;
};
A6BCF9492F809550001CF9B9 /* BitFoundation */ = {
isa = XCSwiftPackageProductDependency;
package = A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */;
productName = BitFoundation;
};
A6E3E56F2E77036A0032EA8A /* BitLogger */ = {
isa = XCSwiftPackageProductDependency;
productName = BitLogger;
+9 -6
View File
@@ -6,8 +6,10 @@
// For more information, see <https://unlicense.org>
//
import Nostr
import Tor
import SwiftUI
import BitFoundation
import UserNotifications
@main
@@ -26,9 +28,11 @@ struct BitchatApp: App {
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
#endif
private let idBridge = NostrIdentityBridge()
private let idBridge = NostrIdentityBridge(keychain: KeychainManager())
init() {
GeoRelayDirectory.setupShared(dependencies: .live())
NostrRelayManager.setupShared(dependencies: .live())
let keychain = KeychainManager()
let idBridge = self.idBridge
_chatViewModel = StateObject(
@@ -63,6 +67,10 @@ struct BitchatApp: App {
// Initialize network activation policy; will start Tor/Nostr only when allowed
NetworkActivationService.shared.start()
// Start presence service (will wait for Tor readiness)
GeohashPresenceService.shared.start()
// Check for shared content
checkForSharedContent()
}
@@ -275,8 +283,3 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
}
}
extension String {
var nilIfEmpty: String? {
self.isEmpty ? nil : self
}
}
@@ -9,6 +9,18 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
@Published private(set) var duration: TimeInterval = 0
@Published private(set) var progress: Double = 0
/// rounded so 4.9s shows "00:05"
var roundedDuration: Int {
guard duration.isFinite else { return 0 }
return Int(duration.rounded())
}
/// ceil so "00:01" stays visible until playback ends, capped to rounded duration
var remainingSeconds: Int {
let remaining = max(0, duration - currentTime)
return min(roundedDuration, Int(ceil(remaining)))
}
private var player: AVAudioPlayer?
private var timer: Timer?
private var url: URL
+75 -90
View File
@@ -2,8 +2,7 @@ import Foundation
import AVFoundation
/// Manages audio capture for mesh voice notes with predictable encoding settings.
/// Recording runs on an internal serial queue to avoid AVAudioSession contention.
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
actor VoiceRecorder {
enum RecorderError: Error {
case microphoneAccessDenied
case recorderInitializationFailed
@@ -12,21 +11,16 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
static let shared = VoiceRecorder()
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
private let paddingInterval: TimeInterval = 0.5
private let maxRecordingDuration: TimeInterval = 120
static let minRecordingDuration: TimeInterval = 1
private var recorder: AVAudioRecorder?
private var currentURL: URL?
private var stopWorkItem: DispatchWorkItem?
private override init() {
super.init()
}
// MARK: - Permissions
@discardableResult
nonisolated
func requestPermission() async -> Bool {
#if os(iOS)
return await withCheckedContinuation { continuation in
@@ -47,97 +41,88 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
// MARK: - Recording Lifecycle
@discardableResult
func startRecording() throws -> URL {
try queue.sync {
if recorder?.isRecording == true {
throw RecorderError.recordingInProgress
}
#if os(iOS)
let session = AVAudioSession.sharedInstance()
guard session.recordPermission == .granted else {
throw RecorderError.microphoneAccessDenied
}
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
)
try session.setActive(true, options: .notifyOthersOnDeactivation)
#endif
#if os(macOS)
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
throw RecorderError.microphoneAccessDenied
}
#endif
let outputURL = try makeOutputURL()
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 16_000,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 16_000
]
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
audioRecorder.delegate = self
audioRecorder.isMeteringEnabled = true
audioRecorder.prepareToRecord()
audioRecorder.record(forDuration: maxRecordingDuration)
recorder = audioRecorder
currentURL = outputURL
stopWorkItem?.cancel()
stopWorkItem = nil
return outputURL
if recorder?.isRecording == true {
throw RecorderError.recordingInProgress
}
#if os(iOS)
let session = AVAudioSession.sharedInstance()
guard session.recordPermission == .granted else {
throw RecorderError.microphoneAccessDenied
}
#if targetEnvironment(simulator)
// allowBluetoothHFP is not available on iOS Simulator
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP]
)
#else
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
)
#endif
try session.setActive(true, options: .notifyOthersOnDeactivation)
#endif
#if os(macOS)
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
throw RecorderError.microphoneAccessDenied
}
#endif
let outputURL = try makeOutputURL()
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 16_000,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 16_000
]
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
audioRecorder.isMeteringEnabled = true
audioRecorder.prepareToRecord()
audioRecorder.record(forDuration: maxRecordingDuration)
recorder = audioRecorder
currentURL = outputURL
return outputURL
}
func stopRecording(completion: @escaping (URL?) -> Void) {
queue.async { [weak self] in
guard let self = self, let recorder = self.recorder, recorder.isRecording else {
completion(self?.currentURL)
return
}
let item = DispatchWorkItem { [weak self] in
guard let self = self else { return }
recorder.stop()
self.cleanupSession()
let url = self.currentURL
self.recorder = nil
self.currentURL = url
completion(url)
}
self.stopWorkItem = item
self.queue.asyncAfter(deadline: .now() + self.paddingInterval, execute: item)
func stopRecording() async -> URL? {
guard let recorder, recorder.isRecording else {
return currentURL
}
let sessionURL = currentURL
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
recorder.stop()
// A new session may have started during the sleep don't touch its state
if self.recorder === recorder {
cleanupSession()
self.recorder = nil
currentURL = nil
}
return sessionURL
}
func cancelRecording() {
queue.async { [weak self] in
guard let self = self else { return }
self.stopWorkItem?.cancel()
self.stopWorkItem = nil
if let recorder = self.recorder, recorder.isRecording {
recorder.stop()
}
self.cleanupSession()
if let url = self.currentURL {
try? FileManager.default.removeItem(at: url)
}
self.recorder = nil
self.currentURL = nil
if let recorder, recorder.isRecording {
recorder.stop()
}
}
// MARK: - Metering
func currentAveragePower() -> Float {
queue.sync {
recorder?.updateMeters()
return recorder?.averagePower(forChannel: 0) ?? -160
cleanupSession()
if let currentURL {
try? FileManager.default.removeItem(at: currentURL)
}
recorder = nil
currentURL = nil
}
// MARK: - Helpers
+1
View File
@@ -81,6 +81,7 @@
///
import Foundation
import BitFoundation
// MARK: - Three-Layer Identity Model
@@ -91,6 +91,7 @@
///
import BitLogger
import BitFoundation
import Foundation
import CryptoKit
@@ -331,16 +332,15 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func updateSocialIdentity(_ identity: SocialIdentity) {
queue.async(flags: .barrier) {
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
self.cache.socialIdentities[identity.fingerprint] = identity
// Update nickname index
if let existingIdentity = self.cache.socialIdentities[identity.fingerprint] {
// Remove old nickname from index if changed
if existingIdentity.claimedNickname != identity.claimedNickname {
self.cache.nicknameIndex[existingIdentity.claimedNickname]?.remove(identity.fingerprint)
if self.cache.nicknameIndex[existingIdentity.claimedNickname]?.isEmpty == true {
self.cache.nicknameIndex.removeValue(forKey: existingIdentity.claimedNickname)
}
if let previousClaimedNickname,
previousClaimedNickname != identity.claimedNickname {
self.cache.nicknameIndex[previousClaimedNickname]?.remove(identity.fingerprint)
if self.cache.nicknameIndex[previousClaimedNickname]?.isEmpty == true {
self.cache.nicknameIndex.removeValue(forKey: previousClaimedNickname)
}
}
@@ -532,4 +532,16 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
return cache.verifiedFingerprints
}
}
var debugNicknameIndex: [String: Set<String>] {
queue.sync { cache.nicknameIndex }
}
func debugEphemeralSession(for peerID: PeerID) -> EphemeralIdentity? {
queue.sync { ephemeralSessions[peerID] }
}
func debugLastInteraction(for fingerprint: String) -> Date? {
queue.sync { cache.lastInteractions[fingerprint] }
}
}
+6 -4
View File
@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AppGroupID</key>
<string>$(APP_GROUP_ID)</string>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
@@ -37,12 +39,12 @@
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
<key>NSCameraUsageDescription</key>
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
<key>NSMicrophoneUsageDescription</key>
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
<key>NSMicrophoneUsageDescription</key>
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
+68
View File
@@ -0,0 +1,68 @@
//
// BitchatMessage+Media.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
extension BitchatMessage {
enum Media {
case voice(URL)
case image(URL)
var url: URL {
switch self {
case .voice(let url), .image(let url):
return url
}
}
}
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
private struct Cache {
let filesDir: URL?
static let shared = Cache()
private init() {
do {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let filesDir = base.appendingPathComponent("files", isDirectory: true)
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
self.filesDir = filesDir
} catch {
filesDir = nil
}
}
}
func mediaAttachment(for nickname: String) -> Media? {
guard let baseDirectory = Cache.shared.filesDir else { return nil }
func url(for category: MimeType.Category) -> URL? {
guard content.hasPrefix(category.messagePrefix),
let filename = String(content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty
else {
return nil
}
// Check outgoing first for sent messages, incoming for received
let subdir = sender == nickname ? "\(category.mediaDir)/outgoing" : "\(category.mediaDir)/incoming"
// Construct URL directly without fileExists check (avoids blocking disk I/O in view body)
// Files are checked during playback/display, so missing files fail gracefully
let directory = baseDirectory.appendingPathComponent(subdir, isDirectory: true)
return directory.appendingPathComponent(filename)
}
if let url = url(for: .audio) {
return .voice(url)
}
if let url = url(for: .image) {
return .image(url)
}
return nil
}
}
+2 -1
View File
@@ -7,6 +7,7 @@
//
import Foundation
import BitFoundation
/// Represents a user-visible message in the BitChat system.
/// Handles both broadcast messages and private encrypted messages,
@@ -338,7 +339,7 @@ extension BitchatMessage {
extension Array where Element == BitchatMessage {
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
func cleanedAndDeduped() -> [Element] {
let arr = filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false }
let arr = filter { $0.content.trimmed.isEmpty == false }
guard arr.count > 1 else {
return arr
}
+8 -3
View File
@@ -7,6 +7,7 @@
//
import Foundation
import BitFoundation
/// The core packet structure for all BitChat protocol messages.
/// Encapsulates all data needed for routing through the mesh network,
@@ -22,8 +23,9 @@ struct BitchatPacket: Codable {
var signature: Data?
var ttl: UInt8
var route: [Data]?
var isRSR: Bool
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil) {
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil, isRSR: Bool = false) {
self.version = version
self.type = type
self.senderID = senderID
@@ -33,10 +35,11 @@ struct BitchatPacket: Codable {
self.signature = signature
self.ttl = ttl
self.route = route
self.isRSR = isRSR
}
// Convenience initializer for new binary format
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data) {
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data, isRSR: Bool = false) {
self.version = 1
self.type = type
// Convert hex string peer ID to binary data (8 bytes)
@@ -56,6 +59,7 @@ struct BitchatPacket: Codable {
self.signature = nil
self.ttl = ttl
self.route = nil
self.isRSR = isRSR
}
var data: Data? {
@@ -85,7 +89,8 @@ struct BitchatPacket: Codable {
signature: nil, // Remove signature for signing
ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility
version: version,
route: route
route: route,
isRSR: false // RSR flag is mutable and not part of the signature
)
return BinaryProtocol.encode(unsignedPacket)
}
+1
View File
@@ -1,5 +1,6 @@
import Foundation
import CoreBluetooth
import BitFoundation
/// Represents a peer in the BitChat network with all associated metadata
struct BitchatPeer: Equatable {
+1
View File
@@ -7,6 +7,7 @@
//
import Foundation
import BitFoundation
struct ReadReceipt: Codable {
let originalMessageID: String
+26 -3
View File
@@ -9,12 +9,16 @@ struct RequestSyncPacket {
let m: UInt32
let data: Data
let types: SyncTypeFlags?
let sinceTimestamp: UInt64?
let fragmentIdFilter: String?
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil) {
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) {
self.p = p
self.m = m
self.data = data
self.types = types
self.sinceTimestamp = sinceTimestamp
self.fragmentIdFilter = fragmentIdFilter
}
func encode() -> Data {
@@ -36,15 +40,24 @@ struct RequestSyncPacket {
if let typesData = types?.toData() {
putTLV(0x04, typesData)
}
if let ts = sinceTimestamp {
var tsBE = ts.bigEndian
putTLV(0x05, withUnsafeBytes(of: &tsBE) { Data($0) })
}
if let fid = fragmentIdFilter, let fidData = fid.data(using: .utf8) {
putTLV(0x06, fidData)
}
return out
}
static func decode(from data: Data, maxAcceptBytes: Int = 1024) -> RequestSyncPacket? {
var off = 0
var p: Int? = nil
var m: UInt32? = nil
var payload: Data? = nil
var types: SyncTypeFlags? = nil
var sinceTimestamp: UInt64? = nil
var fragmentIdFilter: String? = nil
while off + 3 <= data.count {
let t = Int(data[off]); off += 1
@@ -68,12 +81,22 @@ struct RequestSyncPacket {
if let decoded = SyncTypeFlags.decode(v) {
types = decoded
}
case 0x05:
if v.count == 8 {
var ts: UInt64 = 0
for b in v { ts = (ts << 8) | UInt64(b) }
sinceTimestamp = ts
}
case 0x06:
if let fid = String(data: v, encoding: .utf8) {
fragmentIdFilter = fid
}
default:
break // forward compatible; ignore unknown TLVs
}
}
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types)
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter)
}
}
-345
View File
@@ -1,345 +0,0 @@
import BitLogger
import Foundation
import Tor
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
@MainActor
final class GeoRelayDirectory {
struct Entry: Hashable {
let host: String
let lat: Double
let lon: Double
}
static let shared = GeoRelayDirectory()
private(set) var entries: [Entry] = []
private let cacheFileName = "georelays_cache.csv"
private let lastFetchKey = "georelay.lastFetchAt"
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds
private var refreshTimer: Timer?
private var retryTask: Task<Void, Never>?
private var retryAttempt: Int = 0
private var isFetching: Bool = false
private var observers: [NSObjectProtocol] = []
private init() {
entries = loadLocalEntries()
registerObservers()
startRefreshTimer()
prefetchIfNeeded()
}
deinit {
observers.forEach { NotificationCenter.default.removeObserver($0) }
refreshTimer?.invalidate()
retryTask?.cancel()
}
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
let center = Geohash.decodeCenter(geohash)
return closestRelays(toLat: center.lat, lon: center.lon, count: count)
}
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
guard !entries.isEmpty, count > 0 else { return [] }
if entries.count <= count {
return entries
.sorted { a, b in
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
}
.map { "wss://\($0.host)" }
}
var best: [(entry: Entry, distance: Double)] = []
best.reserveCapacity(count)
for entry in entries {
let distance = haversineKm(lat, lon, entry.lat, entry.lon)
if best.count < count {
let idx = best.firstIndex { $0.distance > distance } ?? best.count
best.insert((entry, distance), at: idx)
} else if let worstDistance = best.last?.distance, distance < worstDistance {
let idx = best.firstIndex { $0.distance > distance } ?? best.count
best.insert((entry, distance), at: idx)
best.removeLast()
}
}
return best.map { "wss://\($0.entry.host)" }
}
// MARK: - Remote Fetch
func prefetchIfNeeded(force: Bool = false) {
guard !isFetching else { return }
let now = Date()
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
if !force {
guard now.timeIntervalSince(last) >= fetchInterval else { return }
} else if last != .distantPast,
now.timeIntervalSince(last) < TransportConfig.geoRelayRetryInitialSeconds {
// Skip forced fetches if we just refreshed moments ago.
return
}
cancelRetry()
fetchRemote()
}
private func fetchRemote() {
guard !isFetching else { return }
isFetching = true
let request = URLRequest(
url: remoteURL,
cachePolicy: .reloadIgnoringLocalCacheData,
timeoutInterval: 15
)
Task.detached { [weak self] in
guard let self else { return }
let ready = await TorManager.shared.awaitReady()
if !ready {
await self.handleFetchFailure(.torNotReady)
return
}
do {
let (data, _) = try await TorURLSession.shared.session.data(for: request)
guard let text = String(data: data, encoding: .utf8) else {
await self.handleFetchFailure(.invalidData)
return
}
let parsed = GeoRelayDirectory.parseCSV(text)
guard !parsed.isEmpty else {
await self.handleFetchFailure(.invalidData)
return
}
await self.handleFetchSuccess(entries: parsed, csv: text)
} catch {
await self.handleFetchFailure(.network(error))
}
}
}
private enum FetchFailure {
case torNotReady
case invalidData
case network(Error)
}
@MainActor
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
entries = parsed
persistCache(csv)
UserDefaults.standard.set(Date(), forKey: lastFetchKey)
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
isFetching = false
retryAttempt = 0
cancelRetry()
}
@MainActor
private func handleFetchFailure(_ reason: FetchFailure) {
switch reason {
case .torNotReady:
SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session)
case .invalidData:
SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session)
case .network(let error):
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(error.localizedDescription)", category: .session)
}
isFetching = false
scheduleRetry()
}
@MainActor
private func scheduleRetry() {
retryAttempt = min(retryAttempt + 1, 10)
let base = TransportConfig.geoRelayRetryInitialSeconds
let maxDelay = TransportConfig.geoRelayRetryMaxSeconds
let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))
let calculated = base * multiplier
let delay = min(maxDelay, max(base, calculated))
cancelRetry()
retryTask = Task { [weak self] in
let nanoseconds = UInt64(delay * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanoseconds)
await MainActor.run {
self?.prefetchIfNeeded(force: true)
}
}
}
@MainActor
private func cancelRetry() {
retryTask?.cancel()
retryTask = nil
}
private func persistCache(_ text: String) {
guard let url = cacheURL() else { return }
do {
try text.data(using: .utf8)?.write(to: url, options: .atomic)
} catch {
SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session)
}
}
// MARK: - Loading
private func loadLocalEntries() -> [Entry] {
// Prefer cached file if present
if let cache = cacheURL(),
let data = try? Data(contentsOf: cache),
let text = String(data: data, encoding: .utf8) {
let arr = Self.parseCSV(text)
if !arr.isEmpty { return arr }
}
// Try bundled resource(s)
let bundleCandidates = [
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
].compactMap { $0 }
for url in bundleCandidates {
if let data = try? Data(contentsOf: url),
let text = String(data: data, encoding: .utf8) {
let arr = Self.parseCSV(text)
if !arr.isEmpty { return arr }
}
}
// Try filesystem path (development/test)
if let cwd = FileManager.default.currentDirectoryPath as String?,
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
let text = String(data: data, encoding: .utf8) {
return Self.parseCSV(text)
}
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
return []
}
nonisolated static func parseCSV(_ text: String) -> [Entry] {
var result: Set<Entry> = []
let lines = text.split(whereSeparator: { $0.isNewline })
for (idx, raw) in lines.enumerated() {
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if line.isEmpty { continue }
if idx == 0 && line.lowercased().contains("relay url") { continue }
let parts = line.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }
guard parts.count >= 3 else { continue }
var host = parts[0]
host = host.replacingOccurrences(of: "https://", with: "")
host = host.replacingOccurrences(of: "http://", with: "")
host = host.replacingOccurrences(of: "wss://", with: "")
host = host.replacingOccurrences(of: "ws://", with: "")
host = host.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
result.insert(Entry(host: host, lat: lat, lon: lon))
}
return Array(result)
}
private func cacheURL() -> URL? {
do {
let base = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.appendingPathComponent(cacheFileName)
} catch {
return nil
}
}
// MARK: - Observers & Timers
private func registerObservers() {
let center = NotificationCenter.default
let torReady = center.addObserver(
forName: .TorDidBecomeReady,
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.prefetchIfNeeded(force: true)
}
}
observers.append(torReady)
#if os(iOS)
let didBecomeActive = center.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.prefetchIfNeeded()
}
}
observers.append(didBecomeActive)
#elseif os(macOS)
let didBecomeActive = center.addObserver(
forName: NSApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.prefetchIfNeeded()
}
}
observers.append(didBecomeActive)
#endif
}
private func startRefreshTimer() {
refreshTimer?.invalidate()
let interval = TransportConfig.geoRelayRefreshCheckIntervalSeconds
guard interval > 0 else { return }
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.prefetchIfNeeded()
}
}
refreshTimer = timer
RunLoop.main.add(timer, forMode: .common)
}
}
// MARK: - Distance
private func haversineKm(_ lat1: Double, _ lon1: Double, _ lat2: Double, _ lon2: Double) -> Double {
let r = 6371.0 // Earth radius in km
let dLat = (lat2 - lat1) * .pi / 180
let dLon = (lon2 - lon1) * .pi / 180
let a = sin(dLat/2) * sin(dLat/2) + cos(lat1 * .pi/180) * cos(lat2 * .pi/180) * sin(dLon/2) * sin(dLon/2)
let c = 2 * atan2(sqrt(a), sqrt(1 - a))
return r * c
}
-50
View File
@@ -1,50 +0,0 @@
import Foundation
protocol KeychainHelperProtocol {
func save(key: String, data: Data, service: String, accessible: CFString?)
func load(key: String, service: String) -> Data?
func delete(key: String, service: String)
}
/// Keychain helper for secure storage
struct KeychainHelper: KeychainHelperProtocol {
func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
if let accessible = accessible {
query[kSecAttrAccessible as String] = accessible
}
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
func load(key: String, service: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else { return nil }
return result as? Data
}
func delete(key: String, service: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
}
+1
View File
@@ -1,4 +1,5 @@
import Foundation
import BitFoundation
// MARK: - BitChat-over-Nostr Adapter
+118
View File
@@ -0,0 +1,118 @@
import Nostr
import Combine
import Foundation
import Tor
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
// MARK: - GeoRelayDirectory Live Dependencies
extension GeoRelayDirectoryDependencies {
@MainActor
static func live() -> Self {
#if os(iOS)
let activeNotificationName: Notification.Name? = UIApplication.didBecomeActiveNotification
#elseif os(macOS)
let activeNotificationName: Notification.Name? = NSApplication.didBecomeActiveNotification
#else
let activeNotificationName: Notification.Name? = nil
#endif
return Self(
userDefaults: .standard,
notificationCenter: .default,
now: Date.init,
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!,
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
awaitTorReady: { await TorManager.shared.awaitReady() },
makeFetchData: {
let session = TorURLSession.shared.session
return { request in
let (data, _) = try await session.data(for: request)
return data
}
},
readData: { try? Data(contentsOf: $0) },
writeData: { data, url in
try data.write(to: url, options: .atomic)
},
cacheURL: {
do {
let base = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.appendingPathComponent("georelays_cache.csv")
} catch {
return nil
}
},
bundledCSVURLs: {
[
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
].compactMap { $0 }
},
currentDirectoryPath: { FileManager.default.currentDirectoryPath },
retrySleep: { delay in
let nanoseconds = UInt64(delay * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanoseconds)
},
torReadyNotificationName: .TorDidBecomeReady,
activeNotificationName: activeNotificationName,
autoStart: true
)
}
}
// MARK: - NostrRelayManager Live Dependencies
extension NostrRelayManagerDependencies {
@MainActor
static func live() -> Self {
Self(
activationAllowed: { NetworkActivationService.shared.activationAllowed },
userTorEnabled: { NetworkActivationService.shared.userTorEnabled },
hasMutualFavorites: { !FavoritesPersistenceService.shared.mutualFavorites.isEmpty },
hasLocationPermission: { LocationChannelManager.shared.permissionState == .authorized },
mutualFavoritesPublisher: FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher(),
locationPermissionPublisher: LocationChannelManager.shared.$permissionState
.map { state -> LocationPermissionState in
switch state {
case .notDetermined:.notDetermined
case .authorized: .authorized
case .denied: .denied
case .restricted: .denied
}
}
.eraseToAnyPublisher(),
torEnforced: { TorManager.shared.torEnforced },
torIsReady: { TorManager.shared.isReady },
torIsForeground: { TorManager.shared.isForeground() },
awaitTorReady: { completion in
Task.detached {
let ready = await TorManager.shared.awaitReady()
await MainActor.run {
completion(ready)
}
}
},
makeSession: { NostrRelayManager.makeURLSession(TorURLSession.shared.session) },
scheduleAfter: { delay, action in
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
},
now: Date.init
)
}
}
+1 -34
View File
@@ -6,40 +6,7 @@
//
import Foundation
import CryptoKit
// MARK: - Hex Encoding/Decoding
extension Data {
func hexEncodedString() -> String {
if self.isEmpty {
return ""
}
return self.map { String(format: "%02x", $0) }.joined()
}
func sha256Hex() -> String {
let digest = SHA256.hash(data: self)
return digest.map { String(format: "%02x", $0) }.joined()
}
init?(hexString: String) {
let len = hexString.count / 2
var data = Data(capacity: len)
var index = hexString.startIndex
for _ in 0..<len {
let nextIndex = hexString.index(index, offsetBy: 2)
guard let byte = UInt8(String(hexString[index..<nextIndex]), radix: 16) else {
return nil
}
data.append(byte)
index = nextIndex
}
self = data
}
}
import BitFoundation
// MARK: - Binary Encoding Utilities
+26 -22
View File
@@ -138,6 +138,7 @@ struct BinaryProtocol {
static let hasSignature: UInt8 = 0x02
static let isCompressed: UInt8 = 0x04
static let hasRoute: UInt8 = 0x08
static let isRSR: UInt8 = 0x10
}
// Encode BitchatPacket to binary format
@@ -161,7 +162,9 @@ struct BinaryProtocol {
}
let lengthFieldBytes = lengthFieldSize(for: version)
let originalRoute = packet.route ?? []
// Route is only supported for v2+ packets (per SOURCE_ROUTING.md spec)
let originalRoute = (version >= 2) ? (packet.route ?? []) : []
if originalRoute.contains(where: { $0.isEmpty }) { return nil }
let sanitizedRoute: [Data] = originalRoute.map { hop in
if hop.count == senderIDSize { return hop }
@@ -175,13 +178,14 @@ struct BinaryProtocol {
let hasRoute = !sanitizedRoute.isEmpty
let routeLength = hasRoute ? 1 + sanitizedRoute.count * senderIDSize : 0
let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0
let payloadDataSize = routeLength + payload.count + originalSizeFieldBytes
// payloadLength in header is payload-only (does NOT include route bytes)
let payloadDataSize = payload.count + originalSizeFieldBytes
if version == 1 && payloadDataSize > Int(UInt16.max) { return nil }
if version == 2 && payloadDataSize > Int(UInt32.max) { return nil }
guard let headerSize = headerSize(for: version) else { return nil }
let estimatedHeader = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize)
let estimatedHeader = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + routeLength
let estimatedPayload = payloadDataSize
let estimatedSignature = (packet.signature == nil ? 0 : signatureSize)
var data = Data()
@@ -199,9 +203,11 @@ struct BinaryProtocol {
if packet.recipientID != nil { flags |= Flags.hasRecipient }
if packet.signature != nil { flags |= Flags.hasSignature }
if isCompressed { flags |= Flags.isCompressed }
if hasRoute { flags |= Flags.hasRoute }
// HAS_ROUTE is only valid for v2+ packets
if hasRoute && version >= 2 { flags |= Flags.hasRoute }
if packet.isRSR { flags |= Flags.isRSR }
data.append(flags)
if version == 2 {
let length = UInt32(payloadDataSize)
for shift in stride(from: 24, through: 0, by: -8) {
@@ -323,7 +329,10 @@ struct BinaryProtocol {
let hasRecipient = (flags & Flags.hasRecipient) != 0
let hasSignature = (flags & Flags.hasSignature) != 0
let isCompressed = (flags & Flags.isCompressed) != 0
// HAS_ROUTE is only valid for v2+ packets; ignore the flag for v1
let hasRoute = (version >= 2) && (flags & Flags.hasRoute) != 0
let isRSR = (flags & Flags.isRSR) != 0
let payloadLength: Int
if version == 2 {
guard let len = read32() else { return nil }
@@ -334,6 +343,7 @@ struct BinaryProtocol {
}
guard payloadLength >= 0 else { return nil }
guard payloadLength <= FileTransferLimits.maxFramedFileBytes else { return nil }
guard let senderID = readData(senderIDSize) else { return nil }
@@ -343,27 +353,24 @@ struct BinaryProtocol {
if recipientID == nil { return nil }
}
// Route (optional, v2+ only): route bytes are NOT included in payloadLength
var route: [Data]? = nil
var remainingPayloadBytes = payloadLength
if (flags & Flags.hasRoute) != 0 {
guard remainingPayloadBytes >= 1, let routeCount = read8() else { return nil }
remainingPayloadBytes -= 1
if hasRoute {
guard let routeCount = read8() else { return nil }
if routeCount > 0 {
var hops: [Data] = []
for _ in 0..<Int(routeCount) {
guard remainingPayloadBytes >= senderIDSize,
let hop = readData(senderIDSize) else { return nil }
remainingPayloadBytes -= senderIDSize
guard let hop = readData(senderIDSize) else { return nil }
hops.append(hop)
}
route = hops
}
}
// Payload: payloadLength is exactly the payload size (+ compression preamble if compressed)
let payload: Data
if isCompressed {
guard remainingPayloadBytes >= lengthFieldBytes else { return nil }
guard payloadLength >= lengthFieldBytes else { return nil }
let originalSize: Int
if version == 2 {
guard let rawSize = read32() else { return nil }
@@ -372,11 +379,9 @@ struct BinaryProtocol {
guard let rawSize = read16() else { return nil }
originalSize = Int(rawSize)
}
remainingPayloadBytes -= lengthFieldBytes
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxFramedFileBytes else { return nil }
let compressedSize = remainingPayloadBytes
let compressedSize = payloadLength - lengthFieldBytes
guard compressedSize > 0, let compressed = readData(compressedSize) else { return nil }
remainingPayloadBytes = 0
let compressionRatio = Double(originalSize) / Double(compressedSize)
guard compressionRatio <= 50_000.0 else {
@@ -388,9 +393,7 @@ struct BinaryProtocol {
decompressed.count == originalSize else { return nil }
payload = decompressed
} else {
guard remainingPayloadBytes >= 0,
let rawPayload = readData(remainingPayloadBytes) else { return nil }
remainingPayloadBytes = 0
guard let rawPayload = readData(payloadLength) else { return nil }
payload = rawPayload
}
@@ -411,7 +414,8 @@ struct BinaryProtocol {
signature: signature,
ttl: ttl,
version: version,
route: route
route: route,
isRSR: isRSR
)
}
}
+1
View File
@@ -60,6 +60,7 @@
import Foundation
import CoreBluetooth
import BitFoundation
// MARK: - Message Types
File diff suppressed because it is too large Load Diff
+17
View File
@@ -191,5 +191,22 @@ enum MimeType: CaseIterable, Hashable {
extension MimeType {
enum Category: String {
case audio, image, file
/// Ends with a space
var messagePrefix: String {
switch self {
case .audio: "[voice] "
case .image: "[image] "
case .file: "[file] "
}
}
var mediaDir: String {
switch self {
case .audio: "voicenotes"
case .image: "images"
case .file: "files"
}
}
}
}
+6 -15
View File
@@ -6,7 +6,9 @@
// This is free and unencumbered software released into the public domain.
//
import Nostr
import Foundation
import BitFoundation
/// Result of command processing
enum CommandResult {
@@ -59,22 +61,11 @@ final class CommandProcessor {
weak var meshService: Transport?
private let identityManager: SecureIdentityStateManagerProtocol
/// Backward-compatible property for existing code
weak var chatViewModel: CommandContextProvider? {
get { contextProvider }
set { contextProvider = newValue }
}
init(contextProvider: CommandContextProvider? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
self.contextProvider = contextProvider
self.meshService = meshService
self.identityManager = identityManager
}
/// Backward-compatible initializer
convenience init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
self.init(contextProvider: chatViewModel, meshService: meshService, identityManager: identityManager)
}
/// Process a command string
@MainActor
@@ -177,7 +168,7 @@ final class CommandProcessor {
}
private func handleEmote(_ args: String, command: String, action: String, emoji: String, suffix: String = "") -> CommandResult {
let targetName = args.trimmingCharacters(in: .whitespaces)
let targetName = args.trimmed
guard !targetName.isEmpty else {
return .error(message: "usage: /\(command) <nickname>")
}
@@ -220,7 +211,7 @@ final class CommandProcessor {
}
private func handleBlock(_ args: String) -> CommandResult {
let targetName = args.trimmingCharacters(in: .whitespaces)
let targetName = args.trimmed
if targetName.isEmpty {
// List blocked users (mesh) and geohash (Nostr) blocks
@@ -295,7 +286,7 @@ final class CommandProcessor {
}
private func handleUnblock(_ args: String) -> CommandResult {
let targetName = args.trimmingCharacters(in: .whitespaces)
let targetName = args.trimmed
guard !targetName.isEmpty else {
return .error(message: "usage: /unblock <nickname>")
}
@@ -322,7 +313,7 @@ final class CommandProcessor {
}
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
let targetName = args.trimmingCharacters(in: .whitespaces)
let targetName = args.trimmed
guard !targetName.isEmpty else {
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
}
@@ -1,4 +1,5 @@
import BitLogger
import BitFoundation
import Foundation
import Combine
@@ -26,17 +27,14 @@ final class FavoritesPersistenceService: ObservableObject {
private static let storageKey = "chat.bitchat.favorites"
private static let keychainService = "chat.bitchat.favorites"
private let keychain: KeychainHelperProtocol
private let keychain: KeychainManagerProtocol
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
@Published private(set) var mutualFavorites: Set<Data> = []
private let userDefaults = UserDefaults.standard
private var cancellables = Set<AnyCancellable>()
static let shared = FavoritesPersistenceService()
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
init(keychain: KeychainManagerProtocol = KeychainManager()) {
self.keychain = keychain
loadFavorites()
@@ -83,6 +83,9 @@ public final class GeohashParticipantTracker: ObservableObject {
var map = participants[geohash] ?? [:]
map[key] = Date()
participants[geohash] = map
// Always notify observers that state has changed so counts in UI update
objectWillChange.send()
// Only refresh visible list if this geohash is currently active
if activeGeohash == geohash {
@@ -0,0 +1,265 @@
//
// GeohashPresenceService.swift
// bitchat
//
// Manages the broadcasting of ephemeral presence heartbeats (Kind 20001)
// to geohash location channels.
//
// This is free and unencumbered software released into the public domain.
//
import Nostr
import Foundation
import Combine
import BitLogger
import Tor
protocol GeohashPresenceTimerProtocol: AnyObject {
var isValid: Bool { get }
func invalidate()
}
private final class GeohashPresenceTimerAdapter: GeohashPresenceTimerProtocol {
private let base: Timer
init(base: Timer) {
self.base = base
}
var isValid: Bool { base.isValid }
func invalidate() {
base.invalidate()
}
}
/// Service that coordinates the broadcasting of presence heartbeats.
///
/// Behavior:
/// - Monitors location changes via LocationStateManager
/// - Broadcasts Kind 20001 events to low-precision geohash channels
/// - Uses randomized timing (40-80s loop) and decorrelated bursts
/// - Respects privacy by NOT broadcasting to Neighborhood/Block/Building levels
@MainActor
final class GeohashPresenceService: ObservableObject {
static let shared = GeohashPresenceService()
private var subscriptions = Set<AnyCancellable>()
private var heartbeatTimer: GeohashPresenceTimerProtocol?
private let availableChannelsProvider: () -> [GeohashChannel]
private let locationChanges: AnyPublisher<[GeohashChannel], Never>
private let torReadyPublisher: AnyPublisher<Void, Never>
private let torIsReady: () -> Bool
private let torIsForeground: () -> Bool
private let deriveIdentity: (String) throws -> NostrIdentity
private let relayLookup: (String, Int) -> [String]
private let relaySender: (NostrEvent, [String]) -> Void
private let sleeper: (UInt64) async -> Void
private let scheduleTimer: (TimeInterval, @escaping () -> Void) -> GeohashPresenceTimerProtocol
// MARK: - Constants
// Loop interval range in seconds
private let loopMinInterval: TimeInterval
private let loopMaxInterval: TimeInterval
// Per-broadcast decorrelation delay range in seconds
private let burstMinDelay: TimeInterval
private let burstMaxDelay: TimeInterval
// Privacy: Only broadcast to these levels
private let allowedPrecisions: Set<Int> = [
GeohashChannelLevel.region.precision, // 2
GeohashChannelLevel.province.precision, // 4
GeohashChannelLevel.city.precision // 5
]
private init() {
let idBridge = NostrIdentityBridge(keychain: KeychainManager())
self.availableChannelsProvider = { LocationStateManager.shared.availableChannels }
self.locationChanges = LocationStateManager.shared.$availableChannels.eraseToAnyPublisher()
self.torReadyPublisher = NotificationCenter.default.publisher(for: .TorDidBecomeReady)
.map { _ in () }
.eraseToAnyPublisher()
self.torIsReady = { TorManager.shared.isReady }
self.torIsForeground = { TorManager.shared.isForeground() }
self.deriveIdentity = { try idBridge.deriveIdentity(forGeohash: $0) }
self.relayLookup = { geohash, count in
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
}
self.relaySender = { event, relays in
NostrRelayManager.shared.sendEvent(event, to: relays)
}
self.sleeper = { nanoseconds in
try? await Task.sleep(nanoseconds: nanoseconds)
}
self.scheduleTimer = { interval, action in
GeohashPresenceTimerAdapter(
base: Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in
action()
}
)
}
self.loopMinInterval = 40.0
self.loopMaxInterval = 80.0
self.burstMinDelay = 2.0
self.burstMaxDelay = 5.0
setupObservers()
}
internal init(
availableChannelsProvider: @escaping () -> [GeohashChannel],
locationChanges: AnyPublisher<[GeohashChannel], Never>,
torReadyPublisher: AnyPublisher<Void, Never>,
torIsReady: @escaping () -> Bool,
torIsForeground: @escaping () -> Bool,
deriveIdentity: @escaping (String) throws -> NostrIdentity,
relayLookup: @escaping (String, Int) -> [String],
relaySender: @escaping (NostrEvent, [String]) -> Void,
sleeper: @escaping (UInt64) async -> Void = { nanoseconds in try? await Task.sleep(nanoseconds: nanoseconds) },
scheduleTimer: @escaping (TimeInterval, @escaping () -> Void) -> GeohashPresenceTimerProtocol = { interval, action in
GeohashPresenceTimerAdapter(
base: Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in
action()
}
)
},
loopMinInterval: TimeInterval = 40.0,
loopMaxInterval: TimeInterval = 80.0,
burstMinDelay: TimeInterval = 2.0,
burstMaxDelay: TimeInterval = 5.0
) {
self.availableChannelsProvider = availableChannelsProvider
self.locationChanges = locationChanges
self.torReadyPublisher = torReadyPublisher
self.torIsReady = torIsReady
self.torIsForeground = torIsForeground
self.deriveIdentity = deriveIdentity
self.relayLookup = relayLookup
self.relaySender = relaySender
self.sleeper = sleeper
self.scheduleTimer = scheduleTimer
self.loopMinInterval = loopMinInterval
self.loopMaxInterval = loopMaxInterval
self.burstMinDelay = burstMinDelay
self.burstMaxDelay = burstMaxDelay
setupObservers()
}
/// Start the service (safe to call multiple times)
func start() {
SecureLogger.info("Presence: service starting...", category: .session)
scheduleNextHeartbeat()
}
private func setupObservers() {
// Monitor location channel changes
locationChanges
.dropFirst()
.sink { [weak self] _ in
self?.handleLocationChange()
}
.store(in: &subscriptions)
// Monitor Tor readiness to kick off heartbeat if it was stalled
torReadyPublisher
.sink { [weak self] _ in
self?.handleConnectivityChange()
}
.store(in: &subscriptions)
}
func handleLocationChange() {
// When location changes, we trigger an immediate (but slightly delayed) heartbeat
// to announce presence in the new zone, then reset the loop.
SecureLogger.debug("Presence: location changed, scheduling update", category: .session)
heartbeatTimer?.invalidate()
// Small delay to allow location state to settle
heartbeatTimer = scheduleTimer(5.0) { [weak self] in
Task { @MainActor [weak self] in
self?.performHeartbeat()
}
}
}
func handleConnectivityChange() {
SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session)
// If we were waiting for network, do it now
if heartbeatTimer == nil || !heartbeatTimer!.isValid {
scheduleNextHeartbeat()
}
}
func scheduleNextHeartbeat() {
heartbeatTimer?.invalidate()
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
heartbeatTimer = scheduleTimer(interval) { [weak self] in
Task { @MainActor [weak self] in
self?.performHeartbeat()
}
}
}
func performHeartbeat() {
// Always schedule next loop first ensures continuity even if this one fails/skips
defer { scheduleNextHeartbeat() }
// 1. Check preconditions
guard torIsReady() else {
SecureLogger.debug("Presence: skipping heartbeat (Tor not ready)", category: .session)
return
}
// App must be active (or at least we shouldn't broadcast if in background, usually)
if !torIsForeground() {
return
}
// 2. Get channels
let channels = availableChannelsProvider()
guard !channels.isEmpty else { return }
// 3. Filter and broadcast
// We use Task + sleep for decorrelation to allow the main runloop to proceed
for channel in channels {
// Check privacy restriction
if !self.allowedPrecisions.contains(channel.geohash.count) {
continue
}
// Launch independent task for each channel's delay
Task { @MainActor in
// Random delay for decorrelation
let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay)
let nanoseconds = UInt64(delay * 1_000_000_000)
await self.sleeper(nanoseconds)
self.broadcastPresence(for: channel.geohash)
}
}
}
func broadcastPresence(for geohash: String) {
do {
guard let identity = try? deriveIdentity(geohash) else {
return
}
let event = try NostrProtocol.createGeohashPresenceEvent(
geohash: geohash,
senderIdentity: identity
)
// Send via RelayManager
let targetRelays = relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
if !targetRelays.isEmpty {
relaySender(event, targetRelays)
SecureLogger.debug("Presence: sent heartbeat for \(geohash) (pub=\(identity.publicKeyHex.prefix(6))...)", category: .session)
}
} catch {
SecureLogger.error("Presence: failed to create event for \(geohash): \(error)", category: .session)
}
}
}
+281 -5
View File
@@ -7,19 +7,76 @@
//
import BitLogger
import protocol Nostr.NostrKeychainStoring
import protocol Noise.SecureMemoryCleaner
import Foundation
import Security
protocol KeychainManagerProtocol {
// MARK: - Keychain Error Types
// BCH-01-009: Proper error classification to distinguish expected states from critical failures
/// Result of a keychain read operation with proper error classification
enum KeychainReadResult {
case success(Data)
case itemNotFound // Expected: key doesn't exist yet
case accessDenied // Critical: app lacks keychain access
case deviceLocked // Recoverable: device is locked
case authenticationFailed // Recoverable: biometric/passcode failed
case otherError(OSStatus) // Unexpected error
var isRecoverableError: Bool {
switch self {
case .deviceLocked, .authenticationFailed:
return true
default:
return false
}
}
}
/// Result of a keychain save operation with proper error classification
enum KeychainSaveResult {
case success
case duplicateItem // Can retry with update
case accessDenied // Critical: app lacks keychain access
case deviceLocked // Recoverable: device is locked
case storageFull // Critical: no space available
case otherError(OSStatus)
var isRecoverableError: Bool {
switch self {
case .duplicateItem, .deviceLocked:
return true
default:
return false
}
}
}
protocol KeychainManagerProtocol: SecureMemoryCleaner, NostrKeychainStoring {
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
func getIdentityKey(forKey key: String) -> Data?
func deleteIdentityKey(forKey key: String) -> Bool
func deleteAllKeychainData() -> Bool
func secureClear(_ data: inout Data)
func secureClear(_ string: inout String)
func verifyIdentityKeyExists() -> Bool
// BCH-01-009: Methods with proper error classification
/// Get identity key with detailed result for error handling
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult
/// Save identity key with detailed result for error handling
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
/// Save data with a custom service name
func save(key: String, data: Data, service: String, accessible: CFString?)
/// Load data from a custom service
func load(key: String, service: String) -> Data?
/// Delete data from a custom service
func delete(key: String, service: String)
}
final class KeychainManager: KeychainManagerProtocol {
@@ -46,7 +103,181 @@ final class KeychainManager: KeychainManagerProtocol {
SecureLogger.logKeyOperation(.delete, keyType: key, success: result)
return result
}
// MARK: - BCH-01-009: Methods with Proper Error Classification
/// Get identity key with detailed result for proper error handling
/// Distinguishes between missing keys (expected) and critical failures
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
let fullKey = "identity_\(key)"
return retrieveDataWithResult(forKey: fullKey)
}
/// Save identity key with detailed result and retry logic for transient errors
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
let fullKey = "identity_\(key)"
return saveDataWithResult(keyData, forKey: fullKey)
}
/// Internal method to save data with detailed result and retry for transient errors
private func saveDataWithResult(_ data: Data, forKey key: String, retryCount: Int = 2) -> KeychainSaveResult {
// Delete any existing item first to ensure clean state
_ = delete(forKey: key)
// Build base query
var base: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrService as String: service,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,
kSecAttrLabel as String: "bitchat-\(key)"
]
#if os(macOS)
base[kSecAttrSynchronizable as String] = false
#endif
func attempt(addAccessGroup: Bool) -> OSStatus {
var query = base
if addAccessGroup { query[kSecAttrAccessGroup as String] = appGroup }
return SecItemAdd(query as CFDictionary, nil)
}
#if os(iOS)
var status = attempt(addAccessGroup: true)
if status == -34018 { // Missing entitlement, retry without access group
status = attempt(addAccessGroup: false)
}
#else
let status = attempt(addAccessGroup: false)
#endif
// Classify the result
let result = classifySaveStatus(status)
// Log all outcomes consistently
switch result {
case .success:
SecureLogger.debug("Keychain save succeeded for key: \(key)", category: .keychain)
case .duplicateItem:
SecureLogger.warning("Keychain save found duplicate for key: \(key)", category: .keychain)
case .accessDenied:
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
context: "Keychain access denied for key: \(key)", category: .keychain)
case .deviceLocked:
SecureLogger.warning("Device locked during keychain save for key: \(key)", category: .keychain)
case .storageFull:
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
context: "Keychain storage full for key: \(key)", category: .keychain)
case .otherError(let code):
SecureLogger.error(NSError(domain: "Keychain", code: Int(code)),
context: "Keychain save failed for key: \(key)", category: .keychain)
}
// Retry transient errors with exponential backoff
if result.isRecoverableError && retryCount > 0 {
let delayMs = UInt32((3 - retryCount) * 100) // 100ms, 200ms backoff
usleep(delayMs * 1000)
SecureLogger.debug("Retrying keychain save for key: \(key), attempts remaining: \(retryCount)", category: .keychain)
return saveDataWithResult(data, forKey: key, retryCount: retryCount - 1)
}
return result
}
/// Internal method to retrieve data with detailed result
private func retrieveDataWithResult(forKey key: String) -> KeychainReadResult {
let base: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecAttrService as String: service,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
func attempt(withAccessGroup: Bool) -> OSStatus {
var q = base
if withAccessGroup { q[kSecAttrAccessGroup as String] = appGroup }
return SecItemCopyMatching(q as CFDictionary, &result)
}
#if os(iOS)
var status = attempt(withAccessGroup: true)
if status == -34018 { status = attempt(withAccessGroup: false) }
#else
let status = attempt(withAccessGroup: false)
#endif
// Classify the result
let readResult = classifyReadStatus(status, data: result as? Data)
// Log all outcomes consistently
switch readResult {
case .success:
SecureLogger.debug("Keychain read succeeded for key: \(key)", category: .keychain)
case .itemNotFound:
// Expected case - no logging needed for missing keys
break
case .accessDenied:
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
context: "Keychain access denied for key: \(key)", category: .keychain)
case .deviceLocked:
SecureLogger.warning("Device locked during keychain read for key: \(key)", category: .keychain)
case .authenticationFailed:
SecureLogger.warning("Authentication failed for keychain read of key: \(key)", category: .keychain)
case .otherError(let code):
SecureLogger.error(NSError(domain: "Keychain", code: Int(code)),
context: "Keychain read failed for key: \(key)", category: .keychain)
}
return readResult
}
/// Classify keychain read status into meaningful categories
private func classifyReadStatus(_ status: OSStatus, data: Data?) -> KeychainReadResult {
switch status {
case errSecSuccess:
if let data = data {
return .success(data)
}
return .otherError(status)
case errSecItemNotFound:
return .itemNotFound
case errSecInteractionNotAllowed:
// Device is locked or in a state that doesn't allow keychain access
return .deviceLocked
case errSecAuthFailed:
return .authenticationFailed
case -34018: // errSecMissingEntitlement
return .accessDenied
case errSecNotAvailable:
return .accessDenied
default:
return .otherError(status)
}
}
/// Classify keychain save status into meaningful categories
private func classifySaveStatus(_ status: OSStatus) -> KeychainSaveResult {
switch status {
case errSecSuccess:
return .success
case errSecDuplicateItem:
return .duplicateItem
case errSecInteractionNotAllowed:
return .deviceLocked
case -34018: // errSecMissingEntitlement
return .accessDenied
case errSecNotAvailable:
return .accessDenied
case errSecDiskFull:
return .storageFull
default:
return .otherError(status)
}
}
// MARK: - Generic Operations
private func save(_ value: String, forKey key: String) -> Bool {
@@ -309,9 +540,54 @@ final class KeychainManager: KeychainManagerProtocol {
}
// MARK: - Debug
func verifyIdentityKeyExists() -> Bool {
let key = "identity_noiseStaticKey"
return retrieveData(forKey: key) != nil
}
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
/// Save data with a custom service name
func save(key: String, data: Data, service customService: String, accessible: CFString?) {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: customService,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
if let accessible = accessible {
query[kSecAttrAccessible as String] = accessible
}
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
/// Load data from a custom service
func load(key: String, service customService: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: customService,
kSecAttrAccount as String: key,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else { return nil }
return result as? Data
}
/// Delete data from a custom service
func delete(key: String, service customService: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: customService,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
}
+4 -4
View File
@@ -1,4 +1,5 @@
import BitLogger
import Nostr
import Foundation
/// Dependencies for location notes, allowing tests to stub relay/identity behavior.
@@ -15,7 +16,7 @@ struct LocationNotesDependencies {
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
var now: () -> Date
private static let idBridge = NostrIdentityBridge()
private static let idBridge = NostrIdentityBridge(keychain: KeychainManager())
static let live = LocationNotesDependencies(
relayLookup: { geohash, count in
@@ -63,7 +64,7 @@ final class LocationNotesManager: ObservableObject {
var displayName: String {
let suffix = String(pubkey.suffix(4))
if let nick = nickname, !nick.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if let nick = nickname?.trimmedOrNilIfEmpty {
return "\(nick)#\(suffix)"
}
return "anon#\(suffix)"
@@ -199,8 +200,7 @@ final class LocationNotesManager: ObservableObject {
/// Send a location note for the current geohash using the per-geohash identity.
func send(content: String, nickname: String) {
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
state = .noRelays
+100 -15
View File
@@ -5,6 +5,79 @@ import Combine
#if os(iOS) || os(macOS)
import CoreLocation
protocol LocationStateManaging: AnyObject {
var delegate: CLLocationManagerDelegate? { get set }
var desiredAccuracy: CLLocationAccuracy { get set }
var distanceFilter: CLLocationDistance { get set }
var authorizationStatus: CLAuthorizationStatus { get }
func requestWhenInUseAuthorization()
func requestLocation()
func startUpdatingLocation()
func stopUpdatingLocation()
}
protocol LocationStateGeocoding: AnyObject {
func cancelGeocode()
func reverseGeocodeLocation(
_ location: CLLocation,
completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void
)
}
private final class CLLocationManagerAdapter: NSObject, LocationStateManaging {
private let base = CLLocationManager()
var delegate: CLLocationManagerDelegate? {
get { base.delegate }
set { base.delegate = newValue }
}
var desiredAccuracy: CLLocationAccuracy {
get { base.desiredAccuracy }
set { base.desiredAccuracy = newValue }
}
var distanceFilter: CLLocationDistance {
get { base.distanceFilter }
set { base.distanceFilter = newValue }
}
var authorizationStatus: CLAuthorizationStatus {
base.authorizationStatus
}
func requestWhenInUseAuthorization() {
base.requestWhenInUseAuthorization()
}
func requestLocation() {
base.requestLocation()
}
func startUpdatingLocation() {
base.startUpdatingLocation()
}
func stopUpdatingLocation() {
base.stopUpdatingLocation()
}
}
private final class CLGeocoderAdapter: LocationStateGeocoding {
private let base = CLGeocoder()
func cancelGeocode() {
base.cancelGeocode()
}
func reverseGeocodeLocation(
_ location: CLLocation,
completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void
) {
base.reverseGeocodeLocation(location, completionHandler: completionHandler)
}
}
/// Unified manager for location-based channel state including:
/// - CoreLocation permissions and one-shot location retrieval
/// - Geohash channel computation from coordinates
@@ -26,8 +99,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
// MARK: - Private Properties (CoreLocation)
private let cl = CLLocationManager()
private let geocoder = CLGeocoder()
private let cl: LocationStateManaging
private let geocoder: LocationStateGeocoding
private var lastLocation: CLLocation?
private var refreshTimer: Timer?
private var isGeocoding: Bool = false
@@ -73,6 +146,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
private override init() {
self.storage = .standard
self.cl = CLLocationManagerAdapter()
self.geocoder = CLGeocoderAdapter()
super.init()
// Skip CoreLocation setup in test environments
@@ -92,10 +167,30 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
/// Internal initializer for testing with custom storage
init(storage: UserDefaults) {
self.storage = storage
self.cl = CLLocationManagerAdapter()
self.geocoder = CLGeocoderAdapter()
super.init()
loadPersistedState()
}
internal init(
storage: UserDefaults,
locationManager: LocationStateManaging,
geocoder: LocationStateGeocoding,
shouldInitializeCoreLocation: Bool
) {
self.storage = storage
self.cl = locationManager
self.geocoder = geocoder
super.init()
loadPersistedState()
guard shouldInitializeCoreLocation else { return }
cl.delegate = self
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
initializePermissionState()
}
private func loadPersistedState() {
// Load selected channel
if let data = storage.data(forKey: selectedChannelKey),
@@ -132,12 +227,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
}
private func initializePermissionState() {
let status: CLAuthorizationStatus
if #available(iOS 14.0, macOS 11.0, *) {
status = cl.authorizationStatus
} else {
status = CLLocationManager.authorizationStatus()
}
let status = cl.authorizationStatus
updatePermissionState(from: status)
// Fall back to persisted teleport state if no location authorization
@@ -156,12 +246,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
// MARK: - Public API (Permissions & Location)
func enableLocationChannels() {
let status: CLAuthorizationStatus
if #available(iOS 14.0, macOS 11.0, *) {
status = cl.authorizationStatus
} else {
status = CLLocationManager.authorizationStatus()
}
let status = cl.authorizationStatus
switch status {
case .notDetermined:
cl.requestWhenInUseAuthorization()
@@ -512,7 +597,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
private static func normalizeGeohash(_ s: String) -> String {
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
return s
.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmed
.lowercased()
.replacingOccurrences(of: "#", with: "")
.filter { allowed.contains($0) }
+79 -69
View File
@@ -6,104 +6,114 @@ final class MeshTopologyTracker {
private let queue = DispatchQueue(label: "mesh.topology", attributes: .concurrent)
private let hopSize = 8
private var adjacency: [RoutingID: Set<RoutingID>] = [:]
// Directed claims: Key claims to see Value (neighbors)
private var claims: [RoutingID: Set<RoutingID>] = [:]
// Last time we received an update from a node
private var lastSeen: [RoutingID: Date] = [:]
// Maximum age for topology claims to be considered fresh for routing
// Routes computed using stale topology can fail when the network has changed
private static let routeFreshnessThreshold: TimeInterval = 60 // 60 seconds
func reset() {
queue.sync(flags: .barrier) {
self.adjacency.removeAll()
self.claims.removeAll()
self.lastSeen.removeAll()
}
}
func recordDirectLink(between a: Data?, and b: Data?) {
guard let left = sanitize(a), let right = sanitize(b), left != right else { return }
/// Update the topology with a node's self-reported neighbor list
func updateNeighbors(for sourceData: Data?, neighbors: [Data]) {
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) {
var setA = self.adjacency[left] ?? []
setA.insert(right)
self.adjacency[left] = setA
var setB = self.adjacency[right] ?? []
setB.insert(left)
self.adjacency[right] = setB
}
}
func removeDirectLink(between a: Data?, and b: Data?) {
guard let left = sanitize(a), let right = sanitize(b), left != right else { return }
queue.sync(flags: .barrier) {
if var setA = self.adjacency[left] {
setA.remove(right)
self.adjacency[left] = setA.isEmpty ? nil : setA
}
if var setB = self.adjacency[right] {
setB.remove(left)
self.adjacency[right] = setB.isEmpty ? nil : setB
}
self.claims[source] = validNeighbors
self.lastSeen[source] = Date()
}
}
func removePeer(_ data: Data?) {
guard let peer = sanitize(data) else { return }
queue.sync(flags: .barrier) {
guard let neighbors = self.adjacency.removeValue(forKey: peer) else { return }
for neighbor in neighbors {
if var set = self.adjacency[neighbor] {
set.remove(peer)
self.adjacency[neighbor] = set.isEmpty ? nil : set
}
}
self.claims.removeValue(forKey: peer)
self.lastSeen.removeValue(forKey: peer)
}
}
func recordRoute(_ hops: [Data]) {
let sanitized = hops.compactMap { sanitize($0) }
guard sanitized.count >= 2 else { return }
/// Prune nodes that haven't updated their topology in `age` seconds
func prune(olderThan age: TimeInterval) {
let deadline = Date().addingTimeInterval(-age)
queue.sync(flags: .barrier) {
for idx in 0..<(sanitized.count - 1) {
let left = sanitized[idx]
let right = sanitized[idx + 1]
guard left != right else { continue }
var setA = self.adjacency[left] ?? []
setA.insert(right)
self.adjacency[left] = setA
var setB = self.adjacency[right] ?? []
setB.insert(left)
self.adjacency[right] = setB
let stale = self.lastSeen.filter { $0.value < deadline }
for (peer, _) in stale {
self.claims.removeValue(forKey: peer)
self.lastSeen.removeValue(forKey: peer)
}
}
}
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 255) -> [Data]? {
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 10) -> [Data]? {
guard let source = sanitize(start), let target = sanitize(goal) else { return nil }
if source == target { return [source] }
if source == target { return [] } // Direct connection, no intermediate hops
let graph = queue.sync { adjacency }
guard graph[source] != nil, graph[target] != nil else { return nil }
return queue.sync {
let now = Date()
let freshnessDeadline = now.addingTimeInterval(-Self.routeFreshnessThreshold)
var visited: Set<RoutingID> = [source]
var queuePaths: [[RoutingID]] = [[source]]
var index = 0
// BFS
var visited: Set<RoutingID> = [source]
// Queue stores paths: [Start, Hop1, Hop2, ..., Current]
var queuePaths: [[RoutingID]] = [[source]]
while index < queuePaths.count {
let path = queuePaths[index]
index += 1
guard path.count <= maxHops else { continue }
guard let last = path.last, let neighbors = graph[last] else { continue }
while !queuePaths.isEmpty {
let path = queuePaths.removeFirst()
// Limit path length (path contains source + maxHops + target) -> maxHops intermediate
// If maxHops = 10, max edges = 11, max nodes = 12.
if path.count > maxHops + 1 { continue }
for neighbor in neighbors {
if visited.contains(neighbor) { continue }
var nextPath = path
nextPath.append(neighbor)
if neighbor == target { return nextPath }
if nextPath.count <= maxHops {
guard let last = path.last else { continue }
// Get neighbors that 'last' claims to see
guard let neighbors = claims[last] else { continue }
// Check if 'last' node's topology info is fresh
guard let lastSeenTime = lastSeen[last], lastSeenTime > freshnessDeadline else {
continue // Skip stale nodes
}
for neighbor in neighbors {
if visited.contains(neighbor) { continue }
// CONFIRMED EDGE CHECK:
// 'last' claims 'neighbor' (checked above)
// Does 'neighbor' claim 'last'?
guard let neighborClaims = claims[neighbor],
neighborClaims.contains(last) else {
continue
}
// Check if 'neighbor' node's topology info is fresh
guard let neighborSeenTime = lastSeen[neighbor], neighborSeenTime > freshnessDeadline else {
continue // Skip edges to stale nodes
}
var nextPath = path
nextPath.append(neighbor)
if neighbor == target {
// Return only intermediate hops
// Path: [Source, I1, I2, Target] -> [I1, I2]
return Array(nextPath.dropFirst().dropLast())
}
visited.insert(neighbor)
queuePaths.append(nextPath)
}
visited.insert(neighbor)
}
return nil
}
return nil
}
// MARK: - Helpers
@@ -12,6 +12,8 @@ import Foundation
/// Generic LRU (Least Recently Used) cache for deduplication.
/// Uses an efficient O(1) lookup with periodic compaction.
/// Thread-safe via @MainActor - all callers are already on main actor.
@MainActor
final class LRUDeduplicationCache<Value> {
private var map: [String: Value] = [:]
private var order: [String] = []
@@ -143,7 +145,7 @@ enum ContentNormalizer {
}
// Trim and collapse whitespace
let trimmed = simplified.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmed = simplified.trimmed
let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
// Take prefix and hash
@@ -157,6 +159,8 @@ enum ContentNormalizer {
/// Service that manages message deduplication using LRU caches.
/// Provides separate caches for content-based dedup and Nostr event ID dedup.
/// Thread-safe via @MainActor - all callers are already on main actor.
@MainActor
final class MessageDeduplicationService {
/// Cache for content-based near-duplicate detection
@@ -6,6 +6,7 @@
// This is free and unencumbered software released into the public domain.
//
import BitFoundation
import Foundation
import SwiftUI
+72 -31
View File
@@ -1,11 +1,25 @@
import BitLogger
import BitFoundation
import Foundation
/// Routes messages using available transports (Mesh, Nostr, etc.)
@MainActor
final class MessageRouter {
private let transports: [Transport]
private var outbox: [PeerID: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
// Outbox entry with timestamp for TTL-based eviction
private struct QueuedMessage {
let content: String
let nickname: String
let messageID: String
let timestamp: Date
}
private var outbox: [PeerID: [QueuedMessage]] = [:]
// Outbox limits to prevent unbounded memory growth
private static let maxMessagesPerPeer = 100
private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours
init(transports: [Transport]) {
self.transports = transports
@@ -34,52 +48,60 @@ final class MessageRouter {
}
}
// MARK: - Transport Selection
private func reachableTransport(for peerID: PeerID) -> Transport? {
transports.first { $0.isPeerReachable(peerID) }
}
private func connectedTransport(for peerID: PeerID) -> Transport? {
transports.first { $0.isPeerConnected(peerID) }
}
// MARK: - Message Sending
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
// Try to find a reachable transport
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
if let transport = reachableTransport(for: peerID) {
SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else {
// Queue for later
// Queue for later with timestamp for TTL tracking
if outbox[peerID] == nil { outbox[peerID] = [] }
outbox[peerID]?.append((content, recipientNickname, messageID))
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))", category: .session)
let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: Date())
outbox[peerID]?.append(message)
// Enforce per-peer size limit with FIFO eviction
if let count = outbox[peerID]?.count, count > Self.maxMessagesPerPeer {
let evicted = outbox[peerID]?.removeFirst()
SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted?.messageID.prefix(8) ?? "?")", category: .session)
}
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
}
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
if let transport = reachableTransport(for: peerID) {
SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session)
transport.sendReadReceipt(receipt, to: peerID)
} else if !transports.isEmpty {
// Fallback to last transport (usually Nostr) if neither is explicitly reachable?
// Or better: just try the first one that supports it?
// Existing logic preferred mesh, then nostr.
// If neither reachable, existing logic queued it (via mesh usually) or sent via nostr.
// Let's stick to "try reachable". If none, maybe pick the first one to queue?
// Actually, for READ receipts, we might want to just fire-and-forget on the "best effort" transport.
// But let's stick to the reachable check.
SecureLogger.debug("No reachable transport for READ ack to \(peerID.id.prefix(8))", category: .session)
}
}
func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
if let transport = reachableTransport(for: peerID) {
SecureLogger.debug("Routing DELIVERED ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
transport.sendDeliveryAck(for: messageID, to: peerID)
}
}
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
if let transport = transports.first(where: { $0.isPeerConnected(peerID) }) {
if let transport = connectedTransport(for: peerID) {
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
} else if let transport = reachableTransport(for: peerID) {
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
} else if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
} else {
// Fallback: try all? or just the last one?
// Old logic: if mesh connected, mesh. Else nostr.
// Note: NostrTransport.isPeerReachable now returns true if mapped.
// If not mapped, we can't send via Nostr anyway.
}
}
@@ -88,17 +110,25 @@ final class MessageRouter {
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)
var remaining: [(content: String, nickname: String, messageID: String)] = []
for (content, nickname, messageID) in queued {
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
let now = Date()
var remaining: [QueuedMessage] = []
for message in queued {
// 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)
continue
}
if let transport = reachableTransport(for: peerID) {
SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
} else {
remaining.append((content, nickname, messageID))
remaining.append(message)
}
}
if remaining.isEmpty {
outbox.removeValue(forKey: peerID)
} else {
@@ -109,4 +139,15 @@ final class MessageRouter {
func flushAllOutbox() {
for key in Array(outbox.keys) { flushOutbox(for: key) }
}
/// Periodically clean up expired messages from all outboxes
func cleanupExpiredMessages() {
let now = Date()
for peerID in Array(outbox.keys) {
outbox[peerID]?.removeAll { now.timeIntervalSince($0.timestamp) > Self.messageTTLSeconds }
if outbox[peerID]?.isEmpty == true {
outbox.removeValue(forKey: peerID)
}
}
}
}
+81 -18
View File
@@ -1,8 +1,30 @@
import Nostr
import Foundation
import BitLogger
import Combine
import Tor
@MainActor
protocol NetworkActivationTorControlling: AnyObject {
func setAutoStartAllowed(_ allowed: Bool)
func startIfNeeded()
func shutdownCompletely()
}
@MainActor
protocol NetworkActivationRelayControlling: AnyObject {
func connect()
func disconnect()
}
protocol NetworkActivationProxyControlling: AnyObject {
func setProxyMode(useTor: Bool)
}
extension TorManager: NetworkActivationTorControlling {}
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.
@@ -17,14 +39,55 @@ final class NetworkActivationService: ObservableObject {
private var started = false
private let torPreferenceKey = "networkActivationService.userTorEnabled"
private var torAutoStartDesired: Bool = false
private let storage: UserDefaults
private let locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
private let mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
private let permissionProvider: () -> LocationChannelManager.PermissionState
private let mutualFavoritesProvider: () -> Set<Data>
private let torController: NetworkActivationTorControlling
private let relayController: NetworkActivationRelayControlling
private let proxyController: NetworkActivationProxyControlling
private let notificationCenter: NotificationCenter
private init() {}
private init() {
storage = .standard
locationPermissionPublisher = LocationChannelManager.shared.$permissionState.eraseToAnyPublisher()
mutualFavoritesPublisher = FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher()
permissionProvider = { LocationChannelManager.shared.permissionState }
mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites }
torController = TorManager.shared
relayController = NostrRelayManager.shared
proxyController = TorURLSession.shared
notificationCenter = .default
}
internal init(
storage: UserDefaults,
locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>,
mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>,
permissionProvider: @escaping () -> LocationChannelManager.PermissionState,
mutualFavoritesProvider: @escaping () -> Set<Data>,
torController: NetworkActivationTorControlling,
relayController: NetworkActivationRelayControlling,
proxyController: NetworkActivationProxyControlling,
notificationCenter: NotificationCenter = .default
) {
self.storage = storage
self.locationPermissionPublisher = locationPermissionPublisher
self.mutualFavoritesPublisher = mutualFavoritesPublisher
self.permissionProvider = permissionProvider
self.mutualFavoritesProvider = mutualFavoritesProvider
self.torController = torController
self.relayController = relayController
self.proxyController = proxyController
self.notificationCenter = notificationCenter
}
func start() {
guard !started else { return }
started = true
if let stored = UserDefaults.standard.object(forKey: torPreferenceKey) as? Bool {
if let stored = storage.object(forKey: torPreferenceKey) as? Bool {
userTorEnabled = stored
} else {
userTorEnabled = true
@@ -34,16 +97,16 @@ final class NetworkActivationService: ObservableObject {
let allowed = basePolicyAllowed()
activationAllowed = allowed
torAutoStartDesired = allowed && userTorEnabled
TorManager.shared.setAutoStartAllowed(torAutoStartDesired)
torController.setAutoStartAllowed(torAutoStartDesired)
applyTorState(torDesired: torAutoStartDesired)
if allowed {
NostrRelayManager.shared.connect()
relayController.connect()
} else {
NostrRelayManager.shared.disconnect()
relayController.disconnect()
}
// React to location permission changes
LocationChannelManager.shared.$permissionState
locationPermissionPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.reevaluate()
@@ -51,7 +114,7 @@ final class NetworkActivationService: ObservableObject {
.store(in: &cancellables)
// React to mutual favorites changes
FavoritesPersistenceService.shared.$mutualFavorites
mutualFavoritesPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.reevaluate()
@@ -62,8 +125,8 @@ final class NetworkActivationService: ObservableObject {
func setUserTorEnabled(_ enabled: Bool) {
guard enabled != userTorEnabled else { return }
userTorEnabled = enabled
UserDefaults.standard.set(enabled, forKey: torPreferenceKey)
NotificationCenter.default.post(
storage.set(enabled, forKey: torPreferenceKey)
notificationCenter.post(
name: .TorUserPreferenceChanged,
object: nil,
userInfo: ["enabled": enabled]
@@ -82,33 +145,33 @@ final class NetworkActivationService: ObservableObject {
}
if statusChanged || torChanged {
torAutoStartDesired = torDesired
TorManager.shared.setAutoStartAllowed(torDesired)
torController.setAutoStartAllowed(torDesired)
applyTorState(torDesired: torDesired)
}
if allowed {
if torChanged {
// Reset relay sockets when switching transport path (Tor direct)
NostrRelayManager.shared.disconnect()
relayController.disconnect()
}
NostrRelayManager.shared.connect()
relayController.connect()
} else if statusChanged {
NostrRelayManager.shared.disconnect()
relayController.disconnect()
}
}
private func basePolicyAllowed() -> Bool {
let permOK = LocationChannelManager.shared.permissionState == .authorized
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
let permOK = permissionProvider() == .authorized
let hasMutual = !mutualFavoritesProvider().isEmpty
return permOK || hasMutual
}
private func applyTorState(torDesired: Bool) {
TorURLSession.shared.setProxyMode(useTor: torDesired)
proxyController.setProxyMode(useTor: torDesired)
if torDesired {
TorManager.shared.startIfNeeded()
torController.startIfNeeded()
} else {
TorManager.shared.shutdownCompletely()
torController.shutdownCompletely()
}
}
}
+139 -37
View File
@@ -83,6 +83,8 @@
///
import BitLogger
import BitFoundation
import Noise
import Foundation
import CryptoKit
@@ -199,64 +201,153 @@ final class NoiseEncryptionService {
init(keychain: KeychainManagerProtocol) {
self.keychain = keychain
// Load or create static identity key (ONLY from keychain)
// BCH-01-009: Load or create static identity key with proper error handling
let loadedKey: Curve25519.KeyAgreement.PrivateKey
// Try to load from keychain
if let identityData = keychain.getIdentityKey(forKey: "noiseStaticKey"),
let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) {
loadedKey = key
SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true)
}
// If no identity exists, create new one
else {
// Try to load from keychain with proper error classification
let noiseKeyResult = keychain.getIdentityKeyWithResult(forKey: "noiseStaticKey")
switch noiseKeyResult {
case .success(let identityData):
if let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) {
loadedKey = key
SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true)
} else {
// Data corrupted, regenerate
SecureLogger.warning("Noise static key data corrupted, regenerating", category: .keychain)
loadedKey = Self.generateAndSaveNoiseKey(keychain: keychain)
}
case .itemNotFound:
// Expected case: no key exists yet, create new one
loadedKey = Self.generateAndSaveNoiseKey(keychain: keychain)
case .accessDenied:
// Critical error - log but proceed with ephemeral key (will be lost on restart)
SecureLogger.error(NSError(domain: "Keychain", code: -1),
context: "Keychain access denied - using ephemeral identity", category: .keychain)
loadedKey = Curve25519.KeyAgreement.PrivateKey()
case .deviceLocked, .authenticationFailed:
// Recoverable error - use ephemeral key and warn
SecureLogger.warning("Device locked or auth failed - using ephemeral identity until unlocked", category: .keychain)
loadedKey = Curve25519.KeyAgreement.PrivateKey()
case .otherError(let status):
// Unexpected error - log and use ephemeral key
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
context: "Unexpected keychain error - using ephemeral identity", category: .keychain)
loadedKey = Curve25519.KeyAgreement.PrivateKey()
let keyData = loadedKey.rawRepresentation
// Save to keychain
let saved = keychain.saveIdentityKey(keyData, forKey: "noiseStaticKey")
SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: saved)
}
// Now assign the final value
self.staticIdentityKey = loadedKey
self.staticIdentityPublicKey = staticIdentityKey.publicKey
// Load or create signing key pair
// BCH-01-009: Load or create signing key pair with proper error handling
let loadedSigningKey: Curve25519.Signing.PrivateKey
// Try to load from keychain
if let signingData = keychain.getIdentityKey(forKey: "ed25519SigningKey"),
let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) {
loadedSigningKey = key
SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true)
}
// If no signing key exists, create new one
else {
let signingKeyResult = keychain.getIdentityKeyWithResult(forKey: "ed25519SigningKey")
switch signingKeyResult {
case .success(let signingData):
if let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) {
loadedSigningKey = key
SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true)
} else {
// Data corrupted, regenerate
SecureLogger.warning("Ed25519 signing key data corrupted, regenerating", category: .keychain)
loadedSigningKey = Self.generateAndSaveSigningKey(keychain: keychain)
}
case .itemNotFound:
// Expected case: no key exists yet, create new one
loadedSigningKey = Self.generateAndSaveSigningKey(keychain: keychain)
case .accessDenied:
// Critical error - log but proceed with ephemeral key
SecureLogger.error(NSError(domain: "Keychain", code: -1),
context: "Keychain access denied - using ephemeral signing key", category: .keychain)
loadedSigningKey = Curve25519.Signing.PrivateKey()
case .deviceLocked, .authenticationFailed:
// Recoverable error - use ephemeral key and warn
SecureLogger.warning("Device locked or auth failed - using ephemeral signing key until unlocked", category: .keychain)
loadedSigningKey = Curve25519.Signing.PrivateKey()
case .otherError(let status):
// Unexpected error - log and use ephemeral key
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
context: "Unexpected keychain error - using ephemeral signing key", category: .keychain)
loadedSigningKey = Curve25519.Signing.PrivateKey()
let keyData = loadedSigningKey.rawRepresentation
// Save to keychain
let saved = keychain.saveIdentityKey(keyData, forKey: "ed25519SigningKey")
SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: saved)
}
// Now assign the signing keys
self.signingKey = loadedSigningKey
self.signingPublicKey = signingKey.publicKey
// Initialize session manager
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
// Set up session callbacks
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey)
}
// Start session maintenance timer
startRekeyTimer()
}
// MARK: - BCH-01-009: Key Generation Helpers with Save Verification
/// Generate and save a new Noise static key, verifying the save succeeds
private static func generateAndSaveNoiseKey(keychain: KeychainManagerProtocol) -> Curve25519.KeyAgreement.PrivateKey {
let newKey = Curve25519.KeyAgreement.PrivateKey()
let keyData = newKey.rawRepresentation
// Save to keychain and verify success
let saveResult = keychain.saveIdentityKeyWithResult(keyData, forKey: "noiseStaticKey")
switch saveResult {
case .success:
SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: true)
case .duplicateItem:
// This shouldn't happen since we just tried to load, but handle it
SecureLogger.warning("Noise key already exists (race condition?)", category: .keychain)
default:
// Save failed - log but continue with the key (it will be ephemeral)
SecureLogger.error(NSError(domain: "Keychain", code: -1),
context: "Failed to persist noise static key - identity will be lost on restart",
category: .keychain)
}
return newKey
}
/// Generate and save a new Ed25519 signing key, verifying the save succeeds
private static func generateAndSaveSigningKey(keychain: KeychainManagerProtocol) -> Curve25519.Signing.PrivateKey {
let newKey = Curve25519.Signing.PrivateKey()
let keyData = newKey.rawRepresentation
// Save to keychain and verify success
let saveResult = keychain.saveIdentityKeyWithResult(keyData, forKey: "ed25519SigningKey")
switch saveResult {
case .success:
SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: true)
case .duplicateItem:
// This shouldn't happen since we just tried to load, but handle it
SecureLogger.warning("Signing key already exists (race condition?)", category: .keychain)
default:
// Save failed - log but continue with the key (it will be ephemeral)
SecureLogger.error(NSError(domain: "Keychain", code: -1),
context: "Failed to persist signing key - identity will be lost on restart",
category: .keychain)
}
return newKey
}
// MARK: - Public Interface
@@ -527,6 +618,17 @@ final class NoiseEncryptionService {
}
rateLimiter.resetAll()
}
/// Clear session for a specific peer (e.g., on decryption failure to allow re-handshake)
func clearSession(for peerID: PeerID) {
sessionManager.removeSession(for: peerID)
serviceQueue.sync(flags: .barrier) {
if let fingerprint = peerFingerprints.removeValue(forKey: peerID) {
fingerprintToPeerID.removeValue(forKey: fingerprint)
}
}
SecureLogger.debug("🔓 Cleared Noise session for \(peerID)", category: .session)
}
// MARK: - Private Helpers
+115 -113
View File
@@ -1,9 +1,37 @@
import BitLogger
import BitFoundation
import Nostr
import Foundation
import Combine
// Minimal Nostr transport conforming to Transport for offline sending
final class NostrTransport: Transport, @unchecked Sendable {
struct Dependencies {
let notificationCenter: NotificationCenter
let loadFavorites: @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship]
let favoriteStatusForNoiseKey: @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?
let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
let currentIdentity: @MainActor () throws -> NostrIdentity?
let registerPendingGiftWrap: @MainActor (String) -> Void
let sendEvent: @MainActor (NostrEvent) -> Void
let scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
static func live(idBridge: NostrIdentityBridge) -> Dependencies {
Dependencies(
notificationCenter: .default,
loadFavorites: { FavoritesPersistenceService.shared.favorites },
favoriteStatusForNoiseKey: { FavoritesPersistenceService.shared.getFavoriteStatus(for: $0) },
favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) },
currentIdentity: { try idBridge.getCurrentNostrIdentity() },
registerPendingGiftWrap: { NostrRelayManager.registerPendingGiftWrap(id: $0) },
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
scheduleAfter: { delay, action in
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
}
)
}
}
// Provide BLE short peer ID for BitChat embedding
var senderPeerID = PeerID(str: "")
@@ -17,20 +45,27 @@ final class NostrTransport: Transport, @unchecked Sendable {
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
private let keychain: KeychainManagerProtocol
private let idBridge: NostrIdentityBridge
private let dependencies: Dependencies
private var favoriteStatusObserver: NSObjectProtocol?
// Reachability Cache (thread-safe)
private var reachablePeers: Set<PeerID> = []
private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent)
@MainActor
init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) {
init(
keychain: KeychainManagerProtocol,
idBridge: NostrIdentityBridge,
dependencies: Dependencies? = nil
) {
self.keychain = keychain
self.idBridge = idBridge
self.dependencies = dependencies ?? .live(idBridge: idBridge)
setupObservers()
// Synchronously warm the cache to avoid startup race
let favorites = FavoritesPersistenceService.shared.favorites
let favorites = self.dependencies.loadFavorites()
let reachable = favorites.values
.filter { $0.peerNostrPublicKey != nil }
.map { PeerID(publicKey: $0.peerNoisePublicKey) }
@@ -40,8 +75,14 @@ final class NostrTransport: Transport, @unchecked Sendable {
}
}
deinit {
if let favoriteStatusObserver {
dependencies.notificationCenter.removeObserver(favoriteStatusObserver)
}
}
private func setupObservers() {
NotificationCenter.default.addObserver(
favoriteStatusObserver = dependencies.notificationCenter.addObserver(
forName: .favoriteStatusChanged,
object: nil,
queue: nil
@@ -52,7 +93,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
private func refreshReachablePeers() {
Task { @MainActor in
let favorites = FavoritesPersistenceService.shared.favorites
let favorites = dependencies.loadFavorites()
let reachable = favorites.values
.filter { $0.peerNostrPublicKey != nil }
.map { PeerID(publicKey: $0.peerNoisePublicKey) }
@@ -118,95 +159,54 @@ final class NostrTransport: Transport, @unchecked Sendable {
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
// Convert recipient npub -> hex (x-only)
let recipientHex: String
do {
let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else {
SecureLogger.error("NostrTransport: recipient key not npub (hrp=\(hrp))", category: .session)
return
}
recipientHex = data.hexEncodedString()
} catch {
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
return
}
guard let recipientNpub = resolveRecipientNpub(for: peerID),
let recipientHex = npubToHex(recipientNpub),
let senderIdentity = try? dependencies.currentIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
return
}
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for PM", category: .session)
return
}
SecureLogger.debug("NostrTransport: sending PM giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
}
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
// Enqueue and process with throttling to avoid relay rate limits
readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
processReadQueueIfNeeded()
// Use barrier to synchronize access to readQueue
queue.async(flags: .barrier) { [weak self] in
self?.readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
self?.processReadQueueIfNeeded()
}
}
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
guard let recipientNpub = resolveRecipientNpub(for: peerID),
let recipientHex = npubToHex(recipientNpub),
let senderIdentity = try? dependencies.currentIdentity() else { return }
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))", category: .session)
// Convert recipient npub -> hex
let recipientHex: String
do {
let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else { return }
recipientHex = data.hexEncodedString()
} catch {
SecureLogger.error("NostrTransport: failed to decode recipient npub for favorite notification: \(error.localizedDescription)", category: .session)
return
}
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
return
}
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for favorite notification", category: .session)
return
}
SecureLogger.debug("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
}
}
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session)
let recipientHex: String
do {
let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else { return }
recipientHex = data.hexEncodedString()
} catch {
SecureLogger.error("NostrTransport: failed to decode recipient npub for delivery ack: \(error.localizedDescription)", category: .session)
return
}
guard let recipientNpub = resolveRecipientNpub(for: peerID),
let recipientHex = npubToHex(recipientNpub),
let senderIdentity = try? dependencies.currentIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))", category: .session)
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
return
}
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for DELIVERED ack", category: .session)
return
}
SecureLogger.debug("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
}
}
}
@@ -218,21 +218,17 @@ extension NostrTransport {
// MARK: Geohash ACK helpers
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
Task { @MainActor in
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
NostrRelayManager.registerPendingGiftWrap(id: event.id)
NostrRelayManager.shared.sendEvent(event)
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
}
}
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
Task { @MainActor in
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
NostrRelayManager.registerPendingGiftWrap(id: event.id)
NostrRelayManager.shared.sendEvent(event)
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
}
}
@@ -240,19 +236,12 @@ extension NostrTransport {
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
Task { @MainActor in
guard !recipientHex.isEmpty else { return }
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
// Build embedded BitChat packet without recipient peer ID
SecureLogger.debug("GeoDM: send PM mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
return
}
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for geohash PM", category: .session)
return
}
SecureLogger.debug("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.registerPendingGiftWrap(id: event.id)
NostrRelayManager.shared.sendEvent(event)
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
}
}
}
@@ -260,62 +249,75 @@ extension NostrTransport {
// MARK: - Private Helpers
extension NostrTransport {
/// Converts npub bech32 string to hex pubkey
@MainActor
private func npubToHex(_ npub: String) -> String? {
do {
let (hrp, data) = try Bech32.decode(npub)
guard hrp == "npub" else { return nil }
return data.hexEncodedString()
} catch {
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
return nil
}
}
/// Creates and sends a gift-wrapped private message event
@MainActor
private func sendWrappedMessage(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) {
guard let event = try? NostrProtocol.createPrivateMessage(content: content, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event", category: .session)
return
}
if registerPending {
dependencies.registerPendingGiftWrap(event.id)
}
dependencies.sendEvent(event)
}
/// Must be called within a barrier on `queue`
private func processReadQueueIfNeeded() {
guard !isSendingReadAcks else { return }
guard !readQueue.isEmpty else { return }
isSendingReadAcks = true
sendNextReadAck()
let item = readQueue.removeFirst()
sendReadAckItem(item)
}
private func sendNextReadAck() {
guard !readQueue.isEmpty else { isSendingReadAcks = false; return }
let item = readQueue.removeFirst()
/// Sends a single read ack item (called after extraction from queue within barrier)
private func sendReadAckItem(_ item: QueuedRead) {
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session)
// Convert recipient npub -> hex
let recipientHex: String
do {
let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else { scheduleNextReadAck(); return }
recipientHex = data.hexEncodedString()
} catch {
SecureLogger.error("NostrTransport: failed to decode recipient npub for read ack: \(error.localizedDescription)", category: .session)
scheduleNextReadAck()
return
}
defer { scheduleNextReadAck() }
guard let recipientNpub = resolveRecipientNpub(for: item.peerID),
let recipientHex = npubToHex(recipientNpub),
let senderIdentity = try? dependencies.currentIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing READ ack id=\(item.receipt.originalMessageID.prefix(8))", category: .session)
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
scheduleNextReadAck(); return
return
}
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for READ ack", category: .session)
scheduleNextReadAck(); return
}
SecureLogger.debug("NostrTransport: sending READ ack giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
scheduleNextReadAck()
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
}
}
private func scheduleNextReadAck() {
DispatchQueue.main.asyncAfter(deadline: .now() + readAckInterval) { [weak self] in
guard let self = self else { return }
self.isSendingReadAcks = false
self.processReadQueueIfNeeded()
dependencies.scheduleAfter(readAckInterval) { [weak self] in
self?.queue.async(flags: .barrier) { [weak self] in
self?.isSendingReadAcks = false
self?.processReadQueueIfNeeded()
}
}
}
@MainActor
private func resolveRecipientNpub(for peerID: PeerID) -> String? {
if let noiseKey = Data(hexString: peerID.id),
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
let fav = dependencies.favoriteStatusForNoiseKey(noiseKey),
let npub = fav.peerNostrPublicKey {
return npub
}
if peerID.id.count == 16,
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
let fav = dependencies.favoriteStatusForPeerID(peerID),
let npub = fav.peerNostrPublicKey {
return npub
}
+91 -10
View File
@@ -6,6 +6,7 @@
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
import UserNotifications
#if os(iOS)
@@ -14,24 +15,103 @@ import UIKit
import AppKit
#endif
protocol NotificationAuthorizing {
func requestAuthorization(
options: UNAuthorizationOptions,
completionHandler: @escaping (Bool, Error?) -> Void
)
}
protocol NotificationRequestDelivering {
func add(_ request: UNNotificationRequest)
}
private final class NotificationCenterAuthorizerAdapter: NotificationAuthorizing {
private let center: UNUserNotificationCenter
init(center: UNUserNotificationCenter) {
self.center = center
}
func requestAuthorization(
options: UNAuthorizationOptions,
completionHandler: @escaping (Bool, Error?) -> Void
) {
center.requestAuthorization(options: options, completionHandler: completionHandler)
}
}
private final class NotificationCenterRequestDelivererAdapter: NotificationRequestDelivering {
private let center: UNUserNotificationCenter
init(center: UNUserNotificationCenter) {
self.center = center
}
func add(_ request: UNNotificationRequest) {
Task {
try? await center.add(request)
}
}
}
private struct NoopNotificationAuthorizer: NotificationAuthorizing {
func requestAuthorization(
options: UNAuthorizationOptions,
completionHandler: @escaping (Bool, Error?) -> Void
) {
completionHandler(false, nil)
}
}
private struct NoopNotificationRequestDeliverer: NotificationRequestDelivering {
func add(_ request: UNNotificationRequest) {}
}
final class NotificationService {
static let shared = NotificationService()
private let isRunningTestsProvider: () -> Bool
private let authorizer: NotificationAuthorizing
private let requestDeliverer: NotificationRequestDelivering
/// Returns true if running in test environment (XCTest, Swift Testing, or CI)
private var isRunningTests: Bool {
let env = ProcessInfo.processInfo.environment
return NSClassFromString("XCTestCase") != nil ||
env["XCTestConfigurationFilePath"] != nil ||
env["XCTestBundlePath"] != nil ||
env["GITHUB_ACTIONS"] != nil ||
env["CI"] != nil
isRunningTestsProvider()
}
private init() {}
private init() {
self.isRunningTestsProvider = {
let env = ProcessInfo.processInfo.environment
return NSClassFromString("XCTestCase") != nil ||
env["XCTestConfigurationFilePath"] != nil ||
env["XCTestBundlePath"] != nil ||
env["GITHUB_ACTIONS"] != nil ||
env["CI"] != nil
}
if isRunningTestsProvider() {
self.authorizer = NoopNotificationAuthorizer()
self.requestDeliverer = NoopNotificationRequestDeliverer()
} else {
let center = UNUserNotificationCenter.current()
self.authorizer = NotificationCenterAuthorizerAdapter(center: center)
self.requestDeliverer = NotificationCenterRequestDelivererAdapter(center: center)
}
}
internal init(
isRunningTestsProvider: @escaping () -> Bool,
authorizer: NotificationAuthorizing,
requestDeliverer: NotificationRequestDelivering
) {
self.isRunningTestsProvider = isRunningTestsProvider
self.authorizer = authorizer
self.requestDeliverer = requestDeliverer
}
func requestAuthorization() {
guard !isRunningTests else { return }
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
if granted {
// Permission granted
} else {
@@ -64,7 +144,7 @@ final class NotificationService {
trigger: nil // Deliver immediately
)
UNUserNotificationCenter.current().add(request)
requestDeliverer.add(request)
}
func sendMentionNotification(from sender: String, message: String) {
@@ -96,7 +176,8 @@ final class NotificationService {
func sendNetworkAvailableNotification(peerCount: Int) {
let title = "👥 bitchatters nearby!"
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
let identifier = "network-available-\(Date().timeIntervalSince1970)"
// Fixed identifier so iOS updates the existing notification instead of creating new ones
let identifier = "network-available"
sendLocalNotification(
title: title,
@@ -62,6 +62,7 @@ struct NotificationStreamAssembler {
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
let isCompressed = (flags & BinaryProtocol.Flags.isCompressed) != 0
let hasRoute = (version >= 2) && (flags & BinaryProtocol.Flags.hasRoute) != 0
let lengthOffset = 12
let payloadLength: Int
@@ -80,6 +81,15 @@ struct NotificationStreamAssembler {
var frameLength = framePrefix + payloadLength
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
if hasSignature { frameLength += BinaryProtocol.signatureSize }
if hasRoute {
let routeCountOffset = framePrefix + (hasRecipient ? BinaryProtocol.recipientIDSize : 0)
let routeCountIndex = buffer.startIndex + routeCountOffset
guard buffer.count > routeCountOffset else { break }
let routeCount = Int(buffer[routeCountIndex])
frameLength += 1 + (routeCount * BinaryProtocol.senderIDSize)
}
if isCompressed {
let rawLengthFieldBytes = (version == 2) ? 4 : 2
if payloadLength < rawLengthFieldBytes {
@@ -7,6 +7,7 @@
//
import BitLogger
import BitFoundation
import Foundation
import SwiftUI
+8
View File
@@ -1,3 +1,4 @@
import BitFoundation
import Foundation
import Combine
@@ -58,6 +59,10 @@ protocol Transport: AnyObject {
// QR verification (optional for transports)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
// Pending file management (BCH-01-002: files held in memory until user accepts)
func acceptPendingFile(id: String) -> URL?
func declinePendingFile(id: String)
}
extension Transport {
@@ -70,6 +75,9 @@ extension Transport {
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
sendMessage(content, mentions: mentions)
}
func acceptPendingFile(id: String) -> URL? { nil }
func declinePendingFile(id: String) {}
}
protocol TransportPeerEventsDelegate: AnyObject {
+29 -5
View File
@@ -21,6 +21,7 @@ enum TransportConfig {
// Timers
static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes
static let networkNotificationCooldownSeconds: TimeInterval = 300 // 5 minutes
static let basePublicFlushInterval: TimeInterval = 0.08 // ~12.5 fps batching
// BLE duty/announce/connect
@@ -96,11 +97,12 @@ enum TransportConfig {
// Keep scanning fully ON when we saw traffic very recently
static let bleRecentTrafficForceScanSeconds: TimeInterval = 10.0
static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05
static let bleExpectedWritePerFragmentMs: Int = 8
static let bleExpectedWriteMaxMs: Int = 2000
// Faster fragment pacing; use slightly tighter spacing for directed trains
static let bleFragmentSpacingMs: Int = 5
static let bleFragmentSpacingDirectedMs: Int = 4
static let bleExpectedWritePerFragmentMs: Int = 20
static let bleExpectedWriteMaxMs: Int = 5000
// Fragment pacing: Conservative spacing to prevent BLE buffer overflow
// Aggressive pacing causes packet loss; needs 25-30ms between fragments for reliable delivery
static let bleFragmentSpacingMs: Int = 30
static let bleFragmentSpacingDirectedMs: Int = 25
static let bleAnnounceIntervalSeconds: TimeInterval = 4.0
static let bleDutyOnDurationDense: TimeInterval = 3.0
static let bleDutyOffDurationDense: TimeInterval = 15.0
@@ -162,6 +164,14 @@ enum TransportConfig {
static let blePostAnnounceDelaySeconds: TimeInterval = 0.4
static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.15
// BCH-01-004: Rate-limiting for subscription-triggered announces
// Prevents rapid enumeration attacks by rate-limiting announce responses
static let bleSubscriptionRateLimitMinSeconds: TimeInterval = 2.0 // Minimum interval between announces per central
static let bleSubscriptionRateLimitBackoffFactor: Double = 2.0 // Exponential backoff multiplier
static let bleSubscriptionRateLimitMaxBackoffSeconds: TimeInterval = 30.0 // Maximum backoff period
static let bleSubscriptionRateLimitWindowSeconds: TimeInterval = 60.0 // Window for tracking subscription attempts
static let bleSubscriptionRateLimitMaxAttempts: Int = 5 // Max attempts before extended cooldown
// Store-and-forward for directed packets at relays
static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0
@@ -203,4 +213,18 @@ enum TransportConfig {
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
// Gossip Sync Configuration
static let syncSeenCapacity: Int = 1000
static let syncGCSMaxBytes: Int = 400
static let syncGCSTargetFpr: Double = 0.01
static let syncMaxMessageAgeSeconds: TimeInterval = 900
static let syncMaintenanceIntervalSeconds: TimeInterval = 30.0
static let syncStalePeerCleanupIntervalSeconds: TimeInterval = 60.0
static let syncStalePeerTimeoutSeconds: TimeInterval = 60.0
static let syncFragmentCapacity: Int = 600
static let syncFileTransferCapacity: Int = 200
static let syncFragmentIntervalSeconds: TimeInterval = 30.0
static let syncFileTransferIntervalSeconds: TimeInterval = 60.0
static let syncMessageIntervalSeconds: TimeInterval = 15.0
}
@@ -7,6 +7,8 @@
//
import BitLogger
import BitFoundation
import Nostr
import Foundation
import Combine
import SwiftUI
+29 -2
View File
@@ -1,4 +1,6 @@
import Foundation
import BitLogger
import BitFoundation
// Gossip-based sync manager using on-demand GCS filters
final class GossipSyncManager {
@@ -6,6 +8,7 @@ final class GossipSyncManager {
func sendPacket(_ packet: BitchatPacket)
func sendPacket(to peerID: PeerID, packet: BitchatPacket)
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
func getConnectedPeers() -> [PeerID]
}
private struct PacketStore {
@@ -74,6 +77,7 @@ final class GossipSyncManager {
private let myPeerID: PeerID
private let config: Config
private let requestSyncManager: RequestSyncManager
weak var delegate: Delegate?
// Storage: broadcast packets by type, and latest announce per sender
@@ -88,9 +92,10 @@ final class GossipSyncManager {
private var lastStalePeerCleanup: Date = .distantPast
private var syncSchedules: [SyncSchedule] = []
init(myPeerID: PeerID, config: Config = Config()) {
init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager) {
self.myPeerID = myPeerID
self.config = config
self.requestSyncManager = requestSyncManager
var schedules: [SyncSchedule] = []
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
@@ -202,6 +207,19 @@ final class GossipSyncManager {
}
}
private func sendPeriodicSync(for types: SyncTypeFlags) {
// Unicast sync to connected peers to allow RSR attribution
if let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty {
SecureLogger.debug("Sending periodic sync to \(connectedPeers.count) connected peers", category: .sync)
for peerID in connectedPeers {
sendRequestSync(to: peerID, types: types)
}
} else {
// Fallback to broadcast (discovery phase)
sendRequestSync(for: types)
}
}
private func sendRequestSync(for types: SyncTypeFlags) {
let payload = buildGcsPayload(for: types)
let pkt = BitchatPacket(
@@ -218,6 +236,9 @@ final class GossipSyncManager {
}
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) {
// Register the request for RSR validation
requestSyncManager.registerRequest(to: peerID)
let payload = buildGcsPayload(for: types)
var recipient = Data()
var temp = peerID.id
@@ -262,6 +283,7 @@ final class GossipSyncManager {
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
@@ -274,6 +296,7 @@ final class GossipSyncManager {
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
@@ -286,6 +309,7 @@ final class GossipSyncManager {
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
@@ -298,6 +322,7 @@ final class GossipSyncManager {
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
@@ -366,11 +391,13 @@ final class GossipSyncManager {
private func performPeriodicMaintenance(now: Date = Date()) {
cleanupExpiredMessages()
cleanupStaleAnnouncementsIfNeeded(now: now)
requestSyncManager.cleanup() // Cleanup expired sync requests
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
sendRequestSync(for: syncSchedules[index].types)
sendPeriodicSync(for: syncSchedules[index].types)
}
}
}
+86
View File
@@ -0,0 +1,86 @@
//
// RequestSyncManager.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import BitLogger
import BitFoundation
/// Manages outgoing sync requests and validates incoming responses.
///
/// Allows attributing RSR (Request-Sync Response) packets to specific peers
/// that we have actively requested sync from.
final class RequestSyncManager {
private let queue = DispatchQueue(label: "request.sync.manager", attributes: .concurrent)
private var pendingRequests: [PeerID: TimeInterval] = [:]
private let responseWindow: TimeInterval
private let now: () -> TimeInterval
init(
responseWindow: TimeInterval = 30.0,
now: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 }
) {
self.responseWindow = responseWindow
self.now = now
}
/// Register that we are sending a sync request to a peer.
/// - Parameter peerID: The peer we are requesting sync from
func registerRequest(to peerID: PeerID) {
let now = self.now()
queue.async(flags: .barrier) {
SecureLogger.debug("Registering sync request to \(peerID.id.prefix(8))", category: .sync)
self.pendingRequests[peerID] = now
}
}
/// Check if a packet from a peer is a valid response to a sync request.
///
/// - Parameters:
/// - peerID: The sender of the packet
/// - isRSR: Whether the packet is marked as a Request-Sync Response
/// - Returns: true if we have a pending request for this peer and the window is open
func isValidResponse(from peerID: PeerID, isRSR: Bool) -> Bool {
guard isRSR else { return false }
return queue.sync {
guard let requestTime = pendingRequests[peerID] else {
SecureLogger.warning("Received unsolicited RSR packet from \(peerID.id.prefix(8))", category: .security)
return false
}
let now = self.now()
if now - requestTime > responseWindow {
SecureLogger.warning("Received RSR packet from \(peerID.id.prefix(8))… outside of response window", category: .security)
// We don't remove here because we might receive multiple packets for one request
return false
}
return true
}
}
/// Periodic cleanup of expired requests
func cleanup() {
let now = self.now()
queue.async(flags: .barrier) {
let originalCount = self.pendingRequests.count
self.pendingRequests = self.pendingRequests.filter { _, timestamp in
now - timestamp <= self.responseWindow
}
let removed = originalCount - self.pendingRequests.count
if removed > 0 {
SecureLogger.debug("Cleaned up \(removed) expired sync requests", category: .sync)
}
}
}
var debugPendingRequestCount: Int {
queue.sync { pendingRequests.count }
}
}
-22
View File
@@ -1,22 +0,0 @@
//
// Data+SHA256.swift
// bitchat
//
// Created by Islam on 26/09/2025.
//
import struct Foundation.Data
import struct CryptoKit.SHA256
extension Data {
/// Returns the hex representation of SHA256 hash
func sha256Fingerprint() -> String {
// Implementation matches existing fingerprint generation in NoiseEncryptionService
sha256Hash().hexEncodedString()
}
/// Returns the SHA256 hash wrapped in Data
func sha256Hash() -> Data {
Data(SHA256.hash(data: self))
}
}
+6 -6
View File
@@ -21,9 +21,7 @@ struct InputValidator {
/// Rejects strings containing control characters to prevent potential security issues
/// and UI rendering problems. This strict approach ensures data integrity at input time.
static func validateUserString(_ string: String, maxLength: Int) -> String? {
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard trimmed.count <= maxLength else { return nil }
guard let trimmed = string.trimmedOrNilIfEmpty, trimmed.count <= maxLength else { return nil }
// Reject control characters outright instead of rewriting the string.
// This prevents injection attacks and ensures consistent UI rendering.
@@ -52,11 +50,13 @@ struct InputValidator {
// MessageType/NoisePayloadType enums; keeping validator free of stale lists.
/// Validates timestamp is reasonable (not too far in past or future)
/// BCH-01-011: Reduced from ±1 hour to ±5 minutes to limit replay attack window
static func validateTimestamp(_ timestamp: Date) -> Bool {
let now = Date()
let oneHourAgo = now.addingTimeInterval(-3600)
let oneHourFromNow = now.addingTimeInterval(3600)
return timestamp >= oneHourAgo && timestamp <= oneHourFromNow
// 5 minutes = 300 seconds (industry standard for replay protection)
let fiveMinutesAgo = now.addingTimeInterval(-300)
let fiveMinutesFromNow = now.addingTimeInterval(300)
return timestamp >= fiveMinutesAgo && timestamp <= fiveMinutesFromNow
}
}
+28 -21
View File
@@ -5,7 +5,7 @@ import Foundation
/// Thread-safe deduplicator with LRU eviction and time-based expiry.
/// Used for both message ID deduplication (network layer) and content key deduplication (UI layer).
final class MessageDeduplicator {
private struct Entry {
private struct Entry: Equatable {
let id: String
let timestamp: Date
}
@@ -31,18 +31,20 @@ final class MessageDeduplicator {
self.maxCount = maxCount
}
/// Check if message is duplicate and add if not
/// Check if message is duplicate and add if not.
/// - Parameter id: The message identifier to check.
/// - Returns: `true` if the message was already seen, `false` otherwise.
func isDuplicate(_ id: String) -> Bool {
lock.lock()
defer { lock.unlock() }
cleanupOldEntries()
let now = Date()
cleanupOldEntries(before: now.addingTimeInterval(-maxAge))
if lookup[id] != nil {
return true
}
let now = Date()
entries.append(Entry(id: id, timestamp: now))
lookup[id] = now
trimIfNeeded()
@@ -89,18 +91,22 @@ final class MessageDeduplicator {
}
private func trimIfNeeded() {
// Soft-cap and advance head by a chunk to avoid O(n) shifting
if (entries.count - head) > maxCount {
let removeCount = min(100, entries.count - head)
for i in head..<(head + removeCount) {
lookup.removeValue(forKey: entries[i].id)
}
head += removeCount
// Periodically compact to reclaim memory
if head > entries.count / 2 {
entries.removeFirst(head)
head = 0
}
let activeCount = entries.count - head
guard activeCount > maxCount else { return }
// Remove down to 75% of maxCount for better amortization
let targetCount = (maxCount * 3) / 4
let removeCount = activeCount - targetCount
for i in head..<(head + removeCount) {
lookup.removeValue(forKey: entries[i].id)
}
head += removeCount
// Compact when head exceeds half the array to reclaim memory
if head > entries.count / 2 {
entries.removeFirst(head)
head = 0
}
}
@@ -114,24 +120,25 @@ final class MessageDeduplicator {
lookup.removeAll()
}
/// Periodic cleanup
/// Periodic cleanup of expired entries and memory optimization.
func cleanup() {
lock.lock()
defer { lock.unlock() }
cleanupOldEntries()
cleanupOldEntries(before: Date().addingTimeInterval(-maxAge))
if entries.capacity > maxCount * 2 {
// Shrink capacity if significantly oversized
if entries.capacity > maxCount * 2 && entries.count < maxCount {
entries.reserveCapacity(maxCount)
}
}
private func cleanupOldEntries() {
let cutoff = Date().addingTimeInterval(-maxAge)
private func cleanupOldEntries(before cutoff: Date) {
while head < entries.count, entries[head].timestamp < cutoff {
lookup.removeValue(forKey: entries[head].id)
head += 1
}
// Compact when head exceeds half the array
if head > 0 && head > entries.count / 2 {
entries.removeFirst(head)
head = 0
@@ -1,4 +1,5 @@
import Foundation
import BitFoundation
/// Resolves a stable display name for peers, adding a short suffix when collisions exist.
struct PeerDisplayNameResolver {
+44
View File
@@ -0,0 +1,44 @@
//
// SystemSettings.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
enum SystemSettings {
case bluetooth
case location
case microphone
#if os(macOS)
private static let baseURL = "x-apple.systempreferences:com.apple.preference.security"
private var macPrivacyAnchor: String {
switch self {
case .bluetooth: "Privacy_Bluetooth"
case .location: "Privacy_LocationServices"
case .microphone: "Privacy_Microphone"
}
}
#endif
func open() {
#if os(iOS)
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
#elseif os(macOS)
let urlString = "\(Self.baseURL)?\(macPrivacyAnchor)"
if let url = URL(string: urlString) {
NSWorkspace.shared.open(url)
}
#endif
}
}
+140 -120
View File
@@ -78,6 +78,8 @@
///
import BitLogger
import BitFoundation
import Nostr
import Foundation
import SwiftUI
import Combine
@@ -144,10 +146,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects
@Published var nickname: String = "" {
didSet {
// Trim whitespace whenever nickname is set
let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
// Trim whitespace whenever nickname is set; whitespace-only becomes ""
let trimmed = nickname.trimmedOrNilIfEmpty ?? ""
if trimmed != nickname {
nickname = trimmed
return
}
// Update mesh service nickname if it's initialized
if !meshService.myPeerID.isEmpty {
@@ -279,8 +282,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname
// Show Tor status once per app launch
var torStatusAnnounced = false
private var torProgressCancellable: AnyCancellable?
private var lastTorProgressAnnounced = -1
// Track whether a Tor restart is pending so we only announce
// "tor restarted" after an actual restart, not the first launch.
var torRestartPending: Bool = false
@@ -323,8 +324,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
)
// Channel activity tracking for background nudges
var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time
private var lastPublicActivityNotifyAt: [String: Date] = [:]
private let channelInactivityThreshold: TimeInterval = TransportConfig.uiChannelInactivityThresholdSeconds
// Geohash participant tracker
let participantTracker = GeohashParticipantTracker(activityCutoff: -TransportConfig.uiRecentCutoffFiveMinutesSeconds)
// Participants who indicated they teleported (by tag in their events)
@@ -448,7 +447,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
self.deduplicationService = MessageDeduplicationService()
// Wire up dependencies
self.commandProcessor.chatViewModel = self
self.commandProcessor.contextProvider = self
self.participantTracker.configure(context: self)
// Subscribe to privateChatManager changes to trigger UI updates
@@ -503,8 +502,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
addGeohashOnlySystemMessage(
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
)
// Suppress incremental Tor progress messages
torProgressCancellable = nil
} else if !TorManager.shared.torEnforced && !torStatusAnnounced {
torStatusAnnounced = true
addGeohashOnlySystemMessage(
@@ -631,8 +628,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
object: nil
)
// Listen for delivery acknowledgments
// When app becomes active, send read receipts for visible messages
#if os(macOS)
NotificationCenter.default.addObserver(
@@ -755,8 +750,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
private func loadNickname() {
if let savedNickname = userDefaults.string(forKey: nicknameKey) {
// Trim whitespace when loading
nickname = savedNickname.trimmingCharacters(in: .whitespacesAndNewlines)
nickname = savedNickname.trimmed
} else {
nickname = "anon\(Int.random(in: 1000...9999))"
saveNickname()
@@ -772,20 +766,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
}
func validateAndSaveNickname() {
// Trim whitespace from nickname
let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
// Check if nickname is empty after trimming
if trimmed.isEmpty {
nickname = "anon\(Int.random(in: 1000...9999))"
} else {
nickname = trimmed
}
nickname = nickname.trimmedOrNilIfEmpty ?? "anon\(Int.random(in: 1000...9999))"
saveNickname()
}
// MARK: - Favorites Management
// MARK: - Blocked Users Management (Delegated to PeerStateManager)
@@ -1006,8 +990,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
@MainActor
func sendMessage(_ content: String) {
// Ignore messages that are empty or whitespace-only to prevent blank lines
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
// Check for commands
if content.hasPrefix("/") {
@@ -1468,56 +1451,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
selectedPrivateChatFingerprint = nil
}
// MARK: - Nostr Message Handling
@MainActor
@objc private func handleNostrMessage(_ notification: Notification) {
guard let message = notification.userInfo?["message"] as? BitchatMessage else { return }
// Store the Nostr pubkey if provided (for messages from unknown senders)
if let nostrPubkey = notification.userInfo?["nostrPubkey"] as? String,
let senderPeerID = message.senderPeerID {
// Store mapping for read receipts
nostrKeyMapping[senderPeerID] = nostrPubkey
}
// Process the Nostr message through the same flow as Bluetooth messages
didReceiveMessage(message)
}
@objc private func handleDeliveryAcknowledgment(_ notification: Notification) {
guard let messageId = notification.userInfo?["messageId"] as? String else { return }
// Update the delivery status for the message
if let index = messages.firstIndex(where: { $0.id == messageId }) {
// Update delivery status to delivered
messages[index].deliveryStatus = DeliveryStatus.delivered(to: "nostr", at: Date())
// Schedule UI update for delivery status
// UI will update automatically
}
// Also update in private chats if it's a private message
for (peerID, chatMessages) in privateChats {
if let index = chatMessages.firstIndex(where: { $0.id == messageId }) {
privateChats[peerID]?[index].deliveryStatus = DeliveryStatus.delivered(to: "nostr", at: Date())
// UI will update automatically
break
}
}
}
@objc private func handleNostrReadReceipt(_ notification: Notification) {
guard let receipt = notification.userInfo?["receipt"] as? ReadReceipt else { return }
SecureLogger.info("📖 Handling read receipt for message \(receipt.originalMessageID) from Nostr", category: .session)
// Process the read receipt through the same flow as Bluetooth read receipts
didReceiveReadReceipt(receipt)
}
@MainActor
@objc private func handlePeerStatusUpdate(_ notification: Notification) {
// Update private chat peer if needed when peer status changes
@@ -1868,7 +1801,15 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
}
}
}
func getMessages(for peerID: PeerID?) -> [BitchatMessage] {
if let peerID {
return getPrivateChatMessages(for: peerID)
} else {
return messages
}
}
@MainActor
func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] {
var combined: [BitchatMessage] = []
@@ -2027,7 +1968,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds
// Reinitialize Nostr relay manager with new identity
nostrRelayManager = NostrRelayManager()
nostrRelayManager = NostrRelayManager(dependencies: .live())
setupNostrMessageHandling()
nostrRelayManager?.connect()
}
@@ -2055,13 +1996,42 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
} catch {
SecureLogger.error("Failed to clear media files during panic: \(error)", category: .session)
}
// BCH-01-013: Clear iOS app switcher snapshots
// These are stored in Library/Caches/Snapshots/<bundle_id>/
#if os(iOS)
Self.clearAppSwitcherSnapshots()
#endif
}
// Force immediate UI update for panic mode
// UI updates immediately - no flushing needed
}
/// BCH-01-013: Clear iOS app switcher snapshots during panic mode
/// iOS stores preview screenshots in Library/Caches/Snapshots/<bundle_id>/
/// These could reveal sensitive information visible in the app at the time
#if os(iOS)
private nonisolated static func clearAppSwitcherSnapshots() {
do {
let cacheDir = try FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
let snapshotsDir = cacheDir.appendingPathComponent("Snapshots", isDirectory: true)
// Clear all snapshots (iOS stores them in subdirectories by bundle ID and scene)
if FileManager.default.fileExists(atPath: snapshotsDir.path) {
let contents = try FileManager.default.contentsOfDirectory(at: snapshotsDir, includingPropertiesForKeys: nil)
for item in contents {
try FileManager.default.removeItem(at: item)
}
SecureLogger.info("🗑️ Cleared app switcher snapshots during panic clear", category: .session)
}
} catch {
SecureLogger.error("Failed to clear app switcher snapshots: \(error)", category: .session)
}
}
#endif
// MARK: - Autocomplete
func updateAutocomplete(for text: String, cursorPosition: Int) {
@@ -3031,7 +3001,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
Task { @MainActor in
// Early validation
guard !isMessageBlocked(message) else { return }
guard !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || message.isPrivate else { return }
guard !message.content.trimmed.isEmpty || message.isPrivate else { return }
// Route to appropriate handler
if message.isPrivate {
@@ -3046,46 +3016,91 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
}
}
/// Find message index trying both short (16-hex) and long (64-hex) peer ID formats.
/// Returns the peer ID where the message was found and its index, or nil if not found.
private func findMessageIndex(messageID: String, peerID: PeerID) -> (peerID: PeerID, index: Int)? {
// Try direct lookup first
if let messages = privateChats[peerID],
let idx = messages.firstIndex(where: { $0.id == messageID }) {
return (peerID, idx)
}
// Try with full noise key if peerID is short (16 hex chars)
if peerID.bare.count == 16,
let peer = unifiedPeerService.getPeer(by: peerID),
!peer.noisePublicKey.isEmpty {
let longID = PeerID(hexData: peer.noisePublicKey)
if let messages = privateChats[longID],
let idx = messages.firstIndex(where: { $0.id == messageID }) {
return (longID, idx)
}
}
// Try with short form if peerID is long (64 hex = noise key)
if peerID.bare.count == 64 {
let shortID = peerID.toShort()
if let messages = privateChats[shortID],
let idx = messages.firstIndex(where: { $0.id == messageID }) {
return (shortID, idx)
}
}
return nil
}
// Low-level BLE events
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {
Task { @MainActor in
switch type {
case .privateMessage:
guard let pm = PrivateMessagePacket.decode(from: payload) else { return }
// BCH-01-012: Check blocking before processing private message to prevent notification bypass
if isPeerBlocked(peerID) {
SecureLogger.debug("🚫 Ignoring Noise payload from blocked peer: \(peerID)", category: .security)
return
}
let senderName = unifiedPeerService.getPeer(by: peerID)?.nickname ?? "Unknown"
let pmMentions = parseMentions(from: pm.content)
let msg = BitchatMessage(
id: pm.messageID,
sender: senderName,
content: pm.content,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: nickname,
senderPeerID: peerID,
mentions: pmMentions.isEmpty ? nil : pmMentions
)
let pmMentions = parseMentions(from: pm.content)
let msg = BitchatMessage(
id: pm.messageID,
sender: senderName,
content: pm.content,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: nickname,
senderPeerID: peerID,
mentions: pmMentions.isEmpty ? nil : pmMentions
)
handlePrivateMessage(msg)
// Send delivery ACK back over BLE
meshService.sendDeliveryAck(for: pm.messageID, to: peerID)
case .delivered:
guard let messageID = String(data: payload, encoding: .utf8) else { return }
if let name = unifiedPeerService.getPeer(by: peerID)?.nickname {
if let messages = privateChats[peerID], let idx = messages.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[idx].deliveryStatus = .delivered(to: name, at: Date())
objectWillChange.send()
}
}
guard let name = unifiedPeerService.getPeer(by: peerID)?.nickname,
let (foundPeerID, idx) = findMessageIndex(messageID: messageID, peerID: peerID) else { return }
// Don't downgrade from .read to .delivered
if case .read = privateChats[foundPeerID]?[idx].deliveryStatus { return }
privateChats[foundPeerID]?[idx].deliveryStatus = .delivered(to: name, at: Date())
objectWillChange.send()
case .readReceipt:
guard let messageID = String(data: payload, encoding: .utf8) else { return }
if let name = unifiedPeerService.getPeer(by: peerID)?.nickname {
if let messages = privateChats[peerID], let idx = messages.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[idx].deliveryStatus = .read(by: name, at: Date())
objectWillChange.send()
}
guard let name = unifiedPeerService.getPeer(by: peerID)?.nickname,
let (foundPeerID, idx) = findMessageIndex(messageID: messageID, peerID: peerID) else { return }
// Explicitly unwrap and re-assign to ensure the @Published setter is called
if let messages = privateChats[foundPeerID], idx < messages.count {
messages[idx].deliveryStatus = .read(by: name, at: Date())
privateChats[foundPeerID] = messages
privateChatManager.objectWillChange.send()
objectWillChange.send()
}
case .verifyChallenge:
// Parse and respond
@@ -3159,7 +3174,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
Task { @MainActor in
let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines)
let normalized = content.trimmed
let publicMentions = parseMentions(from: normalized)
let msg = BitchatMessage(
id: messageID,
@@ -3332,18 +3347,25 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
self.scheduleNetworkEmptyTimer()
} else {
self.invalidateNetworkEmptyTimer()
// Trim out peers we no longer observe before comparing for new arrivals
self.recentlySeenPeers.formIntersection(meshPeerSet)
// Don't trim recentlySeenPeers here - let timers handle cleanup.
// Trimming immediately causes peers to be treated as "new" when they
// briefly drop and reconnect, triggering notification floods.
let newPeers = meshPeerSet.subtracting(self.recentlySeenPeers)
if !newPeers.isEmpty {
self.lastNetworkNotificationTime = Date()
self.recentlySeenPeers.formUnion(newPeers)
NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count)
SecureLogger.info(
"👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))",
category: .session
)
// Rate limit: max one notification per 5 minutes
let cooldown = TransportConfig.networkNotificationCooldownSeconds
if Date().timeIntervalSince(self.lastNetworkNotificationTime) >= cooldown {
// Only mark peers as seen when we actually notify about them
// This ensures peers arriving during cooldown will be included in the next notification
self.recentlySeenPeers.formUnion(newPeers)
self.lastNetworkNotificationTime = Date()
NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count)
SecureLogger.info(
"👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))",
category: .session
)
}
self.scheduleNetworkResetTimer()
}
}
@@ -3760,10 +3782,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
// Removed background nudge notification for generic "new chats!"
// Append via batching buffer (skip empty content) with simple dedup by ID
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if !messages.contains(where: { $0.id == finalMessage.id }) {
publicMessagePipeline.enqueue(finalMessage)
}
if !finalMessage.content.trimmed.isEmpty, !messages.contains(where: { $0.id == finalMessage.id }) {
publicMessagePipeline.enqueue(finalMessage)
}
}
@@ -5,9 +5,11 @@
// Geohash and Nostr logic for ChatViewModel
//
import Nostr
import Foundation
import Combine
import BitLogger
import BitFoundation
import SwiftUI
import Tor
@@ -56,7 +58,9 @@ extension ChatViewModel {
}
func subscribeNostrEvent(_ event: NostrEvent) {
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue,
guard event.isValidSignature() else { return }
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
!deduplicationService.hasProcessedNostrEvent(event.id)
else {
return
@@ -75,7 +79,7 @@ extension ChatViewModel {
}
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines)
let nick = nickTag[1].trimmed
geoNicknames[event.pubkey.lowercased()] = nick
}
@@ -86,6 +90,11 @@ extension ChatViewModel {
// Update participants last-seen for this pubkey
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
// If presence heartbeat (Kind 20001), stop here - no content to display
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
return
}
// Track teleported tag (only our format ["t","teleport"]) for icon state
let hasTeleportTag = event.tags.contains(where: { tag in
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
@@ -108,8 +117,8 @@ extension ChatViewModel {
}
let senderName = displayNameForNostrPubkey(event.pubkey)
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
let content = event.content.trimmed
// Clamp future timestamps to now to avoid future-dated messages skewing order
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let timestamp = min(rawTs, Date())
@@ -124,20 +133,28 @@ extension ChatViewModel {
mentions: mentions.isEmpty ? nil : mentions
)
Task { @MainActor in
// BCH-01-012: Check blocking before any notifications
// handlePublicMessage has its own blocking check but returns silently,
// so we must also guard checkForMentions to prevent notification bypass
let isBlocked = identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
handlePublicMessage(msg)
checkForMentions(msg)
sendHapticFeedback(for: msg)
// Only check mentions and send haptic if sender is not blocked
if !isBlocked {
checkForMentions(msg)
sendHapticFeedback(for: msg)
}
}
}
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard giftWrap.isValidSignature() else { return }
guard !deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return }
deduplicationService.recordNostrEvent(giftWrap.id)
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id),
content.hasPrefix("bitchat1:"),
let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
let packet = BitchatPacket.from(packetData),
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
packet.type == MessageType.noiseEncrypted.rawValue,
let noisePayload = NoisePayload.decode(packet.payload)
else {
@@ -177,7 +194,7 @@ extension ChatViewModel {
case .mesh:
refreshVisibleMessages(from: .mesh)
// Debug: log if any empty messages are present
let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
let emptyMesh = messages.filter { $0.content.trimmed.isEmpty }.count
if emptyMesh > 0 {
SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session)
}
@@ -239,8 +256,10 @@ extension ChatViewModel {
}
func handleNostrEvent(_ event: NostrEvent) {
// Only handle ephemeral kind 20000 with matching tag
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
guard event.isValidSignature() else { return }
// Only handle ephemeral kind 20000 or presence kind 20001 with matching tag
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
// Deduplicate
if deduplicationService.hasProcessedNostrEvent(event.id) { return }
@@ -250,6 +269,11 @@ extension ChatViewModel {
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session)
// If this pubkey is blocked, skip mapping, participants, and timeline
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
return
}
// Track teleport tag for participants only our format ["t", "teleport"]
let hasTeleportTag: Bool = event.tags.contains { tag in
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
@@ -273,6 +297,9 @@ extension ChatViewModel {
}
}
// Update participants last-seen for this pubkey
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
// Skip only very recent self-echo from relay; include older self events for hydration
if isSelf {
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
@@ -283,28 +310,26 @@ extension ChatViewModel {
// Cache nickname from tag if present
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines)
geoNicknames[event.pubkey.lowercased()] = nick
}
// If this pubkey is blocked, skip mapping, participants, and timeline
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
return
geoNicknames[event.pubkey.lowercased()] = nickTag[1].trimmed
}
// Store mapping for geohash DM initiation
nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
// Update participants last-seen for this pubkey
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
// If presence heartbeat (Kind 20001), stop here - no content to display
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
return
}
let senderName = displayNameForNostrPubkey(event.pubkey)
let content = event.content
// If this is a teleport presence event (no content), don't add to timeline
if let teleTag = event.tags.first(where: { $0.first == "t" }), teleTag.count >= 2, (teleTag[1] == "teleport"),
content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if let teleTag = event.tags.first(where: { $0.first == "t" }),
teleTag.count >= 2,
teleTag[1] == "teleport",
content.trimmed.isEmpty {
return
}
@@ -346,6 +371,7 @@ extension ChatViewModel {
}
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard giftWrap.isValidSignature() else { return }
if deduplicationService.hasProcessedNostrEvent(giftWrap.id) {
return
}
@@ -359,9 +385,7 @@ extension ChatViewModel {
SecureLogger.debug("GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", category: .session)
guard content.hasPrefix("bitchat1:"),
let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
let packet = BitchatPacket.from(packetData),
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),
packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload)
else {
@@ -464,7 +488,9 @@ extension ChatViewModel {
}
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
guard event.isValidSignature() else { return }
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
// Compute current participant count (5-minute window) BEFORE updating with this event
let existingCount = participantTracker.participantCount(for: gh)
@@ -473,9 +499,8 @@ extension ChatViewModel {
participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh)
// Notify only on rising-edge: previously zero people, now someone sends a chat
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty else { return }
guard let content = event.content.trimmedOrNilIfEmpty else { return }
// Respect geohash blocks
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
@@ -580,6 +605,7 @@ extension ChatViewModel {
}
func processNostrMessage(_ giftWrap: NostrEvent) async {
guard giftWrap.isValidSignature() else { return }
guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
do {
@@ -597,8 +623,7 @@ extension ChatViewModel {
// Check if it's a BitChat packet embedded in the content (bitchat1:...)
if content.hasPrefix("bitchat1:") {
guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
let packet = BitchatPacket.from(packetData) else {
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content) else {
SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session)
return
}
@@ -609,24 +634,23 @@ extension ChatViewModel {
// Stable target ID if we know Noise key; otherwise temporary Nostr-based peer
let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey)
if packet.type == MessageType.noiseEncrypted.rawValue {
if let payload = NoisePayload.decode(packet.payload) {
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
// Store Nostr mapping
await MainActor.run {
nostrKeyMapping[targetPeerID] = senderPubkey
// Handle packet types
switch payload.type {
case .privateMessage:
handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: targetPeerID, id: currentIdentity, messageTimestamp: messageTimestamp)
case .delivered:
handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .readReceipt:
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .verifyChallenge, .verifyResponse:
break
}
if packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload) {
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
// Store Nostr mapping
await MainActor.run {
nostrKeyMapping[targetPeerID] = senderPubkey
// Handle packet types
switch payload.type {
case .privateMessage:
handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: targetPeerID, id: currentIdentity, messageTimestamp: messageTimestamp)
case .delivered:
handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .readReceipt:
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .verifyChallenge, .verifyResponse:
break
}
}
}
@@ -779,6 +803,19 @@ extension ChatViewModel {
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
}
private static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
guard content.hasPrefix("bitchat1:") else { return nil }
let encoded = String(content.dropFirst("bitchat1:".count))
let maxBytes = FileTransferLimits.maxFramedFileBytes
// Base64url length upper bound for maxBytes (padded length; unpadded is <= this).
let maxEncoded = ((maxBytes + 2) / 3) * 4
guard encoded.count <= maxEncoded else { return nil }
guard let packetData = Self.base64URLDecode(encoded),
packetData.count <= maxBytes
else { return nil }
return BitchatPacket.from(packetData)
}
// MARK: - Geohash Nickname Resolution (for /block in geohash)
func nostrPubkeyForDisplayName(_ name: String) -> String? {
@@ -5,9 +5,11 @@
// Private chat and media transfer logic for ChatViewModel
//
import Nostr
import Foundation
import Combine
import BitLogger
import BitFoundation
import SwiftUI
extension ChatViewModel {
@@ -319,7 +321,7 @@ extension ChatViewModel {
}
let targetPeer = selectedPrivateChatPeer
let message = enqueueMediaMessage(content: "[voice] \(url.lastPathComponent)", targetPeer: targetPeer)
let message = enqueueMediaMessage(content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)", targetPeer: targetPeer)
let messageID = message.id
let transferId = makeTransferID(messageID: messageID)
@@ -364,6 +366,36 @@ extension ChatViewModel {
}
}
#if os(iOS)
func processThenSendImage(_ image: UIImage?) {
guard let image else { return }
Task.detached {
do {
let processedURL = try ImageUtils.processImage(image)
await MainActor.run {
self.sendImage(from: processedURL)
}
} catch {
SecureLogger.error("Image processing failed: \(error)", category: .session)
}
}
}
#elseif os(macOS)
func processThenSendImage(from url: URL?) {
guard let url else { return }
Task.detached {
do {
let processedURL = try ImageUtils.processImage(at: url)
await MainActor.run {
self.sendImage(from: processedURL)
}
} catch {
SecureLogger.error("Image processing failed: \(error)", category: .session)
}
}
}
#endif
@MainActor
func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) {
guard canSendMediaInCurrentContext else {
@@ -398,7 +430,7 @@ extension ChatViewModel {
)
guard packet.encode() != nil else { throw MediaSendError.encodingFailed }
await MainActor.run {
let message = self.enqueueMediaMessage(content: "[image] \(outputURL.lastPathComponent)", targetPeer: targetPeer)
let message = self.enqueueMediaMessage(content: "\(MimeType.Category.image.messagePrefix)\(outputURL.lastPathComponent)", targetPeer: targetPeer)
let messageID = message.id
let transferId = self.makeTransferID(messageID: messageID)
self.registerTransfer(transferId: transferId, messageID: messageID)
@@ -517,20 +549,19 @@ extension ChatViewModel {
func cleanupLocalFile(forMessage message: BitchatMessage) {
// Check both outgoing and incoming directories for thorough cleanup
let prefixes = ["[voice] ", "[image] ", "[file] "]
let subdirs = ["voicenotes/outgoing", "voicenotes/incoming",
"images/outgoing", "images/incoming",
"files/outgoing", "files/incoming"]
guard let prefix = prefixes.first(where: { message.content.hasPrefix($0) }) else { return }
let rawFilename = String(message.content.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines)
guard !rawFilename.isEmpty, let base = try? applicationFilesDirectory() else { return }
// Security: Extract only the last path component to prevent directory traversal
let safeFilename = (rawFilename as NSString).lastPathComponent
guard !safeFilename.isEmpty && safeFilename != "." && safeFilename != ".." else { return }
let categories: [MimeType.Category] = [.audio, .image, .file]
guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }),
let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty,
let base = try? applicationFilesDirectory(),
// Security: Extract only the last path component to prevent directory traversal
let safeFilename = (rawFilename as NSString).lastPathComponent.nilIfEmpty,
safeFilename != "." && safeFilename != ".."
else {
return
}
// Try all possible locations (outgoing and incoming)
let subdirs = categories.flatMap { ["\($0.mediaDir)/outgoing", "\($0.mediaDir)/incoming"] }
for subdir in subdirs {
let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename)
@@ -740,15 +771,11 @@ extension ChatViewModel {
if isViewing {
// Mark read immediately if viewing
// Use router to send read receipt
// Use the incoming peerID directly - it has the established Noise session.
// Don't use PeerID(hexData: noiseKey) as that creates a 64-hex ID without a session.
// Use meshService directly (not messageRouter) so it queues if peer disconnects.
let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname)
if let key = noiseKey {
// Send via router to stable key if available (preferred for persistence/Nostr fallback)
messageRouter.sendReadReceipt(receipt, to: PeerID(hexData: key))
} else {
// Fallback to mesh direct
meshService.sendReadReceipt(receipt, to: peerID)
}
meshService.sendReadReceipt(receipt, to: peerID)
sentReadReceipts.insert(message.id)
} else {
// Notify
@@ -24,7 +24,7 @@ extension ChatViewModel {
}
}
}
@objc func handleTorWillRestart() {
Task { @MainActor in
self.torRestartPending = true
@@ -0,0 +1,142 @@
//
// VoiceRecordingViewModel.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
@MainActor
final class VoiceRecordingViewModel: ObservableObject {
enum State: Equatable {
case idle
case requestingPermission
case permissionDenied
case preparing
case recording(startDate: Date)
case error(message: String)
var isActive: Bool {
switch self {
case .preparing, .recording: true
case .idle, .requestingPermission, .permissionDenied, .error: false
}
}
var alertMessage: String {
switch self {
case .error(let message): message
case .permissionDenied: "Microphone access is required to record voice notes."
case .idle, .requestingPermission, .preparing, .recording: ""
}
}
fileprivate func duration(for date: Date) -> TimeInterval {
switch self {
case .idle, .requestingPermission, .preparing, .permissionDenied, .error: 0
case .recording(let startDate): date.timeIntervalSince(startDate)
}
}
}
var showAlert: Bool {
get {
switch state {
case .permissionDenied, .error: true
case .idle, .requestingPermission, .preparing, .recording: false
}
}
set {
if !newValue { state = .idle }
}
}
@Published private(set) var state = State.idle
func formattedDuration(for date: Date) -> String {
let clamped = max(0, state.duration(for: date))
let totalMilliseconds = Int(clamped * 1000)
let minutes = totalMilliseconds / 60_000
let seconds = (totalMilliseconds % 60_000) / 1_000
let centiseconds = (totalMilliseconds % 1_000) / 10
return String(format: "%02d:%02d.%02d", minutes, seconds, centiseconds)
}
func start(shouldShow: Bool) {
guard shouldShow, state == .idle else { return }
state = .requestingPermission
Task {
let granted = await VoiceRecorder.shared.requestPermission()
guard state == .requestingPermission else { return }
guard granted else {
state = .permissionDenied
return
}
state = .preparing
do {
try await VoiceRecorder.shared.startRecording()
guard state == .preparing else {
cancel()
return
}
state = .recording(startDate: Date())
} catch {
SecureLogger.error("Voice recording failed to start: \(error)", category: .session)
await VoiceRecorder.shared.cancelRecording()
guard state == .preparing else { return }
state = .error(message: "Could not start recording.")
}
}
}
func finish(completion: ((URL) -> Void)?) {
let previousState = state
switch previousState {
case .permissionDenied, .error:
return
case .idle, .requestingPermission, .preparing, .recording:
break
}
state = .idle
guard case .recording(let startDate) = previousState, let completion else {
Task { await VoiceRecorder.shared.cancelRecording() }
return
}
Task {
let finalDuration = Date().timeIntervalSince(startDate)
if let url = await VoiceRecorder.shared.stopRecording(),
isValidRecording(at: url, duration: finalDuration) {
completion(url)
} else {
guard state == .idle else { return }
state = .error(
message: finalDuration < VoiceRecorder.minRecordingDuration
? "Recording is too short."
: "Recording failed to save."
)
}
}
}
func cancel() {
finish(completion: nil)
}
private func isValidRecording(at url: URL, duration: TimeInterval) -> Bool {
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
let fileSize = attributes[.size] as? NSNumber,
fileSize.intValue > 0,
duration >= VoiceRecorder.minRecordingDuration {
return true
}
try? FileManager.default.removeItem(at: url)
return false
}
}
-22
View File
@@ -86,10 +86,6 @@ struct AppInfoView: View {
]
}
enum Warning {
static let title: LocalizedStringKey = "app_info.warning.title"
static let message: LocalizedStringKey = "app_info.warning.message"
}
}
var body: some View {
@@ -192,24 +188,6 @@ struct AppInfoView: View {
FeatureRow(info: Strings.Privacy.panic)
}
// Warning
VStack(alignment: .leading, spacing: 6) {
SectionHeader(Strings.Warning.title)
.foregroundColor(Color.red)
Text(Strings.Warning.message)
.font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(Color.red)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.top, 6)
.padding(.bottom, 16)
.padding(.horizontal)
.background(Color.red.opacity(0.1))
.cornerRadius(8)
.padding(.top)
}
.padding()
}
@@ -5,6 +5,7 @@
// Created by Islam on 29/10/2025.
//
import Nostr
import SwiftUI
struct CommandSuggestionsView: View {
@@ -76,7 +77,7 @@ struct CommandSuggestionsView: View {
let keychain = KeychainManager()
let viewModel = ChatViewModel(
keychain: keychain,
idBridge: NostrIdentityBridge(),
idBridge: NostrIdentityBridge(keychain: keychain),
identityManager: SecureIdentityStateManager(keychain)
)
+14 -2
View File
@@ -15,10 +15,22 @@ struct PaymentChipView: View {
enum PaymentType {
case cashu(String)
case lightning(String)
private static let cashuAllowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
private static func cashuURL(from link: String) -> URL? {
if let url = URL(string: link), url.scheme != nil {
return url
}
let enc = link.addingPercentEncoding(withAllowedCharacters: cashuAllowedCharacters) ?? link
return URL(string: "cashu:\(enc)")
}
var url: URL? {
switch self {
case .cashu(let link), .lightning(let link):
case .cashu(let link):
return Self.cashuURL(from: link)
case .lightning(let link):
return URL(string: link)
}
}
@@ -6,6 +6,7 @@
// For more information, see <https://unlicense.org>
//
import Nostr
import SwiftUI
struct TextMessageView: View {
@@ -13,7 +14,7 @@ struct TextMessageView: View {
@EnvironmentObject private var viewModel: ChatViewModel
let message: BitchatMessage
@Binding var expandedMessageIDs: Set<String>
@State private var expandedMessageIDs: Set<String> = []
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@@ -66,14 +67,12 @@ struct TextMessageView: View {
}
}
@available(macOS 14, iOS 17, *)
#Preview {
@Previewable @State var ids: Set<String> = []
let keychain = PreviewKeychainManager()
Group {
List {
TextMessageView(message: .preview, expandedMessageIDs: $ids)
TextMessageView(message: .preview)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets())
.listRowBackground(EmptyView())
@@ -81,7 +80,7 @@ struct TextMessageView: View {
.environment(\.colorScheme, .light)
List {
TextMessageView(message: .preview, expandedMessageIDs: $ids)
TextMessageView(message: .preview)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets())
.listRowBackground(EmptyView())
@@ -91,7 +90,7 @@ struct TextMessageView: View {
.environmentObject(
ChatViewModel(
keychain: keychain,
idBridge: NostrIdentityBridge(),
idBridge: NostrIdentityBridge(keychain: keychain),
identityManager: SecureIdentityStateManager(keychain)
)
)
File diff suppressed because it is too large Load Diff
+1
View File
@@ -7,6 +7,7 @@
//
import SwiftUI
import BitFoundation
struct FingerprintView: View {
@ObservedObject var viewModel: ChatViewModel
@@ -0,0 +1,79 @@
//
// ImagePickerView.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
#if os(iOS)
import SwiftUI
/// Camera or Photo Library
struct ImagePickerView: UIViewControllerRepresentable {
let sourceType: UIImagePickerController.SourceType
let completion: (UIImage?) -> Void
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.sourceType = sourceType
picker.delegate = context.coordinator
picker.allowsEditing = false
// Use standard full screen - iOS handles safe areas automatically
picker.modalPresentationStyle = .fullScreen
// Force dark mode to make safe area bars black instead of white
picker.overrideUserInterfaceStyle = .dark
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(completion: completion)
}
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let completion: (UIImage?) -> Void
init(completion: @escaping (UIImage?) -> Void) {
self.completion = completion
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
let image = info[.originalImage] as? UIImage
completion(image)
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
completion(nil)
}
}
}
@available(iOS 17, *)
#Preview {
@Previewable @State var isPresented = true
@Previewable @State var selectedImage: UIImage?
VStack {
if let selectedImage {
Image(uiImage: selectedImage)
.resizable()
.scaledToFit()
} else {
Text("No image selected")
}
Button("Show") { isPresented = true }
}
.sheet(isPresented: $isPresented) {
ImagePickerView(sourceType: .photoLibrary) { image in
selectedImage = image
isPresented = false
}
}
}
#endif
@@ -0,0 +1,147 @@
//
// ImagePreviewView.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
#if os(macOS)
import BitLogger
#endif
struct ImagePreviewView: View {
let url: URL
@Environment(\.dismiss) private var dismiss
#if os(iOS)
@State private var showExporter = false
@State private var platformImage: UIImage?
#else
@State private var platformImage: NSImage?
#endif
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
VStack {
Spacer()
if let image = platformImage {
#if os(iOS)
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.padding()
#else
Image(nsImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.padding()
#endif
} else {
ProgressView()
.progressViewStyle(.circular)
.tint(.white)
}
Spacer()
HStack {
Button(action: { dismiss() }) {
Text("close", comment: "Button to dismiss fullscreen media viewer")
.font(.bitchatSystem(size: 15, weight: .semibold))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(RoundedRectangle(cornerRadius: 12).stroke(Color.white.opacity(0.5), lineWidth: 1))
}
Spacer()
Button(action: saveCopy) {
Text("save", comment: "Button to save media to device")
.font(.bitchatSystem(size: 15, weight: .semibold))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(RoundedRectangle(cornerRadius: 12).fill(Color.blue.opacity(0.6)))
}
}
.padding([.horizontal, .bottom], 24)
}
}
.onAppear(perform: loadImage)
#if os(iOS)
.sheet(isPresented: $showExporter) {
FileExportWrapper(url: url)
}
#endif
}
private func loadImage() {
DispatchQueue.global(qos: .userInitiated).async {
#if os(iOS)
guard let image = UIImage(contentsOfFile: url.path) else { return }
#else
guard let image = NSImage(contentsOf: url) else { return }
#endif
DispatchQueue.main.async {
self.platformImage = image
}
}
}
private func saveCopy() {
#if os(iOS)
showExporter = true
#else
Task { @MainActor in
let panel = NSSavePanel()
panel.canCreateDirectories = true
panel.nameFieldStringValue = url.lastPathComponent
panel.prompt = "save"
if panel.runModal() == .OK, let destination = panel.url {
do {
if FileManager.default.fileExists(atPath: destination.path) {
try FileManager.default.removeItem(at: destination)
}
try FileManager.default.copyItem(at: url, to: destination)
} catch {
SecureLogger.error("Failed to save image preview copy: \(error)", category: .session)
}
}
}
#endif
}
#if os(iOS)
private struct FileExportWrapper: UIViewControllerRepresentable {
let url: URL
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
let controller = UIDocumentPickerViewController(forExporting: [url])
controller.shouldShowFileExtensions = true
return controller
}
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
}
#endif
}
#Preview {
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("dummy.jpg")
if !FileManager.default.fileExists(atPath: tempURL.path(percentEncoded: false)) {
#if os(iOS)
let image = UIImage(named: "dummy")
let data = image?.jpegData(compressionQuality: 0.8)
let _ = try? data?.write(to: tempURL)
#elseif os(macOS)
let image = NSImage(named: "dummy")
var rect = NSRect(origin: .zero, size: image?.size ?? .zero)
if let cgImage = image?.cgImage(forProposedRect: &rect, context: nil, hints: nil) {
let rep = NSBitmapImageRep(cgImage: cgImage)
let jpegData = rep.representation(using: .jpeg, properties: [.compressionFactor: 0.8])
let _ = try? jpegData?.write(to: tempURL)
}
#endif
}
ImagePreviewView(url: tempURL)
}
@@ -0,0 +1,71 @@
//
// MacImagePickerView.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
#if os(macOS)
import SwiftUI
struct MacImagePickerView: View {
let completion: (URL?) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 16) {
Text("Choose an image")
.font(.headline)
Button("Select Image") {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.allowedContentTypes = [.image, .png, .jpeg, .heic]
panel.message = "Choose an image to send"
if panel.runModal() == .OK {
completion(panel.url)
} else {
dismiss()
}
}
.buttonStyle(.borderedProminent)
Button("Cancel") {
completion(nil)
}
.buttonStyle(.bordered)
}
.padding(40)
.frame(minWidth: 300, minHeight: 150)
}
}
@available(OSX 14, *)
#Preview {
@Previewable @State var isPresented = true
@Previewable @State var selectedImage: NSImage?
VStack {
if let selectedImage {
Image(nsImage: selectedImage)
.resizable()
.scaledToFit()
} else {
Text("No image selected")
}
Button("Show") { isPresented = true }
}
.sheet(isPresented: $isPresented) {
MacImagePickerView { url in
selectedImage = url.map(NSImage.init)
isPresented = false
}
}
}
#endif
+25 -21
View File
@@ -40,10 +40,31 @@ struct LocationChannelsSheet: View {
}
static func levelTitle(for level: GeohashChannelLevel, count: Int) -> String {
// High-precision uncertainty: if count is 0 for high-precision levels,
// show "?" because presence broadcasting is disabled for privacy.
let isHighPrecision = (level == .neighborhood || level == .block || level == .building)
if isHighPrecision && count == 0 {
return String(
format: String(localized: "location_channels.row_title_unknown", defaultValue: "%@ [? people]"),
locale: .current,
level.displayName
)
}
return rowTitle(label: level.displayName, count: count)
}
static func bookmarkTitle(geohash: String, count: Int) -> String {
// Check precision for bookmarks too
let len = geohash.count
// Neighborhood=6, Block=7, Building=8+
let isHighPrecision = (len >= 6)
if isHighPrecision && count == 0 {
return String(
format: String(localized: "location_channels.row_title_unknown", defaultValue: "%@ [? people]"),
locale: .current,
"#\(geohash)"
)
}
return rowTitle(label: "#\(geohash)", count: count)
}
@@ -104,7 +125,7 @@ struct LocationChannelsSheet: View {
Text(Strings.permissionDenied)
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary)
Button(Strings.openSettings) { openSystemLocationSettings() }
Button(Strings.openSettings, action: SystemSettings.location.open)
.buttonStyle(.plain)
}
case LocationChannelManager.PermissionState.authorized:
@@ -225,9 +246,7 @@ struct LocationChannelsSheet: View {
sectionDivider
torToggleSection
.padding(.top, 12)
Button(action: {
openSystemLocationSettings()
}) {
Button(action: SystemSettings.location.open) {
Text(Strings.removeAccess)
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
@@ -283,7 +302,7 @@ struct LocationChannelsSheet: View {
}
}
let normalized = customGeohash
.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmed
.lowercased()
.replacingOccurrences(of: "#", with: "")
let isValid = validateGeohash(normalized)
@@ -430,7 +449,7 @@ struct LocationChannelsSheet: View {
// Split a title like "#mesh [3 people]" into base and suffix "[3 people]"
private func splitTitleAndCount(_ s: String) -> (base: String, countSuffix: String?) {
guard let idx = s.lastIndex(of: "[") else { return (s, nil) }
let prefix = String(s[..<idx]).trimmingCharacters(in: .whitespaces)
let prefix = String(s[..<idx]).trimmed
let suffix = String(s[idx...])
return (prefix, suffix)
}
@@ -601,18 +620,3 @@ extension LocationChannelsSheet {
}
}
}
// MARK: - Open Settings helper
private func openSystemLocationSettings() {
#if os(iOS)
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
#else
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices") {
NSWorkspace.shared.open(url)
} else if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security") {
NSWorkspace.shared.open(url)
}
#endif
}
+8 -5
View File
@@ -12,11 +12,15 @@ struct LocationNotesView: View {
@Environment(\.dismiss) private var dismiss
@State private var draft: String = ""
init(geohash: String, onNotesCountChanged: ((Int) -> Void)? = nil) {
init(
geohash: String,
onNotesCountChanged: ((Int) -> Void)? = nil,
manager: LocationNotesManager? = nil
) {
let gh = geohash.lowercased()
self.geohash = gh
self.onNotesCountChanged = onNotesCountChanged
_manager = StateObject(wrappedValue: LocationNotesManager(geohash: gh))
_manager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh))
}
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
@@ -260,14 +264,13 @@ struct LocationNotesView: View {
}
private func send() {
let content = draft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty else { return }
guard let content = draft.trimmedOrNilIfEmpty else { return }
manager.send(content: content, nickname: viewModel.nickname)
draft = ""
}
private var sendButtonEnabled: Bool {
!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && manager.state != .noRelays
!draft.trimmed.isEmpty && manager.state != .noRelays
}
// MARK: - Timestamp Formatting
@@ -0,0 +1,87 @@
//
// MediaMessageView.swift
// bitchat
//
// Created by Islam on 30/03/2026.
//
import SwiftUI
struct MediaMessageView: View {
@Environment(\.colorScheme) private var colorScheme
@EnvironmentObject var viewModel: ChatViewModel
let message: BitchatMessage
let media: BitchatMessage.Media
@Binding var imagePreviewURL: URL?
var body: some View {
let state = mediaSendState(for: message)
let isFromMe = message.sender == viewModel.nickname || message.senderPeerID == viewModel.meshService.myPeerID
let cancelAction: (() -> Void)? = state.canCancel ? { viewModel.cancelMediaSend(messageID: message.id) } : nil
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .center, spacing: 4) {
Text(viewModel.formatMessageHeader(message, colorScheme: colorScheme))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
if message.isPrivate && message.sender == viewModel.nickname,
let status = message.deliveryStatus {
DeliveryStatusView(status: status)
.padding(.leading, 4)
}
}
Group {
switch media {
case .voice(let url):
VoiceNoteView(
url: url,
isSending: state.isSending,
sendProgress: state.progress,
onCancel: cancelAction
)
case .image(let url):
BlockRevealImageView(
url: url,
revealProgress: state.progress,
isSending: state.isSending,
onCancel: cancelAction,
initiallyBlurred: !isFromMe,
onOpen: {
if !state.isSending {
imagePreviewURL = url
}
},
onDelete: !isFromMe ? { viewModel.deleteMediaMessage(messageID: message.id) } : nil
)
.frame(maxWidth: 280)
}
}
}
.padding(.vertical, 4)
}
private func mediaSendState(for message: BitchatMessage) -> (isSending: Bool, progress: Double?, canCancel: Bool) {
var isSending = false
var progress: Double?
if let status = message.deliveryStatus {
switch status {
case .sending:
isSending = true
progress = 0
case .partiallyDelivered(let reached, let total):
if total > 0 {
isSending = true
progress = Double(reached) / Double(total)
}
case .sent, .read, .delivered, .failed:
break
}
}
let canCancel = isSending && message.sender == viewModel.nickname
let clamped = progress.map { max(0, min(1, $0)) }
return (isSending, isSending ? clamped : nil, canCancel)
}
}
+3 -17
View File
@@ -34,24 +34,10 @@ struct VoiceNoteView: View {
colorScheme == .dark ? Color.green.opacity(0.3) : Color.green.opacity(0.2)
}
private var durationText: String {
let duration = playback.duration
guard duration.isFinite, duration > 0 else { return "--:--" }
let minutes = Int(duration) / 60
let seconds = Int(duration) % 60
return String(format: "%02d:%02d", minutes, seconds)
}
private var currentText: String {
let current = playback.currentTime
guard current.isFinite, current > 0 else { return "00:00" }
let minutes = Int(current) / 60
let seconds = Int(current) % 60
return String(format: "%02d:%02d", minutes, seconds)
}
private var playbackLabel: String {
playback.isPlaying ? currentText + "/" + durationText : durationText
guard playback.duration.isFinite else { return "--:--" }
let seconds = playback.isPlaying ? playback.remainingSeconds : playback.roundedDuration
return String(format: "%02d:%02d", seconds / 60, seconds % 60)
}
var body: some View {
+1
View File
@@ -1,4 +1,5 @@
import SwiftUI
import BitFoundation
struct MeshPeerList: View {
@ObservedObject var viewModel: ChatViewModel
+449
View File
@@ -0,0 +1,449 @@
//
// MessageListView.swift
// bitchat
//
// Created by Islam on 30/03/2026.
//
import BitFoundation
import SwiftUI
private struct MessageDisplayItem: Identifiable {
let id: String
let message: BitchatMessage
}
struct MessageListView: View {
@EnvironmentObject private var viewModel: ChatViewModel
@ObservedObject private var locationManager = LocationChannelManager.shared
@Environment(\.colorScheme) private var colorScheme
let privatePeer: PeerID?
@Binding var isAtBottom: Bool
@Binding var messageText: String
@Binding var selectedMessageSender: String?
@Binding var selectedMessageSenderID: PeerID?
@Binding var imagePreviewURL: URL?
@Binding var windowCountPublic: Int
@Binding var windowCountPrivate: [PeerID: Int]
@Binding var showSidebar: Bool
var isTextFieldFocused: FocusState<Bool>.Binding
@State private var showMessageActions = false
@State private var lastScrollTime: Date = .distantPast
@State private var scrollThrottleTimer: Timer?
var body: some View {
let currentWindowCount: Int = {
if let peer = privatePeer {
return windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate
}
return windowCountPublic
}()
let messages = viewModel.getMessages(for: privatePeer)
let windowedMessages = Array(messages.suffix(currentWindowCount))
let contextKey: String = {
if let peer = privatePeer {
"dm:\(peer)"
} else {
locationManager.selectedChannel.contextKey
}
}()
let messageItems: [MessageDisplayItem] = windowedMessages.compactMap { message in
guard !message.content.trimmed.isEmpty else { return nil }
return MessageDisplayItem(id: "\(contextKey)|\(message.id)", message: message)
}
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(messageItems) { item in
let message = item.message
messageRow(for: message)
.onAppear {
if message.id == windowedMessages.last?.id {
isAtBottom = true
}
if message.id == windowedMessages.first?.id,
messages.count > windowedMessages.count {
expandWindow(
ifNeededFor: message,
allMessages: messages,
privatePeer: privatePeer,
proxy: proxy
)
}
}
.onDisappear {
if message.id == windowedMessages.last?.id {
isAtBottom = false
}
}
.contentShape(Rectangle())
.onTapGesture {
if message.sender != "system" {
messageText = "@\(message.sender) "
isTextFieldFocused.wrappedValue = true
}
}
.contextMenu {
Button("content.message.copy") {
#if os(iOS)
UIPasteboard.general.string = message.content
#else
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(message.content, forType: .string)
#endif
}
}
.padding(.horizontal, 12)
.padding(.vertical, 1)
}
}
.transaction { tx in if viewModel.isBatchingPublic { tx.disablesAnimations = true } }
.padding(.vertical, 2)
}
.onOpenURL(perform: handleOpenURL)
.onTapGesture(count: 3) {
viewModel.sendMessage("/clear")
}
.onAppear {
scrollToBottom(on: proxy)
}
.onChange(of: privatePeer) { _ in
scrollToBottom(on: proxy)
}
.onChange(of: viewModel.messages.count) { _ in
onMessagesChange(proxy: proxy)
}
.onChange(of: viewModel.privateChats) { _ in
onPrivateChatsChange(proxy: proxy)
}
.onChange(of: locationManager.selectedChannel) { newChannel in
onSelectedChannelChange(newChannel, proxy: proxy)
}
.confirmationDialog(
selectedMessageSender.map { "@\($0)" } ?? String(localized: "content.actions.title", comment: "Fallback title for the message action sheet"),
isPresented: $showMessageActions,
titleVisibility: .visible
) {
Button("content.actions.mention") {
if let sender = selectedMessageSender {
// Pre-fill the input with an @mention and focus the field
messageText = "@\(sender) "
isTextFieldFocused.wrappedValue = true
}
}
Button("content.actions.direct_message") {
if let peerID = selectedMessageSenderID {
if peerID.isGeoChat {
if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
viewModel.startGeohashDM(withPubkeyHex: full)
}
} else {
viewModel.startPrivateChat(with: peerID)
}
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar = true
}
}
}
Button("content.actions.hug") {
if let sender = selectedMessageSender {
viewModel.sendMessage("/hug @\(sender)")
}
}
Button("content.actions.slap") {
if let sender = selectedMessageSender {
viewModel.sendMessage("/slap @\(sender)")
}
}
Button("content.actions.block", role: .destructive) {
// Prefer direct geohash block when we have a Nostr sender ID
if let peerID = selectedMessageSenderID, peerID.isGeoChat,
let full = viewModel.fullNostrHex(forSenderPeerID: peerID),
let sender = selectedMessageSender {
viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender)
} else if let sender = selectedMessageSender {
viewModel.sendMessage("/block \(sender)")
}
}
Button("common.cancel", role: .cancel) {}
}
.onAppear {
// Also check when view appears
if let peerID = privatePeer {
// Try multiple times to ensure read receipts are sent
viewModel.markPrivateMessagesAsRead(from: peerID)
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) {
viewModel.markPrivateMessagesAsRead(from: peerID)
}
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryLongSeconds) {
viewModel.markPrivateMessagesAsRead(from: peerID)
}
}
}
.onDisappear {
scrollThrottleTimer?.invalidate()
}
}
.environment(\.openURL, OpenURLAction { url in
// Intercept custom cashu: links created in attributed text
if let scheme = url.scheme?.lowercased(), scheme == "cashu" || scheme == "lightning" {
#if os(iOS)
UIApplication.shared.open(url)
return .handled
#else
// On non-iOS platforms, let the system handle or ignore
return .systemAction
#endif
}
return .systemAction
})
}
}
private extension MessageListView {
@ViewBuilder
func messageRow(for message: BitchatMessage) -> some View {
Group {
if message.sender == "system" {
systemMessageRow(message)
} else if let media = message.mediaAttachment(for: viewModel.nickname) {
MediaMessageView(message: message, media: media, imagePreviewURL: $imagePreviewURL)
} else {
TextMessageView(message: message)
}
}
}
@ViewBuilder
func systemMessageRow(_ message: BitchatMessage) -> some View {
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
func expandWindow(ifNeededFor message: BitchatMessage,
allMessages: [BitchatMessage],
privatePeer: PeerID?,
proxy: ScrollViewProxy) {
let step = TransportConfig.uiWindowStepCount
let contextKey: String = {
if let peer = privatePeer {
"dm:\(peer)"
} else {
locationManager.selectedChannel.contextKey
}
}()
let preserveID = "\(contextKey)|\(message.id)"
if let peer = privatePeer {
let current = windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate
let newCount = min(allMessages.count, current + step)
guard newCount != current else { return }
windowCountPrivate[peer] = newCount
DispatchQueue.main.async {
proxy.scrollTo(preserveID, anchor: .top)
}
} else {
let current = windowCountPublic
let newCount = min(allMessages.count, current + step)
guard newCount != current else { return }
windowCountPublic = newCount
DispatchQueue.main.async {
proxy.scrollTo(preserveID, anchor: .top)
}
}
}
func handleOpenURL(_ url: URL) {
guard url.scheme == "bitchat" else { return }
switch url.host {
case "user":
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let peerID = PeerID(str: id.removingPercentEncoding ?? id)
selectedMessageSenderID = peerID
if peerID.isGeoDM || peerID.isGeoChat {
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
} else if let name = viewModel.meshService.peerNickname(peerID: peerID) {
selectedMessageSender = name
} else {
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
}
if viewModel.isSelfSender(peerID: peerID, displayName: selectedMessageSender) {
selectedMessageSender = nil
selectedMessageSenderID = nil
} else {
showMessageActions = true
}
case "geohash":
let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return }
func levelForLength(_ len: Int) -> GeohashChannelLevel {
switch len {
case 0...2: return .region
case 3...4: return .province
case 5: return .city
case 6: return .neighborhood
case 7: return .block
default: return .block
}
}
let level = levelForLength(gh.count)
let channel = GeohashChannel(level: level, geohash: gh)
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == gh }
if !inRegional && !LocationChannelManager.shared.availableChannels.isEmpty {
LocationChannelManager.shared.markTeleported(for: gh, true)
}
LocationChannelManager.shared.select(ChannelID.location(channel))
default:
return
}
}
func scrollToBottom(on proxy: ScrollViewProxy) {
isAtBottom = true
if let targetPeerID {
proxy.scrollTo(targetPeerID, anchor: .bottom)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
if let secondTarget = self.targetPeerID {
proxy.scrollTo(secondTarget, anchor: .bottom)
}
}
}
var targetPeerID: String? {
if let peer = privatePeer,
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
return "dm:\(peer)|\(last)"
}
if let last = viewModel.messages.suffix(300).last?.id {
return "\(locationManager.selectedChannel.contextKey)|\(last)"
}
return nil
}
func onMessagesChange(proxy: ScrollViewProxy) {
guard privatePeer == nil, let lastMsg = viewModel.messages.last else { return }
// If the newest message is from me, always scroll to bottom
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
return
} else { // Ensure we consider ourselves at bottom for subsequent messages
isAtBottom = true
}
func scrollIfNeeded(date: Date) {
lastScrollTime = date
let contextKey = locationManager.selectedChannel.contextKey
if let target = viewModel.messages.suffix(windowCountPublic).last.map({ "\(contextKey)|\($0.id)" }) {
proxy.scrollTo(target, anchor: .bottom)
}
}
// Throttle scroll animations to prevent excessive UI updates
let now = Date()
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
// Immediate scroll if enough time has passed
scrollIfNeeded(date: now)
} else {
// Schedule a delayed scroll
scrollThrottleTimer?.invalidate()
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
Task { @MainActor in
scrollIfNeeded(date: Date())
}
}
}
}
func onPrivateChatsChange(proxy: ScrollViewProxy) {
guard let peerID = privatePeer, let messages = viewModel.privateChats[peerID], let lastMsg = messages.last else {
return
}
// If the newest private message is from me, always scroll
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
return
} else {
isAtBottom = true
}
func scrollIfNeeded(date: Date) {
lastScrollTime = date
let contextKey = "dm:\(peerID)"
let count = windowCountPrivate[peerID] ?? 300
if let target = messages.suffix(count).last.map({ "\(contextKey)|\($0.id)" }){
proxy.scrollTo(target, anchor: .bottom)
}
}
// Same throttling for private chats
let now = Date()
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
scrollIfNeeded(date: now)
} else {
scrollThrottleTimer?.invalidate()
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
Task { @MainActor in
scrollIfNeeded(date: Date())
}
}
}
}
func onSelectedChannelChange(_ channel: ChannelID, proxy: ScrollViewProxy) {
// When switching to a new geohash channel, scroll to the bottom
guard privatePeer == nil else { return }
switch channel {
case .mesh:
break
case .location(let ch):
// Reset window size
isAtBottom = true
windowCountPublic = TransportConfig.uiWindowInitialCountPublic
let contextKey = "geo:\(ch.geohash)"
if let target = viewModel.messages.suffix(windowCountPublic).last?.id.map({ "\(contextKey)|\($0)" }) {
proxy.scrollTo(target, anchor: .bottom)
}
}
}
}
private extension ChannelID {
var contextKey: String {
switch self {
case .mesh: "mesh"
case .location(let ch): "geo:\(ch.geohash)"
}
}
}
//#Preview {
// MessageListView()
//}
+4 -19
View File
@@ -5,21 +5,6 @@
import Foundation
private enum RegexCache {
static let cashu: NSRegularExpression = {
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
}()
static let lightningScheme: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
}()
static let bolt11: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
}()
static let lnurl: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
}()
}
extension String {
// Detect if there is an extremely long token (no whitespace/newlines) that could break layout
func hasVeryLongToken(threshold: Int) -> Bool {
@@ -38,7 +23,7 @@ extension String {
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
func extractCashuLinks(max: Int = 3) -> [String] {
let regex = RegexCache.cashu
let regex = MessageFormattingEngine.Patterns.cashu
let ns = self as NSString
let range = NSRange(location: 0, length: ns.length)
var found: [String] = []
@@ -59,19 +44,19 @@ extension String {
let ns = self as NSString
let full = NSRange(location: 0, length: ns.length)
// lightning: scheme
for m in RegexCache.lightningScheme.matches(in: self, range: full) {
for m in MessageFormattingEngine.Patterns.lightningScheme.matches(in: self, range: full) {
let s = ns.substring(with: m.range(at: 0))
results.append(s)
if results.count >= max { return results }
}
// BOLT11
for m in RegexCache.bolt11.matches(in: self, range: full) {
for m in MessageFormattingEngine.Patterns.bolt11.matches(in: self, range: full) {
let s = ns.substring(with: m.range(at: 0))
results.append("lightning:\(s)")
if results.count >= max { return results }
}
// LNURL bech32
for m in RegexCache.lnurl.matches(in: self, range: full) {
for m in MessageFormattingEngine.Patterns.lnurl.matches(in: self, range: full) {
let s = ns.substring(with: m.range(at: 0))
results.append("lightning:\(s)")
if results.count >= max { return results }
+1
View File
@@ -1,3 +1,4 @@
import Nostr
import SwiftUI
import CoreImage
import CoreImage.CIFilterBuiltins
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "dummy.jpg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -10,32 +10,64 @@ import Foundation
final class PreviewKeychainManager: KeychainManagerProtocol {
private var storage: [String: Data] = [:]
private var serviceStorage: [String: [String: Data]] = [:]
init() {}
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
storage[key] = keyData
return true
}
func getIdentityKey(forKey key: String) -> Data? {
storage[key]
}
func deleteIdentityKey(forKey key: String) -> Bool {
storage.removeValue(forKey: key)
return true
}
func deleteAllKeychainData() -> Bool {
storage.removeAll()
serviceStorage.removeAll()
return true
}
func secureClear(_ data: inout Data) {}
func secureClear(_ string: inout String) {}
func verifyIdentityKeyExists() -> Bool {
storage["identity_noiseStaticKey"] != nil
}
// BCH-01-009: New methods with proper error classification
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
if let data = storage[key] {
return .success(data)
}
return .itemNotFound
}
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
storage[key] = keyData
return .success
}
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
func save(key: String, data: Data, service: String, accessible: CFString?) {
if serviceStorage[service] == nil {
serviceStorage[service] = [:]
}
serviceStorage[service]?[key] = data
}
func load(key: String, service: String) -> Data? {
serviceStorage[service]?[key]
}
func delete(key: String, service: String) {
serviceStorage[service]?.removeValue(forKey: key)
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.chat.bitchat</string>
<string>$(APP_GROUP_ID)</string>
</array>
<key>com.apple.security.device.bluetooth</key>
<true/>
+1 -1
View File
@@ -6,7 +6,7 @@
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.chat.bitchat</string>
<string>$(APP_GROUP_ID)</string>
</array>
<key>com.apple.security.device.bluetooth</key>
<true/>
+2
View File
@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AppGroupID</key>
<string>$(APP_GROUP_ID)</string>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
@@ -13,7 +13,7 @@ import UniformTypeIdentifiers
/// Avoids deprecated Social framework and SLComposeServiceViewController.
final class ShareViewController: UIViewController {
// Bundle.main.bundleIdentifier would get the extension's bundleID
private static let groupID = "group.chat.bitchat"
private static let groupID = Bundle.main.object(forInfoDictionaryKey: "AppGroupID") as? String ?? "group.chat.bitchat"
private enum Strings {
static let nothingToShare = String(localized: "share.status.nothing_to_share", comment: "Shown when the share extension receives no content")
@@ -6,7 +6,7 @@
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.chat.bitchat</string>
<string>$(APP_GROUP_ID)</string>
</array>
</dict>
</plist>
+155
View File
@@ -0,0 +1,155 @@
//
// BLEServiceCoreTests.swift
// bitchatTests
//
// Focused BLEService tests for packet handling behavior.
//
import Testing
import Nostr
import Foundation
import CoreBluetooth
import BitFoundation
@testable import bitchat
struct BLEServiceCoreTests {
@Test
func duplicatePacket_isDeduped() async {
let ble = makeService()
let delegate = PublicCaptureDelegate()
ble.delegate = delegate
let sender = PeerID(str: "1122334455667788")
let timestamp = UInt64(Date().timeIntervalSince1970 * 1000)
let packet = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp)
ble._test_handlePacket(packet, fromPeerID: sender)
let receivedFirst = await TestHelpers.waitUntil(
{ delegate.publicMessagesSnapshot().count == 1 },
timeout: TestConstants.defaultTimeout
)
#expect(receivedFirst)
ble._test_handlePacket(packet, fromPeerID: sender)
let receivedDuplicate = await TestHelpers.waitUntil(
{ delegate.publicMessagesSnapshot().count > 1 },
timeout: TestConstants.shortTimeout
)
#expect(!receivedDuplicate)
let messages = delegate.publicMessagesSnapshot()
#expect(messages.count == 1)
#expect(messages.first?.content == "Hello")
}
@Test
func staleBroadcast_isIgnored() async {
let ble = makeService()
let delegate = PublicCaptureDelegate()
ble.delegate = delegate
let sender = PeerID(str: "A1B2C3D4E5F60708")
let oldTimestamp = UInt64(Date().addingTimeInterval(-901).timeIntervalSince1970 * 1000)
let packet = makePublicPacket(content: "Old", sender: sender, timestamp: oldTimestamp)
ble._test_handlePacket(packet, fromPeerID: sender)
let didReceive = await TestHelpers.waitUntil({ !delegate.publicMessagesSnapshot().isEmpty }, timeout: 0.3)
#expect(!didReceive)
#expect(delegate.publicMessagesSnapshot().isEmpty)
}
@Test
func announceSenderMismatch_isRejected() async throws {
let ble = makeService()
let signer = NoiseEncryptionService(keychain: MockKeychain())
let announcement = AnnouncementPacket(
nickname: "Spoof",
noisePublicKey: signer.getStaticPublicKeyData(),
signingPublicKey: signer.getSigningPublicKeyData(),
directNeighbors: nil
)
let payload = try #require(announcement.encode(), "Failed to encode announcement")
let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey)
let wrongFirst = derivedPeerID.bare.first == "0" ? "1" : "0"
let wrongBare = String(wrongFirst) + String(derivedPeerID.bare.dropFirst())
let wrongPeerID = PeerID(str: wrongBare)
let packet = BitchatPacket(
type: MessageType.announce.rawValue,
senderID: Data(hexString: wrongPeerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 7
)
let signed = try #require(signer.signPacket(packet), "Failed to sign announce packet")
ble._test_handlePacket(signed, fromPeerID: wrongPeerID, preseedPeer: false)
_ = await TestHelpers.waitUntil({ !ble.currentPeerSnapshots().isEmpty }, timeout: 0.3)
#expect(ble.currentPeerSnapshots().isEmpty)
}
}
private func makeService() -> BLEService {
let keychain = MockKeychain()
let identityManager = MockIdentityManager(keychain)
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
return BLEService(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
initializeBluetoothManagers: false
)
}
private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64) -> BitchatPacket {
BitchatPacket(
type: MessageType.message.rawValue,
senderID: Data(hexString: sender.id) ?? Data(),
recipientID: nil,
timestamp: timestamp,
payload: Data(content.utf8),
signature: nil,
ttl: 3
)
}
private final class PublicCaptureDelegate: BitchatDelegate {
private let lock = NSLock()
private(set) var publicMessages: [BitchatMessage] = []
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
let message = BitchatMessage(
id: messageID,
sender: nickname,
content: content,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: peerID,
mentions: nil
)
lock.lock()
publicMessages.append(message)
lock.unlock()
}
func didReceiveMessage(_ message: BitchatMessage) {}
func didConnectToPeer(_ peerID: PeerID) {}
func didDisconnectFromPeer(_ peerID: PeerID) {}
func didUpdatePeerList(_ peers: [PeerID]) {}
func didUpdateBluetoothState(_ state: CBManagerState) {}
func publicMessagesSnapshot() -> [BitchatMessage] {
lock.lock()
defer { lock.unlock() }
return publicMessages
}
}
+8 -7
View File
@@ -8,6 +8,7 @@
import Testing
import CoreBluetooth
import BitFoundation
@testable import bitchat
struct BLEServiceTests {
@@ -73,7 +74,7 @@ struct BLEServiceTests {
service.sendMessage("Hello, world!")
// Allow async processing
try await sleep(0.5)
try await sleep(1.0)
}
#expect(service.sentMessages.count == 1)
}
@@ -97,7 +98,7 @@ struct BLEServiceTests {
)
// Allow async processing
try await sleep(0.5)
try await sleep(1.0)
}
#expect(service.sentMessages.count == 1)
}
@@ -113,7 +114,7 @@ struct BLEServiceTests {
service.sendMessage("@alice @bob check this out", mentions: ["alice", "bob"])
// Allow async processing
try await sleep(0.5)
try await sleep(1.0)
}
}
@@ -146,7 +147,7 @@ struct BLEServiceTests {
service.simulateIncomingMessage(incomingMessage)
// Allow async processing
try await sleep(0.5)
try await sleep(1.0)
}
}
@@ -189,7 +190,7 @@ struct BLEServiceTests {
service.simulateIncomingPacket(packet)
// Allow async processing
try await sleep(0.5)
try await sleep(1.0)
}
}
@@ -231,7 +232,7 @@ struct BLEServiceTests {
service.sendMessage("Test delivery")
// Allow async processing
try await sleep(0.5)
try await sleep(1.0)
}
}
@@ -273,7 +274,7 @@ struct BLEServiceTests {
service.simulateIncomingPacket(packet)
// Allow async processing
try await sleep(0.5)
try await sleep(1.0)
}
}
}
+84
View File
@@ -0,0 +1,84 @@
import Foundation
import Testing
import BitFoundation
@testable import bitchat
@Suite("BitchatPeer Tests")
struct BitchatPeerTests {
typealias FavoriteRelationship = FavoritesPersistenceService.FavoriteRelationship
@Test("Connection state prioritizes bluetooth, mesh, nostr, then offline")
func connectionStatePriorityIsCorrect() {
let peerID = PeerID(str: "0123456789abcdef")
let noiseKey = Data((0..<32).map(UInt8.init))
let mutual = makeRelationship(isFavorite: true, theyFavoritedUs: true)
let bluetooth = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: true, isReachable: true)
let mesh = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: true)
var nostr = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: false)
nostr.favoriteStatus = mutual
let offline = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: false)
#expect(bluetooth.connectionState == .bluetoothConnected)
#expect(mesh.connectionState == .meshReachable)
#expect(nostr.connectionState == .nostrAvailable)
#expect(offline.connectionState == .offline)
}
@Test("Display name falls back to peer prefix and offline icon reflects inbound favorite")
func displayNameAndOfflineIconUseDerivedState() {
let peerID = PeerID(str: "fedcba9876543210")
let noiseKey = Data((32..<64).map(UInt8.init))
var peer = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "", isConnected: false, isReachable: false)
peer.favoriteStatus = makeRelationship(isFavorite: false, theyFavoritedUs: true)
#expect(peer.displayName == String(peerID.id.prefix(8)))
#expect(peer.statusIcon == "🌙")
}
@Test("Mutual offline peers show Nostr icon")
func mutualFavoriteOfflinePeerShowsNostrIcon() {
let peerID = PeerID(str: "0011223344556677")
let noiseKey = Data((64..<96).map(UInt8.init))
var peer = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "Peer", isConnected: false, isReachable: false)
peer.favoriteStatus = makeRelationship(isFavorite: true, theyFavoritedUs: true)
#expect(peer.statusIcon == "🌐")
#expect(peer.isFavorite)
#expect(peer.isMutualFavorite)
#expect(peer.theyFavoritedUs)
}
@Test("Equality is based only on peer ID")
func equalityUsesPeerIDOnly() {
let peerID = PeerID(str: "8899aabbccddeeff")
let first = BitchatPeer(
peerID: peerID,
noisePublicKey: Data(repeating: 1, count: 32),
nickname: "First",
isConnected: false,
isReachable: false
)
let second = BitchatPeer(
peerID: peerID,
noisePublicKey: Data(repeating: 2, count: 32),
nickname: "Second",
isConnected: true,
isReachable: true
)
#expect(first == second)
}
private func makeRelationship(isFavorite: Bool, theyFavoritedUs: Bool) -> FavoriteRelationship {
FavoriteRelationship(
peerNoisePublicKey: Data(repeating: 7, count: 32),
peerNostrPublicKey: "npub1example",
peerNickname: "Peer",
isFavorite: isFavorite,
theyFavoritedUs: theyFavoritedUs,
favoritedAt: Date(timeIntervalSince1970: 1),
lastUpdated: Date(timeIntervalSince1970: 2)
)
}
}
@@ -0,0 +1,230 @@
//
// ChatViewModelDeliveryStatusTests.swift
// bitchatTests
//
// Tests for ChatViewModel delivery status state machine.
//
import Testing
import Nostr
import Foundation
import BitFoundation
@testable import bitchat
// MARK: - Test Helpers
@MainActor
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
let keychain = MockKeychain()
let keychainHelper = MockKeychainHelper()
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
let identityManager = MockIdentityManager(keychain)
let transport = MockTransport()
let viewModel = ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
transport: transport
)
return (viewModel, transport)
}
// MARK: - Delivery Status Tests
struct ChatViewModelDeliveryStatusTests {
// MARK: - Status Transition Tests
@Test @MainActor
func deliveryStatus_noDowngrade_readToDelivered() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0102030405060708")
let messageID = "test-msg-1"
// Setup: create a message with .read status
let message = BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: "Test message",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Peer",
senderPeerID: transport.myPeerID,
deliveryStatus: .read(by: "Peer", at: Date())
)
viewModel.privateChats[peerID] = [message]
// Action: try to downgrade to .delivered
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date()))
// Assert: status should remain .read (no downgrade)
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
#expect({
if case .read = currentStatus { return true }
return false
}())
}
@Test @MainActor
func deliveryStatus_upgrade_sentToDelivered() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0102030405060708")
let messageID = "test-msg-2"
// Setup: create a message with .sent status
let message = BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: "Test message",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Peer",
senderPeerID: transport.myPeerID,
deliveryStatus: .sent
)
viewModel.privateChats[peerID] = [message]
// Action: upgrade to .delivered
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date()))
// Assert: status should be .delivered
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
#expect({
if case .delivered = currentStatus { return true }
return false
}())
}
@Test @MainActor
func deliveryStatus_upgrade_deliveredToRead() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0102030405060708")
let messageID = "test-msg-3"
// Setup: create a message with .delivered status
let message = BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: "Test message",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Peer",
senderPeerID: transport.myPeerID,
deliveryStatus: .delivered(to: "Peer", at: Date().addingTimeInterval(-60))
)
viewModel.privateChats[peerID] = [message]
// Action: upgrade to .read
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .read(by: "Peer", at: Date()))
// Assert: status should be .read
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
#expect({
if case .read = currentStatus { return true }
return false
}())
}
// MARK: - Read Receipt Handling
@Test @MainActor
func didReceiveReadReceipt_updatesMessageStatus() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0102030405060708")
let messageID = "test-msg-4"
// Setup: create a message with .sent status
let message = BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: "Test message",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Peer",
senderPeerID: transport.myPeerID,
deliveryStatus: .sent
)
viewModel.privateChats[peerID] = [message]
// Action: receive read receipt
let receipt = ReadReceipt(
originalMessageID: messageID,
readerID: peerID,
readerNickname: "Peer"
)
viewModel.didReceiveReadReceipt(receipt)
// Assert: status should be .read
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
#expect({
if case .read = currentStatus { return true }
return false
}())
}
// MARK: - Public Timeline Status Tests
@Test @MainActor
func deliveryStatus_publicTimeline_updatesCorrectly() async {
let (viewModel, _) = makeTestableViewModel()
let messageID = "public-msg-1"
// Setup: add a message to public timeline with .sending status
let message = BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: "Public message",
timestamp: Date(),
isRelay: false,
isPrivate: false,
deliveryStatus: .sending
)
viewModel.messages.append(message)
// Action: update to .sent
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .sent)
// Assert
let updatedMessage = viewModel.messages.first(where: { $0.id == messageID })
#expect({
if case .sent = updatedMessage?.deliveryStatus { return true }
return false
}())
}
// MARK: - Status Rank Tests (for deduplication)
@Test @MainActor
func statusRank_orderingIsCorrect() async {
// This tests the implicit ordering used in refreshVisibleMessages
// failed < sending < sent < partiallyDelivered < delivered < read
let statuses: [DeliveryStatus] = [
.failed(reason: "test"),
.sending,
.sent,
.partiallyDelivered(reached: 1, total: 3),
.delivered(to: "B", at: Date()),
.read(by: "C", at: Date())
]
// Verify each status has a logical progression
// This is more of a documentation test to ensure the ranking logic is understood
for (index, status) in statuses.enumerated() {
switch status {
case .failed: #expect(index == 0)
case .sending: #expect(index == 1)
case .sent: #expect(index == 2)
case .partiallyDelivered: #expect(index == 3)
case .delivered: #expect(index == 4)
case .read: #expect(index == 5)
}
}
}
}
+880 -22
View File
@@ -6,8 +6,15 @@
//
import Testing
import Nostr
import Foundation
import Combine
#if os(iOS)
import UIKit
#else
import AppKit
#endif
import BitFoundation
@testable import bitchat
// MARK: - Test Helpers
@@ -64,6 +71,22 @@ struct ChatViewModelPrivateChatExtensionTests {
// MockTransport.sendPrivateMessage is what MessageRouter calls for connected peers
// Check MockTransport implementation... it might need update or verification
}
@Test @MainActor
func sendPrivateMessage_unreachable_setsFailedStatus() async {
let (viewModel, _) = makeTestableViewModel()
let validHex = "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10"
let peerID = PeerID(str: validHex)
viewModel.sendPrivateMessage("Hello", to: peerID)
#expect(viewModel.privateChats[peerID]?.count == 1)
let status = viewModel.privateChats[peerID]?.last?.deliveryStatus
#expect({
if case .failed = status { return true }
return false
}())
}
@Test @MainActor
func handlePrivateMessage_storesMessage() async {
@@ -249,37 +272,872 @@ struct ChatViewModelNostrExtensionTests {
}
@Test @MainActor
func subscribeNostrEvent_addsToTimeline_ifMatchesGeohash() async {
let (viewModel, _) = makeTestableViewModel()
func subscribeNostrEvent_addsToTimeline_ifMatchesGeohash() async throws {
let geohash = "u4pruydq"
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
LocationChannelManager.shared.select(channel)
defer { LocationChannelManager.shared.select(.mesh) }
_ = await TestHelpers.waitUntil({ LocationChannelManager.shared.selectedChannel == channel })
let (viewModel, _) = makeTestableViewModel()
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
_ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel })
var event = NostrEvent(
pubkey: "pub1",
let signer = try NostrIdentity.generate()
let event = NostrEvent(
pubkey: signer.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: [["g", geohash]],
content: "Hello Geo"
)
event.id = "evt1"
event.sig = "sig"
let signed = try event.sign(with: signer.schnorrSigningKey())
viewModel.handleNostrEvent(signed)
viewModel.handleNostrEvent(event)
// Allow async processing
let didAppend = await TestHelpers.waitUntil({
viewModel.publicMessagePipeline.flushIfNeeded()
return viewModel.messages.contains { $0.content == "Hello Geo" }
})
#expect(didAppend)
}
@Test @MainActor
func handleNostrEvent_ignoresRecentSelfEcho() async throws {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: [["g", geohash]],
content: "Self echo"
)
let signed = try event.sign(with: identity.schnorrSigningKey())
viewModel.handleNostrEvent(signed)
try? await Task.sleep(nanoseconds: 100_000_000)
// Check timeline
// This depends on `handlePublicMessage` being called and updating `messages`
// Since `handlePublicMessage` delegates to `timelineStore` and updates `messages`...
// And we are in the correct channel...
// However, `handleNostrEvent` in the extension now calls `handlePublicMessage`.
// Let's verify if the message appears.
// Note: `handleNostrEvent` logic was refactored.
// The new logic in `ChatViewModel+Nostr.swift` calls `handlePublicMessage`.
// We need to ensure `deduplicationService` doesn't block it (new instance, so empty).
viewModel.publicMessagePipeline.flushIfNeeded()
#expect(!viewModel.messages.contains { $0.content == "Self echo" })
}
@Test @MainActor
func handleNostrEvent_skipsBlockedSender() async throws {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
let blockedIdentity = try NostrIdentity.generate()
let blockedPubkey = blockedIdentity.publicKeyHex
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
viewModel.identityManager.setNostrBlocked(blockedPubkey, isBlocked: true)
let event = NostrEvent(
pubkey: blockedPubkey,
createdAt: Date(),
kind: .ephemeralEvent,
tags: [["g", geohash]],
content: "Blocked"
)
let signed = try event.sign(with: blockedIdentity.schnorrSigningKey())
viewModel.handleNostrEvent(signed)
try? await Task.sleep(nanoseconds: 100_000_000)
viewModel.publicMessagePipeline.flushIfNeeded()
#expect(!viewModel.messages.contains { $0.content == "Blocked" })
}
@Test @MainActor
func handleNostrEvent_rejectsInvalidSignature() async throws {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
let identity = try NostrIdentity.generate()
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: [["g", geohash]],
content: "Valid"
)
var signed = try event.sign(with: identity.schnorrSigningKey())
signed.id = "deadbeef"
viewModel.handleNostrEvent(signed)
try? await Task.sleep(nanoseconds: 100_000_000)
viewModel.publicMessagePipeline.flushIfNeeded()
#expect(!viewModel.messages.contains { $0.content == "Tampered" })
}
@Test @MainActor
func subscribeGiftWrap_rejectsOversizedEmbeddedPacket() async throws {
let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let oversized = Data(repeating: 0x41, count: FileTransferLimits.maxFramedFileBytes + 1)
let content = "bitchat1:" + base64URLEncode(oversized)
let giftWrap = try NostrProtocol.createPrivateMessage(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
try? await Task.sleep(nanoseconds: 100_000_000)
#expect(viewModel.privateChats.isEmpty)
}
@Test @MainActor
func switchLocationChannel_clearsNostrDedupCache() async {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
viewModel.deduplicationService.recordNostrEvent("evt-cache")
#expect(viewModel.deduplicationService.hasProcessedNostrEvent("evt-cache"))
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
#expect(!viewModel.deduplicationService.hasProcessedNostrEvent("evt-cache"))
}
@Test @MainActor
func handleNostrEvent_presenceTracksParticipantWithoutTimelineMessage() async throws {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
let identity = try NostrIdentity.generate()
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .geohashPresence,
tags: [["g", geohash]],
content: ""
)
let signed = try event.sign(with: identity.schnorrSigningKey())
viewModel.handleNostrEvent(signed)
try? await Task.sleep(nanoseconds: 50_000_000)
#expect(viewModel.geohashParticipantCount(for: geohash) >= 1)
viewModel.publicMessagePipeline.flushIfNeeded()
#expect(viewModel.messages.isEmpty)
}
@Test @MainActor
func subscribeGiftWrap_deliveredAckUpdatesExistingMessage() async throws {
let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let convKey = PeerID(nostr_: sender.publicKeyHex)
let messageID = "geo-ack-delivered"
viewModel.privateChats[convKey] = [
BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: "Hello",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Friend",
senderPeerID: viewModel.meshService.myPeerID,
deliveryStatus: .sent
)
]
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
let giftWrap = try NostrProtocol.createPrivateMessage(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
let didUpdate = await TestHelpers.waitUntil(
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
timeout: 0.5
)
#expect(didUpdate)
}
@Test @MainActor
func subscribeGiftWrap_readAckUpdatesExistingMessage() async throws {
let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let convKey = PeerID(nostr_: sender.publicKeyHex)
let messageID = "geo-ack-read"
viewModel.privateChats[convKey] = [
BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: "Hello",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Friend",
senderPeerID: viewModel.meshService.myPeerID,
deliveryStatus: .delivered(to: "Friend", at: Date())
)
]
let content = try ackContent(type: .readReceipt, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
let giftWrap = try NostrProtocol.createPrivateMessage(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
let didUpdate = await TestHelpers.waitUntil(
{ isRead(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
timeout: 0.5
)
#expect(didUpdate)
}
@Test @MainActor
func handleGiftWrap_privateMessageStoresConversationAndMapping() async throws {
let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let messageID = "gift-private"
let convKey = PeerID(nostr_: sender.publicKeyHex)
let content = try privateMessageContent(
text: "Hello from gift wrap",
messageID: messageID,
senderPeerID: PeerID(str: "0123456789abcdef")
)
let giftWrap = try NostrProtocol.createPrivateMessage(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
viewModel.handleGiftWrap(giftWrap, id: recipient)
let didStore = await TestHelpers.waitUntil(
{ viewModel.privateChats[convKey]?.first?.content == "Hello from gift wrap" },
timeout: 0.5
)
#expect(didStore)
#expect(viewModel.nostrKeyMapping[convKey] == sender.publicKeyHex)
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
}
@Test @MainActor
func handleGiftWrap_blockedSenderSkipsMessageStorage() async throws {
let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let messageID = "gift-blocked"
let convKey = PeerID(nostr_: sender.publicKeyHex)
viewModel.identityManager.setNostrBlocked(sender.publicKeyHex, isBlocked: true)
let content = try privateMessageContent(
text: "Blocked",
messageID: messageID,
senderPeerID: PeerID(str: "0123456789abcdef")
)
let giftWrap = try NostrProtocol.createPrivateMessage(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
viewModel.handleGiftWrap(giftWrap, id: recipient)
try? await Task.sleep(nanoseconds: 50_000_000)
#expect(viewModel.privateChats[convKey] == nil)
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
}
@Test @MainActor
func handleGiftWrap_deliveredAckUpdatesExistingMessage() async throws {
let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let convKey = PeerID(nostr_: sender.publicKeyHex)
let messageID = "gift-delivered"
viewModel.privateChats[convKey] = [
BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: "Hello",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Friend",
senderPeerID: viewModel.meshService.myPeerID,
deliveryStatus: .sent
)
]
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
let giftWrap = try NostrProtocol.createPrivateMessage(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
viewModel.handleGiftWrap(giftWrap, id: recipient)
let didUpdate = await TestHelpers.waitUntil(
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
timeout: 0.5
)
#expect(didUpdate)
}
@Test @MainActor
func findNoiseKey_matchesFavoriteStoredAsNpub() async throws {
let (viewModel, _) = makeTestableViewModel()
let identity = try NostrIdentity.generate()
let noiseKey = Data((0..<32).map { UInt8(($0 + 80) & 0xFF) })
FavoritesPersistenceService.shared.addFavorite(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: identity.npub,
peerNickname: "Alice"
)
defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) }
#expect(viewModel.findNoiseKey(for: identity.publicKeyHex) == noiseKey)
}
@Test @MainActor
func findNoiseKey_matchesFavoriteStoredAsHex() async {
let (viewModel, _) = makeTestableViewModel()
let nostrHex = String(repeating: "ab", count: 32)
let noiseKey = Data((0..<32).map { UInt8(($0 + 112) & 0xFF) })
FavoritesPersistenceService.shared.addFavorite(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: nostrHex,
peerNickname: "Bob"
)
defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) }
#expect(viewModel.findNoiseKey(for: nostrHex) == noiseKey)
}
@Test @MainActor
func handleFavoriteNotification_updatesFavoriteAssociation() async throws {
let (viewModel, _) = makeTestableViewModel()
let identity = try NostrIdentity.generate()
let noiseKey = Data((0..<32).map { UInt8(($0 + 144) & 0xFF) })
FavoritesPersistenceService.shared.addFavorite(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: identity.npub,
peerNickname: "Before"
)
defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) }
viewModel.handleFavoriteNotification(
content: "FAVORITE:TRUE|NPUB:\(identity.npub)|Alice",
from: identity.publicKeyHex
)
let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
#expect(relationship?.peerNickname == "Alice")
#expect(relationship?.peerNostrPublicKey == identity.npub)
#expect(relationship?.isFavorite == true)
}
@Test @MainActor
func geohashDMHelpers_exposeMappingAndDisplayName() async {
let (viewModel, _) = makeTestableViewModel()
let nostrHex = String(repeating: "cd", count: 32)
let convKey = PeerID(nostr_: nostrHex)
viewModel.geoNicknames[nostrHex] = "Alice"
viewModel.startGeohashDM(withPubkeyHex: nostrHex)
#expect(viewModel.selectedPrivateChatPeer == convKey)
#expect(viewModel.fullNostrHex(forSenderPeerID: convKey) == nostrHex)
#expect(viewModel.geohashDisplayName(for: convKey).hasPrefix("Alice"))
#expect(viewModel.nostrPubkeyForDisplayName("Alice") == nostrHex)
}
}
// MARK: - Geohash Queue Tests
struct ChatViewModelGeohashQueueTests {
@Test @MainActor
func addGeohashOnlySystemMessage_queuesUntilLocationChannel() async {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
viewModel.addGeohashOnlySystemMessage("Queued system")
#expect(!viewModel.messages.contains { $0.content == "Queued system" })
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
#expect(viewModel.messages.contains { $0.content == "Queued system" })
}
}
// MARK: - GeoDM Tests
struct ChatViewModelGeoDMTests {
@Test @MainActor
func handlePrivateMessage_geohash_dedupsAndTracksAck() async throws {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
let senderPubkey = "0000000000000000000000000000000000000000000000000000000000000001"
let messageID = "pm-1"
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)
let convKey = PeerID(nostr_: senderPubkey)
let packet = PrivateMessagePacket(messageID: messageID, content: "Hello")
let payloadData = try #require(packet.encode(), "Failed to encode private message")
let payload = NoisePayload(type: .privateMessage, data: payloadData)
viewModel.handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: identity, messageTimestamp: Date())
viewModel.handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: identity, messageTimestamp: Date())
#expect(viewModel.privateChats[convKey]?.count == 1)
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
}
@Test @MainActor
func sendGeohashDM_requiresActiveLocationChannel() async {
let (viewModel, _) = makeTestableViewModel()
let convKey = PeerID(nostr_: "0000000000000000000000000000000000000000000000000000000000000001")
viewModel.sendGeohashDM("hello", to: convKey)
#expect(viewModel.privateChats[convKey] == nil)
#expect(viewModel.messages.count == 1)
#expect(viewModel.messages.last?.sender == "system")
}
@Test @MainActor
func sendGeohashDM_missingRecipientMapping_marksFailed() async {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
let convKey = PeerID(nostr_: "0000000000000000000000000000000000000000000000000000000000000002")
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
viewModel.sendGeohashDM("hello", to: convKey)
#expect(viewModel.privateChats[convKey]?.count == 1)
#expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus))
}
@Test @MainActor
func sendGeohashDM_blockedRecipient_marksFailedAndAddsSystemMessage() async {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
let recipientHex = "0000000000000000000000000000000000000000000000000000000000000003"
let convKey = PeerID(nostr_: recipientHex)
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
viewModel.nostrKeyMapping[convKey] = recipientHex
viewModel.identityManager.setNostrBlocked(recipientHex, isBlocked: true)
viewModel.sendGeohashDM("hello", to: convKey)
#expect(viewModel.privateChats[convKey]?.count == 1)
#expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus))
#expect(viewModel.messages.contains(where: { $0.sender == "system" }))
}
@Test @MainActor
func handlePrivateMessage_geohashViewingConversationRecordsReadReceipt() async throws {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
let senderPubkey = "0000000000000000000000000000000000000000000000000000000000000004"
let convKey = PeerID(nostr_: senderPubkey)
let messageID = "pm-viewing"
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
viewModel.selectedPrivateChatPeer = convKey
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)
let packet = PrivateMessagePacket(messageID: messageID, content: "Hello")
let payloadData = try #require(packet.encode(), "Failed to encode private message")
let payload = NoisePayload(type: .privateMessage, data: payloadData)
viewModel.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: convKey,
id: identity,
messageTimestamp: Date()
)
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
#expect(viewModel.sentReadReceipts.contains(messageID))
#expect(!viewModel.unreadPrivateMessages.contains(convKey))
}
}
struct ChatViewModelMediaTransferTests {
@Test @MainActor
func handleTransferEvent_updatesPrivateMessageProgressAndClearsMappingOnCompletion() async {
let (viewModel, _) = makeTestableViewModel()
let peerID = PeerID(str: "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10")
let message = viewModel.enqueueMediaMessage(content: "[voice] clip.m4a", targetPeer: peerID)
let transferID = "transfer-1"
viewModel.registerTransfer(transferId: transferID, messageID: message.id)
viewModel.handleTransferEvent(.started(id: transferID, totalFragments: 4))
#expect(isPartiallyDelivered(status: deliveryStatus(in: viewModel, peerID: peerID, messageID: message.id), reached: 0, total: 4))
viewModel.handleTransferEvent(.updated(id: transferID, sentFragments: 2, totalFragments: 4))
#expect(isPartiallyDelivered(status: deliveryStatus(in: viewModel, peerID: peerID, messageID: message.id), reached: 2, total: 4))
viewModel.handleTransferEvent(.completed(id: transferID, totalFragments: 4))
#expect(isSent(status: deliveryStatus(in: viewModel, peerID: peerID, messageID: message.id)))
#expect(viewModel.messageIDToTransferId[message.id] == nil)
#expect(viewModel.transferIdToMessageIDs[transferID] == nil)
}
@Test @MainActor
func handleTransferEvent_cancelledRemovesOutgoingMessage() async {
let (viewModel, _) = makeTestableViewModel()
let peerID = PeerID(str: "1111111111111111111111111111111111111111111111111111111111111111")
let message = viewModel.enqueueMediaMessage(content: "[image] pic.jpg", targetPeer: peerID)
let transferID = "transfer-2"
viewModel.registerTransfer(transferId: transferID, messageID: message.id)
viewModel.handleTransferEvent(.cancelled(id: transferID, sentFragments: 1, totalFragments: 3))
#expect(viewModel.privateChats[peerID]?.contains(where: { $0.id == message.id }) != true)
#expect(viewModel.messageIDToTransferId[message.id] == nil)
}
@Test @MainActor
func sendVoiceNote_outsideAllowedContextDeletesTempFile() async throws {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-\(UUID().uuidString).m4a")
try Data("voice".utf8).write(to: url)
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
viewModel.sendVoiceNote(at: url)
#expect(!FileManager.default.fileExists(atPath: url.path))
#expect(viewModel.messages.contains(where: { $0.sender == "system" }))
}
@Test @MainActor
func sendImage_outsideAllowedContextRunsCleanup() async {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
var cleanupCalled = false
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
viewModel.sendImage(from: URL(fileURLWithPath: "/tmp/ignored.jpg")) {
cleanupCalled = true
}
#expect(cleanupCalled)
#expect(viewModel.messages.contains(where: { $0.sender == "system" }))
}
@Test @MainActor
func sendVoiceNote_privateChatUsesPrivateFileTransfer() async throws {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "2222222222222222222222222222222222222222222222222222222222222222")
let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-\(UUID().uuidString).m4a")
try Data("voice payload".utf8).write(to: url, options: .atomic)
defer { try? FileManager.default.removeItem(at: url) }
viewModel.selectedPrivateChatPeer = peerID
viewModel.sendVoiceNote(at: url)
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 0.5)
#expect(didSend)
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
#expect(viewModel.privateChats[peerID]?.last?.content.contains("[voice]") == true)
#expect(viewModel.messageIDToTransferId.count == 1)
#expect(viewModel.transferIdToMessageIDs.count == 1)
}
@Test @MainActor
func sendVoiceNote_oversizedFileFailsAndDeletesTempFile() async throws {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "3333333333333333333333333333333333333333333333333333333333333333")
let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-too-large-\(UUID().uuidString).m4a")
try Data(repeating: 0x55, count: FileTransferLimits.maxVoiceNoteBytes + 1).write(to: url, options: .atomic)
viewModel.selectedPrivateChatPeer = peerID
viewModel.sendVoiceNote(at: url)
let didFail = await TestHelpers.waitUntil({
isFailed(status: viewModel.privateChats[peerID]?.last?.deliveryStatus)
}, timeout: 0.5)
#expect(didFail)
#expect(!FileManager.default.fileExists(atPath: url.path))
#expect(transport.sentPrivateFiles.isEmpty)
}
@Test @MainActor
func sendImage_privateChatProcessesAndTransfersImage() async throws {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "4444444444444444444444444444444444444444444444444444444444444444")
let sourceURL = try makeTemporaryImageURL()
defer { try? FileManager.default.removeItem(at: sourceURL) }
viewModel.selectedPrivateChatPeer = peerID
viewModel.sendImage(from: sourceURL)
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 1.0)
#expect(didSend)
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
#expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg")
#expect(viewModel.privateChats[peerID]?.last?.content.contains("[image]") == true)
#expect(viewModel.messageIDToTransferId.count == 1)
}
@Test @MainActor
func sendImage_invalidSourceAddsFailureSystemMessage() async throws {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "5555555555555555555555555555555555555555555555555555555555555555")
let url = FileManager.default.temporaryDirectory.appendingPathComponent("invalid-\(UUID().uuidString).jpg")
try Data("not-an-image".utf8).write(to: url, options: .atomic)
defer { try? FileManager.default.removeItem(at: url) }
viewModel.selectedPrivateChatPeer = peerID
viewModel.sendImage(from: url)
let didNotify = await TestHelpers.waitUntil({
viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") })
}, timeout: 2.0)
#expect(didNotify)
#expect(transport.sentPrivateFiles.isEmpty)
#expect(viewModel.privateChats[peerID]?.isEmpty != false)
}
@Test @MainActor
func clearTransferMapping_promotesQueuedTransferForSameID() async {
let (viewModel, _) = makeTestableViewModel()
viewModel.registerTransfer(transferId: "transfer-queue", messageID: "first")
viewModel.registerTransfer(transferId: "transfer-queue", messageID: "second")
viewModel.clearTransferMapping(for: "first")
#expect(viewModel.messageIDToTransferId["first"] == nil)
#expect(viewModel.transferIdToMessageIDs["transfer-queue"] == ["second"])
#expect(viewModel.messageIDToTransferId["second"] == "transfer-queue")
}
@Test @MainActor
func cancelMediaSend_cancelsActiveTransferRemovesMessageAndDeletesFile() async throws {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "6666666666666666666666666666666666666666666666666666666666666666")
let fileName = "cancel-\(UUID().uuidString).m4a"
let fileURL = try mediaFileURL(subdirectory: "voicenotes/outgoing", fileName: fileName)
try Data("cancel me".utf8).write(to: fileURL, options: .atomic)
let message = BitchatMessage(
id: "cancel-msg",
sender: viewModel.nickname,
content: "[voice] \(fileName)",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Peer",
senderPeerID: viewModel.meshService.myPeerID,
deliveryStatus: .sending
)
viewModel.privateChats[peerID] = [message]
viewModel.registerTransfer(transferId: "transfer-cancel", messageID: message.id)
viewModel.cancelMediaSend(messageID: message.id)
#expect(transport.cancelledTransfers == ["transfer-cancel"])
#expect(viewModel.privateChats[peerID] == nil)
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
}
@Test @MainActor
func deleteMediaMessage_removesStoredMessageAndCleansImageFile() async throws {
let (viewModel, _) = makeTestableViewModel()
let peerID = PeerID(str: "7777777777777777777777777777777777777777777777777777777777777777")
let fileName = "delete-\(UUID().uuidString).jpg"
let fileURL = try mediaFileURL(subdirectory: "images/outgoing", fileName: fileName)
try Data("image bytes".utf8).write(to: fileURL, options: .atomic)
let message = BitchatMessage(
id: "delete-msg",
sender: viewModel.nickname,
content: "[image] \(fileName)",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Peer",
senderPeerID: viewModel.meshService.myPeerID,
deliveryStatus: .sent
)
viewModel.privateChats[peerID] = [message]
viewModel.registerTransfer(transferId: "transfer-delete", messageID: message.id)
viewModel.deleteMediaMessage(messageID: message.id)
#expect(viewModel.privateChats[peerID] == nil)
#expect(viewModel.messageIDToTransferId[message.id] == nil)
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
}
@Test @MainActor
func makeTransferID_isPrefixedByMessageIDAndUnique() async {
let (viewModel, _) = makeTestableViewModel()
let first = viewModel.makeTransferID(messageID: "base")
let second = viewModel.makeTransferID(messageID: "base")
#expect(first.hasPrefix("base-"))
#expect(second.hasPrefix("base-"))
#expect(first != second)
}
}
private func base64URLEncode(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
private func ackContent(type: NoisePayloadType, messageID: String, senderPeerID: PeerID) throws -> String {
if let content = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
type: type,
messageID: messageID,
senderPeerID: senderPeerID
) {
return content
}
throw ChatViewModelExtensionsTestError.invalidAckContent
}
private func privateMessageContent(text: String, messageID: String, senderPeerID: PeerID) throws -> String {
if let content = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
content: text,
messageID: messageID,
senderPeerID: senderPeerID
) {
return content
}
throw ChatViewModelExtensionsTestError.invalidPrivateMessageContent
}
@MainActor
private func deliveryStatus(in viewModel: ChatViewModel, peerID: PeerID, messageID: String) -> DeliveryStatus? {
viewModel.privateChats[peerID]?.first(where: { $0.id == messageID })?.deliveryStatus
}
private func isFailed(status: DeliveryStatus?) -> Bool {
if case .failed = status {
return true
}
return false
}
private func isDelivered(status: DeliveryStatus?) -> Bool {
if case .delivered = status {
return true
}
return false
}
private func isRead(status: DeliveryStatus?) -> Bool {
if case .read = status {
return true
}
return false
}
private func isSent(status: DeliveryStatus?) -> Bool {
if case .sent = status {
return true
}
return false
}
private func isPartiallyDelivered(status: DeliveryStatus?, reached: Int, total: Int) -> Bool {
if case .partiallyDelivered(let actualReached, let actualTotal) = status {
return actualReached == reached && actualTotal == total
}
return false
}
private enum ChatViewModelExtensionsTestError: Error {
case invalidAckContent
case invalidPrivateMessageContent
}
private func mediaFileURL(subdirectory: String, fileName: String) throws -> URL {
let base = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
).appendingPathComponent("files", isDirectory: true)
let directory = base.appendingPathComponent(subdirectory, isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return directory.appendingPathComponent(fileName)
}
private func makeTemporaryImageURL() throws -> URL {
let url = FileManager.default.temporaryDirectory.appendingPathComponent("image-\(UUID().uuidString).png")
let data = try makeImageData()
try data.write(to: url, options: .atomic)
return url
}
private func makeImageData() throws -> Data {
#if os(iOS)
let image = UIGraphicsImageRenderer(size: CGSize(width: 64, height: 64)).image { context in
UIColor.systemTeal.setFill()
context.fill(CGRect(x: 0, y: 0, width: 64, height: 64))
}
guard let data = image.pngData() else {
throw ChatViewModelExtensionsTestError.invalidPrivateMessageContent
}
return data
#else
let image = NSImage(size: CGSize(width: 64, height: 64))
image.lockFocus()
NSColor.systemTeal.setFill()
NSBezierPath(rect: CGRect(x: 0, y: 0, width: 64, height: 64)).fill()
image.unlockFocus()
guard
let tiffData = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiffData),
let data = bitmap.representation(using: .png, properties: [:])
else {
throw ChatViewModelExtensionsTestError.invalidPrivateMessageContent
}
return data
#endif
}
@@ -0,0 +1,150 @@
//
// ChatViewModelRefactoringTests.swift
// bitchatTests
//
// Pinning tests to characterize ChatViewModel behavior before refactoring.
// These tests act as a safety net to ensure we don't break existing functionality.
//
import Testing
import Nostr
import Foundation
import BitFoundation
@testable import bitchat
struct ChatViewModelRefactoringTests {
// Helper to setup the environment
@MainActor
private func makePinnedViewModel() -> (viewModel: ChatViewModel, transport: MockTransport, identity: MockIdentityManager) {
let keychain = MockKeychain()
let keychainHelper = MockKeychainHelper()
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
let identityManager = MockIdentityManager(keychain)
let transport = MockTransport()
let viewModel = ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
transport: transport
)
return (viewModel, transport, identityManager)
}
// MARK: - Command Processor Integration "Pinning"
@Test @MainActor
func command_msg_routesToTransport() async throws {
let (viewModel, transport, _) = makePinnedViewModel()
// Setup: Use simulateConnect so ChatViewModel and UnifiedPeerService are notified
let peerID = PeerID(str: "0000000000000001")
transport.simulateConnect(peerID, nickname: "alice")
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("alice") != nil },
timeout: TestConstants.shortTimeout)
#expect(didResolve)
// Action: User types /msg command
viewModel.sendMessage("/msg @alice Hello Private World")
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateMessages.count == 1 },
timeout: TestConstants.shortTimeout)
#expect(didSend)
// Assert:
// 1. Should NOT go to public transport
#expect(transport.sentMessages.isEmpty, "Command should not be sent as public message")
// 2. Should go to private transport logic
#expect(transport.sentPrivateMessages.count == 1)
#expect(transport.sentPrivateMessages.first?.content == "Hello Private World")
#expect(transport.sentPrivateMessages.first?.peerID == peerID)
}
@Test @MainActor
func command_block_updatesIdentity() async throws {
let (viewModel, transport, identity) = makePinnedViewModel()
// Setup: Use simulateConnect
let peerID = PeerID(str: "0000000000000002")
// Mock the fingerprint so the block command finds it
transport.peerFingerprints[peerID] = "fingerprint_123"
transport.simulateConnect(peerID, nickname: "troll")
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("troll") != nil },
timeout: TestConstants.shortTimeout)
#expect(didResolve)
// Action
viewModel.sendMessage("/block @troll")
// Assert
// Verify identity manager was called to block "fingerprint_123"
let didBlock = await TestHelpers.waitUntil({ identity.isBlocked(fingerprint: "fingerprint_123") },
timeout: TestConstants.shortTimeout)
#expect(didBlock)
}
// MARK: - Message Routing Logic
@Test @MainActor
func routing_incomingPrivateMessage_addsToPrivateChats() async {
let (viewModel, _, _) = makePinnedViewModel()
let senderID = PeerID(str: "sender_1")
// Setup
let message = BitchatMessage(
id: "msg_1",
sender: "bob",
content: "Secret",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: "me",
senderPeerID: senderID,
mentions: nil
)
// Action: Simulate incoming private message
viewModel.didReceiveMessage(message)
// Wait for async processing with proper timeout
let found = await TestHelpers.waitUntil(
{ viewModel.privateChats[senderID]?.first?.content == "Secret" },
timeout: TestConstants.defaultTimeout
)
// Assert
#expect(found)
}
@Test @MainActor
func routing_incomingPublicMessage_addsToPublicTimeline() async {
let (viewModel, _, _) = makePinnedViewModel()
let senderID = PeerID(str: "sender_2")
// Action
viewModel.didReceivePublicMessage(
from: senderID,
nickname: "charlie",
content: "Public Hi",
timestamp: Date(),
messageID: "msg_2"
)
// Wait for async processing with proper timeout
let found = await TestHelpers.waitUntil(
{
viewModel.timelineStore.messages(for: .mesh).contains(where: { $0.content == "Public Hi" })
},
timeout: TestConstants.defaultTimeout
)
// Assert
#expect(found)
}
}
+192 -3
View File
@@ -7,7 +7,9 @@
//
import Testing
import Nostr
import Foundation
import BitFoundation
@testable import bitchat
// MARK: - Test Helpers
@@ -114,6 +116,44 @@ struct ChatViewModelSendingTests {
}
}
// MARK: - Command Handling Tests
struct ChatViewModelCommandTests {
@Test @MainActor
func sendMessage_commandsNotSentToTransport() async {
let (viewModel, transport) = makeTestableViewModel()
let commands = ["/nick bob", "/who", "/help", "/clear"]
for command in commands {
transport.resetRecordings()
viewModel.sendMessage(command)
try? await Task.sleep(nanoseconds: 100_000_000)
#expect(transport.sentMessages.isEmpty)
#expect(transport.sentPrivateMessages.isEmpty)
}
}
}
// MARK: - Timeline Cap Tests
struct ChatViewModelTimelineCapTests {
@Test @MainActor
func sendMessage_trimsTimelineToCap() async {
let (viewModel, _) = makeTestableViewModel()
let total = TransportConfig.meshTimelineCap + 5
for i in 0..<total {
viewModel.sendMessage("cap-msg-\(i)")
}
#expect(viewModel.messages.count == TransportConfig.meshTimelineCap)
#expect(viewModel.messages.last?.content == "cap-msg-\(total - 1)")
}
}
// MARK: - Message Receiving Tests
struct ChatViewModelReceivingTests {
@@ -157,10 +197,45 @@ struct ChatViewModelReceivingTests {
messageID: "pub-001"
)
// Give time for async Task and pipeline processing
try? await Task.sleep(nanoseconds: 500_000_000)
let found = await TestHelpers.waitUntil({
viewModel.timelineStore.messages(for: .mesh).contains { $0.content == "Public hello from Bob" }
}, timeout: TestConstants.defaultTimeout)
#expect(viewModel.messages.contains { $0.content == "Public hello from Bob" })
#expect(found)
}
}
// MARK: - Rate Limiting Tests
struct ChatViewModelRateLimitingTests {
@Test @MainActor
func handlePublicMessage_rateLimitsBurstBySender() async {
let (viewModel, _) = makeTestableViewModel()
let senderID = PeerID(str: "1122334455667788")
let now = Date()
for i in 0..<6 {
let message = BitchatMessage(
id: "rate-\(i)",
sender: "Spammer",
content: "rate-msg-\(i)",
timestamp: now,
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: senderID,
mentions: nil
)
viewModel.handlePublicMessage(message)
}
viewModel.publicMessagePipeline.flushIfNeeded()
let burstMessages = viewModel.messages.filter { $0.content.hasPrefix("rate-msg-") }
#expect(burstMessages.count == 5)
#expect(!burstMessages.contains { $0.content == "rate-msg-5" })
}
}
@@ -258,6 +333,94 @@ struct ChatViewModelPrivateChatTests {
}
}
// MARK: - Private Chat Selection Tests
struct ChatViewModelPrivateChatSelectionTests {
@Test @MainActor
func openMostRelevantPrivateChat_prefersUnreadMostRecent() async {
let (viewModel, _) = makeTestableViewModel()
let peerA = PeerID(str: "PEER_A")
let peerB = PeerID(str: "PEER_B")
let older = Date().addingTimeInterval(-120)
let newer = Date().addingTimeInterval(-30)
viewModel.privateChats = [
peerA: [
BitchatMessage(
id: "a-1",
sender: "A",
content: "Old",
timestamp: older,
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerA
)
],
peerB: [
BitchatMessage(
id: "b-1",
sender: "B",
content: "New",
timestamp: newer,
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerB
)
]
]
viewModel.unreadPrivateMessages = [peerA, peerB]
viewModel.openMostRelevantPrivateChat()
#expect(viewModel.selectedPrivateChatPeer == peerB)
}
@Test @MainActor
func openMostRelevantPrivateChat_fallsBackToMostRecentChat() async {
let (viewModel, _) = makeTestableViewModel()
let peerA = PeerID(str: "PEER_A")
let peerB = PeerID(str: "PEER_B")
let older = Date().addingTimeInterval(-200)
let newer = Date().addingTimeInterval(-20)
viewModel.privateChats = [
peerA: [
BitchatMessage(
id: "a-1",
sender: "A",
content: "Old",
timestamp: older,
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerA
)
],
peerB: [
BitchatMessage(
id: "b-1",
sender: "B",
content: "New",
timestamp: newer,
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerB
)
]
]
viewModel.openMostRelevantPrivateChat()
#expect(viewModel.selectedPrivateChatPeer == peerB)
}
}
// MARK: - Bluetooth State Tests
struct ChatViewModelBluetoothTests {
@@ -309,11 +472,37 @@ struct ChatViewModelPanicTests {
// Set up some state
transport.connectedPeers.insert(PeerID(str: "PEER1"))
viewModel.messages = [
BitchatMessage(
id: "panic-1",
sender: "Tester",
content: "Before",
timestamp: Date(),
isRelay: false
)
]
viewModel.privateChats[PeerID(str: "PEER1")] = [
BitchatMessage(
id: "pm-1",
sender: "Peer",
content: "Secret",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: PeerID(str: "PEER1")
)
]
viewModel.unreadPrivateMessages.insert(PeerID(str: "PEER1"))
viewModel.panicClearAllData()
// After panic, emergency disconnect should be called
#expect(transport.emergencyDisconnectCallCount == 1)
#expect(viewModel.messages.isEmpty)
#expect(viewModel.privateChats.isEmpty)
#expect(viewModel.unreadPrivateMessages.isEmpty)
#expect(viewModel.selectedPrivateChatPeer == nil)
}
}
+179
View File
@@ -0,0 +1,179 @@
//
// ChatViewModelTorTests.swift
// bitchatTests
//
// Tests for ChatViewModel+Tor.swift Tor lifecycle notification handlers.
//
import Testing
import Nostr
import Foundation
@testable import bitchat
// MARK: - Test Helpers
@MainActor
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
let keychain = MockKeychain()
let keychainHelper = MockKeychainHelper()
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
let identityManager = MockIdentityManager(keychain)
let transport = MockTransport()
let viewModel = ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
transport: transport
)
return (viewModel, transport)
}
// MARK: - Tor Notification Handler Tests
struct ChatViewModelTorTests {
// MARK: - handleTorWillStart Tests
@Test @MainActor
func handleTorWillStart_whenEnforced_setsAnnouncedFlag() async {
let (viewModel, _) = makeTestableViewModel()
// Precondition: flag should start false
#expect(!viewModel.torStatusAnnounced)
// Action: simulate Tor starting notification
viewModel.handleTorWillStart()
// Wait for Task to complete
try? await Task.sleep(nanoseconds: 100_000_000)
// Assert: flag should be set (torEnforced is true in tests)
#expect(viewModel.torStatusAnnounced)
}
@Test @MainActor
func handleTorWillStart_whenAlreadyAnnounced_doesNotDuplicate() async {
let (viewModel, _) = makeTestableViewModel()
// Setup: pre-set the flag
viewModel.torStatusAnnounced = true
// Switch to a geohash channel so messages would be visible
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: "u4pruydq")))
try? await Task.sleep(nanoseconds: 100_000_000)
let initialMessageCount = viewModel.messages.count
// Action: call handler again
viewModel.handleTorWillStart()
try? await Task.sleep(nanoseconds: 100_000_000)
// Assert: no new message added (flag was already true)
#expect(viewModel.messages.count == initialMessageCount)
}
// MARK: - handleTorWillRestart Tests
@Test @MainActor
func handleTorWillRestart_setsPendingFlag() async {
let (viewModel, _) = makeTestableViewModel()
// Precondition
#expect(!viewModel.torRestartPending)
// Action
viewModel.handleTorWillRestart()
try? await Task.sleep(nanoseconds: 100_000_000)
// Assert
#expect(viewModel.torRestartPending)
}
@Test @MainActor
func handleTorWillRestart_setsFlag_regardlessOfChannel() async {
let (viewModel, _) = makeTestableViewModel()
// Action: call handler (works regardless of channel)
viewModel.handleTorWillRestart()
try? await Task.sleep(nanoseconds: 100_000_000)
// Assert: flag should be set
#expect(viewModel.torRestartPending)
}
// MARK: - handleTorDidBecomeReady Tests
@Test @MainActor
func handleTorDidBecomeReady_afterRestart_clearsPendingFlag() async {
let (viewModel, _) = makeTestableViewModel()
// Setup: simulate restart pending state
viewModel.torRestartPending = true
// Action
viewModel.handleTorDidBecomeReady()
try? await Task.sleep(nanoseconds: 100_000_000)
// Assert: should clear pending flag
#expect(!viewModel.torRestartPending)
}
@Test @MainActor
func handleTorDidBecomeReady_initialStart_setsAnnouncedFlag() async {
let (viewModel, _) = makeTestableViewModel()
// Setup: not restarting, but initial ready not announced yet
viewModel.torRestartPending = false
viewModel.torInitialReadyAnnounced = false
// Action
viewModel.handleTorDidBecomeReady()
try? await Task.sleep(nanoseconds: 100_000_000)
// Assert: should set flag (torEnforced is true in tests)
#expect(viewModel.torInitialReadyAnnounced)
}
@Test @MainActor
func handleTorDidBecomeReady_alreadyAnnounced_noDuplicate() async {
let (viewModel, _) = makeTestableViewModel()
// Setup: already announced initial ready
viewModel.torRestartPending = false
viewModel.torInitialReadyAnnounced = true
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: "u4pruydq")))
try? await Task.sleep(nanoseconds: 100_000_000)
let initialMessageCount = viewModel.messages.count
// Action
viewModel.handleTorDidBecomeReady()
try? await Task.sleep(nanoseconds: 100_000_000)
// Assert: no new message
#expect(viewModel.messages.count == initialMessageCount)
}
// MARK: - handleTorPreferenceChanged Tests
@Test @MainActor
func handleTorPreferenceChanged_resetsAllFlags() async {
let (viewModel, _) = makeTestableViewModel()
// Setup: set all flags
viewModel.torStatusAnnounced = true
viewModel.torInitialReadyAnnounced = true
viewModel.torRestartPending = true
// Action
viewModel.handleTorPreferenceChanged(Notification(name: .init("test")))
try? await Task.sleep(nanoseconds: 100_000_000)
// Assert: all flags reset
#expect(!viewModel.torStatusAnnounced)
#expect(!viewModel.torInitialReadyAnnounced)
#expect(!viewModel.torRestartPending)
}
}
+395 -4
View File
@@ -1,12 +1,16 @@
import Foundation
import Nostr
import Testing
import BitFoundation
@testable import bitchat
@Suite(.serialized)
struct CommandProcessorTests {
private var identityManager = MockIdentityManager(MockKeychain())
@MainActor
@Test func slapNotFoundGrammar() {
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
let identityManager = MockIdentityManager(MockKeychain())
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
let result = processor.process("/slap @system")
switch result {
case .error(let message):
@@ -18,7 +22,8 @@ struct CommandProcessorTests {
@MainActor
@Test func hugNotFoundGrammar() {
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
let identityManager = MockIdentityManager(MockKeychain())
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
let result = processor.process("/hug @system")
switch result {
case .error(let message):
@@ -30,7 +35,8 @@ struct CommandProcessorTests {
@MainActor
@Test func slapUsageMessage() {
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
let identityManager = MockIdentityManager(MockKeychain())
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
let result = processor.process("/slap")
switch result {
case .error(let message):
@@ -39,4 +45,389 @@ struct CommandProcessorTests {
Issue.record("Expected error result for usage message")
}
}
@MainActor
@Test func msgStartsPrivateChatAndSendsMessage() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let peerID = PeerID(str: "abcd1234abcd1234")
context.nicknameToPeerID["alice"] = peerID
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
processor.process("/msg @alice hello there")
}
switch result {
case .success(let message):
#expect(message == "started private chat with alice")
default:
Issue.record("Expected success result")
}
#expect(context.startedPrivateChats == [peerID])
#expect(context.sentPrivateMessages.count == 1)
#expect(context.sentPrivateMessages.first?.content == "hello there")
#expect(context.sentPrivateMessages.first?.peerID == peerID)
}
@MainActor
@Test func whoInMeshListsSortedPeerNicknames() async {
let identityManager = MockIdentityManager(MockKeychain())
let transport = MockTransport()
transport.peerNicknames = [
PeerID(str: "b"): "bob",
PeerID(str: "a"): "alice"
]
let processor = CommandProcessor(contextProvider: MockCommandContextProvider(), meshService: transport, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
processor.process("/who")
}
switch result {
case .success(let message):
#expect(message == "online: alice, bob")
default:
Issue.record("Expected success result")
}
}
@MainActor
@Test func whoInGeohashListsVisibleParticipantsExcludingSelf() async throws {
let bridge = NostrIdentityBridge(keychain: MockKeychain())
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider(idBridge: bridge)
let geohash = "u4pruy"
let selfPubkey = try bridge.deriveIdentity(forGeohash: geohash).publicKeyHex.lowercased()
context.visibleGeoParticipants = [
CommandGeoParticipant(id: selfPubkey, displayName: "me"),
CommandGeoParticipant(id: String(repeating: "b", count: 64), displayName: "bob")
]
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
let result = await withSelectedChannel(channel) {
processor.process("/who")
}
switch result {
case .success(let message):
#expect(message == "online: bob")
default:
Issue.record("Expected success result")
}
}
@MainActor
@Test func clearInPrivateChatRemovesOnlySelectedConversation() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let activePeer = PeerID(str: "active")
let otherPeer = PeerID(str: "other")
context.selectedPrivateChatPeer = activePeer
context.privateChats = [
activePeer: [makeMessage(sender: "alice", content: "secret")],
otherPeer: [makeMessage(sender: "bob", content: "keep")]
]
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
processor.process("/clear")
}
switch result {
case .handled:
break
default:
Issue.record("Expected handled result")
}
#expect(context.privateChats[activePeer] == [])
#expect(context.privateChats[otherPeer]?.count == 1)
}
@MainActor
@Test func clearInPublicChatClearsTimeline() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
processor.process("/clear")
}
switch result {
case .handled:
break
default:
Issue.record("Expected handled result")
}
#expect(context.clearCurrentPublicTimelineCallCount == 1)
}
@MainActor
@Test func hugInPrivateChatSendsPersonalizedMessageAndLocalEcho() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider(nickname: "me")
let transport = MockTransport()
let peerID = PeerID(str: "abcd1234abcd1234")
context.selectedPrivateChatPeer = peerID
context.nicknameToPeerID["bob"] = peerID
transport.peerNicknames[peerID] = "Bob"
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
processor.process("/hug @bob")
}
switch result {
case .handled:
break
default:
Issue.record("Expected handled result")
}
#expect(transport.sentPrivateMessages.count == 1)
#expect(transport.sentPrivateMessages.first?.content == "* 🫂 me hugs you *")
#expect(context.localPrivateSystemMessages.first?.content == "🫂 you hugged bob")
#expect(context.localPrivateSystemMessages.first?.peerID == peerID)
}
@MainActor
@Test func slapInPublicChatSendsPublicRawAndEcho() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider(nickname: "me")
let peerID = PeerID(str: "abcd1234abcd1234")
context.nicknameToPeerID["bob"] = peerID
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
processor.process("/slap @bob")
}
switch result {
case .handled:
break
default:
Issue.record("Expected handled result")
}
#expect(context.sentPublicRawMessages == ["* 🐟 me slaps bob around a bit with a large trout *"])
#expect(context.publicSystemMessages == ["🐟 me slaps bob around a bit with a large trout"])
}
@MainActor
@Test func blockWithoutArgsListsMeshAndGeohashBlocks() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let transport = MockTransport()
let peerID = PeerID(str: "abcd1234abcd1234")
transport.peerNicknames[peerID] = "bob"
transport.peerFingerprints[peerID] = "fp-bob"
context.blockedUsers = ["fp-bob"]
context.visibleGeoParticipants = [
CommandGeoParticipant(id: String(repeating: "c", count: 64), displayName: "carol")
]
identityManager.setNostrBlocked(String(repeating: "c", count: 64), isBlocked: true)
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
processor.process("/block")
}
switch result {
case .success(let message):
#expect(message == "blocked peers: bob | geohash blocks: carol")
default:
Issue.record("Expected success result")
}
}
@MainActor
@Test func blockAndUnblockMeshPeerUpdateIdentityState() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let transport = MockTransport()
let peerID = PeerID(str: "abcd1234abcd1234")
transport.peerFingerprints[peerID] = "fp-bob"
context.nicknameToPeerID["bob"] = peerID
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
let blockResult = await withSelectedChannel(.mesh) {
processor.process("/block @bob")
}
switch blockResult {
case .success(let message):
#expect(message == "blocked bob. you will no longer receive messages from them")
default:
Issue.record("Expected success result")
}
#expect(identityManager.isBlocked(fingerprint: "fp-bob"))
let unblockResult = await withSelectedChannel(.mesh) {
processor.process("/unblock bob")
}
switch unblockResult {
case .success(let message):
#expect(message == "unblocked bob")
default:
Issue.record("Expected success result")
}
#expect(!identityManager.isBlocked(fingerprint: "fp-bob"))
}
@MainActor
@Test func blockAndUnblockGeohashPeerUseNostrBlockList() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
context.displayNameToNostrPubkey["carol"] = String(repeating: "d", count: 64)
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
let blockResult = await withSelectedChannel(.mesh) {
processor.process("/block carol")
}
switch blockResult {
case .success(let message):
#expect(message == "blocked carol in geohash chats")
default:
Issue.record("Expected success result")
}
#expect(identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64)))
let unblockResult = await withSelectedChannel(.mesh) {
processor.process("/unblock @carol")
}
switch unblockResult {
case .success(let message):
#expect(message == "unblocked carol in geohash chats")
default:
Issue.record("Expected success result")
}
#expect(!identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64)))
}
@MainActor
@Test func favoriteCommandIsRejectedOutsideMesh() async {
let identityManager = MockIdentityManager(MockKeychain())
let processor = CommandProcessor(
contextProvider: MockCommandContextProvider(),
meshService: MockTransport(),
identityManager: identityManager
)
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: "u4pruy"))
let result = await withSelectedChannel(channel) {
processor.process("/fav alice")
}
switch result {
case .error(let message):
#expect(message == "favorites are only for mesh peers in #mesh")
default:
Issue.record("Expected error result")
}
}
@MainActor
private func withSelectedChannel<T>(_ channel: ChannelID, perform work: @escaping () throws -> T) async rethrows -> T {
let originalChannel = LocationChannelManager.shared.selectedChannel
await setSelectedChannel(channel)
do {
let result = try work()
await setSelectedChannel(originalChannel)
return result
} catch {
await setSelectedChannel(originalChannel)
throw error
}
}
@MainActor
private func setSelectedChannel(_ channel: ChannelID) async {
LocationChannelManager.shared.select(channel)
for _ in 0..<40 {
if LocationChannelManager.shared.selectedChannel == channel {
return
}
await Task.yield()
try? await Task.sleep(nanoseconds: 5_000_000)
}
}
private func makeMessage(sender: String, content: String) -> BitchatMessage {
BitchatMessage(
sender: sender,
content: content,
timestamp: Date(timeIntervalSince1970: 1_700_000_000),
isRelay: false
)
}
}
@MainActor
private final class MockCommandContextProvider: CommandContextProvider {
var nickname: String
var selectedPrivateChatPeer: PeerID?
var blockedUsers: Set<String> = []
var privateChats: [PeerID: [BitchatMessage]] = [:]
let idBridge: NostrIdentityBridge
var nicknameToPeerID: [String: PeerID] = [:]
var visibleGeoParticipants: [CommandGeoParticipant] = []
var displayNameToNostrPubkey: [String: String] = [:]
private(set) var startedPrivateChats: [PeerID] = []
private(set) var sentPrivateMessages: [(content: String, peerID: PeerID)] = []
private(set) var clearCurrentPublicTimelineCallCount = 0
private(set) var sentPublicRawMessages: [String] = []
private(set) var localPrivateSystemMessages: [(content: String, peerID: PeerID)] = []
private(set) var publicSystemMessages: [String] = []
private(set) var toggledFavorites: [PeerID] = []
private(set) var favoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
init(nickname: String = "tester", idBridge: NostrIdentityBridge = NostrIdentityBridge(keychain: MockKeychain())) {
self.nickname = nickname
self.idBridge = idBridge
}
func getPeerIDForNickname(_ nickname: String) -> PeerID? {
nicknameToPeerID[nickname]
}
func getVisibleGeoParticipants() -> [CommandGeoParticipant] {
visibleGeoParticipants
}
func nostrPubkeyForDisplayName(_ displayName: String) -> String? {
displayNameToNostrPubkey[displayName]
}
func startPrivateChat(with peerID: PeerID) {
startedPrivateChats.append(peerID)
}
func sendPrivateMessage(_ content: String, to peerID: PeerID) {
sentPrivateMessages.append((content, peerID))
}
func clearCurrentPublicTimeline() {
clearCurrentPublicTimelineCallCount += 1
}
func sendPublicRaw(_ content: String) {
sentPublicRawMessages.append(content)
}
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {
localPrivateSystemMessages.append((content, peerID))
}
func addPublicSystemMessage(_ content: String) {
publicSystemMessages.append(content)
}
func toggleFavorite(peerID: PeerID) {
toggledFavorites.append(peerID)
}
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
favoriteNotifications.append((peerID, isFavorite))
}
}
@@ -9,6 +9,8 @@
import Testing
import CryptoKit
import struct Foundation.UUID
import BitFoundation
import Noise
@testable import bitchat
struct PrivateChatE2ETests {
@@ -8,6 +8,7 @@
import Testing
import struct Foundation.UUID
import BitFoundation
@testable import bitchat
struct PublicChatE2ETests {
@@ -0,0 +1,67 @@
import Testing
import Foundation
#if os(iOS)
import UIKit
#else
import AppKit
#endif
@testable import bitchat
private func makeTemporaryFileURL(_ name: String) -> URL {
FileManager.default.temporaryDirectory.appendingPathComponent(name)
}
#if os(iOS)
private func makePlatformImage(size: CGSize) -> UIImage {
UIGraphicsImageRenderer(size: size).image { context in
UIColor.systemTeal.setFill()
context.fill(CGRect(origin: .zero, size: size))
}
}
#else
private func makePlatformImage(size: CGSize) -> NSImage {
let image = NSImage(size: size)
image.lockFocus()
NSColor.systemTeal.setFill()
NSBezierPath(rect: CGRect(origin: .zero, size: size)).fill()
image.unlockFocus()
return image
}
#endif
struct ImageUtilsTests {
@Test
func processImage_rejectsOversizedSourceFile() throws {
let url = makeTemporaryFileURL("image-too-large.bin")
try Data(repeating: 0xFF, count: 10 * 1024 * 1024 + 1).write(to: url, options: .atomic)
defer { try? FileManager.default.removeItem(at: url) }
#expect(throws: ImageUtilsError.self) {
try ImageUtils.processImage(at: url)
}
}
@Test
func processImage_rejectsInvalidImageData() throws {
let url = makeTemporaryFileURL("image-invalid.bin")
try Data("not-an-image".utf8).write(to: url, options: .atomic)
defer { try? FileManager.default.removeItem(at: url) }
#expect(throws: ImageUtilsError.self) {
try ImageUtils.processImage(at: url)
}
}
@Test
func processImage_writesCompressedJpeg() throws {
let image = makePlatformImage(size: CGSize(width: 1024, height: 768))
let outputURL = try ImageUtils.processImage(image, maxDimension: 256)
defer { try? FileManager.default.removeItem(at: outputURL) }
let data = try Data(contentsOf: outputURL)
#expect(outputURL.pathExtension.lowercased() == "jpg")
#expect(data.starts(with: Data([0xFF, 0xD8])))
#expect(data.count > 0)
}
}
@@ -7,8 +7,10 @@
//
import Testing
import Nostr
import Foundation
import CoreBluetooth
import BitFoundation
@testable import bitchat
struct FragmentationTests {
@@ -28,33 +30,33 @@ struct FragmentationTests {
let ble = BLEService(
keychain: mockKeychain,
idBridge: idBridge,
identityManager: mockIdentityManager
identityManager: mockIdentityManager,
initializeBluetoothManagers: false
)
let capture = CaptureDelegate()
ble.delegate = capture
// Construct a big packet (3KB) from a remote sender (not our own ID)
let remoteShortID = PeerID(str: "1122334455667788")
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000)
// Use a small fragment size to ensure multiple pieces
let fragments = fragmentPacket(original, fragmentSize: 400)
// Shuffle fragments to simulate out-of-order arrival
let shuffled = fragments.shuffled()
// Inject fragments spaced out to avoid concurrent mutation inside BLEService
// Send fragments sequentially with small delays (no fire-and-forget Tasks)
for (i, fragment) in shuffled.enumerated() {
let delay = 5 * Double(i) * 0.001
Task {
try await sleep(delay)
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
if i > 0 {
try await Task.sleep(for: .milliseconds(5))
}
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
}
// Allow async processing
try await sleep(0.5)
// Wait for delegate callback with proper timeout
try await capture.waitForPublicMessages(count: 1, timeout: .seconds(2))
#expect(capture.publicMessages.count == 1)
#expect(capture.publicMessages.first?.content.count == 3_000)
}
@@ -64,30 +66,31 @@ struct FragmentationTests {
let ble = BLEService(
keychain: mockKeychain,
idBridge: idBridge,
identityManager: mockIdentityManager
identityManager: mockIdentityManager,
initializeBluetoothManagers: false
)
let capture = CaptureDelegate()
ble.delegate = capture
let remoteShortID = PeerID(str: "A1B2C3D4E5F60708")
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048)
var frags = fragmentPacket(original, fragmentSize: 300)
// Duplicate one fragment
if let dup = frags.first {
frags.insert(dup, at: 1)
}
// Send fragments sequentially with small delays (no fire-and-forget Tasks)
for (i, fragment) in frags.enumerated() {
let delay = 5 * Double(i) * 0.001
Task {
try await sleep(delay)
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
if i > 0 {
try await Task.sleep(for: .milliseconds(5))
}
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
}
// Allow async processing
try await sleep(0.5)
// Wait for delegate callback with proper timeout
try await capture.waitForPublicMessages(count: 1, timeout: .seconds(2))
#expect(capture.publicMessages.count == 1)
#expect(capture.publicMessages.first?.content.count == 2048)
@@ -98,7 +101,8 @@ struct FragmentationTests {
let ble = BLEService(
keychain: mockKeychain,
idBridge: idBridge,
identityManager: mockIdentityManager
identityManager: mockIdentityManager,
initializeBluetoothManagers: false
)
let capture = CaptureDelegate()
ble.delegate = capture
@@ -135,7 +139,7 @@ struct FragmentationTests {
}
}
try await sleep(1.0)
try await capture.waitForReceivedMessages(count: 1, timeout: .seconds(2))
let message = try #require(capture.receivedMessages.first, "Expected file transfer message")
#expect(message.content.hasPrefix("[file]"))
@@ -154,7 +158,8 @@ struct FragmentationTests {
let ble = BLEService(
keychain: mockKeychain,
idBridge: idBridge,
identityManager: mockIdentityManager
identityManager: mockIdentityManager,
initializeBluetoothManagers: false
)
let capture = CaptureDelegate()
ble.delegate = capture
@@ -196,12 +201,140 @@ struct FragmentationTests {
}
extension FragmentationTests {
private final class CaptureDelegate: BitchatDelegate {
var publicMessages: [(peerID: PeerID, nickname: String, content: String)] = []
var receivedMessages: [BitchatMessage] = []
func didReceiveMessage(_ message: BitchatMessage) {
receivedMessages.append(message)
/// Thread-safe delegate that supports awaiting message delivery
private final class CaptureDelegate: BitchatDelegate, @unchecked Sendable {
private let lock = NSLock()
private var _publicMessages: [(peerID: PeerID, nickname: String, content: String)] = []
private var _receivedMessages: [BitchatMessage] = []
private var publicMessageContinuation: CheckedContinuation<Void, Never>?
private var receivedMessageContinuation: CheckedContinuation<Void, Never>?
private var expectedPublicMessageCount: Int = 0
private var expectedReceivedMessageCount: Int = 0
private func withLock<T>(_ body: () -> T) -> T {
lock.lock()
defer { lock.unlock() }
return body()
}
var publicMessages: [(peerID: PeerID, nickname: String, content: String)] {
withLock { _publicMessages }
}
var receivedMessages: [BitchatMessage] {
withLock { _receivedMessages }
}
func didReceiveMessage(_ message: BitchatMessage) {
lock.lock()
_receivedMessages.append(message)
let count = _receivedMessages.count
let expected = expectedReceivedMessageCount
let continuation = receivedMessageContinuation
lock.unlock()
if count >= expected, let cont = continuation {
lock.lock()
receivedMessageContinuation = nil
lock.unlock()
cont.resume()
}
}
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
lock.lock()
_publicMessages.append((peerID, nickname, content))
let count = _publicMessages.count
let expected = expectedPublicMessageCount
let continuation = publicMessageContinuation
lock.unlock()
if count >= expected, let cont = continuation {
lock.lock()
publicMessageContinuation = nil
lock.unlock()
cont.resume()
}
}
/// Waits for the specified number of public messages to be received
func waitForPublicMessages(count: Int, timeout: Duration = .seconds(2)) async throws {
let isAlreadySatisfied = withLock { () -> Bool in
if _publicMessages.count >= count {
return true
}
expectedPublicMessageCount = count
return false
}
if isAlreadySatisfied {
return
}
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
await withCheckedContinuation { continuation in
let shouldResumeImmediately = self.withLock {
// Recheck count after acquiring lock to avoid race condition
// where message arrives between initial check and continuation install
if self._publicMessages.count >= count {
return true
}
self.publicMessageContinuation = continuation
return false
}
if shouldResumeImmediately {
continuation.resume()
}
}
}
group.addTask {
try await Task.sleep(for: timeout)
throw CancellationError()
}
try await group.next()
group.cancelAll()
}
}
/// Waits for the specified number of received messages
func waitForReceivedMessages(count: Int, timeout: Duration = .seconds(2)) async throws {
let isAlreadySatisfied = withLock { () -> Bool in
if _receivedMessages.count >= count {
return true
}
expectedReceivedMessageCount = count
return false
}
if isAlreadySatisfied {
return
}
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
await withCheckedContinuation { continuation in
let shouldResumeImmediately = self.withLock {
// Recheck count after acquiring lock to avoid race condition
// where message arrives between initial check and continuation install
if self._receivedMessages.count >= count {
return true
}
self.receivedMessageContinuation = continuation
return false
}
if shouldResumeImmediately {
continuation.resume()
}
}
}
group.addTask {
try await Task.sleep(for: timeout)
throw CancellationError()
}
try await group.next()
group.cancelAll()
}
}
func didConnectToPeer(_ peerID: PeerID) {}
func didDisconnectFromPeer(_ peerID: PeerID) {}
func didUpdatePeerList(_ peers: [PeerID]) {}
@@ -209,9 +342,6 @@ extension FragmentationTests {
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
func didUpdateBluetoothState(_ state: CBManagerState) {}
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
publicMessages.append((peerID, nickname, content))
}
func didReceiveRegionalPublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {}
}
+568
View File
@@ -0,0 +1,568 @@
//
// GeohashPresenceTests.swift
// bitchatTests
//
// Tests for the Geohash Presence (Kind 20001) feature.
// This is free and unencumbered software released into the public domain.
//
import Testing
import Nostr
import Foundation
import Combine
@testable import bitchat
// MARK: - NostrProtocol Presence Event Tests
struct NostrProtocolPresenceTests {
@Test func createGeohashPresenceEvent_hasCorrectKind() throws {
let identity = try makeTestIdentity()
let event = try NostrProtocol.createGeohashPresenceEvent(
geohash: "u4pruydq",
senderIdentity: identity
)
#expect(event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
#expect(event.kind == 20001)
}
@Test func createGeohashPresenceEvent_hasEmptyContent() throws {
let identity = try makeTestIdentity()
let event = try NostrProtocol.createGeohashPresenceEvent(
geohash: "u4pruydq",
senderIdentity: identity
)
#expect(event.content == "")
}
@Test func createGeohashPresenceEvent_hasOnlyGeohashTag() throws {
let identity = try makeTestIdentity()
let event = try NostrProtocol.createGeohashPresenceEvent(
geohash: "u4pruydq",
senderIdentity: identity
)
// Should have exactly one tag: ["g", geohash]
#expect(event.tags.count == 1)
#expect(event.tags[0] == ["g", "u4pruydq"])
}
@Test func createGeohashPresenceEvent_noNicknameTag() throws {
let identity = try makeTestIdentity()
let event = try NostrProtocol.createGeohashPresenceEvent(
geohash: "u4pruydq",
senderIdentity: identity
)
// Should NOT contain nickname tag
let hasNicknameTag = event.tags.contains { $0.first == "n" }
#expect(!hasNicknameTag)
}
@Test func createGeohashPresenceEvent_usesSenderPubkey() throws {
let identity = try makeTestIdentity()
let event = try NostrProtocol.createGeohashPresenceEvent(
geohash: "u4pruydq",
senderIdentity: identity
)
#expect(event.pubkey == identity.publicKeyHex)
}
@Test func createGeohashPresenceEvent_isSigned() throws {
let identity = try makeTestIdentity()
let event = try NostrProtocol.createGeohashPresenceEvent(
geohash: "u4pruydq",
senderIdentity: identity
)
#expect(event.sig != nil && !event.sig!.isEmpty)
#expect(!event.id.isEmpty)
}
@Test func createGeohashPresenceEvent_differentGeohashes() throws {
let identity = try makeTestIdentity()
let event1 = try NostrProtocol.createGeohashPresenceEvent(geohash: "87", senderIdentity: identity)
let event2 = try NostrProtocol.createGeohashPresenceEvent(geohash: "87yw", senderIdentity: identity)
let event3 = try NostrProtocol.createGeohashPresenceEvent(geohash: "87yw7", senderIdentity: identity)
#expect(event1.tags[0][1] == "87")
#expect(event2.tags[0][1] == "87yw")
#expect(event3.tags[0][1] == "87yw7")
}
// MARK: - Helper
private func makeTestIdentity() throws -> NostrIdentity {
// Generate a fresh test identity
return try NostrIdentity.generate()
}
}
// MARK: - NostrFilter Presence Tests
struct NostrFilterPresenceTests {
@Test func geohashEphemeral_includesBothKinds() {
let filter = NostrFilter.geohashEphemeral("u4pruydq")
#expect(filter.kinds?.contains(20000) == true)
#expect(filter.kinds?.contains(20001) == true)
}
@Test func geohashEphemeral_hasLimit1000() {
let filter = NostrFilter.geohashEphemeral("u4pruydq")
#expect(filter.limit == 1000)
}
@Test func geohashEphemeral_respectsSinceParameter() {
let since = Date(timeIntervalSince1970: 1700000000)
let filter = NostrFilter.geohashEphemeral("u4pruydq", since: since)
#expect(filter.since == 1700000000)
}
}
// MARK: - ChatViewModel Presence Handling Tests
@MainActor
struct ChatViewModelPresenceHandlingTests {
@Test func handleNostrEvent_presenceUpdatesParticipantTracker() async throws {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
// Set up the channel
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
// Create a presence event (kind 20001)
let identity = try NostrIdentity.generate()
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .geohashPresence,
tags: [["g", geohash]],
content: ""
)
let signed = try event.sign(with: identity.schnorrSigningKey())
// Handle the event
viewModel.handleNostrEvent(signed)
// Allow async processing
try? await Task.sleep(nanoseconds: 50_000_000)
// Participant should be recorded
let count = viewModel.geohashParticipantCount(for: geohash)
#expect(count >= 1)
}
@Test func handleNostrEvent_presenceDoesNotAddToTimeline() async throws {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
let initialMessageCount = viewModel.messages.count
// Create a presence event (kind 20001)
let identity = try NostrIdentity.generate()
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .geohashPresence,
tags: [["g", geohash]],
content: ""
)
let signed = try event.sign(with: identity.schnorrSigningKey())
viewModel.handleNostrEvent(signed)
try? await Task.sleep(nanoseconds: 50_000_000)
// Message count should NOT increase
#expect(viewModel.messages.count == initialMessageCount)
}
@Test func handleNostrEvent_chatMessageUpdatesParticipant() async throws {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
// Create a chat event (kind 20000) - NOT presence
let identity = try NostrIdentity.generate()
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: [["g", geohash]],
content: "Hello world"
)
let signed = try event.sign(with: identity.schnorrSigningKey())
viewModel.handleNostrEvent(signed)
try? await Task.sleep(nanoseconds: 50_000_000)
// Chat messages should also update participant count (not just presence)
let count = viewModel.geohashParticipantCount(for: geohash)
#expect(count >= 1)
}
@Test func presenceEvent_hasDifferentKindThanChat() {
// Verify the two event kinds are distinct
let presenceKind = NostrProtocol.EventKind.geohashPresence.rawValue
let chatKind = NostrProtocol.EventKind.ephemeralEvent.rawValue
#expect(presenceKind != chatKind)
#expect(presenceKind == 20001)
#expect(chatKind == 20000)
}
@Test func subscribeNostrEvent_acceptsPresenceKind() async throws {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
// Create presence event
let identity = try NostrIdentity.generate()
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .geohashPresence,
tags: [["g", geohash]],
content: ""
)
let signed = try event.sign(with: identity.schnorrSigningKey())
// subscribeNostrEvent should accept kind 20001
viewModel.subscribeNostrEvent(signed)
try? await Task.sleep(nanoseconds: 50_000_000)
// Should record participant
let count = viewModel.geohashParticipantCount(for: geohash)
#expect(count >= 1)
}
@Test func subscribeNostrEvent_presenceForNonActiveGeohash() async throws {
let (viewModel, _) = makeTestableViewModel()
let activeGeohash = "u4pruydq"
let otherGeohash = "87yw7"
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: activeGeohash)))
// Create presence event for a DIFFERENT geohash
let identity = try NostrIdentity.generate()
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .geohashPresence,
tags: [["g", otherGeohash]],
content: ""
)
let signed = try event.sign(with: identity.schnorrSigningKey())
// Use subscribeNostrEvent with geohash parameter
viewModel.subscribeNostrEvent(signed, gh: otherGeohash)
try? await Task.sleep(nanoseconds: 50_000_000)
// Should record for the other geohash
let count = viewModel.geohashParticipantCount(for: otherGeohash)
#expect(count >= 1)
}
// MARK: - Test Helper
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
let keychain = MockKeychain()
let keychainHelper = MockKeychainHelper()
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
let identityManager = MockIdentityManager(keychain)
let transport = MockTransport()
let viewModel = ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
transport: transport
)
return (viewModel, transport)
}
}
// MARK: - Presence Privacy Tests
struct GeohashPresencePrivacyTests {
@Test func allowedPrecisions_onlyLowPrecision() {
// The allowed precisions for presence broadcasting should be:
// Region (2), Province (4), City (5)
// NOT Neighborhood (6), Block (7), Building (8+)
let regionPrecision = GeohashChannelLevel.region.precision
let provincePrecision = GeohashChannelLevel.province.precision
let cityPrecision = GeohashChannelLevel.city.precision
let neighborhoodPrecision = GeohashChannelLevel.neighborhood.precision
let blockPrecision = GeohashChannelLevel.block.precision
let buildingPrecision = GeohashChannelLevel.building.precision
#expect(regionPrecision == 2)
#expect(provincePrecision == 4)
#expect(cityPrecision == 5)
#expect(neighborhoodPrecision == 6)
#expect(blockPrecision == 7)
#expect(buildingPrecision == 8)
// High precision channels should NOT receive presence broadcasts
#expect(neighborhoodPrecision > 5)
#expect(blockPrecision > 5)
#expect(buildingPrecision > 5)
}
@Test func geohashLengthDeterminesPrecision() {
// Verify geohash length maps to expected precision
#expect("87".count == GeohashChannelLevel.region.precision)
#expect("87yw".count == GeohashChannelLevel.province.precision)
#expect("87yw7".count == GeohashChannelLevel.city.precision)
#expect("87yw7t".count == GeohashChannelLevel.neighborhood.precision)
#expect("87yw7tc".count == GeohashChannelLevel.block.precision)
#expect("87yw7tcx".count == GeohashChannelLevel.building.precision)
}
@Test func highPrecisionGeohash_isPrivacySensitive() {
// Helper to check if a geohash is "high precision" (privacy sensitive)
func isHighPrecision(_ geohash: String) -> Bool {
geohash.count >= 6
}
// Low precision - OK to broadcast presence
#expect(!isHighPrecision("87")) // region
#expect(!isHighPrecision("87yw")) // province
#expect(!isHighPrecision("87yw7")) // city
// High precision - should NOT broadcast presence
#expect(isHighPrecision("87yw7t")) // neighborhood
#expect(isHighPrecision("87yw7tc")) // block
#expect(isHighPrecision("87yw7tcx")) // building
}
}
// MARK: - Display Logic Tests
struct LocationChannelsDisplayLogicTests {
@Test func displayLogic_highPrecisionZeroCount_showsUnknown() {
// Test the logic that determines "?" vs actual count
// High precision + count 0 = "?"
let shouldShowUnknown = shouldShowUnknownCount(
level: .neighborhood,
count: 0
)
#expect(shouldShowUnknown)
}
@Test func displayLogic_highPrecisionNonZeroCount_showsActual() {
// High precision + count > 0 = show actual
let shouldShowUnknown = shouldShowUnknownCount(
level: .neighborhood,
count: 5
)
#expect(!shouldShowUnknown)
}
@Test func displayLogic_lowPrecisionZeroCount_showsActual() {
// Low precision + count 0 = show "0" (not "?")
let shouldShowUnknown = shouldShowUnknownCount(
level: .city,
count: 0
)
#expect(!shouldShowUnknown)
}
@Test func displayLogic_lowPrecisionNonZeroCount_showsActual() {
// Low precision + count > 0 = show actual
let shouldShowUnknown = shouldShowUnknownCount(
level: .region,
count: 10
)
#expect(!shouldShowUnknown)
}
@Test func displayLogic_allHighPrecisionLevels() {
// All high precision levels with 0 should show "?"
let highPrecisionLevels: [GeohashChannelLevel] = [.neighborhood, .block, .building]
for level in highPrecisionLevels {
let shouldShowUnknown = shouldShowUnknownCount(level: level, count: 0)
#expect(shouldShowUnknown, "Level \(level) with count 0 should show unknown")
}
}
@Test func displayLogic_allLowPrecisionLevels() {
// All low precision levels with 0 should show actual count
let lowPrecisionLevels: [GeohashChannelLevel] = [.region, .province, .city]
for level in lowPrecisionLevels {
let shouldShowUnknown = shouldShowUnknownCount(level: level, count: 0)
#expect(!shouldShowUnknown, "Level \(level) with count 0 should show actual count")
}
}
@Test func displayLogic_bookmarkHighPrecision() {
// Bookmarks use geohash length to determine precision
#expect(shouldShowUnknownForBookmark(geohash: "87yw7t", count: 0)) // len 6
#expect(shouldShowUnknownForBookmark(geohash: "87yw7tc", count: 0)) // len 7
#expect(shouldShowUnknownForBookmark(geohash: "87yw7tcx", count: 0)) // len 8
}
@Test func displayLogic_bookmarkLowPrecision() {
#expect(!shouldShowUnknownForBookmark(geohash: "87", count: 0)) // len 2
#expect(!shouldShowUnknownForBookmark(geohash: "87yw", count: 0)) // len 4
#expect(!shouldShowUnknownForBookmark(geohash: "87yw7", count: 0)) // len 5
}
// MARK: - Helpers (mirror the logic from LocationChannelsSheet)
private func shouldShowUnknownCount(level: GeohashChannelLevel, count: Int) -> Bool {
let isHighPrecision = (level == .neighborhood || level == .block || level == .building)
return isHighPrecision && count == 0
}
private func shouldShowUnknownForBookmark(geohash: String, count: Int) -> Bool {
let isHighPrecision = (geohash.count >= 6)
return isHighPrecision && count == 0
}
}
// MARK: - Event Kind Tests
struct NostrEventKindTests {
@Test func eventKind_geohashPresence_is20001() {
#expect(NostrProtocol.EventKind.geohashPresence.rawValue == 20001)
}
@Test func eventKind_ephemeralEvent_is20000() {
#expect(NostrProtocol.EventKind.ephemeralEvent.rawValue == 20000)
}
@Test func eventKind_presenceIsEphemeral() {
// Both 20000 and 20001 are in the ephemeral range (20000-29999)
let presenceKind = NostrProtocol.EventKind.geohashPresence.rawValue
let chatKind = NostrProtocol.EventKind.ephemeralEvent.rawValue
#expect(presenceKind >= 20000 && presenceKind < 30000)
#expect(chatKind >= 20000 && chatKind < 30000)
}
}
// MARK: - Participant Tracker Presence Integration Tests
@MainActor
struct ParticipantTrackerPresenceTests {
@Test func recordParticipant_fromPresenceEvent_countsParticipant() async {
let tracker = GeohashParticipantTracker()
let context = PresenceTestParticipantContext()
tracker.configure(context: context)
let geohash = "87yw7"
tracker.setActiveGeohash(geohash)
// Simulate recording from a presence event
tracker.recordParticipant(pubkeyHex: "presence_user_1")
#expect(tracker.participantCount(for: geohash) == 1)
}
@Test func recordParticipant_multiplePresenceEvents_countsUnique() async {
let tracker = GeohashParticipantTracker()
let context = PresenceTestParticipantContext()
tracker.configure(context: context)
let geohash = "87yw7"
tracker.setActiveGeohash(geohash)
// Multiple presence events from same user = 1 participant
tracker.recordParticipant(pubkeyHex: "user_a")
tracker.recordParticipant(pubkeyHex: "user_a")
tracker.recordParticipant(pubkeyHex: "user_a")
#expect(tracker.participantCount(for: geohash) == 1)
// Different user = 2 participants
tracker.recordParticipant(pubkeyHex: "user_b")
#expect(tracker.participantCount(for: geohash) == 2)
}
@Test func recordParticipant_nonActiveGeohash_stillCounts() async {
let tracker = GeohashParticipantTracker()
let context = PresenceTestParticipantContext()
tracker.configure(context: context)
// Active geohash is different from where we're recording
tracker.setActiveGeohash("active_gh")
// Record to a non-active geohash (like when sampling nearby channels)
tracker.recordParticipant(pubkeyHex: "nearby_user", geohash: "other_gh")
#expect(tracker.participantCount(for: "other_gh") == 1)
#expect(tracker.participantCount(for: "active_gh") == 0)
}
@Test func objectWillChange_firesOnNonActiveGeohashUpdate() async {
let tracker = GeohashParticipantTracker()
let context = PresenceTestParticipantContext()
tracker.configure(context: context)
tracker.setActiveGeohash("active_gh")
var changeCount = 0
let cancellable = tracker.objectWillChange.sink { _ in
changeCount += 1
}
// Record to non-active geohash
tracker.recordParticipant(pubkeyHex: "user1", geohash: "other_gh")
// Should fire objectWillChange even for non-active geohash
#expect(changeCount >= 1)
_ = cancellable // Keep alive
}
}
// MARK: - Mock for Participant Context (Presence Tests)
@MainActor
private final class PresenceTestParticipantContext: GeohashParticipantContext {
var blockedPubkeys: Set<String> = []
var nicknameMap: [String: String] = [:]
var selfPubkey: String?
func displayNameForPubkey(_ pubkeyHex: String) -> String {
let suffix = String(pubkeyHex.suffix(4))
if let s = selfPubkey, pubkeyHex.lowercased() == s.lowercased() {
return "me#\(suffix)"
}
if let nick = nicknameMap[pubkeyHex.lowercased()] {
return "\(nick)#\(suffix)"
}
return "anon#\(suffix)"
}
func isBlocked(_ pubkeyHexLowercased: String) -> Bool {
blockedPubkeys.contains(pubkeyHexLowercased.lowercased())
}
}

Some files were not shown because too many files have changed in this diff Show More