Compare commits

..
Author SHA1 Message Date
14c4e586fc Improve background BLE stability and nearby alerts (#864)
* Harden background BLE restoration and notifications

* Guard BLE restoration paths to iOS

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-23 19:54:06 +02:00
83779240ae Prevent mesh self-sync duplicates (#856)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-23 10:41:28 +02:00
5034732515 Simplify validation, compression heuristics, and notification scheduling (#841)
* Simplify validation, compression heuristics, and notification scheduling

* Consolidate notification logic and add InputValidator monitoring

Follow-up improvements to address PR feedback:

1. Consolidate notification functions
   - Add interruptionLevel parameter to sendLocalNotification
   - Refactor sendNetworkAvailableNotification to use consolidated function
   - Removes 12 lines of duplicate code

2. Add monitoring to InputValidator
   - Log control character rejections for production monitoring
   - Privacy-preserving: logs length + count, not actual content
   - Uses .security category for proper log routing

3. Add comprehensive InputValidator tests
   - 28 test cases covering validation, control characters, unicode, edge cases
   - Ensures behavioral changes are well-tested and documented

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-22 12:33:37 +02:00
b6ce4fae43 Add type-aware REQUEST_SYNC sync rounds (#853)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-22 12:24:29 +02:00
98fa02cc16 Prevent duplicate action emote echoes (#852)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-21 13:47:24 +02:00
0812cafd55 Improve mesh media throughput (#845)
* Improve mesh media throughput

* Reserve media slots atomically

* Prioritize small fragment trains

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-21 12:53:54 +02:00
IslamandGitHub 450955525c No fallback mime-type + remove dead code (#844) 2025-10-20 01:50:33 +02:00
IslamandGitHub 475bc70c71 Extract MimeTypes into a separate file + add tests (#843) 2025-10-20 01:21:23 +02:00
IslamandGitHub af6136c01c Minor cleanup (#842) 2025-10-20 01:11:44 +02:00
b839ce5f6c PeerID 31/n: Remove String interop to be explicit (#840)
* PeerID 28/n: `ChatViewModel.getShortIDForNoiseKey`

* PeerID 29/n: `BLEService` + remove dupe funcs from #823

* `handleFileTransfer` to use PeerID

* `sendMessage` and `sendPrivateMessage`

* PeerID 30/n: Update some leftovers

* `lowercased()` inside PeerID for normalization

* PeerID 31/n: Remove String interop to be explicit

* MockBLEService: Remove direct target delivery

This causes a delivery even if the sender and receiver are not connected

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-19 20:50:44 +02:00
0776c9813c PeerID 30/n: Update some leftovers + case normalization (#839)
* PeerID 28/n: `ChatViewModel.getShortIDForNoiseKey`

* PeerID 29/n: `BLEService` + remove dupe funcs from #823

* `handleFileTransfer` to use PeerID

* `sendMessage` and `sendPrivateMessage`

* PeerID 30/n: Update some leftovers

* `lowercased()` inside PeerID for normalization

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2025-10-19 20:40:45 +02:00
0dd999af6b PeerID 29/n: BLEService + remove dupe funcs from #823 (#838)
* PeerID 28/n: `ChatViewModel.getShortIDForNoiseKey`

* PeerID 29/n: `BLEService` + remove dupe funcs from #823

* `handleFileTransfer` to use PeerID

* `sendMessage` and `sendPrivateMessage`

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2025-10-19 20:35:41 +02:00
IslamandGitHub c3a1af7023 PeerID 28/n: ChatViewModel.getShortIDForNoiseKey (#836) 2025-10-19 20:30:10 +02:00
880813f256 Fix GeoRelay prefetch isolation (#837)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-19 17:24:17 +01:00
64fb634166 Fix camera icon in DM sheets using parent-child presentation pattern (#834)
Implements mutually-exclusive image picker presentations to fix the error
"Currently, only presenting a single sheet is supported" when tapping
camera in a DM.

Solution:
- ContentView: Only presents image picker when NOT in a sheet
  (when showSidebar=false and no private chat active)
- peopleSheetView: Only presents image picker when IN a sheet
  (when showSidebar=true or private chat active)

This ensures only ONE presentation is active at any time, preventing
conflicts. Uses fullScreenCover on iOS to allow presentation over sheets.

Addresses feedback from @qalandarov in PR #834 with minimal approach.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-19 15:23:05 +02:00
IslamandGitHub 13b19fb8eb PeerID 27/n: ContentView and MeshPeerList (#835) 2025-10-19 14:42:50 +02:00
IslamandGitHub d4967ae9c3 PeerID 26/n: FingerprintView (#833) 2025-10-19 14:36:06 +02:00
IslamandGitHub 435744a977 PeerID 25/n: ReadReceipt (#832) 2025-10-19 14:30:27 +02:00
IslamandGitHub 70caa9e24a PeerID 24/n: Nostr Transport and Embedding (#830) 2025-10-19 13:54:32 +02:00
5084f87fe5 Improve geohash media gating and relay refresh (#831)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-19 13:48:33 +02:00
lollerfirstandGitHub 8f56e4f0fb no longer commit into main. open PRs instead (#829) 2025-10-19 12:23:11 +02:00
aca44f9f55 Extract sanitization logic into an extensions + add tests (#827)
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2025-10-19 11:34:21 +02:00
IslamandGitHub 3f00cf9467 Remove presentationDetents as default is already large (#826) 2025-10-19 11:16:57 +02:00
40e54a5120 Add voice notes and images over BLE mesh (audio + image only) (#823)
* Add BLE file transfer support and media UX

* Gracefully disable mac attachment pickers in sandbox

* Tighten spacing above media message bubbles

* Reduce vertical padding between chat rows

* Restore iOS file importer for attachments

* Copy imported files before sending to preserve access

* Allow file transfers from connected but unverified peers

* Raise BLE notification buffer cap for large file transfers

* Revert "Raise BLE notification buffer cap for large file transfers"

This reverts commit b624523af843475db84e4a846db8dcbe824ae408.

* Add guard to drop oversized BLE notification assemblies

* Let BLE assembler accept large frames up to hard cap

* Add detailed logging for BLE fragment assembly

* Log incomplete BLE frames for debugging

* Stop dropping partial BLE frames while assembling notifications

* Fix compressed BLE file transfers

* Enable mac attachment importers

* Allow mac microphone access

* Permit mac media library access

* Describe microphone usage

* Harden attachment transfer bookkeeping

* Display recording milliseconds

* Restore mac photo picker access

* Use Photos picker on mac

* Allow long-press reblur on images

* Reblur images via swipe

* Lowercase image preview buttons

* Keep processed images for outgoing messages

* Use save panel for mac image export

* Fix image attachment detection

* Allow user-selected write access

* Align mac image JPEG encoding

* Revert unsupported JPEG option

* Strip metadata in mac image encoding

* Normalize mac JPEG color space

* Target image byte size across platforms

* Fix CFMutableData handling

* Preserve packet version when signing

* Use unique transfer identifiers

* Stub file transfer methods in mock

* Stub file transfer methods in mock

* Hide absolute paths in media messages

* Resolve image/voice path handling

* Fix cleanupLocalFile lookup

* Restore BLE broadcasts when notify buffer is saturated

* Guard peer map reads on BLE message path

* Drop attachment ceilings to 1 MiB and bump release version

* Reset BLE assembler on stalled fragment trains

* Fix binary protocol test fixtures

* Fix critical issues from PR #681 review

Critical fixes:
- BinaryProtocol: Return nil for unknown versions (prevents buffer underflows)
- Add BinaryProtocol.Offsets struct to centralize magic numbers
- Replace magic offset calculations with named constants

Security/Privacy:
- FileAttachmentView: Use url.lastPathComponent instead of url.path
  (prevents exposing full system paths)

Documentation:
- Fix compression algorithm documentation (zlib, not LZ4)

All tests passing.

* Fix UI freeze when receiving voice notes

Problem: AVAudioPlayer initialization in VoiceNotePlaybackController.init()
was running synchronously on main thread during view creation, blocking
UI for 50-200ms per voice note.

Solution:
- Remove eager preparePlayer() call from init
- Load duration asynchronously on background queue
- Player is only prepared when playback is actually requested via ensurePlayerReady()

This prevents UI freezes when voice notes appear in the chat.

* Fix memory leaks and post-playback freeze

Fixes:
1. Post-playback freeze: audioPlayerDidFinishPlaying now dispatches to main
   thread before updating @Published properties (Swift concurrency violation)

2. Unbounded waveform cache: Implement LRU eviction with 20-entry limit
   - Track last access time for each cached waveform
   - Evict oldest entry when cache is full
   - Prevents unlimited memory growth as voice notes accumulate

3. Audio buffer memory leaks: Wrap computeWaveform in autoreleasepool
   - AVAudioPCMBuffer allocations are autoreleased
   - Pool ensures buffers are freed promptly

4. Image processing memory: Add autoreleasepool around compression loops
   - Each jpegData() call creates temporary objects
   - Inner pool per iteration prevents memory spikes during quality search

Memory should now remain stable during extended use.

* Eliminate disk I/O from SwiftUI view rendering path

Critical performance fix for UI freezes when receiving media:

Problem: mediaAttachment(for:) was called during every SwiftUI render,
performing synchronous disk I/O on main thread:
- FileManager.fileExists() called 2-6x per message (checking subdirs)
- applicationFilesDirectory() creating directories on every call
- With multiple media messages, this meant 20-100+ disk ops per render

Solution:
1. Remove fileExists checks - construct URLs directly
   - Files are validated during playback/display (fail gracefully if missing)
   - Sender determines subdirectory (outgoing vs incoming)

2. Cache applicationFilesDirectory() result
   - Static cache prevents repeated FileManager.url() calls
   - Directory created only once

3. Remove redundant playback.replaceURL() in VoiceNoteView.onAppear
   - Controller already initialized with correct URL

This eliminates ALL disk I/O from the view rendering hot path.

* Cache Nostr identity derivation to prevent crypto during view rendering

Critical performance fix:

Problem: formatMessageHeader() called deriveIdentity(forGeohash:) during
every SwiftUI render for every media message. Each call performed:
- Keychain I/O (getOrCreateDeviceSeed)
- HMAC-SHA256 computation
- Up to 10 secp256k1 key validations (elliptic curve crypto)

With multiple media messages, this resulted in 100s of milliseconds of
blocking crypto on main thread per render cycle.

Solution: Add thread-safe cache for derived identities
- Check cache before expensive crypto operations
- NSLock protects concurrent access
- Identity is deterministic per geohash, so caching is safe

This eliminates crypto from the hot rendering path.

* Cache geohash identity in ChatViewModel to prevent crypto during rendering

Additional optimization for location channels (voice notes are mesh-only,
but this helps with text message rendering in geohash channels):

- Add cachedGeohashIdentity to avoid deriveIdentity calls during rendering
- Check cache before falling back to crypto derivation
- Reduces main thread crypto work in location channels

* Make voice note loading completely lazy with deferred initialization

Aggressive performance optimization to prevent UI freezes:

Problem: Even with async loading, creating 10+ VoiceNotePlaybackController
instances simultaneously (when scrolling past multiple voice notes) spawned
20+ concurrent background tasks, potentially starving main thread.

Solution - Ultra-lazy loading:
1. VoiceNotePlaybackController.init() now does ZERO work
   - No duration loading
   - No player creation
   - Instant initialization

2. Duration loaded on-demand via public loadDuration() method
   - Called from VoiceNoteView.onAppear after 150ms delay
   - Reduced priority: .utility instead of .userInitiated
   - Guard prevents duplicate loading

3. Waveform loading also deferred 150ms
   - Gives UI time to settle after message appears
   - Prevents task storms when multiple voice notes appear

This spreads the work over time instead of all at once.

* Ensure /clear and panic triple-tap delete media files

Fix: /clear command and panicClearAllData() now properly delete media files

1. /clear (triple-tap on chat):
   - Deletes outgoing media (voice notes, images, files)
   - Conservative: only our sent media, preserves received media
   - Runs in background to avoid UI freeze

2. panicClearAllData() (triple-tap on bitchat/ header):
   - Deletes ALL media files (incoming + outgoing)
   - Removes entire files directory and recreates structure
   - Ensures complete data wipe for emergency scenarios

Both operations run async on .utility queue to prevent blocking UI.

* Fix infinite render loop and apply all security fixes

CRITICAL BUG FIX - Infinite Render Loop:

Root Cause: Duplicate view identity in ContentView.swift:368
  ForEach(messageItems) { item in  // Already uses item.id via Identifiable
      messageRow(...)
          .id(item.id)  //  REDUNDANT modifier caused identity re-evaluation loop
  }

When @Published properties updated, SwiftUI re-evaluated .id() → appeared as
'new' identity → triggered re-render → infinite loop. Caused UI freezes,
keyboard failures, and 100% CPU usage.

Fix: Remove redundant .id() modifier - ForEach already has stable identity.

PERFORMANCE FIXES:

1. Waveform Cache Deadlock (Waveform.swift)
   - Removed nested queue.async(barrier) on cache hits
   - Was causing task saturation and potential deadlocks

2. Async Send Pattern (ContentView.swift)
   - Clear input immediately, defer actual send to next runloop
   - Prevents blocking current event handler

3. Proper Swift Concurrency (VoiceNoteView.swift)
   - Switch from .onAppear + DispatchQueue to .task
   - Cleaner async/await pattern for loading

4. Remove Redundant objectWillChange (ChatViewModel.swift)
   - @Published already triggers updates automatically
   - Explicit send() was causing double update cycles

SECURITY FIXES (C1-C5, H1-H2):

C1. Path Traversal Protection (BLEService.swift)
    - Unicode normalization, null byte removal
    - Replace ALL path separators, reject dotfiles
    - Validate paths don't escape directory

C2. Integer Overflow (BitchatFilePacket.swift)
    - Use UInt64 for TLV parsing, safe Int conversion

C3. MIME Validation (BLEService.swift)
    - Whitelist: JPEG, PNG, GIF, WebP, M4A, MP3, WAV, OGG, PDF
    - Magic byte validation for all types
    - Lenient on M4A (platform variations)

C4. Compression Bomb (BinaryProtocol.swift)
    - Ratio validation <= 50,000:1
    - Defense-in-depth with 1MB size cap

C5. TOCTOU Race (ChatViewModel.swift)
    - Direct removeItem without fileExists check

H1. File Size Validation (ChatViewModel, ImageUtils)
    - Check attributes BEFORE Data(contentsOf:)
    - Prevents memory exhaustion

H2. Metadata Stripping (ImageUtils.swift)
    - Remove ALL metadata keys from JPEG encoding
    - Only compression quality set
    - Protects GPS/EXIF/device info privacy

RESULT:
 No render loops
 Works with Xcode debugger
 Voice notes display properly
 All security vulnerabilities fixed
 164 tests passing

Production ready.

* Complete all translations to 100% and fix auto-extraction

- Mark non-localizable strings with Text(verbatim:) to prevent extraction
- Update UI strings to lowercase per style guide (open, save, close, recording)
- Add complete translations for all 29 languages (194/194 strings at 100%)
- Remove empty/duplicate entries (@, bitchat/, Open, Recording %@)
- Add proper localization comments for all user-facing strings

* macOS: Focus message input on launch instead of nickname field

* Remove debug print statements from sendMessage

* Optimize voice note codec to 16 kHz / 20 kbps for smaller file sizes

- Reduce sample rate from 44.1 kHz to 16 kHz (telephony standard)
- Lower bitrate from 32 kbps to 20 kbps
- Results in ~37% file size reduction (~150 KB/min vs 240 KB/min)
- Increases max voice note length from 4.4 to 7 minutes over 1 MiB BLE limit
- Maintains excellent voice quality using native AAC-LC codec

* Fix critical security issues in fragment reassembly and file cleanup

Fragment Reassembly Race Condition (CRITICAL):
- Wrap all incomingFragments/fragmentMetadata access in collectionsQueue.sync
- Prevents concurrent modification crashes from multi-threaded access
- Minimizes lock contention by doing heavy work (reassembly/decode) outside locks
- Add upper bound check: reject fragments with total > 10,000 (DoS prevention)
- Add cumulative size validation before storing fragments (memory DoS prevention)

File Cleanup Path Traversal (CRITICAL):
- Use NSString.lastPathComponent to extract filename safely
- Prevents directory traversal attacks via malicious filenames
- Add path prefix validation before file deletion
- Now checks both incoming and outgoing directories (fixes disk leak)

Additional Protections:
- Fragment assemblies now limited by both count (128) and cumulative bytes (1MB)
- Explicit checks for "." and ".." filenames in cleanup
- Defense-in-depth: multiple validation layers

* Fix post-rebase compilation errors

- Remove duplicate NostrIdentityBridge and Bech32 from NostrIdentity.swift (now in separate files)
- Add caching to NostrIdentityBridge.deriveIdentity() for performance
- Remove duplicate NotificationStreamAssembler from BLEService.swift
- Remove duplicate function declarations in BLEService.swift
- Remove duplicate DeliveryStatusView and PaymentChipView from ContentView.swift
- Fix PeerID type conversions throughout (use .id for String, PeerID(str:) for wrapping)
- Update ContentView body to use main's simple VStack structure
- Fix NostrIdentityBridge instance method calls
- Remove privateChatView (replaced with sheet-based UI in main)

Build and tests passing (137/139 tests pass).

* Fix remaining compilation issues after rebase

- Fix PhotosUI import order (must be after platform imports)
- Fix Data.WritingOptions.atomic reference
- Add identity derivation caching to NostrIdentityBridge
- Fix all remaining PeerID type conversions in ChatViewModel
- Fix ContentView body structure to use main's VStack layout
- Fix PaymentChipView API usage (now uses PaymentType enum)

Build and tests now passing.

* Add proper availability checks for PhotosPickerItem

PhotosPickerItem requires iOS 16+ / macOS 13+ but canImport(PhotosUI)
succeeds on older macOS versions. Add compiler version check to ensure
PhotosPicker code only compiles when actually available.

This fixes CI build failures on older macOS environments.

* Limit PhotosPicker to iOS only to fix CI

PhotosPickerItem has SDK availability issues on macOS in CI.
Change PhotosPicker from canImport(PhotosUI) to os(iOS) only.

macOS users can still import images via file importer (.fileImporter).
This is actually cleaner as macOS file picker is more familiar to users.

Fixes CI build failures.

* Convert new tests to Swift Testing

* Fix compilation issue

* Add the missing `fileTransfer` case

* Explicitly list all Enum cases to get compile-time errors

* Revive lost `NotificationStreamAssembler` changes

* Allow file fragments to account for protocol overhead

* Simplify PR: Focus on audio+image, fix EXIF stripping, remove file transfers

- Fix critical EXIF privacy issue in iOS image processing
  - Both iOS and macOS now use CGImageDestination for metadata stripping
  - Shared encodeJPEG function ensures no GPS, camera, or metadata leaks

- Remove file transfer functionality to simplify PR scope
  - Deleted FileAttachmentView
  - Removed sendFileAttachment from ChatViewModel
  - Removed file picker UI from ContentView
  - Simplified attachment dialog to image only (iOS) or voice only

- Keep focused media features:
  - Voice recording and playback
  - Image sending with progressive reveal
  - Binary protocol for media transfer

* Improve camera UX: Direct camera access with camera icon

- Change paperclip icon to camera icon for clearer affordance
- Open camera directly on tap (no confirmation dialog)
- Add CameraPickerView wrapper for UIImagePickerController
- Remove PhotosPicker in favor of direct camera access
- Images still processed through ImageUtils with EXIF stripping
- Accessibility: Added 'Take photo' label

* Full-screen camera with photo library option

- Change to fullScreenCover for immersive camera experience
- Add action sheet with 'Take Photo' and 'Choose from Library' options
- Renamed ImagePickerView to support both camera and library sources
- Both options open full-screen for better UX
- Updated accessibility label to 'Add photo' (more accurate)

* Fix camera white bars with overFullScreen presentation

- Changed modalPresentationStyle from .fullScreen to .overFullScreen
- This should eliminate white bars at top/bottom on notched devices
- Explicitly set showsCameraControls and cameraOverlayView for camera mode

* Gesture-based photo access: Tap for library, long-press for camera

UX improvements:
- Tap camera icon → Photo library (common use case)
- Long press camera icon (0.3s) → Direct camera (quick photos)
- Removed action sheet entirely for cleaner flow
- Power users can long-press for instant camera access

This is more discoverable and eliminates an extra step in the UI.

* Simplify camera presentation to reduce frame errors

- Changed back to standard .fullScreen presentation
- Removed overFullScreen which was causing frame dimension errors
- Let iOS handle safe areas automatically (white bars are intentional)
- Reduces gesture gate timeout warnings

Note: White bars on notched devices are iOS default behavior for
UIImagePickerController. This respects safe areas for status bar
and home indicator. True edge-to-edge would require custom AVFoundation
camera implementation.

* Force dark mode on camera/picker for black safe area bars

- Set overrideUserInterfaceStyle = .dark on UIImagePickerController
- Changes white bars to black (much better looking)
- Camera controls and photo library also appear in dark mode
- Consistent dark appearance regardless of system settings

* Optimize sheet presentation for camera UI

- Force .large detent for maximum height
- Hide drag indicator for cleaner look
- Use ignoresSafeArea to give camera full space
- Should show complete flash button and controls

* Fix P1: Add DoS protections to PeerID fragment handler

Critical security fix addressing Codex review feedback:

The PeerID overload of handleFragment (which is actually called by
CoreBluetooth) was missing key safety checks that existed in the
String overload:

1. Added total <= 10000 check to prevent unbounded fragment counts
2. Added cumulative size check against FileTransferLimits before
   storing each fragment
3. Prevents memory exhaustion DoS attacks via malicious fragment streams

This ensures the actually-used code path has proper bounds checking.

* Fix decompression size limit to support max-sized file transfers

Root cause: BinaryProtocol.decode() was rejecting decompressed payloads
larger than maxPayloadBytes (1 MB), but TLV-encoded file transfers are
slightly larger due to metadata overhead.

Fixes:
- Changed decompression limit from maxPayloadBytes to maxFramedFileBytes
- This accounts for TLV overhead (~50 bytes) + binary protocol headers
- Now allows ~1.12 MB decompressed payloads (1 MB + overhead budget)

The failing test was:
- Creating 1 MB file content
- TLV encoding adds ~50 bytes (1,048,627 total)
- Compression reduces to ~1,084 bytes (highly repetitive data)
- During decode, decompression was rejecting the 1,048,627 byte output
- Now correctly allows it since 1,048,627 < 1,179,760 (maxFramedFileBytes)

All 154 tests now pass including 'Max-sized file transfer survives reassembly'

* Fix critical thread-safety crash in PeerID fragment handler

CRITICAL: The PeerID version of _handleFragment was accessing
incomingFragments dictionary without collectionsQueue synchronization,
causing crashes when multiple BLE threads processed fragments concurrently.

Crash stack trace pointed to line 3431 (dictionary subscript) with:
'doesNotRecognizeSelector' - classic concurrent mutation crash.

Fix:
- Wrapped ALL incomingFragments/fragmentMetadata access in
  collectionsQueue.sync(flags: .barrier)
- Matches the thread-safe pattern used in String version
- Separate cleanup into its own barrier block after reassembly
- Prevents concurrent dictionary mutations from multiple BLE threads

This is the same pattern as the String version (line 1128) which didn't crash.

* Remove Localizable.xcstrings formatting noise

The Localizable.xcstrings file had massive formatting-only changes
(spacing: 'key' vs 'key :') that added 50K+ lines to the PR diff.

This was just Xcode reformatting with no actual string changes.

Reverted to main's version to keep PR focused on actual code changes.

* Add macOS photo picker support

- Added MacImagePickerView with NSOpenPanel for macOS
- macOS shows photo.circle.fill icon (no camera hardware)
- Opens native file picker for images (.png, .jpeg, .heic)
- Images processed through ImageUtils with EXIF stripping
- Simple sheet with Select/Cancel buttons

Cross-platform photo sharing now works:
- iOS: Tap for library, long-press for camera
- macOS: Tap for file picker

* Add Localizable strings for camera and voice features

Xcode auto-generated localization strings for new UI elements:
- Camera/photo picker labels
- Voice recording UI strings
- Media attachment descriptions

These are legitimate new strings needed for the audio+image feature,
not just formatting changes.

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: islam <2553451+qalandarov@users.noreply.github.com>
2025-10-18 20:55:33 +02:00
7040d9ecb0 Add plus-minus feature to location notes (± 1 grid) (#821)
Expands location notes coverage to include 8 neighboring geohash cells,
creating a 3×3 grid around the user's current building-level location.

Changes:
- Add Geohash.neighbors() method to calculate 8 surrounding cells
- Update LocationNotesManager to subscribe to center + neighbors (9 cells total)
- Add NostrFilter.geohashNotes([String]) overload for multi-cell subscriptions
- Display '± 1' indicator in LocationNotesView header

Coverage:
- Single cell: ~38m × 19m
- 3×3 grid: ~114m × 57m total area
- Better discovery of nearby activity

Behavior:
- Subscribe: Fetches notes from all 9 cells (single efficient subscription)
- Post: Still uses center geohash only (correct privacy-preserving behavior)
- UI: Shows 'geohash ± 1' to indicate expanded coverage

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-18 12:44:06 +02:00
88bafb41cc Remove LocationNotesCounter for privacy-first approach (#820)
Simplifies geonotes architecture by eliminating all background subscriptions:

- Delete LocationNotesCounter.swift (104 lines) and related tests (58 lines)
- Remove background subscription system entirely
- Icon is now constant darker orange (indicates availability, not state)
- Subscription ONLY happens when user explicitly opens the sheet
- Total: ~220 lines of code removed

Privacy Benefits:
- Zero network traffic until user action
- No location leaking to relays via background polling
- User must explicitly opt-in to discover notes

The icon (note.text in orange) simply indicates the feature is available
when location permission is granted. Notes are only fetched when the
sheet is opened, prioritizing privacy over convenience.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-18 12:06:31 +02:00
54 changed files with 27041 additions and 25984 deletions
+45 -14
View File
@@ -7,6 +7,7 @@ on:
permissions:
contents: write
pull-requests: write
jobs:
update-relay-data:
@@ -17,24 +18,54 @@ jobs:
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Fetch GeoRelays
run: |
wget https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
mv nostr_relays.csv ./relays/online_relays_gps.csv
- name: Check for changes
id: git-check
- name: Configure git
run: |
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
- name: Commit and push changes
if: steps.git-check.outputs.changes == 'true'
git config user.email "action@github.com"
git config user.name "GitHub Action"
- name: Create update branch if changes
id: create_branch
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
# 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 add relays/online_relays_gps.csv
git commit -m "Automated update of relay data - $(date -u)"
git push
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
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"
@@ -98,8 +98,8 @@
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "-DBITCHAT_DEV_ALLOW_CLEARNET"
value = ""
key = "BITCHAT_LOG_LEVEL"
value = "debug"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
+1 -1
View File
@@ -246,7 +246,7 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
// Get peer ID from userInfo
if let peerID = userInfo["peerID"] as? String {
// Don't show notification if the private chat is already open
if chatViewModel?.selectedPrivateChatPeer == peerID {
if chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) {
completionHandler([])
return
}
+48 -12
View File
@@ -1,10 +1,10 @@
import Foundation
import ImageIO
import UniformTypeIdentifiers
#if os(iOS)
import UIKit
#else
import AppKit
import ImageIO
import UniformTypeIdentifiers
#endif
enum ImageUtilsError: Error {
@@ -13,10 +13,10 @@ enum ImageUtilsError: Error {
}
enum ImageUtils {
private static let compressionQuality: CGFloat = 0.85
private static let targetImageBytes: Int = 60_000
private static let compressionQuality: CGFloat = 0.82
private static let targetImageBytes: Int = 45_000
static func processImage(at url: URL, maxDimension: CGFloat = 512) throws -> URL {
static func processImage(at url: URL, maxDimension: CGFloat = 448) throws -> URL {
// Security H1: Check file size BEFORE reading into memory
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
guard let fileSize = attrs[.size] as? Int else {
@@ -38,21 +38,32 @@ enum ImageUtils {
}
#if os(iOS)
static func processImage(_ image: UIImage, maxDimension: CGFloat = 512) throws -> URL {
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448) throws -> URL {
return try autoreleasepool {
// Scale the image first
let scaled = scaledImage(image, maxDimension: maxDimension)
var quality = compressionQuality
guard var jpegData = scaled.jpegData(compressionQuality: quality) else {
// Get CGImage from UIImage - this is the key to stripping metadata
guard let cgImage = scaled.cgImage else {
throw ImageUtilsError.encodingFailed
}
// Use CGImageDestination to encode without metadata (same as macOS)
var quality = compressionQuality
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
throw ImageUtilsError.encodingFailed
}
// Compress to target size
while jpegData.count > targetImageBytes && quality > 0.3 {
quality -= 0.1
autoreleasepool {
if let next = scaled.jpegData(compressionQuality: quality) {
if let next = encodeJPEG(from: cgImage, quality: quality) {
jpegData = next
}
}
}
let outputURL = try makeOutputURL()
try jpegData.write(to: outputURL, options: .atomic)
return outputURL
@@ -65,14 +76,37 @@ enum ImageUtils {
guard maxSide > maxDimension else { return image }
let scale = maxDimension / maxSide
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
// Draw into a new context to get a clean CGImage without metadata
UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0)
image.draw(in: CGRect(origin: .zero, size: newSize))
let rendered = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return rendered ?? image
}
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
guard let data = CFDataCreateMutable(nil, 0) else {
return nil
}
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
return nil
}
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
// By only specifying compression quality and no metadata keys,
// we ensure a clean JPEG with no privacy-leaking information
let options: [CFString: Any] = [
kCGImageDestinationLossyCompressionQuality: quality
]
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
guard CGImageDestinationFinalize(destination) else {
return nil
}
return data as Data
}
#else
static func processImage(_ image: NSImage, maxDimension: CGFloat = 512) throws -> URL {
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448) throws -> URL {
return try autoreleasepool {
let scaled = scaledImage(image, maxDimension: maxDimension)
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
@@ -130,6 +164,7 @@ enum ImageUtils {
return scaledImage
}
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
guard let data = CFDataCreateMutable(nil, 0) else {
return nil
@@ -137,8 +172,9 @@ enum ImageUtils {
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
return nil
}
// Security H2: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
// Don't add any metadata dictionary keys - fresh CGContext ensures clean image
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
// By only specifying compression quality and no metadata keys,
// we ensure a clean JPEG with no privacy-leaking information
let options: [CFString: Any] = [
kCGImageDestinationLossyCompressionQuality: quality
]
+3 -2
View File
@@ -14,6 +14,7 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
private let paddingInterval: TimeInterval = 0.5
private let maxRecordingDuration: TimeInterval = 120
private var recorder: AVAudioRecorder?
private var currentURL: URL?
@@ -75,14 +76,14 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 16_000,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 20_000
AVEncoderBitRateKey: 16_000
]
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
audioRecorder.delegate = self
audioRecorder.isMeteringEnabled = true
audioRecorder.prepareToRecord()
audioRecorder.record()
audioRecorder.record(forDuration: maxRecordingDuration)
recorder = audioRecorder
currentURL = outputURL
+24248 -23926
View File
File diff suppressed because it is too large Load Diff
+8 -18
View File
@@ -35,7 +35,7 @@ struct PeerID: Equatable, Hashable {
// Private so the callers have to go through a convenience init
private init(prefix: Prefix, bare: any StringProtocol) {
self.prefix = prefix
self.bare = String(bare)
self.bare = String(bare).lowercased()
}
}
@@ -76,6 +76,12 @@ extension PeerID {
init(hexData: Data) {
self.init(str: hexData.hexEncodedString())
}
/// Convenience init to "hide" hex-encoding implementation detail
init?(hexData: Data?) {
guard let hexData else { return nil }
self.init(hexData: hexData)
}
}
// MARK: - Noise Public Key Helpers
@@ -191,9 +197,7 @@ extension PeerID: Comparable {
}
}
// MARK: - String Interop Helpers
// MARK: CustomStringConvertible
// MARK: - CustomStringConvertible
extension PeerID: CustomStringConvertible {
/// So it returns the actual `id` like before even inside another String
@@ -201,17 +205,3 @@ extension PeerID: CustomStringConvertible {
id
}
}
// MARK: Custom Equatable w/ String & Optionality
// PeerID <> String
extension Optional where Wrapped == PeerID {
static func ==(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id == rhs }
static func !=(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id != rhs }
}
// String <> PeerID
extension Optional where Wrapped == String {
static func ==(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs == rhs?.id }
static func !=(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs != rhs?.id }
}
+6 -6
View File
@@ -11,11 +11,11 @@ import Foundation
struct ReadReceipt: Codable {
let originalMessageID: String
let receiptID: String
var readerID: String // Who read it
var readerID: PeerID // Who read it
let readerNickname: String
let timestamp: Date
init(originalMessageID: String, readerID: String, readerNickname: String) {
init(originalMessageID: String, readerID: PeerID, readerNickname: String) {
self.originalMessageID = originalMessageID
self.receiptID = UUID().uuidString
self.readerID = readerID
@@ -24,7 +24,7 @@ struct ReadReceipt: Codable {
}
// For binary decoding
private init(originalMessageID: String, receiptID: String, readerID: String, readerNickname: String, timestamp: Date) {
private init(originalMessageID: String, receiptID: String, readerID: PeerID, readerNickname: String, timestamp: Date) {
self.originalMessageID = originalMessageID
self.receiptID = receiptID
self.readerID = readerID
@@ -48,7 +48,7 @@ struct ReadReceipt: Codable {
data.appendUUID(receiptID)
// ReaderID as 8-byte hex string
var readerData = Data()
var tempID = readerID
var tempID = readerID.id
while tempID.count >= 2 && readerData.count < 8 {
let hexByte = String(tempID.prefix(2))
if let byte = UInt8(hexByte, radix: 16) {
@@ -78,8 +78,8 @@ struct ReadReceipt: Codable {
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
let readerID = readerIDData.hexEncodedString()
guard PeerID(str: readerID).isValid else { return nil }
let readerID = PeerID(hexData: readerIDData)
guard readerID.isValid else { return nil }
guard let timestamp = dataCopy.readDate(at: &offset),
InputValidator.validateTimestamp(timestamp),
+17 -1
View File
@@ -8,6 +8,14 @@ struct RequestSyncPacket {
let p: Int
let m: UInt32
let data: Data
let types: SyncTypeFlags?
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil) {
self.p = p
self.m = m
self.data = data
self.types = types
}
func encode() -> Data {
var out = Data()
@@ -25,6 +33,9 @@ struct RequestSyncPacket {
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
// data
putTLV(0x03, data)
if let typesData = types?.toData() {
putTLV(0x04, typesData)
}
return out
}
@@ -33,6 +44,7 @@ struct RequestSyncPacket {
var p: Int? = nil
var m: UInt32? = nil
var payload: Data? = nil
var types: SyncTypeFlags? = nil
while off + 3 <= data.count {
let t = Int(data[off]); off += 1
@@ -52,12 +64,16 @@ struct RequestSyncPacket {
case 0x03:
if v.count > maxAcceptBytes { return nil }
payload = v
case 0x04:
if let decoded = SyncTypeFlags.decode(v) {
types = decoded
}
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)
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types)
}
}
+194 -30
View File
@@ -1,6 +1,11 @@
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
@@ -12,19 +17,32 @@ final class GeoRelayDirectory {
}
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 // 24h
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() {
// Load cached or bundled data synchronously
self.entries = self.loadLocalEntries()
// Fire-and-forget remote refresh if stale
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)
@@ -62,42 +80,119 @@ final class GeoRelayDirectory {
}
// MARK: - Remote Fetch
func prefetchIfNeeded() {
func prefetchIfNeeded(force: Bool = false) {
guard !isFetching else { return }
let now = Date()
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
guard now.timeIntervalSince(last) >= fetchInterval else { return }
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() {
let req = URLRequest(url: remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
// Ensure Tor readiness before fetching (fail-closed by default)
Task.detached {
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 {
SecureLogger.warning("GeoRelayDirectory: Tor not ready; skipping remote fetch (fail-closed)", category: .session)
await self.handleFetchFailure(.torNotReady)
return
}
let task = TorURLSession.shared.session.dataTask(with: req) { [weak self] data, _, error in
guard let self = self else { return }
if let data = data, error == nil, let text = String(data: data, encoding: .utf8) {
let parsed = GeoRelayDirectory.parseCSV(text)
if !parsed.isEmpty {
Task { @MainActor in
self.entries = parsed
self.persistCache(text)
UserDefaults.standard.set(Date(), forKey: self.lastFetchKey)
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
}
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
}
SecureLogger.warning("GeoRelayDirectory: remote fetch failed; keeping local entries", category: .session)
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))
}
task.resume()
}
}
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 {
@@ -110,30 +205,35 @@ final class GeoRelayDirectory {
// MARK: - Loading
private func loadLocalEntries() -> [Entry] {
// Prefer cached file if present
if let cache = self.cacheURL(),
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) {
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 []
}
@@ -141,7 +241,6 @@ final class GeoRelayDirectory {
nonisolated static func parseCSV(_ text: String) -> [Entry] {
var result: Set<Entry> = []
let lines = text.split(whereSeparator: { $0.isNewline })
// Skip header if present
for (idx, raw) in lines.enumerated() {
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if line.isEmpty { continue }
@@ -162,11 +261,76 @@ final class GeoRelayDirectory {
private func cacheURL() -> URL? {
do {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
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 }
} 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)
}
}
+15 -15
View File
@@ -4,7 +4,7 @@ import Foundation
struct NostrEmbeddedBitChat {
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs.
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
// TLV-encode the private message
let pm = PrivateMessagePacket(messageID: messageID, content: content)
guard let tlv = pm.encode() else { return nil }
@@ -14,12 +14,12 @@ struct NostrEmbeddedBitChat {
payload.append(tlv)
// Determine 8-byte recipient ID to embed
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
let recipientID = normalizeRecipientPeerID(recipientPeerID)
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: senderPeerID) ?? Data(),
recipientID: Data(hexString: recipientIDHex),
senderID: Data(hexString: senderPeerID.id) ?? Data(),
recipientID: Data(hexString: recipientID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
@@ -31,18 +31,18 @@ struct NostrEmbeddedBitChat {
}
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs.
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
guard type == .delivered || type == .readReceipt else { return nil }
var payload = Data([type.rawValue])
payload.append(Data(messageID.utf8))
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
let recipientID = normalizeRecipientPeerID(recipientPeerID)
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: senderPeerID) ?? Data(),
recipientID: Data(hexString: recipientIDHex),
senderID: Data(hexString: senderPeerID.id) ?? Data(),
recipientID: Data(hexString: recipientID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
@@ -54,7 +54,7 @@ struct NostrEmbeddedBitChat {
}
/// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs).
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: String) -> String? {
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: PeerID) -> String? {
guard type == .delivered || type == .readReceipt else { return nil }
var payload = Data([type.rawValue])
@@ -62,7 +62,7 @@ struct NostrEmbeddedBitChat {
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: senderPeerID) ?? Data(),
senderID: Data(hexString: senderPeerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
@@ -75,7 +75,7 @@ struct NostrEmbeddedBitChat {
}
/// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs).
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: String) -> String? {
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: PeerID) -> String? {
let pm = PrivateMessagePacket(messageID: messageID, content: content)
guard let tlv = pm.encode() else { return nil }
@@ -84,7 +84,7 @@ struct NostrEmbeddedBitChat {
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: senderPeerID) ?? Data(),
senderID: Data(hexString: senderPeerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
@@ -96,11 +96,11 @@ struct NostrEmbeddedBitChat {
return "bitchat1:" + base64URLEncode(data)
}
private static func normalizeRecipientPeerID(_ recipientPeerID: String) -> String {
if let maybeData = Data(hexString: recipientPeerID) {
private static func normalizeRecipientPeerID(_ recipientPeerID: PeerID) -> PeerID {
if let maybeData = Data(hexString: recipientPeerID.id) {
if maybeData.count == 32 {
// Treat as Noise static public key; derive peerID from fingerprint
return PeerID(publicKey: maybeData).id
return PeerID(publicKey: maybeData)
} else if maybeData.count == 8 {
// Already an 8-byte peer ID
return recipientPeerID
+10
View File
@@ -906,6 +906,16 @@ struct NostrFilter: Encodable {
filter.limit = limit
return filter
}
// For location notes with neighbors: subscribe to multiple geohashes (center + neighbors)
static func geohashNotes(_ geohashes: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter {
var filter = NostrFilter()
filter.kinds = [1]
filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["g": geohashes]
filter.limit = limit
return filter
}
}
// Dynamic coding key for tag filters
+2 -1
View File
@@ -333,7 +333,8 @@ struct BinaryProtocol {
originalSize = Int(rawSize)
}
// Guard to keep decompression bounded to sane BLE payload limits
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxPayloadBytes else { return nil }
// Use maxFramedFileBytes to account for TLV overhead in file transfer payloads
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxFramedFileBytes else { return nil }
let compressedSize = payloadLength - lengthFieldBytes
guard compressedSize >= 0, let compressed = readData(compressedSize) else { return nil }
+2 -2
View File
@@ -178,7 +178,7 @@ protocol BitchatDelegate: AnyObject {
// Bluetooth state updates for user notifications
func didUpdateBluetoothState(_ state: CBManagerState)
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date)
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
}
// Provide default implementation to make it effectively optional
@@ -195,7 +195,7 @@ extension BitchatDelegate {
// Default empty implementation
}
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
// Default empty implementation
}
}
+53
View File
@@ -119,4 +119,57 @@ enum Geohash {
}
return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1)
}
/// Returns all 8 neighboring geohash cells at the same precision.
/// - Parameter geohash: Base32 geohash string.
/// - Returns: Array of 8 neighboring geohashes (N, NE, E, SE, S, SW, W, NW order).
static func neighbors(of geohash: String) -> [String] {
guard !geohash.isEmpty else { return [] }
let precision = geohash.count
let bounds = decodeBounds(geohash)
let center = decodeCenter(geohash)
// Calculate cell dimensions
let latHeight = bounds.latMax - bounds.latMin
let lonWidth = bounds.lonMax - bounds.lonMin
// Helper to wrap longitude around ±180
func wrapLongitude(_ lon: Double) -> Double {
var wrapped = lon
while wrapped > 180.0 { wrapped -= 360.0 }
while wrapped < -180.0 { wrapped += 360.0 }
return wrapped
}
// Helper to clamp latitude to ±90
func clampLatitude(_ lat: Double) -> Double {
return max(-90.0, min(90.0, lat))
}
// Calculate 8 neighbor centers
let neighbors: [(lat: Double, lon: Double)] = [
(center.lat + latHeight, center.lon), // N
(center.lat + latHeight, center.lon + lonWidth), // NE
(center.lat, center.lon + lonWidth), // E
(center.lat - latHeight, center.lon + lonWidth), // SE
(center.lat - latHeight, center.lon), // S
(center.lat - latHeight, center.lon - lonWidth), // SW
(center.lat, center.lon - lonWidth), // W
(center.lat + latHeight, center.lon - lonWidth) // NW
]
// Encode each neighbor, handling boundary conditions
return neighbors.compactMap { neighbor in
let lat = clampLatitude(neighbor.lat)
let lon = wrapLongitude(neighbor.lon)
// Skip if we've crossed a pole (latitude clamped to boundary)
if (neighbor.lat > 90.0 || neighbor.lat < -90.0) {
return nil
}
return encode(latitude: lat, longitude: lon, precision: precision)
}
}
}
File diff suppressed because it is too large Load Diff
+195
View File
@@ -0,0 +1,195 @@
//
// MimeType.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import UniformTypeIdentifiers
// MARK: - Extensions for missing UTTypes
extension UTType {
static let webP = UTType(importedAs: "image/webp")
static let aac = UTType(importedAs: "audio/aac")
static let m4a = UTType(importedAs: "audio/m4a")
static let ogg = UTType(importedAs: "audio/ogg")
}
// MARK: - MimeType Enum
enum MimeType: CaseIterable, Hashable {
case jpeg
case jpg
case png
case gif
case webp
case mp4Audio
case m4a
case aac
case mpeg
case mp3
case wav
case xWav
case ogg
case pdf
case octetStream
var utType: UTType {
switch self {
case .jpeg, .jpg: .jpeg
case .png: .png
case .gif: .gif
case .webp: .webP
case .aac: .aac
case .m4a: .m4a
case .mp4Audio: .mpeg4Audio
case .mp3, .mpeg: .mp3
case .wav, .xWav: .wav
case .ogg: .ogg
case .pdf: .pdf
case .octetStream: .data
}
}
var category: Category {
switch self {
case .jpeg, .jpg, .png, .gif, .webp:
return .image
case .aac, .m4a, .mp4Audio, .mpeg, .mp3, .wav, .xWav, .ogg:
return .audio
case .pdf, .octetStream:
return .file
}
}
var mimeString: String {
switch self {
case .jpeg, .jpg: "image/jpeg"
case .png: "image/png"
case .gif: "image/gif"
case .webp: "image/webp"
case .mp4Audio: "audio/mp4"
case .m4a: "audio/m4a"
case .aac: "audio/aac"
case .mpeg: "audio/mpeg"
case .mp3: "audio/mp3"
case .wav: "audio/wav"
case .xWav: "audio/x-wav"
case .ogg: "audio/ogg"
case .pdf: "application/pdf"
case .octetStream: "application/octet-stream"
}
}
var defaultExtension: String {
switch self {
case .jpeg, .jpg: "jpg"
case .png: "png"
case .webp: "webp"
case .gif: "gif"
case .mp4Audio, .m4a, .aac: "m4a"
case .mpeg, .mp3: "mp3"
case .wav, .xWav: "wav"
case .ogg: "ogg"
case .pdf: "pdf"
case .octetStream: "bin"
}
}
static var allowed: Set<MimeType> = [
.jpeg, .jpg, .png, .gif, .webp,
.mp4Audio, .m4a, .aac, .mpeg, .mp3,
.wav, .xWav, .ogg,
.pdf, .octetStream
]
var isAllowed: Bool {
Self.allowed.contains(self)
}
// MARK: - Byte signature validation
func matches(data: Data) -> Bool {
guard !data.isEmpty else { return false }
// Generic type skip validation
if self == .octetStream { return true }
switch self {
case .jpeg, .jpg:
return data.count >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF
case .png:
return data.count >= 8 &&
data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 &&
data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A
case .gif:
return data.count >= 6 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 &&
data[3] == 0x38 && (data[4] == 0x37 || data[4] == 0x39) && data[5] == 0x61
case .webp:
return data.count >= 12 &&
data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50
case .m4a, .mp4Audio, .aac:
// AVAudioRecorder output varies by platform - be lenient
// Security: size already capped + sandboxed execution
return data.count > 100
case .mpeg, .mp3:
if data.count >= 3 && data[0] == 0x49 && data[1] == 0x44 && data[2] == 0x33 {
return true // ID3 header
}
return data.count >= 2 && data[0] == 0xFF && (data[1] & 0xE0) == 0xE0
case .wav, .xWav:
return data.count >= 12 &&
data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
data[8] == 0x57 && data[9] == 0x41 && data[10] == 0x56 && data[11] == 0x45
case .ogg:
return data.count >= 4 &&
data[0] == 0x4F && data[1] == 0x67 && data[2] == 0x67 && data[3] == 0x53
case .pdf:
return data.count >= 4 &&
data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46
default:
return false
}
}
// MARK: - Convenience Initializers
init?(_ mimeString: String?) {
guard let mimeString else { return nil }
let normalized = mimeString.lowercased()
// Direct match with our canonical list
if let match = MimeType.allCases.first(where: { $0.mimeString == normalized }) {
self = match
return
}
// Let UTType normalize aliases like "image/jpg", "audio/x-wav", etc.
if let type = UTType(mimeType: normalized),
let match = MimeType.allCases.first(where: { type.conforms(to: $0.utType) }) {
self = match
return
}
return nil
}
}
extension MimeType {
enum Category: String {
case audio, image, file
}
}
-104
View File
@@ -1,104 +0,0 @@
import BitLogger
import Foundation
struct LocationNotesCounterDependencies {
typealias RelayLookup = @MainActor (_ geohash: String, _ count: Int) -> [String]
typealias Subscribe = @MainActor (_ filter: NostrFilter, _ id: String, _ relays: [String], _ handler: @escaping (NostrEvent) -> Void, _ onEOSE: (() -> Void)?) -> Void
typealias Unsubscribe = @MainActor (_ id: String) -> Void
var relayLookup: RelayLookup
var subscribe: Subscribe
var unsubscribe: Unsubscribe
static let live = LocationNotesCounterDependencies(
relayLookup: { geohash, count in
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
},
subscribe: { filter, id, relays, handler, onEOSE in
NostrRelayManager.shared.subscribe(
filter: filter,
id: id,
relayUrls: relays,
handler: handler,
onEOSE: onEOSE
)
},
unsubscribe: { id in
NostrRelayManager.shared.unsubscribe(id: id)
}
)
}
/// Lightweight background counter for location notes (kind 1) at building-level geohash (8 chars).
@MainActor
final class LocationNotesCounter: ObservableObject {
static let shared = LocationNotesCounter()
@Published private(set) var geohash: String? = nil
@Published private(set) var count: Int? = 0
@Published private(set) var initialLoadComplete: Bool = false
@Published private(set) var relayAvailable: Bool = true
private var subscriptionID: String? = nil
private var noteIDs = Set<String>()
private let dependencies: LocationNotesCounterDependencies
private init(dependencies: LocationNotesCounterDependencies = .live) {
self.dependencies = dependencies
}
init(testDependencies: LocationNotesCounterDependencies) {
self.dependencies = testDependencies
}
func subscribe(geohash gh: String) {
let norm = gh.lowercased()
if geohash == norm, subscriptionID != nil { return }
// Validate geohash (building-level precision: 8 chars)
guard Geohash.isValidBuildingGeohash(norm) else {
SecureLogger.warning("LocationNotesCounter: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
return
}
// Unsubscribe previous without clearing count to avoid flicker
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
subscriptionID = nil
geohash = norm
noteIDs.removeAll()
initialLoadComplete = false
relayAvailable = true
// Subscribe only to the building geohash (precision 8)
let subID = "locnotes-count-\(norm)-\(UUID().uuidString.prefix(6))"
let relays = dependencies.relayLookup(norm, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
relayAvailable = false
initialLoadComplete = true
count = 0
SecureLogger.warning("LocationNotesCounter: no geo relays for geohash=\(norm)", category: .session)
return
}
subscriptionID = subID
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 200)
dependencies.subscribe(filter, subID, relays, { [weak self] event in
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == norm }) else { return }
if !self.noteIDs.contains(event.id) {
self.noteIDs.insert(event.id)
self.count = self.noteIDs.count
}
}, { [weak self] in
self?.initialLoadComplete = true
})
}
func cancel() {
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
subscriptionID = nil
geohash = nil
count = 0
noteIDs.removeAll()
relayAvailable = true
}
}
+12 -4
View File
@@ -163,14 +163,22 @@ final class LocationNotesManager: ObservableObject {
subscriptionID = subID
initialLoadComplete = false
// For persistent notes, allow relays to return recent history without an aggressive time cutoff
let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200)
// Subscribe to center + 8 neighbors (± 1 grid)
let neighbors = Geohash.neighbors(of: geohash)
let allGeohashes = [geohash] + neighbors
let filter = NostrFilter.geohashNotes(allGeohashes, since: nil, limit: 200)
// Build a set of valid geohashes for tag matching (includes all 9 cells)
let validGeohashes = Set(allGeohashes.map { $0.lowercased() })
dependencies.subscribe(filter, subID, relays, { [weak self] event in
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
// Ensure matching tag
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) else { return }
// Ensure matching tag - accept any of our 9 geohashes
guard event.tags.contains(where: { tag in
tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased())
}) else { return }
guard !self.noteIDs.contains(event.id) else { return }
self.noteIDs.insert(event.id)
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
@@ -177,18 +177,18 @@ final class NoiseEncryptionService {
private let rekeyCheckInterval: TimeInterval = 60.0 // Check every minute
// Callbacks
private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
// Add a handler for peer authentication
func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) {
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
serviceQueue.async(flags: .barrier) { [weak self] in
self?.onPeerAuthenticatedHandlers.append(handler)
}
}
// Legacy support - setting this will add to the handlers array
var onPeerAuthenticated: ((String, String) -> Void)? {
var onPeerAuthenticated: ((PeerID, String) -> Void)? {
get { nil } // Always return nil for backward compatibility
set {
if let handler = newValue {
@@ -546,7 +546,7 @@ final class NoiseEncryptionService {
// Notify all handlers about authentication
serviceQueue.async { [weak self] in
self?.onPeerAuthenticatedHandlers.forEach { handler in
handler(peerID.id, fingerprint)
handler(peerID, fingerprint)
}
}
}
+7 -7
View File
@@ -82,7 +82,7 @@ final class NostrTransport: Transport {
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
return
}
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
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
}
@@ -114,7 +114,7 @@ final class NostrTransport: Transport {
guard hrp == "npub" else { return }
recipientHex = data.hexEncodedString()
} catch { return }
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
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
}
@@ -139,7 +139,7 @@ final class NostrTransport: Transport {
guard hrp == "npub" else { return }
recipientHex = data.hexEncodedString()
} catch { return }
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
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
}
@@ -161,7 +161,7 @@ extension NostrTransport {
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)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID.id) else { return }
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)
@@ -171,7 +171,7 @@ extension NostrTransport {
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)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID.id) else { return }
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)
@@ -184,7 +184,7 @@ extension NostrTransport {
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
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID.id) else {
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
return
}
@@ -223,7 +223,7 @@ extension NostrTransport {
guard hrp == "npub" else { scheduleNextReadAck(); return }
recipientHex = data.hexEncodedString()
} catch { scheduleNextReadAck(); return }
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID.id, senderPeerID: senderPeerID.id) else {
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
}
+32 -43
View File
@@ -29,28 +29,30 @@ final class NotificationService {
}
}
func sendLocalNotification(title: String, body: String, identifier: String, userInfo: [String: Any]? = nil) {
// For now, skip app state check entirely to avoid thread issues
// The NotificationDelegate will handle foreground presentation
DispatchQueue.main.async {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
if let userInfo = userInfo {
content.userInfo = userInfo
}
let request = UNNotificationRequest(
identifier: identifier,
content: content,
trigger: nil // Deliver immediately
)
UNUserNotificationCenter.current().add(request) { _ in
// Notification added
}
func sendLocalNotification(
title: String,
body: String,
identifier: String,
userInfo: [String: Any]? = nil,
interruptionLevel: UNNotificationInterruptionLevel = .active
) {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
content.interruptionLevel = interruptionLevel
if let userInfo = userInfo {
content.userInfo = userInfo
}
let request = UNNotificationRequest(
identifier: identifier,
content: content,
trigger: nil // Deliver immediately
)
UNUserNotificationCenter.current().add(request)
}
func sendMentionNotification(from sender: String, message: String) {
@@ -61,11 +63,11 @@ final class NotificationService {
sendLocalNotification(title: title, body: body, identifier: identifier)
}
func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) {
func sendPrivateMessageNotification(from sender: String, message: String, peerID: PeerID) {
let title = "🔒 DM from \(sender)"
let body = message
let identifier = "private-\(UUID().uuidString)"
let userInfo = ["peerID": peerID, "senderName": sender]
let userInfo = ["peerID": peerID.id, "senderName": sender]
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
}
@@ -83,25 +85,12 @@ final class NotificationService {
let title = "👥 bitchatters nearby!"
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
let identifier = "network-available-\(Date().timeIntervalSince1970)"
// For network notifications, we want to show them even in foreground
// No app state check - let the notification delegate handle presentation
DispatchQueue.main.async {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
content.interruptionLevel = .timeSensitive // Make it more prominent
let request = UNNotificationRequest(
identifier: identifier,
content: content,
trigger: nil // Deliver immediately
)
UNUserNotificationCenter.current().add(request) { _ in
// Notification added
}
}
sendLocalNotification(
title: title,
body: body,
identifier: identifier,
interruptionLevel: .timeSensitive
)
}
}
+1 -1
View File
@@ -105,7 +105,7 @@ final class PrivateChatManager: ObservableObject {
// Create read receipt using the simplified method
let receipt = ReadReceipt(
originalMessageID: message.id,
readerID: meshService?.myPeerID.id ?? "",
readerID: meshService?.myPeerID ?? PeerID(str: ""),
readerNickname: meshService?.myNickname ?? ""
)
+11
View File
@@ -13,6 +13,7 @@ struct RelayController {
senderIsSelf: Bool,
isEncrypted: Bool,
isDirectedEncrypted: Bool,
isFragment: Bool,
isDirectedFragment: Bool,
isHandshake: Bool,
isAnnounce: Bool,
@@ -36,6 +37,16 @@ struct RelayController {
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
}
if isFragment {
let ttlLimit = min(ttlCap, TransportConfig.bleFragmentRelayTtlCap)
guard ttlLimit > 1 else {
return RelayDecision(shouldRelay: false, newTTL: ttlLimit, delayMs: 0)
}
let newTTL = ttlLimit &- 1
let delayMs = Int.random(in: TransportConfig.bleFragmentRelayMinDelayMs...TransportConfig.bleFragmentRelayMaxDelayMs)
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
}
// TTL clamping for broadcast
// - Dense graphs: keep lower but still allow multi-hop bridging
// - Announces get a bit more headroom
+5
View File
@@ -45,6 +45,7 @@ protocol Transport: AnyObject {
// Messaging
func sendMessage(_ content: String, mentions: [String])
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date)
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
@@ -65,6 +66,10 @@ extension Transport {
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
func cancelTransfer(_ transferId: String) {}
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
sendMessage(content, mentions: mentions)
}
}
protocol TransportPeerEventsDelegate: AnyObject {
+8
View File
@@ -8,6 +8,10 @@ enum TransportConfig {
static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
static let bleFragmentRelayTtlCap: UInt8 = 5 // Clamp fragment TTL to contain floods
// UI / Storage Caps
static let privateChatCap: Int = 1337
@@ -66,6 +70,7 @@ enum TransportConfig {
static let uiAnimationMediumSeconds: TimeInterval = 0.2
static let uiAnimationSidebarSeconds: TimeInterval = 0.25
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
static let uiMeshEmptyConfirmationSeconds: TimeInterval = 30.0
// BLE maintenance & thresholds
static let bleMaintenanceInterval: TimeInterval = 5.0
@@ -145,6 +150,9 @@ enum TransportConfig {
// Geo relay directory
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
static let geoRelayRefreshCheckIntervalSeconds: TimeInterval = 60 * 60
static let geoRelayRetryInitialSeconds: TimeInterval = 60
static let geoRelayRetryMaxSeconds: TimeInterval = 60 * 60
// BLE operational delays
static let bleInitialAnnounceDelaySeconds: TimeInterval = 0.6
+204 -99
View File
@@ -8,6 +8,55 @@ final class GossipSyncManager {
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
}
private struct PacketStore {
private(set) var packets: [String: BitchatPacket] = [:]
private(set) var order: [String] = []
mutating func insert(idHex: String, packet: BitchatPacket, capacity: Int) {
guard capacity > 0 else { return }
if packets[idHex] != nil {
packets[idHex] = packet
return
}
packets[idHex] = packet
order.append(idHex)
while order.count > capacity {
let victim = order.removeFirst()
packets.removeValue(forKey: victim)
}
}
func allPackets(isFresh: (BitchatPacket) -> Bool) -> [BitchatPacket] {
order.compactMap { key in
guard let packet = packets[key], isFresh(packet) else { return nil }
return packet
}
}
mutating func remove(where shouldRemove: (BitchatPacket) -> Bool) {
var nextOrder: [String] = []
for key in order {
guard let packet = packets[key] else { continue }
if shouldRemove(packet) {
packets.removeValue(forKey: key)
} else {
nextOrder.append(key)
}
}
order = nextOrder
}
mutating func removeExpired(isFresh: (BitchatPacket) -> Bool) {
remove { !isFresh($0) }
}
}
private struct SyncSchedule {
let types: SyncTypeFlags
let interval: TimeInterval
var lastSent: Date
}
struct Config {
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
@@ -16,25 +65,43 @@ final class GossipSyncManager {
var maintenanceIntervalSeconds: TimeInterval = 30.0
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
var stalePeerTimeoutSeconds: TimeInterval = 60.0
var fragmentCapacity: Int = 600
var fileTransferCapacity: Int = 200
var fragmentSyncIntervalSeconds: TimeInterval = 30.0
var fileTransferSyncIntervalSeconds: TimeInterval = 60.0
var messageSyncIntervalSeconds: TimeInterval = 15.0
}
private let myPeerID: PeerID
private let config: Config
weak var delegate: Delegate?
// Storage: broadcast messages (ordered by insert), and latest announce per sender
private var messages: [String: BitchatPacket] = [:] // idHex -> packet
private var messageOrder: [String] = []
private var latestAnnouncementByPeer: [String: (id: String, packet: BitchatPacket)] = [:]
// Storage: broadcast packets by type, and latest announce per sender
private var messages = PacketStore()
private var fragments = PacketStore()
private var fileTransfers = PacketStore()
private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:]
// Timer
private var periodicTimer: DispatchSourceTimer?
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
private var lastStalePeerCleanup: Date = .distantPast
private var syncSchedules: [SyncSchedule] = []
init(myPeerID: PeerID, config: Config = Config()) {
self.myPeerID = myPeerID
self.config = config
var schedules: [SyncSchedule] = []
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
}
if config.fragmentCapacity > 0 && config.fragmentSyncIntervalSeconds > 0 {
schedules.append(SyncSchedule(types: .fragment, interval: config.fragmentSyncIntervalSeconds, lastSent: .distantPast))
}
if config.fileTransferCapacity > 0 && config.fileTransferSyncIntervalSeconds > 0 {
schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast))
}
syncSchedules = schedules
}
func start() {
@@ -55,7 +122,18 @@ final class GossipSyncManager {
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
self?.sendRequestSync(to: peerID)
guard let self = self else { return }
self.sendRequestSync(to: peerID, types: .publicMessages)
if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 {
self.queue.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.sendRequestSync(to: peerID, types: .fragment)
}
}
if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 {
self.queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in
self?.sendRequestSync(to: peerID, types: .fileTransfer)
}
}
}
}
@@ -87,47 +165,45 @@ final class GossipSyncManager {
}
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
let mt = MessageType(rawValue: packet.type)
guard let messageType = MessageType(rawValue: packet.type) else { return }
let isBroadcastRecipient: Bool = {
guard let r = packet.recipientID else { return true }
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
}()
let isBroadcastMessage = (mt == .message && isBroadcastRecipient)
let isAnnounce = (mt == .announce)
guard isBroadcastMessage || isAnnounce else { return }
// Reject expired packets to prevent ghost peers and old messages
guard isPacketFresh(packet) else { return }
if isAnnounce {
switch messageType {
case .announce:
guard isPacketFresh(packet) else { return }
guard isAnnouncementFresh(packet) else {
let sender = packet.senderID.hexEncodedString().lowercased()
removeState(forNormalizedPeerID: sender)
let sender = PeerID(hexData: packet.senderID)
removeState(for: sender)
return
}
}
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
if isBroadcastMessage {
if messages[idHex] == nil {
messages[idHex] = packet
messageOrder.append(idHex)
// Enforce capacity
let cap = max(1, config.seenCapacity)
while messageOrder.count > cap {
let victim = messageOrder.removeFirst()
messages.removeValue(forKey: victim)
}
}
} else if isAnnounce {
let sender = packet.senderID.hexEncodedString().lowercased()
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
let sender = PeerID(hexData: packet.senderID)
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
case .message:
guard isBroadcastRecipient else { return }
guard isPacketFresh(packet) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity))
case .fragment:
guard isBroadcastRecipient else { return }
guard isPacketFresh(packet) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
fragments.insert(idHex: idHex, packet: packet, capacity: max(1, config.fragmentCapacity))
case .fileTransfer:
guard isBroadcastRecipient else { return }
guard isPacketFresh(packet) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity))
default:
break
}
}
private func sendRequestSync() {
let payload = buildGcsPayload()
private func sendRequestSync(for types: SyncTypeFlags) {
let payload = buildGcsPayload(for: types)
let pkt = BitchatPacket(
type: MessageType.requestSync.rawValue,
senderID: Data(hexString: myPeerID.id) ?? Data(),
@@ -141,8 +217,8 @@ final class GossipSyncManager {
delegate?.sendPacket(signed)
}
private func sendRequestSync(to peerID: PeerID) {
let payload = buildGcsPayload()
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) {
let payload = buildGcsPayload(for: types)
var recipient = Data()
var temp = peerID.id
while temp.count >= 2 && recipient.count < 8 {
@@ -170,6 +246,7 @@ final class GossipSyncManager {
}
private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
let requestedTypes = (request.types ?? .publicMessages)
// Decode GCS into sorted set and prepare membership checker
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
func mightContain(_ id: Data) -> Bool {
@@ -177,60 +254,100 @@ final class GossipSyncManager {
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
}
// 1) Announcements: send latest per peer if requester lacks them (and not expired)
for (_, pair) in latestAnnouncementByPeer {
let (idHex, pkt) = pair
guard isPacketFresh(pkt) else { continue }
let idBytes = Data(hexString: idHex) ?? Data()
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
delegate?.sendPacket(to: peerID, packet: toSend)
if requestedTypes.contains(.announce) {
for (_, pair) in latestAnnouncementByPeer {
let (idHex, pkt) = pair
guard isPacketFresh(pkt) else { continue }
let idBytes = Data(hexString: idHex) ?? Data()
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
}
// 2) Broadcast messages: send all missing (and not expired)
let toSendMsgs = messageOrder.compactMap { messages[$0] }
for pkt in toSendMsgs {
guard isPacketFresh(pkt) else { continue }
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
delegate?.sendPacket(to: peerID, packet: toSend)
if requestedTypes.contains(.message) {
let toSendMsgs = messages.allPackets(isFresh: isPacketFresh)
for pkt in toSendMsgs {
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
}
if requestedTypes.contains(.fragment) {
let frags = fragments.allPackets(isFresh: isPacketFresh)
for pkt in frags {
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
}
if requestedTypes.contains(.fileTransfer) {
let files = fileTransfers.allPackets(isFresh: isPacketFresh)
for pkt in files {
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
}
}
// Build REQUEST_SYNC payload using current candidates and GCS params
private func buildGcsPayload() -> Data {
// Collect candidates: latest announce per peer + broadcast messages (only fresh)
private func buildGcsPayload(for types: SyncTypeFlags) -> Data {
var candidates: [BitchatPacket] = []
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count)
for (_, pair) in latestAnnouncementByPeer {
if isPacketFresh(pair.packet) {
if types.contains(.announce) {
for (_, pair) in latestAnnouncementByPeer where isPacketFresh(pair.packet) {
candidates.append(pair.packet)
}
}
for id in messageOrder {
if let p = messages[id], isPacketFresh(p) {
candidates.append(p)
}
if types.contains(.message) {
candidates.append(contentsOf: messages.allPackets(isFresh: isPacketFresh))
}
if types.contains(.fragment) {
candidates.append(contentsOf: fragments.allPackets(isFresh: isPacketFresh))
}
if types.contains(.fileTransfer) {
candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh))
}
if candidates.isEmpty {
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
return req.encode()
}
// Sort by timestamp desc
candidates.sort { $0.timestamp > $1.timestamp }
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
let nMax = GCSFilter.estimateMaxElements(sizeBytes: config.gcsMaxBytes, p: p)
let cap = max(1, config.seenCapacity)
let cap: Int
if types == .fragment {
cap = max(1, config.fragmentCapacity)
} else if types == .fileTransfer {
cap = max(1, config.fileTransferCapacity)
} else {
cap = max(1, config.seenCapacity)
}
let takeN = min(candidates.count, min(nMax, cap))
if takeN <= 0 {
let req = RequestSyncPacket(p: p, m: 1, data: Data())
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
return req.encode()
}
let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) }
let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr)
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data)
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types)
return req.encode()
}
@@ -241,20 +358,21 @@ final class GossipSyncManager {
isPacketFresh(pair.packet)
}
// Remove expired messages
let expiredMessageIds = messages.compactMap { id, pkt in
isPacketFresh(pkt) ? nil : id
}
for id in expiredMessageIds {
messages.removeValue(forKey: id)
messageOrder.removeAll { $0 == id }
}
messages.removeExpired(isFresh: isPacketFresh)
fragments.removeExpired(isFresh: isPacketFresh)
fileTransfers.removeExpired(isFresh: isPacketFresh)
}
private func performPeriodicMaintenance(now: Date = Date()) {
cleanupExpiredMessages()
cleanupStaleAnnouncementsIfNeeded(now: now)
sendRequestSync()
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)
}
}
}
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
@@ -270,40 +388,27 @@ final class GossipSyncManager {
let nowMs = UInt64(now.timeIntervalSince1970 * 1000)
guard nowMs >= timeoutMs else { return }
let cutoff = nowMs - timeoutMs
let stalePeerIDs = latestAnnouncementByPeer.compactMap { (peerHex, pair) -> String? in
pair.packet.timestamp < cutoff ? peerHex.lowercased() : nil
let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pair in
pair.packet.timestamp < cutoff ? peerID : nil
}
guard !stalePeerIDs.isEmpty else { return }
for peerKey in stalePeerIDs {
removeState(forNormalizedPeerID: peerKey)
removeState(for: peerKey)
}
}
// Explicit removal hook for LEAVE/stale peer
func removeAnnouncementForPeer(_ peerID: PeerID) {
queue.async { [weak self] in
self?._removeAnnouncementForPeer(peerID)
self?.removeState(for: peerID)
}
}
private func _removeAnnouncementForPeer(_ peerID: PeerID) {
let normalizedPeerID = peerID.id.lowercased()
removeState(forNormalizedPeerID: normalizedPeerID)
}
private func removeState(forNormalizedPeerID normalizedPeerID: String) {
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
// Remove messages from this peer
// Collect IDs to remove first to avoid concurrent modification
let messageIdsToRemove = messages.compactMap { (id, message) -> String? in
message.senderID.hexEncodedString().lowercased() == normalizedPeerID ? id : nil
}
// Remove messages and update messageOrder
for id in messageIdsToRemove {
messages.removeValue(forKey: id)
messageOrder.removeAll { $0 == id }
}
private func removeState(for peerID: PeerID) {
_ = latestAnnouncementByPeer.removeValue(forKey: peerID)
messages.remove { PeerID(hexData: $0.senderID) == peerID }
fragments.remove { PeerID(hexData: $0.senderID) == peerID }
fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID }
}
}
@@ -317,13 +422,13 @@ extension GossipSyncManager {
func _hasAnnouncement(for peerID: PeerID) -> Bool {
queue.sync {
latestAnnouncementByPeer[peerID.id.lowercased()] != nil
latestAnnouncementByPeer[peerID] != nil
}
}
func _messageCount(for peerID: PeerID) -> Int {
queue.sync {
messages.values.filter { $0.senderID.hexEncodedString().lowercased() == peerID.id.lowercased() }.count
messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count
}
}
}
+104
View File
@@ -0,0 +1,104 @@
import Foundation
/// Bitfield describing which message types are covered by a REQUEST_SYNC round.
/// Matches the Android mapping (bit index -> message type).
struct SyncTypeFlags: OptionSet {
let rawValue: UInt64
init(rawValue: UInt64) {
self.rawValue = rawValue & 0x00FF_FFFF_FFFF_FFFF // Trim to max 8 bytes
}
private static func bitIndex(for type: MessageType) -> Int? {
switch type {
case .announce: return 0
case .message: return 1
case .leave: return 2
case .noiseHandshake: return 3
case .noiseEncrypted: return 4
case .fragment: return 5
case .requestSync: return 6
case .fileTransfer: return 7
}
}
private static func type(forBit index: Int) -> MessageType? {
switch index {
case 0: return .announce
case 1: return .message
case 2: return .leave
case 3: return .noiseHandshake
case 4: return .noiseEncrypted
case 5: return .fragment
case 6: return .requestSync
case 7: return .fileTransfer
default:
return nil
}
}
static let announce = SyncTypeFlags(messageTypes: [.announce])
static let message = SyncTypeFlags(messageTypes: [.message])
static let fragment = SyncTypeFlags(messageTypes: [.fragment])
static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer])
static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message])
init(messageTypes: [MessageType]) {
var raw: UInt64 = 0
for type in messageTypes {
guard let bit = SyncTypeFlags.bitIndex(for: type) else { continue }
raw |= (1 << UInt64(bit))
}
self.init(rawValue: raw)
}
func contains(_ type: MessageType) -> Bool {
guard let bit = SyncTypeFlags.bitIndex(for: type) else { return false }
return contains(SyncTypeFlags(rawValue: 1 << UInt64(bit)))
}
func union(_ other: SyncTypeFlags) -> SyncTypeFlags {
SyncTypeFlags(rawValue: rawValue | other.rawValue)
}
func intersection(_ other: SyncTypeFlags) -> SyncTypeFlags {
SyncTypeFlags(rawValue: rawValue & other.rawValue)
}
func toMessageTypes() -> [MessageType] {
guard rawValue != 0 else { return [] }
var types: [MessageType] = []
for bit in 0..<64 {
guard (rawValue & (1 << UInt64(bit))) != 0 else { continue }
if let type = SyncTypeFlags.type(forBit: bit) {
types.append(type)
}
}
return types
}
func toData() -> Data? {
guard rawValue != 0 else { return nil }
var value = rawValue
var bytes: [UInt8] = []
while value > 0 && bytes.count < 8 {
bytes.append(UInt8(value & 0xFF))
value >>= 8
}
while let last = bytes.last, last == 0 {
bytes.removeLast()
}
guard !bytes.isEmpty, bytes.count <= 8 else { return nil }
return Data(bytes)
}
static func decode(_ data: Data) -> SyncTypeFlags? {
guard (1...8).contains(data.count) else { return nil }
var raw: UInt64 = 0
for (index, byte) in data.enumerated() {
raw |= UInt64(byte) << UInt64(index * 8)
}
return SyncTypeFlags(rawValue: raw)
}
}
+7 -9
View File
@@ -61,15 +61,13 @@ struct CompressionUtil {
// 1. Data is too small
// 2. Data appears to be already compressed (high entropy)
guard data.count >= compressionThreshold else { return false }
// Simple entropy check - count unique bytes
var byteFrequency = [UInt8: Int]()
for byte in data {
byteFrequency[byte, default: 0] += 1
}
// If we have very high byte diversity, data is likely already compressed
let uniqueByteRatio = Double(byteFrequency.count) / Double(min(data.count, 256))
// Quick uniqueness check a high diversity of bytes usually means the
// payload is already compressed. We only need to know how many unique
// values exist rather than keeping full frequency counts.
let uniqueByteCount = Set(data).count
let sampleSize = min(data.count, 256)
let uniqueByteRatio = Double(uniqueByteCount) / Double(sampleSize)
return uniqueByteRatio < 0.9 // Compress if less than 90% unique bytes
}
}
+2 -2
View File
@@ -5,9 +5,9 @@ enum FileTransferLimits {
/// Absolute ceiling enforced for any file payload (voice, image, other).
static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
/// Voice notes stay small for low-latency relays.
static let maxVoiceNoteBytes: Int = 1 * 1024 * 1024 // 1 MiB
static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB
/// Compressed images after downscaling should comfortably fit under this budget.
static let maxImageBytes: Int = 1 * 1024 * 1024 // 1 MiB
static let maxImageBytes: Int = 512 * 1024 // 512 KiB
/// Worst-case size once TLV metadata and binary packet framing are included for the largest payloads.
static let maxFramedFileBytes: Int = {
let maxMetadataBytes = Int(UInt16.max) * 2 // fileName + mimeType TLVs
+17 -17
View File
@@ -1,4 +1,5 @@
import Foundation
import BitLogger
/// Comprehensive input validation for BitChat protocol
/// Prevents injection attacks, buffer overflows, and malformed data
@@ -16,29 +17,28 @@ struct InputValidator {
// MARK: - String Content Validation
/// Validates and sanitizes user-provided strings used in UI
///
/// 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? {
// Check empty
guard !string.isEmpty else { return nil }
// Trim whitespace
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
// Check length
guard trimmed.count <= maxLength else { return nil }
// Remove control characters
// Reject control characters outright instead of rewriting the string.
// This prevents injection attacks and ensures consistent UI rendering.
let controlChars = CharacterSet.controlCharacters
let cleaned = trimmed.components(separatedBy: controlChars).joined()
// Ensure valid UTF-8 (should already be, but double-check)
guard cleaned.data(using: .utf8) != nil else { return nil }
// Prevent zero-width characters and other invisible unicode
let invisibleChars = CharacterSet(charactersIn: "\u{200B}\u{200C}\u{200D}\u{FEFF}")
let visible = cleaned.components(separatedBy: invisibleChars).joined()
return visible.isEmpty ? nil : visible
if !trimmed.unicodeScalars.allSatisfy({ !controlChars.contains($0) }) {
// Log rejection for monitoring, without exposing actual content for privacy
let controlCharCount = trimmed.unicodeScalars.filter { controlChars.contains($0) }.count
SecureLogger.debug(
"Input validation rejected string (length: \(trimmed.count), control chars: \(controlCharCount))",
category: .security
)
return nil
}
return trimmed
}
/// Validates nickname
+274 -284
View File
@@ -145,6 +145,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
}
private typealias GeoOutgoingContext = (channel: GeohashChannel, event: NostrEvent, identity: NostrIdentity, teleported: Bool)
@MainActor
private var canSendMediaInCurrentContext: Bool {
if let peer = selectedPrivateChatPeer {
return !(peer.isGeoDM || peer.isGeoChat)
}
switch activeChannel {
case .mesh: return true
case .location: return false
}
}
private var rateBucketsBySender: [String: TokenBucket] = [:]
private var rateBucketsByContent: [String: TokenBucket] = [:]
private let senderBucketCapacity: Double = TransportConfig.uiSenderRateBucketCapacity
@@ -158,7 +171,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if spid.isGeoChat || spid.isGeoDM {
let full = (nostrKeyMapping[spid] ?? spid.bare).lowercased()
return "nostr:" + full
} else if spid.id.count == 16, let full = getNoiseKeyForShortID(spid)?.lowercased() {
} else if spid.id.count == 16, let full = getNoiseKeyForShortID(spid)?.id.lowercased() {
return "noise:" + full
} else {
return "mesh:" + spid.id.lowercased()
@@ -235,10 +248,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@Published var currentColorScheme: ColorScheme = .light
private let maxMessages = TransportConfig.meshTimelineCap // Maximum messages before oldest are removed
@Published var isConnected = false
private var hasNotifiedNetworkAvailable = false
private var recentlySeenPeers: Set<PeerID> = []
private var lastNetworkNotificationTime = Date.distantPast
private var networkResetTimer: Timer? = nil
private var networkEmptyTimer: Timer? = nil
private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects
@Published var nickname: String = "" {
didSet {
@@ -248,7 +261,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
nickname = trimmed
}
// Update mesh service nickname if it's initialized
if meshService.myPeerID != "" {
if !meshService.myPeerID.isEmpty {
meshService.setNickname(nickname)
}
}
@@ -315,16 +328,16 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
private var peerIDToPublicKeyFingerprint: [PeerID: String] = [:]
private var selectedPrivateChatFingerprint: String? = nil
// Map stable short peer IDs (16-hex) to full Noise public key hex (64-hex) for session continuity
private var shortIDToNoiseKey: [PeerID: String] = [:]
private var shortIDToNoiseKey: [PeerID: PeerID] = [:]
// Resolve full Noise key for a peer's short ID (used by UI header rendering)
@MainActor
private func getNoiseKeyForShortID(_ shortPeerID: PeerID) -> String? {
private func getNoiseKeyForShortID(_ shortPeerID: PeerID) -> PeerID? {
if let mapped = shortIDToNoiseKey[shortPeerID] { return mapped }
// Fallback: derive from active Noise session if available
if shortPeerID.id.count == 16,
let key = meshService.getNoiseService().getPeerPublicKeyData(shortPeerID) {
let stable = key.hexEncodedString()
let stable = PeerID(hexData: key)
shortIDToNoiseKey[shortPeerID] = stable
return stable
}
@@ -333,16 +346,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Resolve short mesh ID (16-hex) from a full Noise public key hex (64-hex)
@MainActor
func getShortIDForNoiseKey(_ fullNoiseKeyHex: String) -> PeerID? {
func getShortIDForNoiseKey(_ fullNoiseKeyHex: PeerID) -> PeerID {
guard fullNoiseKeyHex.id.count == 64 else { return fullNoiseKeyHex }
// Check known peers for a noise key match
if let match = allPeers.first(where: { $0.noisePublicKey.hexEncodedString() == fullNoiseKeyHex }) {
if let match = allPeers.first(where: { PeerID(hexData: $0.noisePublicKey) == fullNoiseKeyHex }) {
return match.peerID
}
// Also search cache mapping
if let pair = shortIDToNoiseKey.first(where: { $0.value == fullNoiseKeyHex }) {
return pair.key
}
return nil
return fullNoiseKeyHex
}
private var peerIndex: [PeerID: BitchatPeer] = [:]
@@ -1141,7 +1155,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
NotificationService.shared.sendPrivateMessageNotification(
from: senderName,
message: pm.content,
peerID: convKey.id
peerID: convKey
)
}
}
@@ -1443,20 +1457,48 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Parse mentions from the content (use original content for user intent)
let mentions = parseMentions(from: content)
var geoContext: GeoOutgoingContext? = nil
// Add message to local display
var displaySender = nickname
var localSenderPeerID = meshService.myPeerID
if case .location(let ch) = activeChannel,
let myGeoIdentity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
let suffix = String(myGeoIdentity.publicKeyHex.suffix(4))
displaySender = nickname + "#" + suffix
localSenderPeerID = PeerID(nostr: myGeoIdentity.publicKeyHex)
var messageID: String? = nil
var messageTimestamp = Date()
switch activeChannel {
case .mesh:
break
case .location(let ch):
do {
let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash)
let suffix = String(identity.publicKeyHex.suffix(4))
displaySender = nickname + "#" + suffix
localSenderPeerID = PeerID(nostr: identity.publicKeyHex)
let teleported = LocationChannelManager.shared.teleported
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: trimmed,
geohash: ch.geohash,
senderIdentity: identity,
nickname: nickname,
teleported: teleported
)
messageID = event.id
messageTimestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at))
geoContext = (channel: ch, event: event, identity: identity, teleported: teleported)
} catch {
SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session)
addSystemMessage(
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
)
return
}
}
let message = BitchatMessage(
id: messageID,
sender: displaySender,
content: trimmed,
timestamp: Date(),
timestamp: messageTimestamp,
isRelay: false,
senderPeerID: localSenderPeerID,
mentions: mentions.isEmpty ? nil : mentions
@@ -1487,69 +1529,75 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// UI updates automatically via @Published var messages
updateChannelActivityTimeThenSend(content: content, trimmed: trimmed, mentions: mentions)
updateChannelActivityTimeThenSend(content: content,
trimmed: trimmed,
mentions: mentions,
geoContext: geoContext,
messageID: message.id,
timestamp: message.timestamp)
}
private func updateChannelActivityTimeThenSend(content: String, trimmed: String, mentions: [String]) {
private func updateChannelActivityTimeThenSend(content: String,
trimmed: String,
mentions: [String],
geoContext: GeoOutgoingContext?,
messageID: String,
timestamp: Date) {
switch activeChannel {
case .mesh:
lastPublicActivityAt["mesh"] = Date()
// Send via mesh with mentions
meshService.sendMessage(content, mentions: mentions)
meshService.sendMessage(content, mentions: mentions, messageID: messageID, timestamp: timestamp)
case .location(let ch):
lastPublicActivityAt["geo:\(ch.geohash)"] = Date()
guard let context = geoContext, context.channel.geohash == ch.geohash else {
SecureLogger.error("Geo: missing send context for \(ch.geohash)", category: .session)
addSystemMessage(
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
)
return
}
// Send to geohash channel via Nostr ephemeral
Task { @MainActor in
sendGeohash(ch: ch, content: trimmed)
self.sendGeohash(context: context)
}
}
}
@MainActor
private func sendGeohash(ch: GeohashChannel, content: String) {
do {
let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash)
private func sendGeohash(context: GeoOutgoingContext) {
let ch = context.channel
let event = context.event
let identity = context.identity
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: content,
geohash: ch.geohash,
senderIdentity: identity,
nickname: nickname,
teleported: LocationChannelManager.shared.teleported
)
let targetRelays = GeoRelayDirectory.shared.closestRelays(
toGeohash: ch.geohash,
count: TransportConfig.nostrGeoRelayCount
)
if targetRelays.isEmpty {
SecureLogger.warning("Geo: no geohash relays available for \(ch.geohash); not sending", category: .session)
} else {
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
}
let targetRelays = GeoRelayDirectory.shared.closestRelays(
toGeohash: ch.geohash,
count: TransportConfig.nostrGeoRelayCount
)
// Track ourselves as active participant
recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex
SecureLogger.debug("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(LocationChannelManager.shared.teleported)", category: .session)
// If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI
// Only when not in our regional set (and regional list is known)
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
if LocationChannelManager.shared.teleported && hasRegional && !inRegional {
let key = identity.publicKeyHex.lowercased()
teleportedGeo = teleportedGeo.union([key])
SecureLogger.info("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session)
}
} catch {
SecureLogger.error("❌ Failed to send geohash message: \(error)", category: .session)
addSystemMessage(
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
)
if targetRelays.isEmpty {
SecureLogger.warning("Geo: no geohash relays available for \(ch.geohash); not sending", category: .session)
} else {
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
}
// Track ourselves as active participant
recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex
SecureLogger.debug("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(context.teleported)", category: .session)
// If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI
// Only when not in our regional set (and regional list is known)
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
if context.teleported && hasRegional && !inRegional {
let key = identity.publicKeyHex.lowercased()
teleportedGeo = teleportedGeo.union([key])
SecureLogger.info("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session)
}
recordProcessedEvent(event.id)
}
@MainActor
@@ -1838,7 +1886,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
NotificationService.shared.sendPrivateMessageNotification(
from: senderName,
message: pm.content,
peerID: convKey.id
peerID: convKey
)
}
@@ -1918,8 +1966,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
func isSelfSender(peerID: PeerID?, displayName: String?) -> Bool {
guard let peerID else { return false }
if peerID == meshService.myPeerID { return true }
let lowerPeer = peerID.id.lowercased()
guard lowerPeer.hasPrefix("nostr") else { return false }
guard peerID.isGeoDM || peerID.isGeoChat else { return false }
if let mapped = nostrKeyMapping[peerID]?.lowercased(),
let gh = currentGeohash,
@@ -1929,10 +1976,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if let gh = currentGeohash,
let myIdentity = try? idBridge.deriveIdentity(forGeohash: gh) {
let myLower = myIdentity.publicKeyHex.lowercased()
let shortLen = TransportConfig.nostrShortKeyDisplayLength
let shortKey = "nostr:" + myLower.prefix(shortLen)
if lowerPeer == shortKey { return true }
if peerID == PeerID(nostr: myIdentity.publicKeyHex) { return true }
let suffix = myIdentity.publicKeyHex.suffix(4)
let expected = (nickname + "#" + suffix).lowercased()
if let display = displayName?.lowercased(), display == expected { return true }
@@ -2407,8 +2451,15 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
func sendVoiceNote(at url: URL) {
guard canSendMediaInCurrentContext else {
SecureLogger.info("Voice note blocked outside mesh/private context", category: .session)
try? FileManager.default.removeItem(at: url)
addSystemMessage("Voice notes are only available in mesh chats.")
return
}
let targetPeer = selectedPrivateChatPeer
let message = enqueueMediaMessage(content: "[voice] \(url.lastPathComponent)", targetPeer: targetPeer?.id)
let message = enqueueMediaMessage(content: "[voice] \(url.lastPathComponent)", targetPeer: targetPeer)
let messageID = message.id
let transferId = makeTransferID(messageID: messageID)
@@ -2455,6 +2506,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) {
guard canSendMediaInCurrentContext else {
SecureLogger.info("Image send blocked outside mesh/private context", category: .session)
cleanup?()
addSystemMessage("Images are only available in mesh chats.")
return
}
let targetPeer = selectedPrivateChatPeer
Task.detached(priority: .userInitiated) { [weak self] in
@@ -2480,7 +2538,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
)
guard packet.encode() != nil else { throw MediaSendError.encodingFailed }
await MainActor.run {
let message = self.enqueueMediaMessage(content: "[image] \(outputURL.lastPathComponent)", targetPeer: targetPeer?.id)
let message = self.enqueueMediaMessage(content: "[image] \(outputURL.lastPathComponent)", targetPeer: targetPeer)
let messageID = message.id
let transferId = self.makeTransferID(messageID: messageID)
self.registerTransfer(transferId: transferId, messageID: messageID)
@@ -2503,65 +2561,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
}
@MainActor
func sendFileAttachment(from sourceURL: URL) {
let targetPeer = selectedPrivateChatPeer
Task.detached(priority: .userInitiated) { [weak self] in
guard let self = self else { return }
defer { try? FileManager.default.removeItem(at: sourceURL) }
var destinationURL: URL?
do {
// Security H1: Check file size BEFORE reading into memory
let attrs = try FileManager.default.attributesOfItem(atPath: sourceURL.path)
guard let fileSize = attrs[.size] as? Int else {
throw MediaSendError.encodingFailed
}
guard FileTransferLimits.isValidPayload(fileSize) else {
throw MediaSendError.tooLarge
}
let data = try Data(contentsOf: sourceURL)
let destination = try self.prepareOutgoingFileCopy(from: sourceURL, data: data)
destinationURL = destination
let packet = BitchatFilePacket(
fileName: destination.lastPathComponent,
fileSize: UInt64(data.count),
mimeType: self.mimeType(for: destination),
content: data
)
guard packet.encode() != nil else {
try? FileManager.default.removeItem(at: destination)
throw MediaSendError.encodingFailed
}
await MainActor.run {
let message = self.enqueueMediaMessage(content: "[file] \(destination.lastPathComponent)", targetPeer: targetPeer?.id)
let messageID = message.id
let transferId = self.makeTransferID(messageID: messageID)
self.registerTransfer(transferId: transferId, messageID: messageID)
if let peerID = targetPeer {
self.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId)
} else {
self.meshService.sendFileBroadcast(packet, transferId: transferId)
}
}
} catch MediaSendError.tooLarge {
await MainActor.run {
self.addSystemMessage("File is too large to send.")
}
} catch {
if let destination = destinationURL {
try? FileManager.default.removeItem(at: destination)
}
SecureLogger.error("File attachment send failed: \(error)", category: .session)
await MainActor.run {
self.addSystemMessage("Failed to send file.")
}
}
}
}
@MainActor
func cancelMediaSend(messageID: String) {
@@ -2581,7 +2580,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
@MainActor
private func enqueueMediaMessage(content: String, targetPeer: String?) -> BitchatMessage {
private func enqueueMediaMessage(content: String, targetPeer: PeerID?) -> BitchatMessage {
let timestamp = Date()
let message: BitchatMessage
@@ -2598,7 +2597,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
deliveryStatus: .sending
)
var chats = privateChats
chats[PeerID(str: peerID), default: []].append(message)
chats[peerID, default: []].append(message)
privateChats = chats
trimMessagesIfNeeded()
} else {
@@ -2611,7 +2610,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: PeerID(str: senderPeerID),
senderPeerID: senderPeerID,
deliveryStatus: .sending
)
messages.append(message)
@@ -2636,29 +2635,28 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
return message
}
private func currentPublicSender() -> (name: String, peerID: String) {
private func currentPublicSender() -> (name: String, peerID: PeerID) {
var displaySender = nickname
var senderPeerID = meshService.myPeerID
if case .location(let ch) = activeChannel,
let identity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
let suffix = String(identity.publicKeyHex.suffix(4))
displaySender = nickname + "#" + suffix
let shortKey = identity.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength)
senderPeerID = PeerID(str: "nostr:\(shortKey)")
senderPeerID = PeerID(nostr: identity.publicKeyHex)
}
return (displaySender, senderPeerID.id)
return (displaySender, senderPeerID)
}
@MainActor
private func nicknameForPeer(_ peerID: String) -> String {
if let name = meshService.peerNickname(peerID: PeerID(str: peerID)) {
private func nicknameForPeer(_ peerID: PeerID) -> String {
if let name = meshService.peerNickname(peerID: peerID) {
return name
}
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: PeerID(str: peerID)),
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
!favorite.peerNickname.isEmpty {
return favorite.peerNickname
}
if let noiseKey = Data(hexString: peerID),
if let noiseKey = Data(hexString: peerID.id),
let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
!favorite.peerNickname.isEmpty {
return favorite.peerNickname
@@ -2748,55 +2746,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
}
private func prepareOutgoingFileCopy(from sourceURL: URL, data: Data) throws -> URL {
let base = try applicationFilesDirectory().appendingPathComponent("files/outgoing", isDirectory: true)
try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
let rawName = sourceURL.lastPathComponent
let sanitized = sanitizeOutgoingFileName(rawName.isEmpty ? "file" : rawName)
var destination = base.appendingPathComponent(sanitized)
var counter = 1
while FileManager.default.fileExists(atPath: destination.path) {
let baseName = (sanitized as NSString).deletingPathExtension
let ext = (sanitized as NSString).pathExtension
let newName: String
if ext.isEmpty {
newName = "\(baseName) (\(counter))"
} else {
newName = "\(baseName) (\(counter)).\(ext)"
}
destination = base.appendingPathComponent(newName)
counter += 1
}
try data.write(to: destination, options: .atomic)
return destination
}
private func sanitizeOutgoingFileName(_ name: String) -> String {
var candidate = name
candidate = candidate.replacingOccurrences(of: "\\", with: "/")
candidate = candidate.components(separatedBy: "/").last ?? name
candidate = candidate.trimmingCharacters(in: .whitespacesAndNewlines)
if candidate.isEmpty { candidate = "file" }
let invalid = CharacterSet(charactersIn: "<>:\"|?*")
candidate = candidate.components(separatedBy: invalid).joined(separator: "_")
if candidate.isEmpty { candidate = "file" }
if (candidate as NSString).pathExtension.isEmpty {
candidate += ".bin"
}
return candidate
}
private func mimeType(for url: URL) -> String {
let ext = url.pathExtension.lowercased()
if !ext.isEmpty,
let type = UTType(filenameExtension: ext),
let mime = type.preferredMIMEType {
return mime
}
return "application/octet-stream"
}
private func applicationFilesDirectory() throws -> URL {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
@@ -3396,7 +3345,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// In public chat - send to active public channel
switch activeChannel {
case .mesh:
meshService.sendMessage(screenshotMessage, mentions: [])
meshService.sendMessage(screenshotMessage,
mentions: [],
messageID: UUID().uuidString,
timestamp: Date())
case .location(let ch):
Task { @MainActor in
do {
@@ -3550,7 +3502,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if !sentReadReceipts.contains(message.id) {
// Use stable Noise key hex if available; else fall back to peerID
let recipPeer = peerID.isHex ? peerID : (unifiedPeerService.getPeer(by: peerID)?.peerID ?? peerID)
let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID.id, readerNickname: nickname)
let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname)
messageRouter.sendReadReceipt(receipt, to: recipPeer)
sentReadReceipts.insert(message.id)
}
@@ -4164,7 +4116,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if let spid = message.senderPeerID {
if case .location(let ch) = activeChannel, spid.id.hasPrefix("nostr:") {
if let myGeo = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
return spid == "nostr:\(myGeo.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength))"
return spid == PeerID(nostr: myGeo.publicKeyHex)
}
}
return spid == meshService.myPeerID
@@ -4317,13 +4269,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
let noiseService = meshService.getNoiseService()
if noiseService.hasEstablishedSession(with: peerID) {
// Check if fingerprint is verified using our persisted data
if let fingerprint = getFingerprint(for: peerID),
verifiedFingerprints.contains(fingerprint) {
peerEncryptionStatus[peerID] = .noiseVerified
} else {
peerEncryptionStatus[peerID] = .noiseSecured
}
peerEncryptionStatus[peerID] = encryptionStatus(for: peerID)
} else if noiseService.hasSession(with: peerID) {
// Session exists but not established - handshaking
peerEncryptionStatus[peerID] = .noiseHandshaking
@@ -4358,27 +4304,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Determine status based on session state
switch sessionState {
case .established:
// We have encryption, now check if it's verified
if let fingerprint = getFingerprint(for: peerID) {
if verifiedFingerprints.contains(fingerprint) {
status = .noiseVerified
} else {
status = .noiseSecured
}
} else {
// We have a session but no fingerprint yet - still secured
status = .noiseSecured
}
status = encryptionStatus(for: peerID)
case .handshaking, .handshakeQueued:
// If we've ever established a session, show secured instead of handshaking
if hasEverEstablishedSession {
// Check if it was verified before
if let fingerprint = getFingerprint(for: peerID),
verifiedFingerprints.contains(fingerprint) {
status = .noiseVerified
} else {
status = .noiseSecured
}
status = encryptionStatus(for: peerID)
} else {
// First time establishing - show handshaking
status = .noiseHandshaking
@@ -4387,12 +4318,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// If we've ever established a session, show secured instead of no handshake
if hasEverEstablishedSession {
// Check if it was verified before
if let fingerprint = getFingerprint(for: peerID),
verifiedFingerprints.contains(fingerprint) {
status = .noiseVerified
} else {
status = .noiseSecured
}
status = encryptionStatus(for: peerID)
} else {
// Never established - show no handshake
status = .noHandshake
@@ -4401,12 +4327,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// If we've ever established a session, show secured instead of failed
if hasEverEstablishedSession {
// Check if it was verified before
if let fingerprint = getFingerprint(for: peerID),
verifiedFingerprints.contains(fingerprint) {
status = .noiseVerified
} else {
status = .noiseSecured
}
status = encryptionStatus(for: peerID)
} else {
// Never established - show failed
status = .none
@@ -4480,7 +4401,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
private func meshSeed(for peerID: PeerID) -> String {
if let full = getNoiseKeyForShortID(peerID)?.lowercased() {
if let full = getNoiseKeyForShortID(peerID)?.id.lowercased() {
return "noise:" + full
}
return peerID.id.lowercased()
@@ -4797,16 +4718,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
let noiseService = meshService.getNoiseService()
if noiseService.hasEstablishedSession(with: peerID) {
if let fingerprint = getFingerprint(for: peerID) {
if verifiedFingerprints.contains(fingerprint) {
peerEncryptionStatus[peerID] = .noiseVerified
} else {
peerEncryptionStatus[peerID] = .noiseSecured
}
} else {
// Session established but no fingerprint yet
peerEncryptionStatus[peerID] = .noiseSecured
}
peerEncryptionStatus[peerID] = encryptionStatus(for: peerID)
} else if noiseService.hasSession(with: peerID) {
peerEncryptionStatus[peerID] = .noiseHandshaking
} else {
@@ -4836,6 +4748,16 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
return unifiedPeerService.getFingerprint(for: peerID)
}
/// Check if fingerprint is verified using our persisted data
@MainActor
private func encryptionStatus(for peerID: PeerID) -> EncryptionStatus {
if let fp = getFingerprint(for: peerID), verifiedFingerprints.contains(fp) {
return .noiseVerified
} else {
return .noiseSecured
}
}
/// Helper to resolve nickname for a peer ID through various sources
@MainActor
private func resolveNickname(for peerID: PeerID) -> String {
@@ -4937,7 +4859,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
DispatchQueue.main.async {
guard let self = self else { return }
let peerID = PeerID(str: peerID)
SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
@@ -4956,9 +4877,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Cache shortID -> full Noise key mapping as soon as session authenticates
if self.shortIDToNoiseKey[peerID] == nil,
let keyData = self.meshService.getNoiseService().getPeerPublicKeyData(peerID) {
let stable = keyData.hexEncodedString()
let stable = PeerID(hexData: keyData)
self.shortIDToNoiseKey[peerID] = stable
SecureLogger.debug("🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stable.prefix(8))", category: .session)
SecureLogger.debug("🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stable.id.prefix(8))", category: .session)
}
// If a QR verification is pending but not sent yet, send it now that session is authenticated
@@ -5142,12 +5063,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
}
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
Task { @MainActor in
let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines)
let publicMentions = parseMentions(from: normalized)
let msg = BitchatMessage(
id: UUID().uuidString,
id: messageID,
sender: nickname,
content: normalized,
timestamp: timestamp,
@@ -5223,7 +5144,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Cache mapping to full Noise key for session continuity on disconnect
if let peer = unifiedPeerService.getPeer(by: peerID) {
let noiseKeyHex = peer.noisePublicKey.hexEncodedString()
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
shortIDToNoiseKey[peerID] = noiseKeyHex
}
@@ -5239,15 +5160,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
identityManager.removeEphemeralSession(peerID: peerID)
// If the open PM is tied to this short peer ID, switch UI context to the full Noise key (offline favorite)
var derivedStableKeyHex: String? = shortIDToNoiseKey[peerID]
var derivedStableKeyHex = shortIDToNoiseKey[peerID]
if derivedStableKeyHex == nil,
let key = meshService.getNoiseService().getPeerPublicKeyData(peerID) {
derivedStableKeyHex = key.hexEncodedString()
derivedStableKeyHex = PeerID(hexData: key)
shortIDToNoiseKey[peerID] = derivedStableKeyHex
}
if let current = selectedPrivateChatPeer, current == peerID,
let stableKeyHex = PeerID(str: derivedStableKeyHex) {
if let current = selectedPrivateChatPeer, current == peerID, let stableKeyHex = derivedStableKeyHex {
// Migrate messages view context to stable key so header shows favorite + Nostr globe
if let messages = privateChats[peerID] {
if privateChats[stableKeyHex] == nil { privateChats[stableKeyHex] = [] }
@@ -5309,34 +5229,29 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
self.cleanupStaleUnreadPeerIDs()
// Smart notification logic for "bitchatters nearby"
if !peers.isEmpty {
// Cancel any pending reset if peers are back
self.networkResetTimer?.invalidate()
self.networkResetTimer = nil
// Count mesh peers that are connected OR recently reachable via mesh relays
let meshPeers = peers.filter { peerID in
self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID)
}
// Rising-edge only: previously zero peers, now > 0 peers
let currentPeerSet = Set(meshPeers)
let hadNone = self.recentlySeenPeers.isEmpty
if meshPeers.count > 0 && hadNone && !self.hasNotifiedNetworkAvailable {
self.hasNotifiedNetworkAvailable = true
self.lastNetworkNotificationTime = Date()
self.recentlySeenPeers = currentPeerSet
NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count)
SecureLogger.info("👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers", category: .session)
}
let meshPeers = peers.filter { peerID in
self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID)
}
let meshPeerSet = Set(meshPeers)
if meshPeerSet.isEmpty {
self.scheduleNetworkEmptyTimer()
} else {
// No peers immediately reset to allow next rising-edge to notify
self.hasNotifiedNetworkAvailable = false
self.recentlySeenPeers.removeAll()
if self.networkResetTimer != nil {
self.networkResetTimer?.invalidate()
self.networkResetTimer = nil
self.invalidateNetworkEmptyTimer()
// Trim out peers we no longer observe before comparing for new arrivals
self.recentlySeenPeers.formIntersection(meshPeerSet)
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
)
self.scheduleNetworkResetTimer()
}
SecureLogger.debug("⏳ Mesh empty — reset network notification state", category: .session)
}
// Register ephemeral sessions for all connected peers
@@ -5406,6 +5321,71 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Keep only receipts from messages we still have
cleanupOldReadReceipts()
}
@MainActor
private func scheduleNetworkResetTimer() {
networkResetTimer?.invalidate()
networkResetTimer = Timer.scheduledTimer(
timeInterval: networkResetGraceSeconds,
target: self,
selector: #selector(onNetworkResetTimerFired(_:)),
userInfo: nil,
repeats: false
)
}
@MainActor
@objc private func onNetworkResetTimerFired(_ timer: Timer) {
let activeMeshPeers = meshService
.currentPeerSnapshots()
.filter { snapshot in
snapshot.isConnected || meshService.isPeerReachable(snapshot.peerID)
}
if activeMeshPeers.isEmpty {
recentlySeenPeers.removeAll()
SecureLogger.debug("⏱️ Network notification window reset after quiet period", category: .session)
} else {
SecureLogger.debug("⏱️ Skipped network notification reset; still seeing \(activeMeshPeers.count) mesh peers", category: .session)
}
networkResetTimer = nil
}
@MainActor
private func scheduleNetworkEmptyTimer() {
guard networkEmptyTimer == nil else { return }
networkEmptyTimer = Timer.scheduledTimer(
timeInterval: TransportConfig.uiMeshEmptyConfirmationSeconds,
target: self,
selector: #selector(onNetworkEmptyTimerFired(_:)),
userInfo: nil,
repeats: false
)
SecureLogger.debug("⏳ Mesh empty — waiting before resetting notification state", category: .session)
}
@MainActor
private func invalidateNetworkEmptyTimer() {
if networkEmptyTimer != nil {
networkEmptyTimer?.invalidate()
networkEmptyTimer = nil
}
}
@MainActor
@objc private func onNetworkEmptyTimerFired(_ timer: Timer) {
let activeMeshPeers = meshService
.currentPeerSnapshots()
.filter { snapshot in
snapshot.isConnected || meshService.isPeerReachable(snapshot.peerID)
}
if activeMeshPeers.isEmpty {
recentlySeenPeers.removeAll()
SecureLogger.debug("⏳ Mesh empty — notification state reset after confirmation", category: .session)
} else {
SecureLogger.debug("⏳ Mesh empty timer cancelled; \(activeMeshPeers.count) mesh peers detected again", category: .session)
}
networkEmptyTimer = nil
}
private func cleanupOldReadReceipts() {
// Skip cleanup during startup phase or if privateChats is empty
@@ -5562,6 +5542,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
)
// Append to current visible messages
messages.append(systemMessage)
// Track the content key so relayed copies of the same system-style message are ignored
let contentKey = normalizedContentKey(systemMessage.content)
recordContentKey(contentKey, timestamp: systemMessage.timestamp)
// Persist into the backing store for the active channel to survive rebinds
switch activeChannel {
case .mesh:
@@ -5612,7 +5595,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
return
}
// Default: send over mesh
meshService.sendMessage(content, mentions: [])
meshService.sendMessage(content,
mentions: [],
messageID: UUID().uuidString,
timestamp: Date())
}
// MARK: - Simplified Nostr Integration (Inlined from MessageRouter)
@@ -5686,7 +5672,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
// Validate recipient
if let rid = packet.recipientID, rid.hexEncodedString() != meshService.myPeerID {
if PeerID(hexData: packet.recipientID) != meshService.myPeerID {
return
}
@@ -5896,7 +5882,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
if !sentReadReceipts.contains(message.id) {
if let key {
let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID.id, readerNickname: nickname)
let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname)
SecureLogger.debug("Viewing chat; sending READ ack for \(message.id.prefix(8))… via router", category: .session)
messageRouter.sendReadReceipt(receipt, to: PeerID(hexData: key))
sentReadReceipts.insert(message.id)
@@ -5931,7 +5917,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
NotificationService.shared.sendPrivateMessageNotification(
from: senderNickname,
message: messageContent,
peerID: targetPeerID.id
peerID: targetPeerID
)
}
}
@@ -6324,7 +6310,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
NotificationService.shared.sendPrivateMessageNotification(
from: message.sender,
message: message.content,
peerID: peerID.id
peerID: peerID
)
}
} else {
@@ -6339,7 +6325,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if !sentReadReceipts.contains(message.id) {
let receipt = ReadReceipt(
originalMessageID: message.id,
readerID: meshService.myPeerID.id,
readerID: meshService.myPeerID,
readerNickname: nickname
)
@@ -6378,7 +6364,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
let isGeo = finalMessage.senderPeerID?.isGeoChat == true
// Apply per-sender and per-content rate limits (drop if exceeded)
if finalMessage.sender != "system" {
// Treat action-style system messages (which carry a senderPeerID) the same as regular user messages
let shouldRateLimit = finalMessage.sender != "system" || finalMessage.senderPeerID != nil
if shouldRateLimit {
let senderKey = normalizedSenderKey(for: finalMessage)
let contentKey = normalizedContentKey(finalMessage.content)
let now = Date()
@@ -6396,8 +6384,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Persist mesh messages to mesh timeline always
if !isGeo && finalMessage.sender != "system" {
meshTimeline.append(finalMessage)
trimMeshTimelineIfNeeded()
if !meshTimeline.contains(where: { $0.id == finalMessage.id }) {
meshTimeline.append(finalMessage)
trimMeshTimelineIfNeeded()
}
}
// Persist geochat messages to per-geohash timeline
+283 -187
View File
@@ -15,9 +15,6 @@ import AppKit
#endif
import UniformTypeIdentifiers
import BitLogger
#if canImport(PhotosUI)
import PhotosUI
#endif
// MARK: - Supporting Types
@@ -38,7 +35,6 @@ struct ContentView: View {
@EnvironmentObject var viewModel: ChatViewModel
@ObservedObject private var locationManager = LocationChannelManager.shared
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
@ObservedObject private var notesCounter = LocationNotesCounter.shared
@State private var messageText = ""
@FocusState private var isTextFieldFocused: Bool
@Environment(\.colorScheme) var colorScheme
@@ -51,7 +47,7 @@ struct ContentView: View {
@State private var commandSuggestions: [String] = []
@State private var showMessageActions = false
@State private var selectedMessageSender: String?
@State private var selectedMessageSenderID: String?
@State private var selectedMessageSenderID: PeerID?
@FocusState private var isNicknameFieldFocused: Bool
@State private var isAtBottomPublic: Bool = true
@State private var isAtBottomPrivate: Bool = true
@@ -72,19 +68,19 @@ struct ContentView: View {
@State private var recordingDuration: TimeInterval = 0
@State private var recordingTimer: Timer?
@State private var recordingStartDate: Date?
@State private var showFileImporter = false
#if os(iOS)
@State private var showPhotoPicker = false
@State private var selectedPhotoPickerItem: PhotosPickerItem?
@State private var showImagePicker = false
@State private var imagePickerSourceType: UIImagePickerController.SourceType = .camera
#else
@State private var showMacImagePicker = false
#endif
@State private var showAttachmentActions = false
@ScaledMetric(relativeTo: .body) private var headerHeight: CGFloat = 44
@ScaledMetric(relativeTo: .subheadline) private var headerPeerIconSize: CGFloat = 11
@ScaledMetric(relativeTo: .subheadline) private var headerPeerCountFontSize: CGFloat = 12
// Timer-based refresh removed; use LocationChannelManager live updates instead
// Window sizes for rendering (infinite scroll up)
@State private var windowCountPublic: Int = 300
@State private var windowCountPrivate: [String: Int] = [:]
@State private var windowCountPrivate: [PeerID: Int] = [:]
// MARK: - Computed Properties
@@ -128,7 +124,7 @@ struct ContentView: View {
private struct PrivateHeaderContext {
let headerPeerID: String
let headerPeerID: PeerID
let peer: BitchatPeer?
let displayName: String
let isNostrAvailable: Bool
@@ -192,10 +188,6 @@ struct ContentView: View {
)
) {
peopleSheetView
#if os(iOS)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
#endif
}
.sheet(isPresented: $showAppInfo) {
AppInfoView()
@@ -207,19 +199,64 @@ struct ContentView: View {
set: { _ in viewModel.showingFingerprintFor = nil }
)) {
if let peerID = viewModel.showingFingerprintFor {
FingerprintView(viewModel: viewModel, peerID: peerID.id)
FingerprintView(viewModel: viewModel, peerID: peerID)
}
}
#if os(iOS)
.photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoPickerItem, matching: .images)
.onChange(of: selectedPhotoPickerItem) { newItem in
guard let item = newItem else { return }
Task { await handlePhotoSelection(item) }
// Only present image picker from main view when NOT in a sheet
.fullScreenCover(isPresented: Binding(
get: { showImagePicker && !showSidebar && viewModel.selectedPrivateChatPeer == nil },
set: { newValue in
if !newValue {
showImagePicker = false
}
}
)) {
ImagePickerView(sourceType: imagePickerSourceType) { image in
showImagePicker = false
if let image = image {
Task {
do {
let processedURL = try ImageUtils.processImage(image)
await MainActor.run {
viewModel.sendImage(from: processedURL)
}
} catch {
SecureLogger.error("Image processing failed: \(error)", category: .session)
}
}
}
}
.ignoresSafeArea()
}
#endif
.fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.data], allowsMultipleSelection: false) { result in
handleImportResult(result, handler: handleImportedFile)
#if os(macOS)
// Only present Mac image picker from main view when NOT in a sheet
.sheet(isPresented: Binding(
get: { showMacImagePicker && !showSidebar && viewModel.selectedPrivateChatPeer == nil },
set: { newValue in
if !newValue {
showMacImagePicker = false
}
}
)) {
MacImagePickerView { url in
showMacImagePicker = false
if let url = url {
Task {
do {
let processedURL = try ImageUtils.processImage(at: url)
await MainActor.run {
viewModel.sendImage(from: processedURL)
}
} catch {
SecureLogger.error("Image processing failed: \(error)", category: .session)
}
}
}
}
}
#endif
.sheet(isPresented: Binding(
get: { imagePreviewURL != nil },
set: { presenting in if !presenting { imagePreviewURL = nil } }
@@ -228,19 +265,6 @@ struct ContentView: View {
ImagePreviewView(url: url)
}
}
.confirmationDialog("Attach", isPresented: $showAttachmentActions, titleVisibility: .visible) {
#if os(iOS)
Button("Image") {
showAttachmentActions = false
DispatchQueue.main.async { showPhotoPicker = true }
}
#endif
Button("File") {
showAttachmentActions = false
DispatchQueue.main.async { showFileImporter = true }
}
Button("Cancel", role: .cancel) {}
}
.alert("Recording Error", isPresented: $showRecordingAlert, actions: {
Button("OK", role: .cancel) {}
}, message: {
@@ -261,12 +285,12 @@ struct ContentView: View {
Button("content.actions.direct_message") {
if let peerID = selectedMessageSenderID {
if peerID.hasPrefix("nostr:") {
if let full = viewModel.fullNostrHex(forSenderPeerID: PeerID(str: peerID)) {
if peerID.isGeoChat {
if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
viewModel.startGeohashDM(withPubkeyHex: full)
}
} else {
viewModel.startPrivateChat(with: PeerID(str: peerID))
viewModel.startPrivateChat(with: peerID)
}
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar = true
@@ -288,8 +312,8 @@ struct ContentView: View {
Button("content.actions.block", role: .destructive) {
// Prefer direct geohash block when we have a Nostr sender ID
if let peerID = selectedMessageSenderID, peerID.hasPrefix("nostr:"),
let full = viewModel.fullNostrHex(forSenderPeerID: PeerID(str: peerID)),
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 {
@@ -320,9 +344,9 @@ struct ContentView: View {
// MARK: - Message List View
private func messagesView(privatePeer: String?, isAtBottom: Binding<Bool>) -> some View {
private func messagesView(privatePeer: PeerID?, isAtBottom: Binding<Bool>) -> some View {
let messages: [BitchatMessage] = {
if let peerID = PeerID(str: privatePeer) {
if let peerID = privatePeer {
return viewModel.getPrivateChatMessages(for: peerID)
}
return viewModel.messages
@@ -462,7 +486,7 @@ struct ContentView: View {
}
.onChange(of: viewModel.privateChats) { _ in
if let peerID = privatePeer,
let messages = viewModel.privateChats[PeerID(str: peerID)],
let messages = viewModel.privateChats[peerID],
!messages.isEmpty {
// If the newest private message is from me, always scroll
let lastMsg = messages.last!
@@ -517,7 +541,7 @@ struct ContentView: View {
}
.onAppear {
// Also check when view appears
if let peerID = PeerID(str: privatePeer) {
if let peerID = privatePeer {
// Try multiple times to ensure read receipts are sent
viewModel.markPrivateMessagesAsRead(from: peerID)
@@ -770,7 +794,7 @@ struct ContentView: View {
case "user":
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let peerID = PeerID(str: id.removingPercentEncoding ?? id)
selectedMessageSenderID = peerID.id
selectedMessageSenderID = peerID
if peerID.isGeoDM || peerID.isGeoChat {
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
@@ -818,10 +842,10 @@ struct ContentView: View {
}
private func scrollToBottom(on proxy: ScrollViewProxy,
privatePeer: String?,
privatePeer: PeerID?,
isAtBottom: Binding<Bool>) {
let targetID: String? = {
if let peer = PeerID(str: privatePeer),
if let peer = privatePeer,
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
return "dm:\(peer)|\(last)"
}
@@ -847,7 +871,7 @@ struct ContentView: View {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
let secondTarget: String? = {
if let peer = PeerID(str: privatePeer),
if let peer = privatePeer,
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
return "dm:\(peer)|\(last)"
}
@@ -898,6 +922,53 @@ struct ContentView: View {
#if os(macOS)
.frame(minWidth: 420, minHeight: 520)
#endif
// Present image picker from sheet context when IN a sheet (parent-child pattern)
#if os(iOS)
.fullScreenCover(isPresented: Binding(
get: { showImagePicker && (showSidebar || viewModel.selectedPrivateChatPeer != nil) },
set: { newValue in
if !newValue {
showImagePicker = false
}
}
)) {
ImagePickerView(sourceType: imagePickerSourceType) { image in
showImagePicker = false
if let image = image {
Task {
do {
let processedURL = try ImageUtils.processImage(image)
await MainActor.run {
viewModel.sendImage(from: processedURL)
}
} catch {
SecureLogger.error("Image processing failed: \(error)", category: .session)
}
}
}
}
.ignoresSafeArea()
}
#endif
#if os(macOS)
.sheet(isPresented: $showMacImagePicker) {
MacImagePickerView { url in
showMacImagePicker = false
if let url = url {
Task {
do {
let processedURL = try ImageUtils.processImage(at: url)
await MainActor.run {
viewModel.sendImage(from: processedURL)
}
} catch {
SecureLogger.error("Image processing failed: \(error)", category: .session)
}
}
}
}
}
#endif
}
// MARK: - People Sheet Views
@@ -984,14 +1055,14 @@ struct ContentView: View {
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onTapPeer: { peerID in
viewModel.startPrivateChat(with: PeerID(str: peerID))
viewModel.startPrivateChat(with: peerID)
showSidebar = true
},
onToggleFavorite: { peerID in
viewModel.toggleFavorite(peerID: PeerID(str: peerID))
viewModel.toggleFavorite(peerID: peerID)
},
onShowFingerprint: { peerID in
viewModel.showFingerprint(for: PeerID(str: peerID))
viewModel.showFingerprint(for: peerID)
}
)
}
@@ -1006,7 +1077,7 @@ struct ContentView: View {
private var privateChatSheetView: some View {
VStack(spacing: 0) {
if let privatePeerID = viewModel.selectedPrivateChatPeer?.id {
if let privatePeerID = viewModel.selectedPrivateChatPeer {
let headerContext = makePrivateHeaderContext(for: privatePeerID)
HStack(spacing: 12) {
@@ -1030,12 +1101,11 @@ struct ContentView: View {
HStack(spacing: 8) {
privateHeaderInfo(context: headerContext, privatePeerID: privatePeerID)
let peerID = PeerID(str: headerContext.headerPeerID)
let isFavorite = viewModel.isFavorite(peerID: peerID)
let isFavorite = viewModel.isFavorite(peerID: headerContext.headerPeerID)
if !privatePeerID.hasPrefix("nostr_") {
if !privatePeerID.isGeoDM {
Button(action: {
viewModel.toggleFavorite(peerID: peerID)
viewModel.toggleFavorite(peerID: headerContext.headerPeerID)
}) {
Image(systemName: isFavorite ? "star.fill" : "star")
.font(.bitchatSystem(size: 14))
@@ -1074,7 +1144,7 @@ struct ContentView: View {
.background(backgroundColor)
}
messagesView(privatePeer: viewModel.selectedPrivateChatPeer?.id, isAtBottom: $isAtBottomPrivate)
messagesView(privatePeer: viewModel.selectedPrivateChatPeer, isAtBottom: $isAtBottomPrivate)
.background(backgroundColor)
.frame(maxWidth: .infinity, maxHeight: .infinity)
Divider()
@@ -1096,9 +1166,9 @@ struct ContentView: View {
)
}
private func privateHeaderInfo(context: PrivateHeaderContext, privatePeerID: String) -> some View {
private func privateHeaderInfo(context: PrivateHeaderContext, privatePeerID: PeerID) -> some View {
Button(action: {
viewModel.showFingerprint(for: PeerID(str: context.headerPeerID))
viewModel.showFingerprint(for: context.headerPeerID)
}) {
HStack(spacing: 6) {
if let connectionState = context.peer?.connectionState {
@@ -1121,7 +1191,7 @@ struct ContentView: View {
case .offline:
EmptyView()
}
} else if viewModel.meshService.isPeerReachable(PeerID(str: context.headerPeerID)) {
} else if viewModel.meshService.isPeerReachable(context.headerPeerID) {
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.bitchatSystem(size: 14))
.foregroundColor(textColor)
@@ -1131,7 +1201,7 @@ struct ContentView: View {
.font(.bitchatSystem(size: 14))
.foregroundColor(.purple)
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
} else if viewModel.meshService.isPeerConnected(PeerID(str: context.headerPeerID)) || viewModel.connectedPeers.contains(PeerID(str: context.headerPeerID)) {
} else if viewModel.meshService.isPeerConnected(context.headerPeerID) || viewModel.connectedPeers.contains(context.headerPeerID) {
Image(systemName: "dot.radiowaves.left.and.right")
.font(.bitchatSystem(size: 14))
.foregroundColor(textColor)
@@ -1142,14 +1212,9 @@ struct ContentView: View {
.font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced))
.foregroundColor(textColor)
if !privatePeerID.hasPrefix("nostr_") {
let statusPeerID: String = {
if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
return short.id
}
return context.headerPeerID
}()
let encryptionStatus = viewModel.getEncryptionStatus(for: PeerID(str: statusPeerID))
if !privatePeerID.isGeoDM {
let statusPeerID = viewModel.getShortIDForNoiseKey(privatePeerID)
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
if let icon = encryptionStatus.icon {
Image(systemName: icon)
.font(.bitchatSystem(size: 14))
@@ -1181,33 +1246,27 @@ struct ContentView: View {
.frame(height: headerHeight)
}
private func makePrivateHeaderContext(for privatePeerID: String) -> PrivateHeaderContext {
let headerPeerID: String = {
if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
return short.id
}
return privatePeerID
}()
let peer = viewModel.getPeer(byID: PeerID(str: headerPeerID))
private func makePrivateHeaderContext(for privatePeerID: PeerID) -> PrivateHeaderContext {
let headerPeerID = viewModel.getShortIDForNoiseKey(privatePeerID)
let peer = viewModel.getPeer(byID: headerPeerID)
let displayName: String = {
if privatePeerID.hasPrefix("nostr_"), case .location(let ch) = locationManager.selectedChannel {
let disp = viewModel.geohashDisplayName(for: PeerID(str: privatePeerID))
if privatePeerID.isGeoDM, case .location(let ch) = locationManager.selectedChannel {
let disp = viewModel.geohashDisplayName(for: privatePeerID)
return "#\(ch.geohash)/@\(disp)"
}
if let name = peer?.displayName { return name }
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: headerPeerID)) { return name }
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data()),
if let name = viewModel.meshService.peerNickname(peerID: headerPeerID) { return name }
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID.id) ?? Data()),
!fav.peerNickname.isEmpty { return fav.peerNickname }
if headerPeerID.count == 16 {
let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(PeerID(str: headerPeerID))
if headerPeerID.id.count == 16 {
let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
if let id = candidates.first,
let social = viewModel.identityManager.getSocialIdentity(for: id.fingerprint) {
if let pet = social.localPetname, !pet.isEmpty { return pet }
if !social.claimedNickname.isEmpty { return social.claimedNickname }
}
} else if headerPeerID.count == 64, let keyData = Data(hexString: headerPeerID) {
} else if let keyData = headerPeerID.noiseKey {
let fp = keyData.sha256Fingerprint()
if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
if let pet = social.localPetname, !pet.isEmpty { return pet }
@@ -1219,7 +1278,7 @@ struct ContentView: View {
let isNostrAvailable: Bool = {
guard let connectionState = peer?.connectionState else {
if let noiseKey = Data(hexString: headerPeerID),
if let noiseKey = Data(hexString: headerPeerID.id),
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
favoriteStatus.isMutual {
return true
@@ -1349,10 +1408,9 @@ struct ContentView: View {
showLocationNotes = true
}) {
HStack(alignment: .center, spacing: 4) {
let hasNotes = (notesCounter.count ?? 0) > 0
Image(systemName: "long.text.page.and.pencil")
Image(systemName: "note.text")
.font(.bitchatSystem(size: 12))
.foregroundColor(hasNotes ? textColor : Color.gray)
.foregroundColor(Color.orange.opacity(0.8))
.padding(.top, 1)
}
.fixedSize(horizontal: true, vertical: false)
@@ -1454,7 +1512,7 @@ struct ContentView: View {
}) {
Group {
if let gh = notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {
LocationNotesView(geohash: gh, onNotesCountChanged: { cnt in sheetNotesCount = cnt })
LocationNotesView(geohash: gh)
.environmentObject(viewModel)
} else {
VStack(spacing: 12) {
@@ -1511,7 +1569,6 @@ struct ContentView: View {
}
}
.onAppear {
updateNotesCounterSubscription()
if case .mesh = locationManager.selectedChannel,
locationManager.permissionState == .authorized,
LocationChannelManager.shared.availableChannels.isEmpty {
@@ -1519,16 +1576,13 @@ struct ContentView: View {
}
}
.onChange(of: locationManager.selectedChannel) { _ in
updateNotesCounterSubscription()
if case .mesh = locationManager.selectedChannel,
locationManager.permissionState == .authorized,
LocationChannelManager.shared.availableChannels.isEmpty {
LocationChannelManager.shared.refreshChannels()
}
}
.onChange(of: locationManager.availableChannels) { _ in updateNotesCounterSubscription() }
.onChange(of: locationManager.permissionState) { _ in
updateNotesCounterSubscription()
if case .mesh = locationManager.selectedChannel,
locationManager.permissionState == .authorized,
LocationChannelManager.shared.availableChannels.isEmpty {
@@ -1545,34 +1599,6 @@ struct ContentView: View {
}
// MARK: - Notes Counter Subscription Helper
extension ContentView {
private func updateNotesCounterSubscription() {
switch locationManager.selectedChannel {
case .mesh:
// Ensure we have a fresh one-shot location fix so building geohash is current
if locationManager.permissionState == .authorized {
LocationChannelManager.shared.refreshChannels()
}
if locationManager.permissionState == .authorized {
if let building = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {
LocationNotesCounter.shared.subscribe(geohash: building)
} else {
// Keep existing subscription if we had one to avoid flicker
// Only cancel if we have no known geohash
if LocationNotesCounter.shared.geohash == nil {
LocationNotesCounter.shared.cancel()
}
}
} else {
LocationNotesCounter.shared.cancel()
}
case .location:
LocationNotesCounter.shared.cancel()
}
}
}
// MARK: - Helper Views
// Rounded payment chip button
@@ -1581,11 +1607,10 @@ extension ContentView {
private enum MessageMedia {
case voice(URL)
case image(URL)
case file(URL)
var url: URL {
switch self {
case .voice(let url), .image(let url), .file(let url):
case .voice(let url), .image(let url):
return url
}
}
@@ -1623,13 +1648,6 @@ private extension ContentView {
let url = baseDirectory.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(filename)
return .image(url)
}
if message.content.hasPrefix("[file] ") {
let filename = String(message.content.dropFirst("[file] ".count)).trimmingCharacters(in: .whitespacesAndNewlines)
guard !filename.isEmpty else { return nil }
let subdir = message.sender == viewModel.nickname ? "files/outgoing" : "files/incoming"
let url = baseDirectory.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(filename)
return .file(url)
}
return nil
}
@@ -1721,13 +1739,6 @@ private extension ContentView {
} : nil
)
.frame(maxWidth: 280)
case .file(let url):
FileAttachmentView(
url: url,
isSending: state.isSending,
progress: state.progress,
onCancel: cancelAction
)
}
}
}
@@ -1786,7 +1797,7 @@ private extension ContentView {
private func expandWindow(ifNeededFor message: BitchatMessage,
allMessages: [BitchatMessage],
privatePeer: String?,
privatePeer: PeerID?,
proxy: ScrollViewProxy) {
let step = TransportConfig.uiWindowStepCount
let contextKey: String = {
@@ -1846,7 +1857,19 @@ private extension ContentView {
}
private var shouldShowMediaControls: Bool {
if viewModel.selectedPrivateChatPeer != nil {
if let peer = viewModel.selectedPrivateChatPeer, !(peer.isGeoDM || peer.isGeoChat) {
return true
}
switch locationManager.selectedChannel {
case .mesh:
return true
case .location:
return false
}
}
private var shouldShowVoiceControl: Bool {
if let peer = viewModel.selectedPrivateChatPeer, !(peer.isGeoDM || peer.isGeoChat) {
return true
}
switch locationManager.selectedChannel {
@@ -1862,26 +1885,52 @@ private extension ContentView {
}
var attachmentButton: some View {
Button(action: { showAttachmentActions = true }) {
Image(systemName: "paperclip.circle.fill")
#if os(iOS)
Image(systemName: "camera.circle.fill")
.font(.bitchatSystem(size: 24))
.foregroundColor(composerAccentColor)
.frame(width: 36, height: 36)
.contentShape(Circle())
.onTapGesture {
// Tap = Photo Library
imagePickerSourceType = .photoLibrary
showImagePicker = true
}
.onLongPressGesture(minimumDuration: 0.3) {
// Long press = Camera
imagePickerSourceType = .camera
showImagePicker = true
}
.accessibilityLabel("Tap for library, long press for camera")
#else
Button(action: { showMacImagePicker = true }) {
Image(systemName: "photo.circle.fill")
.font(.bitchatSystem(size: 24))
.foregroundColor(composerAccentColor)
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
.accessibilityLabel("Choose photo")
#endif
}
@ViewBuilder
var sendOrMicButton: some View {
let hasText = !trimmedMessageText.isEmpty
return ZStack {
micButtonView
.opacity(hasText ? 0 : 1)
.allowsHitTesting(!hasText)
if shouldShowVoiceControl {
ZStack {
micButtonView
.opacity(hasText ? 0 : 1)
.allowsHitTesting(!hasText)
sendButtonView(enabled: hasText)
.opacity(hasText ? 1 : 0)
.allowsHitTesting(hasText)
}
.frame(width: 36, height: 36)
} else {
sendButtonView(enabled: hasText)
.opacity(hasText ? 1 : 0)
.allowsHitTesting(hasText)
.frame(width: 36, height: 36)
}
.frame(width: 36, height: 36)
}
private var micButtonView: some View {
@@ -1934,6 +1983,7 @@ private extension ContentView {
}
func startVoiceRecording() {
guard shouldShowVoiceControl else { return }
guard !isRecordingVoiceNote && !isPreparingVoiceNote else { return }
isPreparingVoiceNote = true
Task { @MainActor in
@@ -2037,44 +2087,6 @@ private extension ContentView {
}
}
#if os(iOS)
func handlePhotoSelection(_ item: PhotosPickerItem) async {
defer { Task { @MainActor in selectedPhotoPickerItem = nil } }
do {
if let data = try await item.loadTransferable(type: Data.self) {
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("jpg")
try data.write(to: tempURL, options: Data.WritingOptions.atomic)
await MainActor.run {
viewModel.sendImage(from: tempURL) {
try? FileManager.default.removeItem(at: tempURL)
}
}
}
} catch {
SecureLogger.error("Photo picker load failed: \(error)", category: .session)
}
}
#endif
func handleImportedFile(url: URL) async {
do {
let fileManager = FileManager.default
let tempDir = fileManager.temporaryDirectory
let fileName = url.lastPathComponent.isEmpty ? "attachment" : url.lastPathComponent
let destination = tempDir.appendingPathComponent(UUID().uuidString + "_" + fileName)
if fileManager.fileExists(atPath: destination.path) {
try fileManager.removeItem(at: destination)
}
try fileManager.copyItem(at: url, to: destination)
await MainActor.run {
viewModel.sendFileAttachment(from: destination)
}
} catch {
SecureLogger.error("File copy failed before send: \(error)", category: .session)
}
}
func applicationFilesDirectory() -> URL? {
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
@@ -2218,3 +2230,87 @@ struct ImagePreviewView: View {
}
#endif
}
#if os(iOS)
// MARK: - Image Picker (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)
}
}
}
#endif
#if os(macOS)
// MARK: - macOS Image Picker
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)
}
}
#endif
+7 -13
View File
@@ -10,7 +10,7 @@ import SwiftUI
struct FingerprintView: View {
@ObservedObject var viewModel: ChatViewModel
let peerID: String
let peerID: PeerID
@Environment(\.dismiss) var dismiss
@Environment(\.colorScheme) var colorScheme
@@ -65,15 +65,12 @@ struct FingerprintView: View {
VStack(alignment: .leading, spacing: 16) {
// Prefer short mesh ID for session/encryption status
let statusPeerID: String = {
if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short.id }
return peerID
}()
let statusPeerID = viewModel.getShortIDForNoiseKey(peerID)
// Resolve a friendly name
let peerNickname: String = {
if let p = viewModel.getPeer(byID: PeerID(str: statusPeerID)) { return p.displayName }
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: statusPeerID)) { return name }
if peerID.count == 64, let data = Data(hexString: peerID) {
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name }
if let data = peerID.noiseKey {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
let fp = data.sha256Fingerprint()
if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
@@ -84,7 +81,7 @@ struct FingerprintView: View {
return Strings.unknownPeer()
}()
// Accurate encryption state based on short ID session
let encryptionStatus = viewModel.getEncryptionStatus(for: PeerID(str: statusPeerID))
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
HStack {
if let icon = encryptionStatus.icon {
@@ -115,7 +112,7 @@ struct FingerprintView: View {
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
.foregroundColor(textColor.opacity(0.7))
if let fingerprint = viewModel.getFingerprint(for: PeerID(str: statusPeerID)) {
if let fingerprint = viewModel.getFingerprint(for: statusPeerID) {
Text(formatFingerprint(fingerprint))
.font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(textColor)
@@ -176,7 +173,6 @@ struct FingerprintView: View {
// Verification status
if encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified {
let isVerified = encryptionStatus == .noiseVerified
let peerID = PeerID(str: peerID)
VStack(spacing: 12) {
Text(isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
@@ -240,8 +236,6 @@ struct FingerprintView: View {
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(backgroundColor)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
private func formatFingerprint(_ fingerprint: String) -> String {
@@ -125,9 +125,6 @@ struct LocationChannelsSheet: View {
.navigationTitle("")
#endif
}
#if os(iOS)
.presentationDetents([.large])
#endif
#if os(macOS)
.frame(minWidth: 420, minHeight: 520)
#endif
+1 -4
View File
@@ -78,9 +78,6 @@ struct LocationNotesView: View {
.navigationTitle("")
#endif
}
#if os(iOS)
.presentationDetents([.large])
#endif
.background(backgroundColor)
.onDisappear { manager.cancel() }
.onChange(of: geohash) { newValue in
@@ -141,7 +138,7 @@ struct LocationNotesView: View {
String(
format: String(localized: "location_notes.header", comment: "Header displaying the geohash and localized note count"),
locale: .current,
geohash, count
"\(geohash) ± 1", count
)
}
@@ -1,118 +0,0 @@
import SwiftUI
struct FileAttachmentView: View {
private let url: URL
private let isSending: Bool
private let progress: Double?
private let onCancel: (() -> Void)?
@Environment(\.colorScheme) private var colorScheme
#if os(iOS)
@State private var showExporter = false
#endif
init(url: URL, isSending: Bool, progress: Double?, onCancel: (() -> Void)?) {
self.url = url
self.isSending = isSending
self.progress = progress
self.onCancel = onCancel
}
private var fileName: String {
url.lastPathComponent
}
private var normalizedProgress: Double? {
guard let progress = progress else { return nil }
return max(0, min(1, progress))
}
var body: some View {
HStack(alignment: .center, spacing: 12) {
Image(systemName: "doc.fill")
.foregroundColor(Color.blue)
.font(.bitchatSystem(size: 24))
VStack(alignment: .leading, spacing: 4) {
Text(fileName)
.font(.bitchatSystem(size: 14, weight: .medium))
.foregroundColor(.primary)
.lineLimit(2)
Text(url.lastPathComponent)
.font(.bitchatSystem(size: 11, design: .monospaced))
.foregroundColor(.secondary)
.lineLimit(1)
if let progress = normalizedProgress {
ProgressView(value: progress)
.progressViewStyle(.linear)
.tint(Color.blue)
}
}
Spacer()
Button(action: openFile) {
Text("open", comment: "Button to open attached file")
.font(.bitchatSystem(size: 13, weight: .semibold))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
Capsule().fill(Color.blue.opacity(0.15))
)
}
.buttonStyle(.plain)
if let onCancel = onCancel, isSending {
Button(action: onCancel) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 11, weight: .bold))
.frame(width: 26, height: 26)
.background(Circle().fill(Color.red.opacity(0.9)))
.foregroundColor(.white)
}
.buttonStyle(.plain)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(colorScheme == .dark ? Color.black.opacity(0.6) : Color.white)
)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(Color.gray.opacity(0.2), lineWidth: 1)
)
#if os(iOS)
.sheet(isPresented: $showExporter) {
FileExportController(url: url)
}
#endif
}
private func openFile() {
#if os(iOS)
showExporter = true
#else
NSWorkspace.shared.open(url)
#endif
}
}
#if os(iOS)
import UniformTypeIdentifiers
import UIKit
private struct FileExportController: 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) {}
}
#else
import AppKit
#endif
+6 -6
View File
@@ -4,9 +4,9 @@ struct MeshPeerList: View {
@ObservedObject var viewModel: ChatViewModel
let textColor: Color
let secondaryTextColor: Color
let onTapPeer: (String) -> Void
let onToggleFavorite: (String) -> Void
let onShowFingerprint: (String) -> Void
let onTapPeer: (PeerID) -> Void
let onToggleFavorite: (PeerID) -> Void
let onShowFingerprint: (PeerID) -> Void
@Environment(\.colorScheme) var colorScheme
@State private var orderedIDs: [String] = []
@@ -130,7 +130,7 @@ struct MeshPeerList: View {
}
if !isMe {
Button(action: { onToggleFavorite(peer.peerID.id) }) {
Button(action: { onToggleFavorite(peer.peerID) }) {
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
.font(.bitchatSystem(size: 12))
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
@@ -142,8 +142,8 @@ struct MeshPeerList: View {
.padding(.vertical, 4)
.padding(.top, idx == 0 ? 10 : 0)
.contentShape(Rectangle())
.onTapGesture { if !isMe { onTapPeer(peer.peerID.id) } }
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID.id) } }
.onTapGesture { if !isMe { onTapPeer(peer.peerID) } }
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID) } }
}
}
// Seed and update order outside result builder
-4
View File
@@ -388,10 +388,6 @@ struct VerificationSheetView: View {
.padding(.vertical, 14)
}
.background(backgroundColor)
#if os(iOS)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
#endif
.onDisappear { showingScanner = false }
}
}
+1 -1
View File
@@ -298,5 +298,5 @@ private final class MockBitchatDelegate: BitchatDelegate {
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: String, nickname: String, content: String, timestamp: Date) {}
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {}
}
@@ -186,11 +186,11 @@ struct PrivateChatE2ETests {
// Bob relays private messages for Charlie
bob.packetDeliveryHandler = { packet in
if let recipientID = packet.recipientID,
String(data: recipientID, encoding: .utf8) == charlie.peerID {
PeerID(data: recipientID) == charlie.peerID {
// Relay to Charlie
var relayPacket = packet
relayPacket.ttl = packet.ttl - 1
self.charlie.simulateIncomingPacket(relayPacket)
charlie.simulateIncomingPacket(relayPacket)
}
}
@@ -388,7 +388,7 @@ struct PublicChatE2ETests {
if let message = BitchatMessage(packet.payload) {
// Don't relay own messages
guard message.senderPeerID?.id != node.peerID else { return }
guard message.senderPeerID != node.peerID else { return }
// Create relay message
let relayMessage = BitchatMessage(
@@ -209,7 +209,7 @@ 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) {
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) {}
+122
View File
@@ -125,16 +125,138 @@ struct GossipSyncManagerTests {
#expect(manager._hasAnnouncement(for: PeerID(str: peerHex)) == false)
#expect(manager._messageCount(for: PeerID(str: peerHex)) == 0)
}
@Test func maintenanceEmitsTypedSyncRequests() throws {
var config = GossipSyncManager.Config()
config.seenCapacity = 10
config.fragmentCapacity = 5
config.fileTransferCapacity = 4
config.messageSyncIntervalSeconds = 1
config.fragmentSyncIntervalSeconds = 1
config.fileTransferSyncIntervalSeconds = 1
config.maintenanceIntervalSeconds = 0
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
let delegate = RecordingDelegate()
manager.delegate = delegate
let sender = try #require(Data(hexString: "1122334455667788"))
let now = UInt64(Date().timeIntervalSince1970 * 1000)
let announcePacket = BitchatPacket(
type: MessageType.announce.rawValue,
senderID: sender,
recipientID: nil,
timestamp: now,
payload: Data(),
signature: nil,
ttl: 1
)
let messagePacket = BitchatPacket(
type: MessageType.message.rawValue,
senderID: sender,
recipientID: nil,
timestamp: now,
payload: Data([0x01]),
signature: nil,
ttl: 1
)
let fragmentPacket = BitchatPacket(
type: MessageType.fragment.rawValue,
senderID: sender,
recipientID: nil,
timestamp: now,
payload: Data([0xAA]),
signature: nil,
ttl: 1
)
let filePacket = BitchatPacket(
type: MessageType.fileTransfer.rawValue,
senderID: sender,
recipientID: nil,
timestamp: now,
payload: Data([0xBB]),
signature: nil,
ttl: 1,
version: 2
)
manager.onPublicPacketSeen(announcePacket)
manager.onPublicPacketSeen(messagePacket)
manager.onPublicPacketSeen(fragmentPacket)
manager.onPublicPacketSeen(filePacket)
manager._performMaintenanceSynchronously(now: Date())
let sentPackets = delegate.packets
#expect(sentPackets.count == 3)
let decoded = sentPackets.compactMap { RequestSyncPacket.decode(from: $0.payload) }
#expect(decoded.count == 3)
#expect(decoded[0].types == .publicMessages)
#expect(decoded[1].types == .fragment)
#expect(decoded[2].types == .fileTransfer)
}
@Test func handleRequestSyncHonorsTypeFilter() async throws {
var config = GossipSyncManager.Config()
config.seenCapacity = 5
config.fragmentCapacity = 5
config.fileTransferCapacity = 0
config.messageSyncIntervalSeconds = 0
config.fragmentSyncIntervalSeconds = 0
config.fileTransferSyncIntervalSeconds = 0
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
let delegate = RecordingDelegate()
manager.delegate = delegate
let sender = try #require(Data(hexString: "aabbccddeeff0011"))
let now = UInt64(Date().timeIntervalSince1970 * 1000)
let messagePacket = BitchatPacket(
type: MessageType.message.rawValue,
senderID: sender,
recipientID: nil,
timestamp: now,
payload: Data([0x10]),
signature: nil,
ttl: 1
)
let fragmentPacket = BitchatPacket(
type: MessageType.fragment.rawValue,
senderID: sender,
recipientID: nil,
timestamp: now,
payload: Data([0x20]),
signature: nil,
ttl: 1
)
manager.onPublicPacketSeen(messagePacket)
manager.onPublicPacketSeen(fragmentPacket)
let peer = PeerID(str: "FFFFFFFFFFFFFFFF")
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .fragment)
manager.handleRequestSync(from: peer, request: request)
try await sleep(0.01)
let sentPackets = delegate.packets
#expect(sentPackets.count == 1)
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
}
}
private final class RecordingDelegate: GossipSyncManager.Delegate {
var onSend: (() -> Void)?
private(set) var lastPacket: BitchatPacket?
private(set) var packets: [BitchatPacket] = []
private let lock = NSLock()
func sendPacket(_ packet: BitchatPacket) {
lock.lock()
lastPacket = packet
packets.append(packet)
lock.unlock()
onSend?()
}
+192
View File
@@ -0,0 +1,192 @@
//
// InputValidatorTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
@testable import bitchat
struct InputValidatorTests {
// MARK: - Basic Validation Tests
@Test func validStringPassesValidation() throws {
let result = InputValidator.validateUserString("Hello World", maxLength: 100)
#expect(result == "Hello World")
}
@Test func emptyStringReturnsNil() throws {
let result = InputValidator.validateUserString("", maxLength: 100)
#expect(result == nil)
}
@Test func whitespaceOnlyStringReturnsNil() throws {
let result = InputValidator.validateUserString(" \n\t ", maxLength: 100)
#expect(result == nil)
}
@Test func stringExceedingMaxLengthReturnsNil() throws {
let longString = String(repeating: "a", count: 101)
let result = InputValidator.validateUserString(longString, maxLength: 100)
#expect(result == nil)
}
@Test func stringAtMaxLengthIsAccepted() throws {
let exactString = String(repeating: "a", count: 100)
let result = InputValidator.validateUserString(exactString, maxLength: 100)
#expect(result == exactString)
}
@Test func whitespaceIsTrimmed() throws {
let result = InputValidator.validateUserString(" Hello ", maxLength: 100)
#expect(result == "Hello")
}
// MARK: - Control Character Tests
@Test func nullCharacterIsRejected() throws {
let stringWithNull = "Hello\u{0000}World"
let result = InputValidator.validateUserString(stringWithNull, maxLength: 100)
#expect(result == nil)
}
@Test func bellCharacterIsRejected() throws {
let stringWithBell = "Hello\u{0007}World"
let result = InputValidator.validateUserString(stringWithBell, maxLength: 100)
#expect(result == nil)
}
@Test func backspaceCharacterIsRejected() throws {
let stringWithBackspace = "Hello\u{0008}World"
let result = InputValidator.validateUserString(stringWithBackspace, maxLength: 100)
#expect(result == nil)
}
@Test func escapeCharacterIsRejected() throws {
let stringWithEscape = "Hello\u{001B}World"
let result = InputValidator.validateUserString(stringWithEscape, maxLength: 100)
#expect(result == nil)
}
@Test func deleteCharacterIsRejected() throws {
let stringWithDelete = "Hello\u{007F}World"
let result = InputValidator.validateUserString(stringWithDelete, maxLength: 100)
#expect(result == nil)
}
@Test func multipleControlCharactersAreRejected() throws {
let stringWithMultiple = "Hello\u{0000}\u{0007}\u{001B}World"
let result = InputValidator.validateUserString(stringWithMultiple, maxLength: 100)
#expect(result == nil)
}
// MARK: - Unicode and Special Character Tests
@Test func emojiIsAccepted() throws {
let result = InputValidator.validateUserString("Hello 👋 World", maxLength: 100)
#expect(result == "Hello 👋 World")
}
@Test func unicodeCharactersAreAccepted() throws {
let result = InputValidator.validateUserString("Hello 世界 مرحبا", maxLength: 100)
#expect(result == "Hello 世界 مرحبا")
}
@Test func specialCharactersAreAccepted() throws {
let result = InputValidator.validateUserString("Hello!@#$%^&*()_+-=[]{}|;':\",./<>?", maxLength: 100)
#expect(result == "Hello!@#$%^&*()_+-=[]{}|;':\",./<>?")
}
// MARK: - Nickname Validation Tests
@Test func validNicknameIsAccepted() throws {
let result = InputValidator.validateNickname("Alice")
#expect(result == "Alice")
}
@Test func nicknameWithEmojiIsAccepted() throws {
let result = InputValidator.validateNickname("Alice 🚀")
#expect(result == "Alice 🚀")
}
@Test func nicknameTooLongIsRejected() throws {
let longNickname = String(repeating: "a", count: 51)
let result = InputValidator.validateNickname(longNickname)
#expect(result == nil)
}
@Test func nicknameAtMaxLengthIsAccepted() throws {
let exactNickname = String(repeating: "a", count: 50)
let result = InputValidator.validateNickname(exactNickname)
#expect(result == exactNickname)
}
@Test func nicknameWithControlCharacterIsRejected() throws {
let result = InputValidator.validateNickname("Alice\u{0000}")
#expect(result == nil)
}
// MARK: - Timestamp Validation Tests
@Test func currentTimestampIsValid() throws {
let now = Date()
let result = InputValidator.validateTimestamp(now)
#expect(result == true)
}
@Test func timestampWithinOneHourIsValid() throws {
let thirtyMinutesAgo = Date().addingTimeInterval(-30 * 60)
let result = InputValidator.validateTimestamp(thirtyMinutesAgo)
#expect(result == true)
}
@Test func timestampTwoHoursAgoIsInvalid() throws {
let twoHoursAgo = Date().addingTimeInterval(-2 * 3600)
let result = InputValidator.validateTimestamp(twoHoursAgo)
#expect(result == false)
}
@Test func timestampTwoHoursInFutureIsInvalid() throws {
let twoHoursFromNow = Date().addingTimeInterval(2 * 3600)
let result = InputValidator.validateTimestamp(twoHoursFromNow)
#expect(result == false)
}
@Test func timestampAtOneHourBoundaryIsValid() throws {
// Just slightly within the one-hour window
let almostOneHourAgo = Date().addingTimeInterval(-3599)
let result = InputValidator.validateTimestamp(almostOneHourAgo)
#expect(result == true)
}
// MARK: - Edge Cases
@Test func singleCharacterStringIsAccepted() throws {
let result = InputValidator.validateUserString("a", maxLength: 100)
#expect(result == "a")
}
@Test func stringWithOnlyNewlinesIsRejected() throws {
let result = InputValidator.validateUserString("\n\n\n", maxLength: 100)
#expect(result == nil)
}
@Test func stringWithMixedWhitespaceIsTrimmed() throws {
let result = InputValidator.validateUserString(" \t\nHello\n\t ", maxLength: 100)
#expect(result == "Hello")
}
@Test func stringWithLeadingControlCharacterIsRejected() throws {
let result = InputValidator.validateUserString("\u{0000}Hello", maxLength: 100)
#expect(result == nil)
}
@Test func stringWithTrailingControlCharacterIsRejected() throws {
let result = InputValidator.validateUserString("Hello\u{0000}", maxLength: 100)
#expect(result == nil)
}
}
@@ -91,61 +91,3 @@ struct LocationNotesManagerTests {
case shouldNotDerive
}
}
@MainActor
struct LocationNotesCounterTests {
@Test func subscribeWithoutRelaysMarksUnavailable() {
var subscribeCalled = false
let deps = LocationNotesCounterDependencies(
relayLookup: { _, _ in [] },
subscribe: { _, _, _, _, _ in subscribeCalled = true },
unsubscribe: { _ in }
)
let counter = LocationNotesCounter(testDependencies: deps)
counter.subscribe(geohash: "u4pruydq")
#expect(!subscribeCalled)
#expect(!counter.relayAvailable)
#expect(counter.initialLoadComplete)
#expect(counter.count == 0)
}
@Test func subscribeCountsUniqueNotes() {
var storedHandler: ((NostrEvent) -> Void)?
var storedEOSE: (() -> Void)?
let deps = LocationNotesCounterDependencies(
relayLookup: { _, _ in ["wss://relay.geo"] },
subscribe: { filter, id, relays, handler, eose in
#expect(relays == ["wss://relay.geo"])
#expect(filter.kinds == [1])
#expect(!id.isEmpty)
storedHandler = handler
storedEOSE = eose
},
unsubscribe: { _ in }
)
let counter = LocationNotesCounter(testDependencies: deps)
counter.subscribe(geohash: "u4pruydq")
var first = NostrEvent(
pubkey: "pub",
createdAt: Date(),
kind: .textNote,
tags: [["g", "u4pruydq"]],
content: "a"
)
first.id = "eventA"
storedHandler?(first)
let duplicate = first
storedHandler?(duplicate)
storedEOSE?()
#expect(counter.relayAvailable)
#expect(counter.count == 1)
#expect(counter.initialLoadComplete)
}
}
+90
View File
@@ -0,0 +1,90 @@
//
// MimeTypeTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
@testable import bitchat
// MARK: - MimeType Mapping and Signature Tests
struct MimeTypeTests {
// MARK: MIME Enum Parsing + Default Extension
@Test(arguments: [
("image/jpeg", MimeType.jpeg, "jpg"),
("image/jpg", MimeType.jpeg, "jpg"),
("image/png", MimeType.png, "png"),
("image/gif", MimeType.gif, "gif"),
("image/webp", MimeType.webp, "webp"),
("audio/mp4", MimeType.mp4Audio, "m4a"),
("audio/m4a", MimeType.m4a, "m4a"),
("audio/aac", MimeType.aac, "m4a"),
("audio/mpeg", MimeType.mpeg, "mp3"),
("audio/mp3", MimeType.mp3, "mp3"),
("audio/wav", MimeType.wav, "wav"),
("audio/x-wav", MimeType.xWav, "wav"),
("audio/ogg", MimeType.ogg, "ogg"),
("application/pdf", MimeType.pdf, "pdf"),
("application/octet-stream", MimeType.octetStream, "bin")
])
func mimeTypeParsingAndExtensions(
mimeString: String,
expectedType: MimeType,
expectedExt: String
) throws {
guard let mime = MimeType(mimeString) else {
Issue.record("Failed to parse \(mimeString)")
return
}
#expect(mime == expectedType, "Expected \(expectedType) for \(mimeString)")
#expect(mime.mimeString == expectedType.mimeString)
#expect(mime.defaultExtension == expectedExt)
#expect(mime.isAllowed)
}
// MARK: - File Signature Validation
@Test(arguments: [
// === Image types ===
(MimeType.jpeg, [0xFF, 0xD8, 0xFF]),
(MimeType.png, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
(MimeType.gif, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // "GIF89a"
(MimeType.webp, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
0x57, 0x45, 0x42, 0x50]), // "RIFF....WEBP"
// === Audio types ===
(MimeType.mp3, [0x49, 0x44, 0x33]), // "ID3"
(MimeType.wav, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
0x57, 0x41, 0x56, 0x45]), // "RIFF....WAVE"
(MimeType.ogg, [0x4F, 0x67, 0x67, 0x53]), // "OggS"
// === Application types ===
(MimeType.pdf, [0x25, 0x50, 0x44, 0x46]) // "%PDF"
])
func validSignatures(mime: MimeType, bytes: [UInt8]) throws {
let data = Data(bytes)
#expect(mime.matches(data: data),
"Expected \(mime.mimeString) to match its signature")
}
// MARK: - Negative Tests
@Test func invalidDataDoesNotMatch() throws {
let badData = Data(repeating: 0x00, count: 16)
for mime in MimeType.allCases where mime != .octetStream {
#expect(!mime.matches(data: badData),
"Unexpectedly matched \(mime.mimeString) with zeroed data")
}
}
// MARK: - Octet-stream (generic binary)
@Test func octetStreamAlwaysMatches() throws {
let randomData = Data([0x00, 0x11, 0x22, 0x33])
#expect(MimeType.octetStream.matches(data: randomData),
"application/octet-stream should always be considered valid")
}
}
+1 -4
View File
@@ -203,10 +203,7 @@ final class MockBLEService: NSObject {
let target = bus.service(for: recipientPeerID) {
target.simulateIncomingPacket(packet)
} else {
// Not directly connected: deliver to neighbors for relay; also deliver directly if target is known
if let target = bus.service(for: recipientPeerID) {
target.simulateIncomingPacket(packet)
}
// Not directly connected: deliver to neighbors for relay
for neighbor in neighbors() where neighbor.peerID != recipientPeerID {
neighbor.simulateIncomingPacket(packet)
}
+2 -2
View File
@@ -126,7 +126,7 @@ struct NostrProtocolTests {
// Build a DELIVERED ack embedded payload (geohash-style, no recipient peer ID)
let messageID = "TEST-MSG-DELIVERED-1"
let senderPeerID = "0123456789abcdef" // 8-byte hex peer ID
let senderPeerID = PeerID(str: "0123456789abcdef") // 8-byte hex peer ID
let embedded = try #require(
NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID),
@@ -176,7 +176,7 @@ struct NostrProtocolTests {
let recipient = try NostrIdentity.generate()
let messageID = "TEST-MSG-READ-1"
let senderPeerID = "fedcba9876543210" // 8-byte hex peer ID
let senderPeerID = PeerID(str: "fedcba9876543210") // 8-byte hex peer ID
let embedded = try #require(
NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID),
"Failed to embed read ack"
+10 -36
View File
@@ -273,7 +273,7 @@ struct PeerIDTests {
@Test func comparable_sorting_and_equality() {
let p1 = PeerID(str: "aaa")
let p2 = PeerID(str: "bbb")
let p3 = PeerID(str: "bbb")
let p3 = PeerID(str: "BBB")
#expect(p1 < p2)
#expect(p2 >= p1)
@@ -284,44 +284,18 @@ struct PeerIDTests {
}
@Test func equality() {
let string = "aaa"
let peerID = PeerID(str: string)
let badString = "bbb"
// PeerID == String
#expect(peerID == string)
#expect(peerID == Optional(string))
#expect(Optional(peerID) == string)
#expect(Optional(peerID) == Optional(string))
// PeerID != String
#expect(peerID != badString)
#expect(peerID != Optional(badString))
#expect(Optional(peerID) != badString)
#expect(Optional(peerID) != Optional(badString))
// String == PeerID
#expect(string == peerID)
#expect(Optional(string) == peerID)
#expect(string == Optional(peerID))
#expect(Optional(string) == Optional(peerID))
// String != PeerID
#expect(badString != peerID)
#expect(Optional(badString) != peerID)
#expect(badString != Optional(peerID))
#expect(Optional(badString) != Optional(peerID))
let peerID = PeerID(str: "aaa")
// Regular PeerID <> PeerID
#expect(peerID == PeerID(str: "aaa"))
#expect(peerID == Optional(PeerID(str: "aaa")))
#expect(PeerID(str: "aaa") == peerID)
#expect(Optional(PeerID(str: "aaa")) == Optional(peerID))
#expect(peerID == PeerID(str: "AAA"))
#expect(peerID == Optional(PeerID(str: "AAA")))
#expect(PeerID(str: "AAA") == peerID)
#expect(Optional(PeerID(str: "AAA")) == Optional(peerID))
#expect(peerID != PeerID(str: "bbb"))
#expect(peerID != Optional(PeerID(str: "bbb")))
#expect(PeerID(str: "bbb") != peerID)
#expect(Optional(PeerID(str: "bbb")) != Optional(peerID))
#expect(peerID != PeerID(str: "BBB"))
#expect(peerID != Optional(PeerID(str: "BBB")))
#expect(PeerID(str: "BBB") != peerID)
#expect(Optional(PeerID(str: "BBB")) != Optional(peerID))
}
// MARK: - Computed properties
+4
View File
@@ -18,6 +18,10 @@ let package = Package(
.target(
name: "BitLogger",
path: "Sources"
),
.testTarget(
name: "BitLoggerTests",
dependencies: ["BitLogger"]
)
]
)
@@ -68,22 +68,6 @@ public final class SecureLogger {
return formatter
}()
// MARK: - Cached Regex Patterns
private static let fingerprintPattern = #/[a-fA-F0-9]{64}/#
private static let base64Pattern = #/[A-Za-z0-9+/]{40,}={0,2}/#
private static let passwordPattern = #/password["\s:=]+["']?[^"'\s]+["']?/#
private static let peerIDPattern = #/peerID: ([a-zA-Z0-9]{8})[a-zA-Z0-9]+/#
// MARK: - Sanitization Cache
private static let sanitizationCache: NSCache<NSString, NSString> = {
let cache = NSCache<NSString, NSString>()
cache.countLimit = 100 // Keep last 100 sanitized strings
return cache
}()
private static let cacheQueue = DispatchQueue(label: "chat.bitchat.securelogger.cache", attributes: .concurrent)
// MARK: - Log Levels
enum LogLevel {
@@ -161,8 +145,8 @@ public extension SecureLogger {
static func error(_ error: Error, context: @autoclosure () -> String, category: OSLog = .noise,
file: String = #file, line: Int = #line, function: String = #function) {
let location = formatLocation(file: file, line: line, function: function)
let sanitized = sanitize(context())
let errorDesc = sanitize(error.localizedDescription)
let sanitized = context().sanitized()
let errorDesc = error.localizedDescription.sanitized()
#if DEBUG
os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc)
@@ -186,15 +170,15 @@ public extension SecureLogger {
var message: String {
switch self {
case .handshakeStarted(let peerID):
return "Handshake started with peer: \(sanitize(peerID))"
return "Handshake started with peer: \(peerID.sanitized())"
case .handshakeCompleted(let peerID):
return "Handshake completed with peer: \(sanitize(peerID))"
return "Handshake completed with peer: \(peerID.sanitized())"
case .handshakeFailed(let peerID, let error):
return "Handshake failed with peer: \(sanitize(peerID)), error: \(error)"
return "Handshake failed with peer: \(peerID.sanitized()), error: \(error)"
case .sessionExpired(let peerID):
return "Session expired for peer: \(sanitize(peerID))"
return "Session expired for peer: \(peerID.sanitized())"
case .authenticationFailed(let peerID):
return "Authentication failed for peer: \(sanitize(peerID))"
return "Authentication failed for peer: \(peerID.sanitized())"
}
}
}
@@ -249,7 +233,7 @@ private extension SecureLogger {
file: String, line: Int, function: String) {
guard shouldLog(level) else { return }
let location = formatLocation(file: file, line: line, function: function)
let sanitized = sanitize("\(location) \(message())")
let sanitized = "\(location) \(message())".sanitized()
#if DEBUG
os_log("%{public}@", log: category, type: level.osLogType, sanitized)
@@ -282,58 +266,6 @@ private extension SecureLogger {
let timestamp = timestampFormatter.string(from: Date())
return "[\(timestamp)] [\(fileName):\(line) \(function)]"
}
/// Sanitize strings to remove potentially sensitive data
static func sanitize(_ input: String) -> String {
let key = input as NSString
// Check cache first
var cachedValue: String?
cacheQueue.sync {
cachedValue = sanitizationCache.object(forKey: key) as String?
}
if let cached = cachedValue {
return cached
}
// Perform sanitization
var sanitized = input
// Remove full fingerprints (keep first 8 chars for debugging)
sanitized = sanitized.replacing(fingerprintPattern) { match in
let fingerprint = String(match.output)
return String(fingerprint.prefix(8)) + "..."
}
// Remove base64 encoded data that might be keys
sanitized = sanitized.replacing(base64Pattern) { _ in
"<base64-data>"
}
// Remove potential passwords (assuming they're in quotes or after "password:")
sanitized = sanitized.replacing(passwordPattern) { _ in
"password: <redacted>"
}
// Truncate peer IDs to first 8 characters
sanitized = sanitized.replacing(peerIDPattern) { match in
"peerID: \(match.1)..."
}
// Cache the result
cacheQueue.sync {
sanitizationCache.setObject(sanitized as NSString, forKey: key)
}
return sanitized
}
/// Sanitize individual values
static func sanitize<T>(_ value: T) -> String {
let stringValue = String(describing: value)
return sanitize(stringValue)
}
}
// MARK: - Migration Helper
@@ -0,0 +1,67 @@
//
// String+Sanitization.swift
// BitLogger
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
extension String {
/// Sanitize strings to remove potentially sensitive data
func sanitized() -> String {
let key = self as NSString
// Check cache first
if let cached = Self.queue.sync(execute: { Self.cache.object(forKey: key) }) {
return cached as String
}
var sanitized = self
// Remove full fingerprints (keep first 8 chars for debugging)
let fingerprintPattern = #/[a-fA-F0-9]{64}/#
sanitized = sanitized.replacing(fingerprintPattern) { match in
let fingerprint = String(match.output)
return String(fingerprint.prefix(8)) + "..."
}
// Remove base64 encoded data that might be keys
let base64Pattern = #/[A-Za-z0-9+/]{40,}={0,2}/#
sanitized = sanitized.replacing(base64Pattern) { _ in
"<base64-data>"
}
// Remove potential passwords (assuming they're in quotes or after "password:")
let passwordPattern = #/password["\s:=]+["']?[^"'\s]+["']?/#
sanitized = sanitized.replacing(passwordPattern) { _ in
"password: <redacted>"
}
// Truncate peer IDs to first 8 characters
let peerIDPattern = #/peerID: ([a-zA-Z0-9]{8})[a-zA-Z0-9]+/#
sanitized = sanitized.replacing(peerIDPattern) { match in
"peerID: \(match.1)..."
}
// Cache the result
Self.queue.sync {
Self.cache.setObject(sanitized as NSString, forKey: key)
}
return sanitized
}
}
// MARK: - Cache Helpers
private extension String {
static let queue = DispatchQueue(label: "chat.bitchat.securelogger.cache", attributes: .concurrent)
static let cache: NSCache<NSString, NSString> = {
let cache = NSCache<NSString, NSString>()
cache.countLimit = 100 // Keep last 100 sanitized strings
return cache
}()
}
@@ -0,0 +1,143 @@
//
// StringSanitizationTests.swift
// BitLogger
//
// Created by Islam on 19/10/2025.
//
import Testing
@testable import BitLogger
struct StringSanitizationTests {
@Test("64-hex fingerprint is truncated to first 8 chars followed by ellipsis")
func fingerprintTruncation() async throws {
let fingerprint = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
#expect(fingerprint.count == 64)
let input = "fingerprint=\(fingerprint)"
let output = input.sanitized()
#expect(output.contains("fingerprint=01234567..."))
// Ensure no full fingerprint remains
#expect(output.contains(fingerprint) == false)
}
@Test("Multiple fingerprints in a string are all truncated")
func multipleFingerprintTruncation() async throws {
let fp1 = String(repeating: "a", count: 64)
let fp2 = String(repeating: "b", count: 64)
let input = "fp1=\(fp1) fp2=\(fp2)"
let output = input.sanitized()
#expect(output.contains("fp1=aaaaaaaa..."))
#expect(output.contains("fp2=bbbbbbbb..."))
#expect(output.contains(fp1) == false)
#expect(output.contains(fp2) == false)
}
@Test("Base64-like long data is replaced with <base64-data>")
func base64Replacement() async throws {
// 44+ chars of base64 characters
let base64ish = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo5ODc2NTQzMjE="
let input = "payload=\(base64ish)"
let output = input.sanitized()
#expect(output == "payload=<base64-data>")
}
@Test("Base64-like without padding is replaced with <base64-data>")
func base64NoPaddingReplacement() async throws {
let base64ish = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
#expect(base64ish.count >= 40)
let input = "b64:\(base64ish)"
let output = input.sanitized()
#expect(output == "b64:<base64-data>")
}
@Test("Short base64-like strings (below threshold) are not replaced")
func shortBase64NotReplaced() async throws {
let short = "QUJDREVGR0hJSktMTU5P" // < 40 chars
let input = "payload=\(short)"
let output = input.sanitized()
#expect(output == input)
}
@Test("Password redaction for key:value formats", arguments: [
"password: secret123",
"password=secret123",
"password = secret123",
"password: 'secret123'",
"password:\"secret123\"",
"password='secret123'"
])
func passwordRedactionKeyValue(password: String) async throws {
#expect(password.sanitized() == "password: <redacted>")
}
@Test("Password redaction inside wider messages")
func passwordRedactionInContext() async throws {
let input = "user=john password: 'p@ssW0rd' attempt=1"
let output = input.sanitized()
#expect(output == "user=john password: <redacted> attempt=1")
}
@Test("PeerID is truncated to first 8 chars followed by ellipsis")
func peerIDTruncation() async throws {
let peer = "ABCDEF12GHIJKL34"
let input = "peerID: \(peer)"
let output = input.sanitized()
#expect(output == "peerID: ABCDEF12...")
}
@Test("PeerID not truncated when exactly 8 chars")
func peerIDExactlyEightNotTruncated() async throws {
let peer = "ABCDEF12"
let input = "peerID: \(peer)"
let output = input.sanitized()
// Pattern only matches when there are more than 8 trailing chars, so unchanged
#expect(output == input)
}
@Test("Non-matching content remains unchanged")
func nonMatchingUnchanged() async throws {
let input = "Hello world 123 - nothing sensitive here."
let output = input.sanitized()
#expect(output == input)
}
@Test("Idempotency: sanitizing twice yields same result")
func idempotentSanitization() async throws {
let input = """
fingerprint=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef \
password: "superSecret" \
peerID: ZYXWVUT987654321 \
payload=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/
"""
let once = input.sanitized()
let twice = once.sanitized()
#expect(once == twice)
}
@Test("Mixed content: all rules apply in a single string")
func mixedContent() async throws {
let fingerprint = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
let base64ish = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
let peer = "PEERID01EXTRA"
let input = "fp=\(fingerprint) password='x' peerID: \(peer) data=\(base64ish)"
let output = input.sanitized()
#expect(output.contains("fp=fedcba98..."))
#expect(output.contains("password: <redacted>"))
#expect(output.contains("peerID: PEERID01..."))
#expect(output.contains("data=<base64-data>"))
#expect(output.contains(fingerprint) == false)
#expect(output.contains(base64ish) == false)
}
@Test("Cache returns consistent result for repeated inputs")
func cacheHitConsistency() async throws {
let input = "password: hunter2"
let first = input.sanitized()
let second = input.sanitized()
#expect(first == "password: <redacted>")
#expect(first == second)
}
}