Compare commits

...
Author SHA1 Message Date
jack ef010dff37 Update Localizable.xcstrings with latest build changes 2025-10-18 21:10:44 +02:00
jack 8fbfa359ff 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.
2025-10-18 20:29:33 +02:00
jack eeb4d55476 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
2025-10-18 15:04:13 +02:00
jack 4882b99752 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.
2025-10-18 14:54:22 +02:00
jack 9b8498c78e 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.
2025-10-18 14:50:25 +02:00
jack 1cf8449e22 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'
2025-10-18 14:47:43 +02:00
jack 7ca1ff0a7e 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.
2025-10-18 14:24:56 +02:00
jack 7316a4a46d 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
2025-10-18 14:23:04 +02:00
jack e992d96939 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
2025-10-18 14:12:51 +02:00
jack e427e1c62f 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.
2025-10-18 14:11:02 +02:00
jack fae76dc515 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.
2025-10-18 13:57:38 +02:00
jack 76f976438f 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
2025-10-18 13:55:44 +02:00
jack 369c335088 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)
2025-10-18 13:52:02 +02:00
jack a84b8c05f1 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
2025-10-18 13:45:04 +02:00
jack 9d738cff45 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
2025-10-18 13:35:14 +02:00
jack 9879cb2580 Merge branch 'main' into feature/ble-binary-transfers
# Conflicts:
#	bitchat/Views/ContentView.swift
2025-10-18 13:24:05 +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
jack e1bd8a69c0 Merge main into feature/ble-binary-transfers
Resolved conflicts in BLEService.swift:
- Kept refactored versions of broadcastPacket() and sendAnnounce()
- These functions were moved earlier in PR #809 with enhancements
- Main's changes (gossipSyncManager integration) already present in refactored versions

Changes from main:
- Dead code removal (PR #811)
- Performance optimizations (PR #812)
- Linux/cross-platform compatibility improvements

Build:  Successful
Tests: 155/156 passing (1 pre-existing failure in FragmentationTests)
2025-10-17 23:22:52 +02:00
jackandGitHub fb43a8b0f5 Reuse cached mention regex in parseMentions (#812) 2025-10-17 23:02:58 +02:00
jackandGitHub eb35608fa1 Remove unused helpers and add cross-platform logging fallbacks (#811) 2025-10-17 22:58:56 +02:00
jackandGitHub 629bea7fd0 Merge branch 'main' into feature/ble-binary-transfers 2025-10-17 22:44:19 +02:00
jack 1cf4ea9e57 Allow file fragments to account for protocol overhead 2025-10-17 22:43:35 +02:00
RedThoroughbredandGitHub b81ae0b4c0 fix: improve Xcode detection in Justfile (#814)
- Add check for full Xcode vs command line tools only
- Provide clearer error messages with setup instructions
- Verify Xcode is installed and properly configured
- Add better validation for development environment

Fixes issue where 'just run' fails with cryptic error when only
command line tools are installed. Now gives clear guidance on
installing full Xcode and configuring xcode-select properly.

Resolves #760
2025-10-17 21:37:51 +02:00
islam 3b96d8590d Revive lost NotificationStreamAssembler changes 2025-10-15 17:37:19 +01:00
islam 6cbfe8ecfb Explicitly list all Enum cases to get compile-time errors 2025-10-15 17:37:19 +01:00
islam 8481e1552d Add the missing fileTransfer case 2025-10-15 17:37:19 +01:00
islam 9ebaa9108c Fix compilation issue 2025-10-15 17:37:19 +01:00
islam 2b3264b581 Convert new tests to Swift Testing 2025-10-15 17:37:19 +01:00
jackandislam 830f7ee4b8 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.
2025-10-15 17:37:19 +01:00
jackandislam c8768c32b8 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.
2025-10-15 17:37:19 +01:00
jackandislam 1352484491 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.
2025-10-15 17:37:19 +01:00
jackandislam ef396936a3 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).
2025-10-15 17:37:19 +01:00
jackandislam c3ce32ad07 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
2025-10-15 17:37:19 +01:00
jackandislam d7ca99ae5d 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
2025-10-15 17:37:19 +01:00
jackandislam b25f0d9f63 Remove debug print statements from sendMessage 2025-10-15 17:37:19 +01:00
jackandislam abb998d809 macOS: Focus message input on launch instead of nickname field 2025-10-15 17:37:19 +01:00
jackandislam d71befd8b7 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
2025-10-15 17:37:19 +01:00
jackandislam 78c4bed1ad 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.
2025-10-15 17:37:19 +01:00
jackandislam c52a2a7772 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.
2025-10-15 17:37:19 +01:00
jackandislam 3df0aa0cc4 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.
2025-10-15 17:37:19 +01:00
jackandislam 46caded099 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
2025-10-15 17:37:19 +01:00
jackandislam a04ac9dabd 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.
2025-10-15 17:37:19 +01:00
jackandislam 1825d115bc 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.
2025-10-15 17:37:19 +01:00
jackandislam 9754881af8 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.
2025-10-15 17:37:19 +01:00
jackandislam 59cc857fde 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.
2025-10-15 17:37:19 +01:00
jackandislam f0ca1b27c2 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.
2025-10-15 17:37:19 +01:00
jackandislam 5beff8b4dc Fix binary protocol test fixtures 2025-10-15 17:37:19 +01:00
jackandislam ba49b885bb Reset BLE assembler on stalled fragment trains 2025-10-15 17:37:19 +01:00
jackandislam ac10e906c0 Drop attachment ceilings to 1 MiB and bump release version 2025-10-15 17:37:19 +01:00
jackandislam 119e882574 Guard peer map reads on BLE message path 2025-10-15 17:37:19 +01:00
jackandislam 0acbbdf2e3 Restore BLE broadcasts when notify buffer is saturated 2025-10-15 17:37:19 +01:00
jackandislam 40fe0c8ba5 Fix cleanupLocalFile lookup 2025-10-15 17:37:19 +01:00
jackandislam 7593053137 Resolve image/voice path handling 2025-10-15 17:37:19 +01:00
jackandislam 4dde063027 Hide absolute paths in media messages 2025-10-15 17:37:19 +01:00
jackandislam 8c4fead945 Stub file transfer methods in mock 2025-10-15 17:37:19 +01:00
jackandislam 2543277755 Stub file transfer methods in mock 2025-10-15 17:37:19 +01:00
jackandislam a8ce4cbf64 Use unique transfer identifiers 2025-10-15 17:37:19 +01:00
jackandislam 93aa714ed4 Preserve packet version when signing 2025-10-15 17:37:19 +01:00
jackandislam 9c7bf26e13 Fix CFMutableData handling 2025-10-15 17:37:19 +01:00
jackandislam c29c7d83fe Target image byte size across platforms 2025-10-15 17:37:19 +01:00
jackandislam 7f743d48fe Normalize mac JPEG color space 2025-10-15 17:37:19 +01:00
jackandislam 27c39332d5 Strip metadata in mac image encoding 2025-10-15 17:37:19 +01:00
jackandislam 53c08d6807 Revert unsupported JPEG option 2025-10-15 17:37:19 +01:00
jackandislam 7efe0a23b1 Align mac image JPEG encoding 2025-10-15 17:37:19 +01:00
jackandislam 8dedc81512 Allow user-selected write access 2025-10-15 17:37:19 +01:00
jackandislam 5cbe9d4aa7 Fix image attachment detection 2025-10-15 17:37:19 +01:00
jackandislam 515fae9e25 Use save panel for mac image export 2025-10-15 17:37:19 +01:00
jackandislam 0946d3921f Keep processed images for outgoing messages 2025-10-15 17:37:19 +01:00
jackandislam 1d40950118 Lowercase image preview buttons 2025-10-15 17:37:19 +01:00
jackandislam b404d48ba6 Reblur images via swipe 2025-10-15 17:37:19 +01:00
jackandislam 2f1ba27d51 Allow long-press reblur on images 2025-10-15 17:37:19 +01:00
jackandislam 23124b36b7 Use Photos picker on mac 2025-10-15 17:37:19 +01:00
jackandislam 3e8d05fa2e Restore mac photo picker access 2025-10-15 17:37:19 +01:00
jackandislam c36b582209 Display recording milliseconds 2025-10-15 17:37:19 +01:00
jackandislam 3c66ca7499 Harden attachment transfer bookkeeping 2025-10-15 17:37:19 +01:00
jackandislam a042576652 Describe microphone usage 2025-10-15 17:37:19 +01:00
jackandislam 3c120042a4 Permit mac media library access 2025-10-15 17:37:19 +01:00
jackandislam 4e3603d5e4 Allow mac microphone access 2025-10-15 17:37:19 +01:00
jackandislam f7ad970a83 Enable mac attachment importers 2025-10-15 17:37:19 +01:00
jackandislam 4cc87633e1 Fix compressed BLE file transfers 2025-10-15 17:37:19 +01:00
jackandislam 2d565ad918 Stop dropping partial BLE frames while assembling notifications 2025-10-15 17:37:19 +01:00
jackandislam b4cb4d36f1 Log incomplete BLE frames for debugging 2025-10-15 17:37:19 +01:00
jackandislam 7fcef2a9cb Add detailed logging for BLE fragment assembly 2025-10-15 17:37:18 +01:00
jackandislam e7dc0e0c5a Let BLE assembler accept large frames up to hard cap 2025-10-15 17:37:18 +01:00
jackandislam 3b2c7d2f73 Add guard to drop oversized BLE notification assemblies 2025-10-15 17:37:18 +01:00
jackandislam 209f926990 Revert "Raise BLE notification buffer cap for large file transfers"
This reverts commit b624523af843475db84e4a846db8dcbe824ae408.
2025-10-15 17:37:18 +01:00
jackandislam ef8b509ea5 Raise BLE notification buffer cap for large file transfers 2025-10-15 17:37:18 +01:00
jackandislam 06b5bdb6da Allow file transfers from connected but unverified peers 2025-10-15 17:37:18 +01:00
jackandislam a5a3efebcb Copy imported files before sending to preserve access 2025-10-15 17:37:18 +01:00
jackandislam cd39d8c8c0 Restore iOS file importer for attachments 2025-10-15 17:37:18 +01:00
jackandislam 1046bfec0f Reduce vertical padding between chat rows 2025-10-15 17:37:18 +01:00
jackandislam d8c83e25f4 Tighten spacing above media message bubbles 2025-10-15 17:37:18 +01:00
jackandislam a9fa614416 Gracefully disable mac attachment pickers in sandbox 2025-10-15 17:37:18 +01:00
jackandislam ecbbc23862 Add BLE file transfer support and media UX 2025-10-15 17:37:18 +01:00
jackandGitHub b6d42261d0 Guard peer collision checks with snapshot (#810) 2025-10-15 16:12:16 +02:00
46 changed files with 29079 additions and 24779 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
MARKETING_VERSION = 1.4.4
MARKETING_VERSION = 1.5.0
CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 16.0
+5 -2
View File
@@ -14,8 +14,11 @@ default:
# Check prerequisites
check:
@echo "Checking prerequisites..."
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ Xcode not found. Install Xcode from App Store" && exit 1)
@security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install Xcode from App Store" && exit 1)
@xcode-select -p | grep -q "Xcode.app" || (echo "❌ Full Xcode required, not just command line tools. Install from App Store and run:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
@test -d "/Applications/Xcode.app" || (echo "❌ Xcode.app not found in Applications folder. Install from App Store" && exit 1)
@xcodebuild -version >/dev/null 2>&1 || (echo "❌ Xcode not properly configured. Try:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
@security find-identity -v -p codesigning | grep -q "Apple Development\|Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
@echo "✅ All prerequisites met"
# Backup original files
+203
View File
@@ -0,0 +1,203 @@
import Foundation
import ImageIO
import UniformTypeIdentifiers
#if os(iOS)
import UIKit
#else
import AppKit
#endif
enum ImageUtilsError: Error {
case invalidImage
case encodingFailed
}
enum ImageUtils {
private static let compressionQuality: CGFloat = 0.85
private static let targetImageBytes: Int = 60_000
static func processImage(at url: URL, maxDimension: CGFloat = 512) 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 {
throw ImageUtilsError.invalidImage
}
// Allow up to 10MB source images (will be scaled down)
guard fileSize <= 10 * 1024 * 1024 else {
throw ImageUtilsError.invalidImage
}
let data = try Data(contentsOf: url)
#if os(iOS)
guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage }
return try processImage(image, maxDimension: maxDimension)
#else
guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }
return try processImage(image, maxDimension: maxDimension)
#endif
}
#if os(iOS)
static func processImage(_ image: UIImage, maxDimension: CGFloat = 512) throws -> URL {
return try autoreleasepool {
// Scale the image first
let scaled = scaledImage(image, maxDimension: maxDimension)
// 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 = encodeJPEG(from: cgImage, quality: quality) {
jpegData = next
}
}
}
let outputURL = try makeOutputURL()
try jpegData.write(to: outputURL, options: .atomic)
return outputURL
}
}
private static func scaledImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage {
let size = image.size
let maxSide = max(size.width, size.height)
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 {
return try autoreleasepool {
let scaled = scaledImage(image, maxDimension: maxDimension)
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
throw ImageUtilsError.encodingFailed
}
let width = inputCG.width
let height = inputCG.height
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
guard let context = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else {
throw ImageUtilsError.encodingFailed
}
context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))
guard let cgImage = context.makeImage() else {
throw ImageUtilsError.encodingFailed
}
var quality = compressionQuality
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
throw ImageUtilsError.encodingFailed
}
while jpegData.count > targetImageBytes && quality > 0.3 {
quality -= 0.1
autoreleasepool {
if let next = encodeJPEG(from: cgImage, quality: quality) {
jpegData = next
}
}
}
let outputURL = try makeOutputURL()
try jpegData.write(to: outputURL, options: .atomic)
return outputURL
}
}
private static func scaledImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage {
let size = image.size
let maxSide = max(size.width, size.height)
guard maxSide > maxDimension else { return image }
let scale = maxDimension / maxSide
let newSize = NSSize(width: size.width * scale, height: size.height * scale)
let scaledImage = NSImage(size: newSize)
scaledImage.lockFocus()
image.draw(in: NSRect(origin: .zero, size: newSize),
from: NSRect(origin: .zero, size: size),
operation: .copy,
fraction: 1.0)
scaledImage.unlockFocus()
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
}
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
}
#endif
private static func makeOutputURL() throws -> URL {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd_HHmmss"
let fileName = "img_\(formatter.string(from: Date())).jpg"
let directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
return directory.appendingPathComponent(fileName)
}
private static func applicationFilesDirectory() throws -> URL {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
return base.appendingPathComponent("files", isDirectory: true)
}
}
@@ -0,0 +1,193 @@
import Foundation
import AVFoundation
import BitLogger
/// Controls playback for a single voice note and coordinates exclusive playback across the app.
final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlayerDelegate {
@Published private(set) var isPlaying: Bool = false
@Published private(set) var currentTime: TimeInterval = 0
@Published private(set) var duration: TimeInterval = 0
@Published private(set) var progress: Double = 0
private var player: AVAudioPlayer?
private var timer: Timer?
private var url: URL
init(url: URL) {
self.url = url
super.init()
// Don't load anything eagerly - wait until user interaction or view is fully displayed
}
func loadDuration() {
guard duration == 0 else { return }
DispatchQueue.global(qos: .utility).async { [weak self] in
guard let self = self else { return }
do {
let player = try AVAudioPlayer(contentsOf: self.url)
let loadedDuration = player.duration
DispatchQueue.main.async { [weak self] in
guard let self = self, self.duration == 0 else { return }
self.duration = loadedDuration
}
} catch {
SecureLogger.error("Failed to load audio duration: \(error)", category: .session)
}
}
}
deinit {
timer?.invalidate()
}
func replaceURL(_ url: URL) {
guard url != self.url else { return }
stop()
self.url = url
player = nil
duration = 0
// Duration will be loaded on demand when needed
}
func togglePlayback() {
isPlaying ? pause() : play()
}
func play() {
guard ensurePlayerReady() else { return }
VoiceNotePlaybackCoordinator.shared.activate(self)
player?.play()
startTimer()
updateProgress()
isPlaying = true
}
func pause() {
player?.pause()
stopTimer()
updateProgress()
isPlaying = false
}
func stop() {
player?.stop()
player?.currentTime = 0
stopTimer()
updateProgress()
isPlaying = false
VoiceNotePlaybackCoordinator.shared.deactivate(self)
}
func seek(to fraction: Double) {
guard ensurePlayerReady() else { return }
let clamped = max(0, min(1, fraction))
if let player = player {
player.currentTime = clamped * player.duration
if isPlaying {
player.play()
}
updateProgress()
}
}
// MARK: - AVAudioPlayerDelegate
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
// Delegate callback may be on background thread - ensure main thread for UI updates
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.stopTimer()
self.updateProgress()
self.isPlaying = false
VoiceNotePlaybackCoordinator.shared.deactivate(self)
}
}
// MARK: - Private Helpers
private func preparePlayer(for url: URL) {
// Prepare player synchronously (only called when playback is requested)
do {
let player = try AVAudioPlayer(contentsOf: url)
player.delegate = self
player.prepareToPlay()
self.player = player
duration = player.duration
currentTime = player.currentTime
progress = duration > 0 ? currentTime / duration : 0
} catch {
SecureLogger.error("Voice note playback failed for \(url.lastPathComponent): \(error)", category: .session)
player = nil
duration = 0
currentTime = 0
progress = 0
}
}
private func ensurePlayerReady() -> Bool {
if player == nil {
preparePlayer(for: url)
}
#if os(iOS)
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
try session.setActive(true, options: [])
} catch {
SecureLogger.error("Failed to activate audio session: \(error)", category: .session)
}
#endif
return player != nil
}
private func startTimer() {
if timer != nil { return }
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
self?.updateProgress()
}
if let timer = timer {
RunLoop.main.add(timer, forMode: .common)
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
private func updateProgress() {
guard let player = player else {
currentTime = 0
duration = 0
progress = 0
return
}
currentTime = player.currentTime
duration = player.duration
progress = duration > 0 ? currentTime / duration : 0
}
}
/// Ensures only one voice note plays at a time.
final class VoiceNotePlaybackCoordinator {
static let shared = VoiceNotePlaybackCoordinator()
private weak var activeController: VoiceNotePlaybackController?
private init() {}
func activate(_ controller: VoiceNotePlaybackController) {
if activeController === controller {
return
}
activeController?.pause()
activeController = controller
}
func deactivate(_ controller: VoiceNotePlaybackController) {
if activeController === controller {
activeController = nil
}
}
}
+169
View File
@@ -0,0 +1,169 @@
import Foundation
import AVFoundation
/// Manages audio capture for mesh voice notes with predictable encoding settings.
/// Recording runs on an internal serial queue to avoid AVAudioSession contention.
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
enum RecorderError: Error {
case microphoneAccessDenied
case recorderInitializationFailed
case recordingInProgress
}
static let shared = VoiceRecorder()
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
private let paddingInterval: TimeInterval = 0.5
private var recorder: AVAudioRecorder?
private var currentURL: URL?
private var stopWorkItem: DispatchWorkItem?
private override init() {
super.init()
}
// MARK: - Permissions
@discardableResult
func requestPermission() async -> Bool {
#if os(iOS)
return await withCheckedContinuation { continuation in
AVAudioSession.sharedInstance().requestRecordPermission { granted in
continuation.resume(returning: granted)
}
}
#elseif os(macOS)
return await withCheckedContinuation { continuation in
AVCaptureDevice.requestAccess(for: .audio) { granted in
continuation.resume(returning: granted)
}
}
#else
return true
#endif
}
// MARK: - Recording Lifecycle
func startRecording() throws -> URL {
try queue.sync {
if recorder?.isRecording == true {
throw RecorderError.recordingInProgress
}
#if os(iOS)
let session = AVAudioSession.sharedInstance()
guard session.recordPermission == .granted else {
throw RecorderError.microphoneAccessDenied
}
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
)
try session.setActive(true, options: .notifyOthersOnDeactivation)
#endif
#if os(macOS)
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
throw RecorderError.microphoneAccessDenied
}
#endif
let outputURL = try makeOutputURL()
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 16_000,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 20_000
]
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
audioRecorder.delegate = self
audioRecorder.isMeteringEnabled = true
audioRecorder.prepareToRecord()
audioRecorder.record()
recorder = audioRecorder
currentURL = outputURL
stopWorkItem?.cancel()
stopWorkItem = nil
return outputURL
}
}
func stopRecording(completion: @escaping (URL?) -> Void) {
queue.async { [weak self] in
guard let self = self, let recorder = self.recorder, recorder.isRecording else {
completion(self?.currentURL)
return
}
let item = DispatchWorkItem { [weak self] in
guard let self = self else { return }
recorder.stop()
self.cleanupSession()
let url = self.currentURL
self.recorder = nil
self.currentURL = url
completion(url)
}
self.stopWorkItem = item
self.queue.asyncAfter(deadline: .now() + self.paddingInterval, execute: item)
}
}
func cancelRecording() {
queue.async { [weak self] in
guard let self = self else { return }
self.stopWorkItem?.cancel()
self.stopWorkItem = nil
if let recorder = self.recorder, recorder.isRecording {
recorder.stop()
}
self.cleanupSession()
if let url = self.currentURL {
try? FileManager.default.removeItem(at: url)
}
self.recorder = nil
self.currentURL = nil
}
}
// MARK: - Metering
func currentAveragePower() -> Float {
queue.sync {
recorder?.updateMeters()
return recorder?.averagePower(forChannel: 0) ?? -160
}
}
// MARK: - Helpers
private func makeOutputURL() throws -> URL {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd_HHmmss"
let fileName = "voice_\(formatter.string(from: Date())).m4a"
let baseDirectory = try applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
return baseDirectory.appendingPathComponent(fileName)
}
private func applicationFilesDirectory() throws -> URL {
#if os(iOS)
return try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
.appendingPathComponent("files", isDirectory: true)
#else
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
return base.appendingPathComponent("files", isDirectory: true)
#endif
}
private func cleanupSession() {
#if os(iOS)
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
#endif
}
}
+113
View File
@@ -0,0 +1,113 @@
import AVFoundation
import Foundation
import BitLogger
/// Generates and caches downsampled waveforms for audio files so UI rendering is cheap.
final class WaveformCache {
static let shared = WaveformCache()
private let queue = DispatchQueue(label: "com.bitchat.waveform-cache", attributes: .concurrent)
private var cache: [URL: (waveform: [Float], lastAccess: Date)] = [:]
private let maxCacheSize = 20 // Limit cache to prevent unbounded memory growth
private init() {}
func cachedWaveform(for url: URL) -> [Float]? {
queue.sync {
guard let entry = cache[url] else { return nil }
return entry.waveform
}
}
func waveform(for url: URL, bins: Int = 120, completion: @escaping ([Float]) -> Void) {
queue.async { [weak self] in
guard let self = self else { return }
// Check cache (read-only, no update needed on cache hit for performance)
if let entry = self.cache[url] {
DispatchQueue.main.async { completion(entry.waveform) }
return
}
guard let computed = self.computeWaveform(url: url, bins: bins) else {
DispatchQueue.main.async { completion([]) }
return
}
self.queue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
// Evict oldest entry if cache is full
if self.cache.count >= self.maxCacheSize {
if let oldest = self.cache.min(by: { $0.value.lastAccess < $1.value.lastAccess }) {
self.cache.removeValue(forKey: oldest.key)
}
}
self.cache[url] = (computed, Date())
}
DispatchQueue.main.async { completion(computed) }
}
}
func purge(url: URL) {
queue.async(flags: .barrier) { [weak self] in
self?.cache.removeValue(forKey: url)
}
}
func purgeAll() {
queue.async(flags: .barrier) { [weak self] in
self?.cache.removeAll()
}
}
private func computeWaveform(url: URL, bins: Int) -> [Float]? {
guard bins > 0 else { return nil }
// Use autoreleasepool to manage memory from audio buffer allocations
return autoreleasepool {
do {
let audioFile = try AVAudioFile(forReading: url)
let length = Int(audioFile.length)
guard length > 0 else { return nil }
guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: AVAudioFrameCount(length)) else {
return nil
}
try audioFile.read(into: buffer, frameCount: AVAudioFrameCount(length))
guard let channelData = buffer.floatChannelData else { return nil }
let channelCount = Int(audioFile.processingFormat.channelCount)
let frameLength = Int(buffer.frameLength)
let samplesPerBin = max(1, frameLength / bins)
var magnitudes: [Float] = Array(repeating: 0, count: bins)
for bin in 0..<bins {
let start = bin * samplesPerBin
let end = min(frameLength, start + samplesPerBin)
if start >= end { break }
var sum: Float = 0
var sampleCount = 0
for frame in start..<end {
var sampleValue: Float = 0
for channel in 0..<channelCount {
sampleValue += fabsf(channelData[channel][frame])
}
sum += sampleValue / Float(channelCount)
sampleCount += 1
}
magnitudes[bin] = sampleCount > 0 ? sum / Float(sampleCount) : 0
}
if let maxMagnitude = magnitudes.max(), maxMagnitude > 0 {
magnitudes = magnitudes.map { min($0 / maxMagnitude, 1.0) }
}
return magnitudes
} catch {
SecureLogger.error("Waveform extraction failed for \(url.lastPathComponent): \(error)", category: .session)
return nil
}
}
}
}
+4
View File
@@ -37,6 +37,10 @@
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
<key>NSCameraUsageDescription</key>
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
<key>NSMicrophoneUsageDescription</key>
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
<key>UIBackgroundModes</key>
+15197 -14511
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -22,8 +22,8 @@ struct BitchatPacket: Codable {
var signature: Data?
var ttl: UInt8
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
self.version = 1
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1) {
self.version = version
self.type = type
self.senderID = senderID
self.recipientID = recipientID
@@ -80,7 +80,8 @@ struct BitchatPacket: Codable {
timestamp: timestamp,
payload: payload,
signature: nil, // Remove signature for signing
ttl: 0 // Use fixed TTL=0 for signing to ensure relay compatibility
ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility
version: version
)
return BinaryProtocol.encode(unsignedPacket)
}
+1 -1
View File
@@ -767,7 +767,7 @@ final class NoiseHandshakeState {
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
default:
case .e, .s:
break
}
}
+23 -4
View File
@@ -33,13 +33,32 @@ final class GeoRelayDirectory {
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
guard !entries.isEmpty else { return [] }
let sorted = entries
guard !entries.isEmpty, count > 0 else { return [] }
if entries.count <= count {
return entries
.sorted { a, b in
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
}
.prefix(count)
return sorted.map { "wss://\($0.host)" }
.map { "wss://\($0.host)" }
}
var best: [(entry: Entry, distance: Double)] = []
best.reserveCapacity(count)
for entry in entries {
let distance = haversineKm(lat, lon, entry.lat, entry.lon)
if best.count < count {
let idx = best.firstIndex { $0.distance > distance } ?? best.count
best.insert((entry, distance), at: idx)
} else if let worstDistance = best.last?.distance, distance < worstDistance {
let idx = best.firstIndex { $0.distance > distance } ?? best.count
best.insert((entry, distance), at: idx)
best.removeLast()
}
}
return best.map { "wss://\($0.entry.host)" }
}
// MARK: - Remote Fetch
+23 -1
View File
@@ -8,6 +8,9 @@ final class NostrIdentityBridge {
private let deviceSeedKey = "nostr-device-seed"
// In-memory cache to avoid transient keychain access issues
private var deviceSeedCache: Data?
// Cache derived identities to avoid repeated crypto during view rendering
private var derivedIdentityCache: [String: NostrIdentity] = [:]
private let cacheLock = NSLock()
private let keychain: KeychainHelperProtocol
@@ -106,6 +109,14 @@ final class NostrIdentityBridge {
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
/// if the candidate is not a valid secp256k1 private key.
func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
// Check cache first to avoid repeated crypto + keychain I/O during view rendering
cacheLock.lock()
if let cached = derivedIdentityCache[geohash] {
cacheLock.unlock()
return cached
}
cacheLock.unlock()
let seed = getOrCreateDeviceSeed()
guard let msg = geohash.data(using: .utf8) else {
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
@@ -125,11 +136,22 @@ final class NostrIdentityBridge {
for i in 0..<10 {
let keyData = candidateKey(iteration: UInt32(i))
if let identity = try? NostrIdentity(privateKeyData: keyData) {
// Cache the result
cacheLock.lock()
derivedIdentityCache[geohash] = identity
cacheLock.unlock()
return identity
}
}
// As a final fallback, hash the seed+msg and try again
let fallback = (seed + msg).sha256Hash()
return try NostrIdentity(privateKeyData: fallback)
let identity = try NostrIdentity(privateKeyData: fallback)
// Cache the result
cacheLock.lock()
derivedIdentityCache[geohash] = identity
cacheLock.unlock()
return identity
}
}
+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
@@ -6,6 +6,7 @@
//
import Foundation
import CryptoKit
// MARK: - Hex Encoding/Decoding
@@ -17,6 +18,11 @@ extension Data {
return self.map { String(format: "%02x", $0) }.joined()
}
func sha256Hex() -> String {
let digest = SHA256.hash(data: self)
return digest.map { String(format: "%02x", $0) }.joined()
}
init?(hexString: String) {
let len = hexString.count / 2
var data = Data(capacity: len)
+145 -96
View File
@@ -22,11 +22,11 @@
///
/// ## Wire Format
/// ```
/// Header (Fixed 13 bytes):
/// +--------+------+-----+-----------+-------+----------------+
/// Header (Fixed 14 bytes for v1, 16 bytes for v2):
/// +--------+------+-----+-----------+-------+------------------+
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes |
/// +--------+------+-----+-----------+-------+----------------+
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 or 4 bytes |
/// +--------+------+-----+-----------+-------+------------------+
///
/// Variable sections:
/// +----------+-------------+---------+------------+
@@ -52,7 +52,7 @@
/// ## Flag Bits
/// - Bit 0: Has recipient ID (directed message)
/// - Bit 1: Has signature (authenticated message)
/// - Bit 2: Is compressed (LZ4 compression applied)
/// - Bit 2: Is compressed (zlib compression applied)
/// - Bits 3-7: Reserved for future use
///
/// ## Size Constraints
@@ -89,6 +89,7 @@
///
import Foundation
import BitLogger
extension Data {
func trimmingNullBytes() -> Data {
@@ -105,11 +106,33 @@ extension Data {
/// their binary wire format representation.
/// - Note: All multi-byte values use network byte order (big-endian)
struct BinaryProtocol {
static let headerSize = 13
static let v1HeaderSize = 14
static let v2HeaderSize = 16
static let senderIDSize = 8
static let recipientIDSize = 8
static let signatureSize = 64
// Field offsets within packet header
struct Offsets {
static let version = 0
static let type = 1
static let ttl = 2
static let timestamp = 3
static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8)
}
static func headerSize(for version: UInt8) -> Int? {
switch version {
case 1: return v1HeaderSize
case 2: return v2HeaderSize
default: return nil
}
}
private static func lengthFieldSize(for version: UInt8) -> Int {
return version == 2 ? 4 : 2
}
struct Flags {
static let hasRecipient: UInt8 = 0x01
static let hasSignature: UInt8 = 0x02
@@ -118,70 +141,69 @@ struct BinaryProtocol {
// Encode BitchatPacket to binary format
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
var data = Data()
let version = packet.version
guard version == 1 || version == 2 else { return nil }
// Try to compress payload if beneficial
// Try to compress payload when beneficial, keeping original size for later decoding
var payload = packet.payload
var originalPayloadSize: UInt16? = nil
var isCompressed = false
var originalPayloadSize: Int?
if CompressionUtil.shouldCompress(payload) {
if let compressedPayload = CompressionUtil.compress(payload) {
// Store original size for decompression (2 bytes after payload)
originalPayloadSize = UInt16(payload.count)
// Only compress when we can represent the original length in the outbound frame
let maxRepresentable = version == 2 ? Int(UInt32.max) : Int(UInt16.max)
if payload.count <= maxRepresentable,
let compressedPayload = CompressionUtil.compress(payload) {
originalPayloadSize = payload.count
payload = compressedPayload
isCompressed = true
} else {
}
} else {
}
// Header
// Reserve capacity to reduce reallocations. Estimate base size conservatively.
// header(13) + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad
let estimatedPayload = payload.count + (isCompressed ? 2 : 0)
let estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255
data.reserveCapacity(estimated)
data.append(packet.version)
let lengthFieldBytes = lengthFieldSize(for: version)
let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0
let payloadDataSize = payload.count + originalSizeFieldBytes
if version == 1 && payloadDataSize > Int(UInt16.max) { return nil }
if version == 2 && payloadDataSize > Int(UInt32.max) { return nil }
guard let headerSize = headerSize(for: version) else { return nil }
let estimatedHeader = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize)
let estimatedPayload = payloadDataSize
let estimatedSignature = (packet.signature == nil ? 0 : signatureSize)
var data = Data()
data.reserveCapacity(estimatedHeader + estimatedPayload + estimatedSignature + 255)
data.append(version)
data.append(packet.type)
data.append(packet.ttl)
// Timestamp (8 bytes, big-endian)
for i in (0..<8).reversed() {
data.append(UInt8((packet.timestamp >> (i * 8)) & 0xFF))
for shift in stride(from: 56, through: 0, by: -8) {
data.append(UInt8((packet.timestamp >> UInt64(shift)) & 0xFF))
}
// Flags
var flags: UInt8 = 0
if packet.recipientID != nil {
flags |= Flags.hasRecipient
}
if packet.signature != nil {
flags |= Flags.hasSignature
}
if isCompressed {
flags |= Flags.isCompressed
}
if packet.recipientID != nil { flags |= Flags.hasRecipient }
if packet.signature != nil { flags |= Flags.hasSignature }
if isCompressed { flags |= Flags.isCompressed }
data.append(flags)
// Payload length (2 bytes, big-endian) - includes original size if compressed
let payloadDataSize = payload.count + (isCompressed ? 2 : 0)
let payloadLength = UInt16(payloadDataSize)
if version == 2 {
let length = UInt32(payloadDataSize)
for shift in stride(from: 24, through: 0, by: -8) {
data.append(UInt8((length >> UInt32(shift)) & 0xFF))
}
} else {
let length = UInt16(payloadDataSize)
data.append(UInt8((length >> 8) & 0xFF))
data.append(UInt8(length & 0xFF))
}
data.append(UInt8((payloadLength >> 8) & 0xFF))
data.append(UInt8(payloadLength & 0xFF))
// SenderID (exactly 8 bytes)
let senderBytes = packet.senderID.prefix(senderIDSize)
data.append(senderBytes)
if senderBytes.count < senderIDSize {
data.append(Data(repeating: 0, count: senderIDSize - senderBytes.count))
}
// RecipientID (if present)
if let recipientID = packet.recipientID {
let recipientBytes = recipientID.prefix(recipientIDSize)
data.append(recipientBytes)
@@ -190,29 +212,29 @@ struct BinaryProtocol {
}
}
// Payload (with original size prepended if compressed)
if isCompressed, let originalSize = originalPayloadSize {
// Prepend original size (2 bytes, big-endian)
data.append(UInt8((originalSize >> 8) & 0xFF))
data.append(UInt8(originalSize & 0xFF))
if version == 2 {
let value = UInt32(originalSize)
for shift in stride(from: 24, through: 0, by: -8) {
data.append(UInt8((value >> UInt32(shift)) & 0xFF))
}
} else {
let value = UInt16(originalSize)
data.append(UInt8((value >> 8) & 0xFF))
data.append(UInt8(value & 0xFF))
}
}
data.append(payload)
// Signature (if present)
if let signature = packet.signature {
data.append(signature.prefix(signatureSize))
}
// Apply padding to standard block sizes for traffic analysis resistance
if padding {
let optimalSize = MessagePadding.optimalBlockSize(for: data.count)
let paddedData = MessagePadding.pad(data, toSize: optimalSize)
return paddedData
} else {
// Caller explicitly requested no padding (e.g., BLE write path)
return data
return MessagePadding.pad(data, toSize: optimalSize)
}
return data
}
// Decode binary data to BitchatPacket
@@ -227,87 +249,113 @@ struct BinaryProtocol {
// Core decoding implementation used by decode(_:) with and without padding removal
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
// Minimum size: header + senderID
guard raw.count >= headerSize + senderIDSize else { return nil }
guard raw.count >= v1HeaderSize + senderIDSize else { return nil }
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
guard let base = buf.baseAddress else { return nil }
var offset = 0
func require(_ n: Int) -> Bool { offset + n <= buf.count }
// Read single byte
func read8() -> UInt8? {
guard require(1) else { return nil }
let v = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee
let value = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee
offset += 1
return v
return value
}
// Read big-endian 16-bit
func read16() -> UInt16? {
guard require(2) else { return nil }
let p = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
let v = (UInt16(p[0]) << 8) | UInt16(p[1])
let ptr = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
let value = (UInt16(ptr[0]) << 8) | UInt16(ptr[1])
offset += 2
return v
return value
}
func read32() -> UInt32? {
guard require(4) else { return nil }
let ptr = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
let value = (UInt32(ptr[0]) << 24) | (UInt32(ptr[1]) << 16) | (UInt32(ptr[2]) << 8) | UInt32(ptr[3])
offset += 4
return value
}
// Copy N bytes into Data
func readData(_ n: Int) -> Data? {
guard require(n) else { return nil }
let ptr = base.advanced(by: offset)
let d = Data(bytes: ptr, count: n)
let data = Data(bytes: ptr, count: n)
offset += n
return d
return data
}
// Version
guard let version = read8(), version == 1 else { return nil }
guard let type = read8() else { return nil }
guard let ttl = read8() else { return nil }
guard let version = read8(), version == 1 || version == 2 else { return nil }
let lengthFieldBytes = lengthFieldSize(for: version)
guard let headerSize = headerSize(for: version) else { return nil }
let minimumRequired = headerSize + senderIDSize
guard raw.count >= minimumRequired else { return nil }
// Timestamp 8 bytes BE
guard require(8) else { return nil }
var ts: UInt64 = 0
guard let type = read8(), let ttl = read8() else { return nil }
var timestamp: UInt64 = 0
for _ in 0..<8 {
guard let b = read8() else { return nil }
ts = (ts << 8) | UInt64(b)
guard let byte = read8() else { return nil }
timestamp = (timestamp << 8) | UInt64(byte)
}
// Flags
guard let flags = read8() else { return nil }
let hasRecipient = (flags & Flags.hasRecipient) != 0
let hasSignature = (flags & Flags.hasSignature) != 0
let isCompressed = (flags & Flags.isCompressed) != 0
// Payload length
guard let payloadLen = read16(), payloadLen <= 65535 else { return nil }
let payloadLength: Int
if version == 2 {
guard let len = read32() else { return nil }
payloadLength = Int(len)
} else {
guard let len = read16() else { return nil }
payloadLength = Int(len)
}
guard payloadLength >= 0 else { return nil }
// SenderID
guard let senderID = readData(senderIDSize) else { return nil }
// Recipient
var recipientID: Data? = nil
if hasRecipient {
recipientID = readData(recipientIDSize)
if recipientID == nil { return nil }
}
// Payload
let payload: Data
if isCompressed {
// Need original size (2 bytes)
guard let origSize16 = read16() else { return nil }
let originalSize = Int(origSize16)
guard originalSize >= 0 && originalSize <= 1_048_576 else { return nil }
let compSize = Int(payloadLen) - 2
guard compSize >= 0, let compressed = readData(compSize) else { return nil }
guard payloadLength >= lengthFieldBytes else { return nil }
let originalSize: Int
if version == 2 {
guard let rawSize = read32() else { return nil }
originalSize = Int(rawSize)
} else {
guard let rawSize = read16() else { return nil }
originalSize = Int(rawSize)
}
// Guard to keep decompression bounded to sane BLE payload limits
// 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 }
// Validate compression ratio to prevent zip bomb attacks
// Primary protection: originalSize capped at 1MB (line 336)
// Defense-in-depth: reject extreme ratios (prevents DoS via memory allocation)
guard compressedSize > 0 else { return nil }
let compressionRatio = Double(originalSize) / Double(compressedSize)
guard compressionRatio <= 50_000.0 else {
SecureLogger.warning("🚫 Suspicious compression ratio: \(String(format: "%.0f", compressionRatio)):1", category: .security)
return nil
}
guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize),
decompressed.count == originalSize else { return nil }
payload = decompressed
} else {
guard let p = readData(Int(payloadLen)) else { return nil }
payload = p
guard let rawPayload = readData(payloadLength) else { return nil }
payload = rawPayload
}
// Signature
var signature: Data? = nil
if hasSignature {
signature = readData(signatureSize)
@@ -320,10 +368,11 @@ struct BinaryProtocol {
type: type,
senderID: senderID,
recipientID: recipientID,
timestamp: ts,
timestamp: timestamp,
payload: payload,
signature: signature,
ttl: ttl
ttl: ttl,
version: version
)
}
}
+155
View File
@@ -0,0 +1,155 @@
//
// BitchatFilePacket.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import BitLogger
/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files).
/// Mirrors the Android client specification to ensure cross-platform interoperability.
struct BitchatFilePacket {
var fileName: String?
var fileSize: UInt64?
var mimeType: String?
var content: Data
/// Canonical TLV tags defined by the Android implementation.
private enum TLVType: UInt8 {
case fileName = 0x01
case fileSize = 0x02
case mimeType = 0x03
case content = 0x04
}
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
func encode() -> Data? {
let resolvedSize = fileSize ?? UInt64(content.count)
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }
guard content.count <= Int(UInt32.max) else { return nil }
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
var big = value.bigEndian
withUnsafeBytes(of: &big) { data.append(contentsOf: $0) }
}
var encoded = Data()
if let name = fileName, let nameData = name.data(using: .utf8), nameData.count <= Int(UInt16.max) {
encoded.append(TLVType.fileName.rawValue)
appendBE(UInt16(nameData.count), into: &encoded)
encoded.append(nameData)
}
encoded.append(TLVType.fileSize.rawValue)
appendBE(UInt16(4), into: &encoded)
appendBE(UInt32(resolvedSize), into: &encoded)
if let mime = mimeType, let mimeData = mime.data(using: .utf8), mimeData.count <= Int(UInt16.max) {
encoded.append(TLVType.mimeType.rawValue)
appendBE(UInt16(mimeData.count), into: &encoded)
encoded.append(mimeData)
}
encoded.append(TLVType.content.rawValue)
appendBE(UInt32(content.count), into: &encoded)
encoded.append(content)
return encoded
}
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
static func decode(_ data: Data) -> BitchatFilePacket? {
var cursor = data.startIndex
let end = data.endIndex
var fileName: String?
var fileSize: UInt64?
var mimeType: String?
var content = Data()
while cursor < end {
let typeRaw = data[cursor]
cursor = data.index(after: cursor)
guard cursor <= end else { return nil }
let tlvType = TLVType(rawValue: typeRaw)
func readBigEndianLength(bytes: Int) -> Int? {
guard data.distance(from: cursor, to: end) >= bytes else { return nil }
// Use UInt64 to prevent integer overflow during shift operations
var result: UInt64 = 0
for _ in 0..<bytes {
result = (result << 8) | UInt64(data[cursor])
cursor = data.index(after: cursor)
}
// Safely convert to Int with overflow check
guard result <= Int.max else { return nil }
return Int(result)
}
let length: Int?
if tlvType == .content {
let snapshot = cursor
let canonical = readBigEndianLength(bytes: 4)
if let canonical = canonical,
canonical <= data.distance(from: cursor, to: end) {
length = canonical
} else {
cursor = snapshot
length = readBigEndianLength(bytes: 2)
}
} else {
length = readBigEndianLength(bytes: 2)
}
guard let tlvLength = length, tlvLength >= 0 else { return nil }
guard data.distance(from: cursor, to: end) >= tlvLength else { return nil }
let valueStart = cursor
cursor = data.index(cursor, offsetBy: tlvLength)
let value = data[valueStart..<cursor]
switch tlvType {
case .fileName:
fileName = String(data: Data(value), encoding: .utf8)
case .fileSize:
if tlvLength == 4 || tlvLength == 8 {
var size: UInt64 = 0
for byte in value {
size = (size << 8) | UInt64(byte)
}
if size > UInt64(FileTransferLimits.maxPayloadBytes) {
return nil
}
fileSize = size
}
case .mimeType:
mimeType = String(data: Data(value), encoding: .utf8)
case .content:
let proposedSize = content.count + value.count
if proposedSize > FileTransferLimits.maxPayloadBytes {
return nil
}
content.append(contentsOf: value)
case nil:
continue
}
}
guard !content.isEmpty else { return nil }
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
return BitchatFilePacket(
fileName: fileName,
fileSize: fileSize ?? UInt64(content.count),
mimeType: mimeType,
content: content
)
}
}
+2
View File
@@ -79,6 +79,7 @@ enum MessageType: UInt8 {
// Fragmentation (simplified)
case fragment = 0x20 // Single fragment type for large messages
case fileTransfer = 0x22 // Binary file/audio/image payloads
var description: String {
switch self {
@@ -89,6 +90,7 @@ enum MessageType: UInt8 {
case .noiseHandshake: return "noiseHandshake"
case .noiseEncrypted: return "noiseEncrypted"
case .fragment: return "fragment"
case .fileTransfer: return "fileTransfer"
}
}
}
+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
-18
View File
@@ -65,9 +65,6 @@ final class CommandProcessor {
case "/unfav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
return handleFavorite(args, add: false)
//
case "/help", "/h":
return .error(message: "unknown command: \(cmd)")
default:
return .error(message: "unknown command: \(cmd)")
}
@@ -311,19 +308,4 @@ final class CommandProcessor {
}
}
private func handleHelp() -> CommandResult {
let helpText = """
commands:
/msg @name - start private chat
/who - list who's online
/clear - clear messages
/hug @name - send a hug
/slap @name - slap with a trout
/fav @name - add to favorites
/unfav @name - remove from favorites
/block @name - block
/unblock @name - unblock
"""
return .success(message: helpText)
}
}
-28
View File
@@ -27,34 +27,6 @@ final class KeychainManager: KeychainManagerProtocol {
private let service = BitchatApp.bundleID
private let appGroup = "group.\(BitchatApp.bundleID)"
private func isSandboxed() -> Bool {
#if os(macOS)
// More robust sandbox detection using multiple methods
// Method 1: Check environment variable (can be spoofed)
let environment = ProcessInfo.processInfo.environment
let hasEnvVar = environment["APP_SANDBOX_CONTAINER_ID"] != nil
// Method 2: Check if we can access a path outside sandbox
let homeDir = FileManager.default.homeDirectoryForCurrentUser
let testPath = homeDir.appendingPathComponent("../../../tmp/bitchat_sandbox_test_\(UUID().uuidString)")
let canWriteOutsideSandbox = FileManager.default.createFile(atPath: testPath.path, contents: nil, attributes: nil)
if canWriteOutsideSandbox {
try? FileManager.default.removeItem(at: testPath)
}
// Method 3: Check container path
let containerPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first?.path ?? ""
let hasContainerPath = containerPath.contains("/Containers/")
// If any method indicates sandbox, we consider it sandboxed
return hasEnvVar || !canWriteOutsideSandbox || hasContainerPath
#else
// iOS is always sandboxed
return true
#endif
}
// MARK: - Identity Keys
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
@@ -64,7 +64,9 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
switch status {
case .authorizedAlways, .authorizedWhenInUse, .authorized:
break // will compute from location
default:
case .notDetermined, .restricted, .denied:
fallthrough
@unknown default:
if case .location(let ch) = selectedChannel {
teleported = teleportedSet.contains(ch.geohash)
}
-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
@@ -6,10 +6,19 @@
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
struct NotificationStreamAssembler {
private var buffer = Data()
private var pendingFrameStartedAt: DispatchTime?
private var pendingFrameExpectedLength: Int = 0
private mutating func resetState() {
buffer.removeAll(keepingCapacity: false)
pendingFrameStartedAt = nil
pendingFrameExpectedLength = 0
}
mutating func append(_ chunk: Data) -> (frames: [Data], droppedPrefixes: [UInt8], reset: Bool) {
guard !chunk.isEmpty else { return ([], [], false) }
@@ -18,64 +27,107 @@ struct NotificationStreamAssembler {
var frames: [Data] = []
var dropped: [UInt8] = []
var reset = false
let maxFrameLength = TransportConfig.blePendingWriteBufferCapBytes
var didReset = false
let now = DispatchTime.now()
let maxFrameLength = TransportConfig.bleNotificationAssemblerHardCapBytes
let minimumFramePrefix = BinaryProtocol.v1HeaderSize + BinaryProtocol.senderIDSize
let minHeaderBytes = 14 // version + type + ttl + timestamp(8) + flags + length(2)
let minFramePrefix = minHeaderBytes + BinaryProtocol.senderIDSize
if buffer.count > TransportConfig.bleNotificationAssemblerHardCapBytes {
SecureLogger.error("❌ Notification assembler overflow (\(buffer.count) bytes); dropping partial frame", category: .session)
resetState()
return ([], [], true)
}
while buffer.count >= minFramePrefix {
guard let first = buffer.first else { break }
if first != 1 {
while buffer.count >= minimumFramePrefix {
guard let version = buffer.first else { break }
guard version == 1 || version == 2 else {
dropped.append(buffer.removeFirst())
pendingFrameStartedAt = nil
pendingFrameExpectedLength = 0
continue
}
guard buffer.count >= minHeaderBytes else { break }
guard let headerSize = BinaryProtocol.headerSize(for: version) else {
dropped.append(buffer.removeFirst())
pendingFrameStartedAt = nil
pendingFrameExpectedLength = 0
continue
}
let framePrefix = headerSize + BinaryProtocol.senderIDSize
guard buffer.count >= framePrefix else { break }
let headerBytes = Array(buffer.prefix(minFramePrefix))
guard headerBytes.count == minFramePrefix else { break }
let flags = headerBytes[11]
let flagsIndex = buffer.startIndex + BinaryProtocol.Offsets.flags
guard flagsIndex < buffer.endIndex else { break }
let flags = buffer[flagsIndex]
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
let payloadLen = (Int(headerBytes[12]) << 8) | Int(headerBytes[13])
let isCompressed = (flags & BinaryProtocol.Flags.isCompressed) != 0
var frameLength = minFramePrefix + payloadLen
let lengthOffset = 12
let payloadLength: Int
if version == 2 {
let lengthIndex = buffer.startIndex + lengthOffset
payloadLength =
(Int(buffer[lengthIndex]) << 24) |
(Int(buffer[lengthIndex + 1]) << 16) |
(Int(buffer[lengthIndex + 2]) << 8) |
Int(buffer[lengthIndex + 3])
} else {
let lengthIndex = buffer.startIndex + lengthOffset
payloadLength = (Int(buffer[lengthIndex]) << 8) | Int(buffer[lengthIndex + 1])
}
var frameLength = framePrefix + payloadLength
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
if hasSignature { frameLength += BinaryProtocol.signatureSize }
if isCompressed {
let rawLengthFieldBytes = (version == 2) ? 4 : 2
if payloadLength < rawLengthFieldBytes {
SecureLogger.error("❌ Invalid compressed payload length (\(payloadLength))", category: .session)
resetState()
didReset = true
break
}
}
guard frameLength > 0, frameLength <= maxFrameLength else {
buffer.removeAll()
reset = true
SecureLogger.error("❌ Notification frame length \(frameLength) invalid (cap=\(maxFrameLength)); resetting stream", category: .session)
resetState()
didReset = true
break
}
if buffer.count < frameLength {
// Check if a new frame start exists within the incomplete buffer; if so, drop leading partial bytes.
if let nextStart = buffer.dropFirst().firstIndex(of: 1) {
let dropCount = buffer.distance(from: buffer.startIndex, to: nextStart)
if dropCount > 0 {
buffer.removeFirst(dropCount)
dropped.append(1) // treat as dropped partial start
let remaining = frameLength - buffer.count
if pendingFrameStartedAt == nil || frameLength != pendingFrameExpectedLength {
pendingFrameStartedAt = now
pendingFrameExpectedLength = frameLength
} else if let started = pendingFrameStartedAt {
let elapsed = now.uptimeNanoseconds - started.uptimeNanoseconds
let threshold = UInt64(TransportConfig.bleAssemblerStallResetMs) * 1_000_000
if elapsed >= threshold {
SecureLogger.debug("📉 Resetting notification assembler after waiting \(remaining)B for \(TransportConfig.bleAssemblerStallResetMs)ms", category: .session)
resetState()
didReset = true
} else {
SecureLogger.debug("⌛ Waiting for remaining \(remaining)B to complete BLE frame", category: .session)
}
}
break
}
pendingFrameStartedAt = nil
pendingFrameExpectedLength = 0
let frame = Data(buffer.prefix(frameLength))
frames.append(frame)
buffer.removeFirst(frameLength)
}
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
buffer.removeAll(keepingCapacity: false)
resetState()
}
return (frames, dropped, reset)
}
mutating func reset() {
buffer.removeAll(keepingCapacity: false)
return (frames, dropped, didReset)
}
}
@@ -0,0 +1,65 @@
import Foundation
import Combine
/// Centralized progress bus for Bluetooth file transfers.
/// Emits Combine events consumed by ChatViewModel to update UI progress indicators.
final class TransferProgressManager {
static let shared = TransferProgressManager()
enum Event {
case started(id: String, totalFragments: Int)
case updated(id: String, sentFragments: Int, totalFragments: Int)
case completed(id: String, totalFragments: Int)
case cancelled(id: String, sentFragments: Int, totalFragments: Int)
}
private let subject = PassthroughSubject<Event, Never>()
private let queue = DispatchQueue(label: "com.bitchat.transfer-progress", attributes: .concurrent)
private var states: [String: (sent: Int, total: Int)] = [:]
var publisher: AnyPublisher<Event, Never> {
subject.eraseToAnyPublisher()
}
func start(id: String, totalFragments: Int) {
queue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
self.states[id] = (sent: 0, total: totalFragments)
self.subject.send(.started(id: id, totalFragments: totalFragments))
}
}
func recordFragmentSent(id: String) {
queue.async(flags: .barrier) { [weak self] in
guard let self = self, var state = self.states[id] else { return }
state.sent = min(state.sent + 1, state.total)
self.states[id] = state
self.subject.send(.updated(id: id, sentFragments: state.sent, totalFragments: state.total))
if state.sent >= state.total {
self.states.removeValue(forKey: id)
self.subject.send(.completed(id: id, totalFragments: state.total))
}
}
}
func cancel(id: String) {
queue.async(flags: .barrier) { [weak self] in
guard let self = self, let state = self.states.removeValue(forKey: id) else { return }
self.subject.send(.cancelled(id: id, sentFragments: state.sent, totalFragments: state.total))
}
}
func reset(id: String) {
queue.async(flags: .barrier) { [weak self] in
self?.states.removeValue(forKey: id)
}
}
func snapshot(id: String) -> (sent: Int, total: Int)? {
var result: (sent: Int, total: Int)?
queue.sync {
result = states[id]
}
return result
}
}
+6
View File
@@ -50,6 +50,9 @@ protocol Transport: AnyObject {
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
func sendBroadcastAnnounce()
func sendDeliveryAck(for messageID: String, to peerID: PeerID)
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
func cancelTransfer(_ transferId: String)
// QR verification (optional for transports)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
@@ -59,6 +62,9 @@ protocol Transport: AnyObject {
extension Transport {
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
func cancelTransfer(_ transferId: String) {}
}
protocol TransportPeerEventsDelegate: AnyObject {
+5 -1
View File
@@ -30,7 +30,11 @@ enum TransportConfig {
static let bleDynamicRSSIThresholdDefault: Int = -90
static let bleConnectionCandidatesMax: Int = 100
static let blePendingWriteBufferCapBytes: Int = 1_000_000
static let blePendingNotificationsCapCount: Int = 20
static let bleNotificationAssemblerHardCapBytes: Int = 8 * 1024 * 1024
static let bleAssemblerStallResetMs: Int = 250
static let blePendingNotificationsCapCount: Int = 128
static let bleNotificationRetryDelayMs: Int = 25
static let bleNotificationRetryMaxAttempts: Int = 80
// Nostr
static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second
+25
View File
@@ -0,0 +1,25 @@
import Foundation
/// Centralized thresholds for Bluetooth file transfers to keep payload sizes sane on constrained radios.
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
/// Compressed images after downscaling should comfortably fit under this budget.
static let maxImageBytes: Int = 1 * 1024 * 1024 // 1 MiB
/// 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
let tlvEnvelopeOverhead = 18 + maxMetadataBytes // TLV tags + lengths + metadata bytes
let binaryEnvelopeOverhead = BinaryProtocol.v2HeaderSize
+ BinaryProtocol.senderIDSize
+ BinaryProtocol.recipientIDSize
+ BinaryProtocol.signatureSize
return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead
}()
static func isValidPayload(_ size: Int) -> Bool {
size <= maxPayloadBytes
}
}
+519 -190
View File
@@ -87,6 +87,7 @@ import Tor
#if os(iOS)
import UIKit
#endif
import UniformTypeIdentifiers
/// Manages the application state and business logic for BitChat.
/// Acts as the primary coordinator between UI components and backend services,
@@ -197,19 +198,36 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Persistent recent content map (LRU) to speed near-duplicate checks
private var contentLRUMap: [String: Date] = [:]
private var contentLRUOrder: [String] = []
private var contentLRUHead = 0
private let contentLRUCap = TransportConfig.contentLRUCap
private func recordContentKey(_ key: String, timestamp: Date) {
if contentLRUMap[key] == nil { contentLRUOrder.append(key) }
contentLRUMap[key] = timestamp
if contentLRUOrder.count > contentLRUCap {
let overflow = contentLRUOrder.count - contentLRUCap
trimContentLRUIfNeeded()
}
private func trimContentLRUIfNeeded() {
let activeCount = contentLRUOrder.count - contentLRUHead
guard activeCount > contentLRUCap else { return }
let overflow = activeCount - contentLRUCap
for _ in 0..<overflow {
if let victim = contentLRUOrder.first {
contentLRUOrder.removeFirst()
guard let victim = popOldestContentKey() else { break }
contentLRUMap.removeValue(forKey: victim)
}
}
private func popOldestContentKey() -> String? {
guard contentLRUHead < contentLRUOrder.count else { return nil }
let victim = contentLRUOrder[contentLRUHead]
contentLRUHead += 1
// Periodically compact the backing storage to avoid unbounded growth.
if contentLRUHead >= 32 && contentLRUHead * 2 >= contentLRUOrder.count {
contentLRUOrder.removeFirst(contentLRUHead)
contentLRUHead = 0
}
return victim
}
// MARK: - Published Properties
@@ -348,6 +366,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// PeerManager replaced by UnifiedPeerService
private var processedNostrEvents = Set<String>() // Simple deduplication
private var processedNostrEventOrder: [String] = []
private var processedNostrEventHead = 0
private let maxProcessedNostrEvents = TransportConfig.uiProcessedNostrEventsCap
private let userDefaults = UserDefaults.standard
private let keychain: KeychainManagerProtocol
@@ -357,6 +376,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
private var geoSubscriptionID: String? = nil
private var geoDmSubscriptionID: String? = nil
private var currentGeohash: String? = nil
private var cachedGeohashIdentity: (geohash: String, identity: NostrIdentity)? = nil // Cache current geohash identity
private var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname
// Show Tor status once per app launch
private var torStatusAnnounced = false
@@ -425,6 +445,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Delivery tracking
private var cancellables = Set<AnyCancellable>()
private var transferIdToMessageIDs: [String: [String]] = [:]
private var messageIDToTransferId: [String: String] = [:]
// MARK: - QR Verification (pending state)
private struct PendingVerification {
@@ -639,6 +661,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Set up Noise encryption callbacks
setupNoiseCallbacks()
TransferProgressManager.shared.publisher
.receive(on: DispatchQueue.main)
.sink { [weak self] event in
self?.handleTransferEvent(event)
}
.store(in: &cancellables)
// Observe location channel selection
LocationChannelManager.shared.$selectedChannel
.receive(on: DispatchQueue.main)
@@ -1453,10 +1482,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
geoTimelines[ch.geohash] = arr
}
trimMessagesIfNeeded()
// Force immediate UI update for user's own messages
objectWillChange.send()
// UI updates automatically via @Published var messages
updateChannelActivityTimeThenSend(content: content, trimmed: trimmed, mentions: mentions)
}
@@ -1985,7 +2014,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
return false
}
default:
case .mesh:
break
}
}
@@ -2164,29 +2193,34 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
return "anon#\(suffix)"
}
// Helper: display name for current active channel (for notifications)
private func activeChannelDisplayName() -> String {
switch activeChannel {
case .mesh:
return "#mesh"
case .location(let ch):
return "#\(ch.geohash)"
}
}
// Dedup helper with small memory cap
private func recordProcessedEvent(_ id: String) {
processedNostrEvents.insert(id)
processedNostrEventOrder.append(id)
if processedNostrEventOrder.count > maxProcessedNostrEvents {
let overflow = processedNostrEventOrder.count - maxProcessedNostrEvents
trimProcessedNostrEventsIfNeeded()
}
private func trimProcessedNostrEventsIfNeeded() {
let activeCount = processedNostrEventOrder.count - processedNostrEventHead
guard activeCount > maxProcessedNostrEvents else { return }
let overflow = activeCount - maxProcessedNostrEvents
for _ in 0..<overflow {
if let old = processedNostrEventOrder.first {
processedNostrEventOrder.removeFirst()
guard let old = popOldestProcessedEvent() else { break }
processedNostrEvents.remove(old)
}
}
private func popOldestProcessedEvent() -> String? {
guard processedNostrEventHead < processedNostrEventOrder.count else { return nil }
let value = processedNostrEventOrder[processedNostrEventHead]
processedNostrEventHead += 1
if processedNostrEventHead >= 32 && processedNostrEventHead * 2 >= processedNostrEventOrder.count {
processedNostrEventOrder.removeFirst(processedNostrEventHead)
processedNostrEventHead = 0
}
return value
}
/// Sends an encrypted private message to a specific peer.
@@ -2363,6 +2397,352 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
}
// MARK: - Media Transfers
private enum MediaSendError: Error {
case encodingFailed
case tooLarge
case copyFailed
}
@MainActor
func sendVoiceNote(at url: URL) {
let targetPeer = selectedPrivateChatPeer
let message = enqueueMediaMessage(content: "[voice] \(url.lastPathComponent)", targetPeer: targetPeer?.id)
let messageID = message.id
let transferId = makeTransferID(messageID: messageID)
Task.detached(priority: .userInitiated) { [weak self] in
guard let self = self else { return }
do {
// 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,
fileSize <= FileTransferLimits.maxVoiceNoteBytes else {
let size = (attrs[.size] as? Int) ?? 0
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
try? FileManager.default.removeItem(at: url)
await MainActor.run {
self.handleMediaSendFailure(messageID: messageID, reason: "Voice note too large")
}
return
}
let data = try Data(contentsOf: url)
let packet = BitchatFilePacket(
fileName: url.lastPathComponent,
fileSize: UInt64(data.count),
mimeType: "audio/mp4",
content: data
)
guard packet.encode() != nil else { throw MediaSendError.encodingFailed }
await MainActor.run {
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 {
SecureLogger.error("Voice note send failed: \(error)", category: .session)
await MainActor.run {
self.handleMediaSendFailure(messageID: messageID, reason: "Failed to send voice note")
}
}
}
}
@MainActor
func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) {
let targetPeer = selectedPrivateChatPeer
Task.detached(priority: .userInitiated) { [weak self] in
guard let self = self else { return }
var processedURL: URL?
do {
let outputURL = try ImageUtils.processImage(at: sourceURL)
processedURL = outputURL
let data = try Data(contentsOf: outputURL)
guard data.count <= FileTransferLimits.maxImageBytes else {
SecureLogger.warning("Processed image exceeds size limit (\(data.count) bytes)", category: .session)
await MainActor.run {
self.addSystemMessage("Image is too large to send.")
}
try? FileManager.default.removeItem(at: outputURL)
return
}
let packet = BitchatFilePacket(
fileName: outputURL.lastPathComponent,
fileSize: UInt64(data.count),
mimeType: "image/jpeg",
content: data
)
guard packet.encode() != nil else { throw MediaSendError.encodingFailed }
await MainActor.run {
let message = self.enqueueMediaMessage(content: "[image] \(outputURL.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 {
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
await MainActor.run {
self.addSystemMessage("Failed to prepare image for sending.")
}
if let url = processedURL {
try? FileManager.default.removeItem(at: url)
}
}
cleanup?()
}
}
@MainActor
func cancelMediaSend(messageID: String) {
if let transferId = messageIDToTransferId[messageID],
let active = transferIdToMessageIDs[transferId]?.first,
active == messageID {
meshService.cancelTransfer(transferId)
}
clearTransferMapping(for: messageID)
removeMessage(withID: messageID, cleanupFile: true)
}
@MainActor
func deleteMediaMessage(messageID: String) {
clearTransferMapping(for: messageID)
removeMessage(withID: messageID, cleanupFile: true)
}
@MainActor
private func enqueueMediaMessage(content: String, targetPeer: String?) -> BitchatMessage {
let timestamp = Date()
let message: BitchatMessage
if let peerID = targetPeer {
message = BitchatMessage(
sender: nickname,
content: content,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: nicknameForPeer(peerID),
senderPeerID: meshService.myPeerID,
deliveryStatus: .sending
)
var chats = privateChats
chats[PeerID(str: peerID), default: []].append(message)
privateChats = chats
trimMessagesIfNeeded()
} else {
let (displayName, senderPeerID) = currentPublicSender()
message = BitchatMessage(
sender: displayName,
content: content,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: PeerID(str: senderPeerID),
deliveryStatus: .sending
)
messages.append(message)
switch activeChannel {
case .mesh:
meshTimeline.append(message)
trimMeshTimelineIfNeeded()
case .location(let ch):
var arr = geoTimelines[ch.geohash] ?? []
arr.append(message)
if arr.count > geoTimelineCap {
arr = Array(arr.suffix(geoTimelineCap))
}
geoTimelines[ch.geohash] = arr
}
trimMessagesIfNeeded()
}
let key = normalizedContentKey(message.content)
recordContentKey(key, timestamp: timestamp)
objectWillChange.send()
return message
}
private func currentPublicSender() -> (name: String, peerID: String) {
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)")
}
return (displaySender, senderPeerID.id)
}
@MainActor
private func nicknameForPeer(_ peerID: String) -> String {
if let name = meshService.peerNickname(peerID: PeerID(str: peerID)) {
return name
}
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: PeerID(str: peerID)),
!favorite.peerNickname.isEmpty {
return favorite.peerNickname
}
if let noiseKey = Data(hexString: peerID),
let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
!favorite.peerNickname.isEmpty {
return favorite.peerNickname
}
return "user"
}
@MainActor
private func registerTransfer(transferId: String, messageID: String) {
transferIdToMessageIDs[transferId, default: []].append(messageID)
messageIDToTransferId[messageID] = transferId
}
private func makeTransferID(messageID: String) -> String {
"\(messageID)-\(UUID().uuidString)"
}
@MainActor
private func clearTransferMapping(for messageID: String) {
guard let transferId = messageIDToTransferId.removeValue(forKey: messageID) else { return }
guard var queue = transferIdToMessageIDs[transferId] else { return }
if !queue.isEmpty {
if queue.first == messageID {
queue.removeFirst()
} else if let idx = queue.firstIndex(of: messageID) {
queue.remove(at: idx)
}
}
transferIdToMessageIDs[transferId] = queue.isEmpty ? nil : queue
}
@MainActor
private func handleMediaSendFailure(messageID: String, reason: String) {
updateMessageDeliveryStatus(messageID, status: .failed(reason: reason))
clearTransferMapping(for: messageID)
}
@MainActor
private func handleTransferEvent(_ event: TransferProgressManager.Event) {
switch event {
case .started(let id, let total):
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: 0, total: total))
case .updated(let id, let sent, let total):
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: sent, total: total))
case .completed(let id, _):
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
updateMessageDeliveryStatus(messageID, status: .sent)
clearTransferMapping(for: messageID)
case .cancelled(let id, _, _):
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
clearTransferMapping(for: messageID)
removeMessage(withID: messageID, cleanupFile: true)
}
}
private func cleanupLocalFile(forMessage message: BitchatMessage) {
// Check both outgoing and incoming directories for thorough cleanup
let prefixes = ["[voice] ", "[image] ", "[file] "]
let subdirs = ["voicenotes/outgoing", "voicenotes/incoming",
"images/outgoing", "images/incoming",
"files/outgoing", "files/incoming"]
guard let prefix = prefixes.first(where: { message.content.hasPrefix($0) }) else { return }
let rawFilename = String(message.content.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines)
guard !rawFilename.isEmpty, let base = try? applicationFilesDirectory() else { return }
// Security: Extract only the last path component to prevent directory traversal
let safeFilename = (rawFilename as NSString).lastPathComponent
guard !safeFilename.isEmpty && safeFilename != "." && safeFilename != ".." else { return }
// Try all possible locations (outgoing and incoming)
for subdir in subdirs {
let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename)
// Security: Verify target is within expected directory before deletion
guard target.path.hasPrefix(base.path) else { continue }
do {
try FileManager.default.removeItem(at: target)
} catch CocoaError.fileNoSuchFile {
// Expected - file not in this directory
} catch {
SecureLogger.error("Failed to cleanup \(safeFilename): \(error)", category: .session)
}
}
}
private func applicationFilesDirectory() throws -> URL {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let filesDir = base.appendingPathComponent("files", isDirectory: true)
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
return filesDir
}
@MainActor
private func removeMessage(withID messageID: String, cleanupFile: Bool = false) {
var removedMessage: BitchatMessage?
if let idx = messages.firstIndex(where: { $0.id == messageID }) {
removedMessage = messages.remove(at: idx)
}
meshTimeline.removeAll { $0.id == messageID }
for key in Array(geoTimelines.keys) {
var entries = geoTimelines[key] ?? []
if let idx = entries.firstIndex(where: { $0.id == messageID }) {
removedMessage = removedMessage ?? entries[idx]
entries.remove(at: idx)
if entries.isEmpty {
geoTimelines.removeValue(forKey: key)
} else {
geoTimelines[key] = entries
}
}
}
var chats = privateChats
for (peerID, items) in chats {
let filtered = items.filter { $0.id != messageID }
if filtered.count != items.count {
if filtered.isEmpty {
chats.removeValue(forKey: peerID)
} else {
chats[peerID] = filtered
}
if removedMessage == nil {
removedMessage = items.first(where: { $0.id == messageID })
}
}
}
privateChats = chats
if cleanupFile, let message = removedMessage {
cleanupLocalFile(forMessage: message)
}
objectWillChange.send()
}
// MARK: - Geohash DMs initiation
@MainActor
func startGeohashDM(withPubkeyHex hex: String) {
@@ -2630,7 +3010,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
switch sessionState {
case .none, .failed:
meshService.triggerHandshake(with: peerID)
default:
case .handshakeQueued, .handshaking, .established:
break
}
} else {
@@ -2650,7 +3030,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
case .read, .delivered:
sentReadReceipts.insert(message.id)
privateChatManager.sentReadReceipts.insert(message.id)
default:
case .failed, .partiallyDelivered, .sending, .sent:
break
}
}
@@ -2880,7 +3260,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
case .established:
// Send the message directly without going through sendPrivateMessage to avoid local echo
messageRouter.sendPrivate(screenshotMessage, to: peerID, recipientNickname: peerNickname, messageID: UUID().uuidString)
default:
case .none, .failed, .handshakeQueued, .handshaking:
// Don't send screenshot notification if no session exists
SecureLogger.debug("Skipping screenshot notification to \(peerID) - no established session", category: .security)
}
@@ -3143,7 +3523,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
nostrKeyMapping[convKey] = pub
return convKey
}
default:
case .mesh:
break
}
// Fallback to mesh nickname resolution
@@ -3234,6 +3614,31 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
nostrRelayManager?.connect()
}
// Delete ALL media files (incoming and outgoing) in background
Task.detached(priority: .utility) {
do {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let filesDir = base.appendingPathComponent("files", isDirectory: true)
// Delete the entire files directory and recreate it
if FileManager.default.fileExists(atPath: filesDir.path) {
try FileManager.default.removeItem(at: filesDir)
SecureLogger.info("🗑️ Deleted all media files during panic clear", category: .session)
}
// Recreate empty directory structure
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("voicenotes/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("voicenotes/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("images/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("images/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("files/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("files/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
} catch {
SecureLogger.error("Failed to clear media files during panic: \(error)", category: .session)
}
}
// Force immediate UI update for panic mode
// UI updates immediately - no flushing needed
@@ -3307,7 +3712,18 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if let spid = message.senderPeerID {
// In geohash channels, compare against our per-geohash nostr short ID
if case .location(let ch) = activeChannel, spid.isGeoChat {
if let myGeo = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
let myGeo: NostrIdentity? = {
if let cached = cachedGeohashIdentity, cached.geohash == ch.geohash {
return cached.identity
}
// Fallback: derive and cache (should rarely happen)
if let identity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
cachedGeohashIdentity = (ch.geohash, identity)
return identity
}
return nil
}()
if let myGeo {
return spid == PeerID(nostr: myGeo.publicKeyHex)
}
}
@@ -3634,6 +4050,53 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
return result
}
@MainActor
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
let isSelf: Bool = {
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 == meshService.myPeerID
}
if message.sender == nickname { return true }
if message.sender.hasPrefix(nickname + "#") { return true }
return false
}()
let isDark = colorScheme == .dark
let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark)
if message.sender == "system" {
var style = AttributeContainer()
style.foregroundColor = baseColor
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
return AttributedString(message.sender).mergingAttributes(style)
}
var result = AttributedString()
let (baseName, suffix) = message.sender.splitSuffix()
var senderStyle = AttributeContainer()
senderStyle.foregroundColor = baseColor
senderStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .medium, design: .monospaced)
if let spid = message.senderPeerID,
let url = URL(string: "bitchat://user/\(spid.id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid.id)") {
senderStyle.link = url
}
result.append(AttributedString("<@").mergingAttributes(senderStyle))
result.append(AttributedString(baseName).mergingAttributes(senderStyle))
if !suffix.isEmpty {
var suffixStyle = senderStyle
suffixStyle.foregroundColor = baseColor.opacity(0.6)
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
}
result.append(AttributedString("> ").mergingAttributes(senderStyle))
return result
}
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
var result = AttributedString()
@@ -4174,6 +4637,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Clear the current public channel's timeline (visible + persistent buffer)
@MainActor
func clearCurrentPublicTimeline() {
// Clear messages from current timeline
switch activeChannel {
case .mesh:
messages.removeAll()
@@ -4182,6 +4646,32 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
messages.removeAll()
geoTimelines[ch.geohash] = []
}
// Delete associated media files (images, voice notes, files) in background
// Only delete from current chat to avoid removing private chat media
Task.detached(priority: .utility) {
do {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let filesDir = base.appendingPathComponent("files", isDirectory: true)
// Only clear public media (mesh channel only - geohash media is separate)
// Note: This is conservative - only clears outgoing since we authored those
let outgoingDirs = [
filesDir.appendingPathComponent("voicenotes/outgoing", isDirectory: true),
filesDir.appendingPathComponent("images/outgoing", isDirectory: true),
filesDir.appendingPathComponent("files/outgoing", isDirectory: true)
]
for dir in outgoingDirs {
if FileManager.default.fileExists(atPath: dir.path) {
try? FileManager.default.removeItem(at: dir)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil)
}
}
} catch {
SecureLogger.error("Failed to clear media files: \(error)", category: .session)
}
}
}
// MARK: - Message Management
@@ -4836,11 +5326,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
private func parseMentions(from content: String) -> [String] {
// Allow optional disambiguation suffix '#abcd' for duplicate nicknames
let pattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)"
let regex = try? NSRegularExpression(pattern: pattern, options: [])
let regex = Regexes.mention
let nsContent = content as NSString
let nsLen = nsContent.length
let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
let matches = regex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
var mentions: [String] = []
let peerNicknames = meshService.getPeerNicknames()
@@ -5351,61 +5840,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
)
}
@MainActor
private func handleNostrAcknowledgment(content: String, from senderPubkey: String) {
// Parse ACK format: "ACK:TYPE:MESSAGE_ID"
let parts = content.split(separator: ":", maxSplits: 2)
guard parts.count >= 3 else {
SecureLogger.warning("⚠️ Invalid ACK format: \(content)", category: .session)
return
}
let ackType = String(parts[1])
let messageId = String(parts[2])
// Check if we've already processed this ACK
let ackKey = "\(messageId):\(ackType):\(senderPubkey)"
if processedNostrAcks.contains(ackKey) {
// Skip duplicate ACK
return
}
processedNostrAcks.insert(ackKey)
SecureLogger.debug("📨 Received \(ackType) ACK for message \(messageId.prefix(16))... from \(senderPubkey.prefix(16))...", category: .session)
// Verify the sender has a valid Noise key
guard findNoiseKey(for: senderPubkey) != nil else {
// Cannot find Noise key for ACK sender
return
}
// Find and update the message status in ALL private chats (both stable and ephemeral)
var messageFound = false
for (chatPeerID, messages) in privateChats {
if let index = messages.firstIndex(where: { $0.id == messageId }) {
// Update delivery status based on ACK type
switch ackType {
case "DELIVERED":
privateChats[chatPeerID]?[index].deliveryStatus = .delivered(to: "recipient", at: Date())
case "READ":
privateChats[chatPeerID]?[index].deliveryStatus = .read(by: "recipient", at: Date())
default:
SecureLogger.warning("⚠️ Unknown ACK type: \(ackType)", category: .session)
}
messageFound = true
SecureLogger.info("✅ Updated message \(messageId.prefix(16))... status to \(ackType) in chat \(chatPeerID.id.prefix(16))...", category: .session)
// Don't break - continue to update in all chats where this message exists
}
}
if messageFound {
objectWillChange.send()
} else {
SecureLogger.warning("⚠️ Could not find message \(messageId) to update status from ACK", category: .session)
}
}
// MARK: - Base64URL utils
private static func base64URLDecode(_ s: String) -> Data? {
var str = s.replacingOccurrences(of: "-", with: "+")
@@ -5464,111 +5898,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
}
@MainActor
private func handleNostrMessageFromUnknownSender(
messageId: String,
content: String,
senderPubkey: String,
senderNickname: String? = nil,
timestamp: Date
) {
// Check if we already have this message in local storage
for (_, messages) in privateChats {
if messages.contains(where: { $0.id == messageId }) {
return // Skipping duplicate message
}
}
// Check if we've read this message before (in a previous session)
let wasReadBefore = sentReadReceipts.contains(messageId)
// Try to find sender by checking all known peers for nickname matches
// This is a fallback when we receive Nostr messages from someone not in favorites
// For now, create a temporary peer ID based on Nostr pubkey
// This allows the message to be displayed even without Noise key mapping
let tempPeerID = PeerID(nostr_: senderPubkey)
// Check if we're viewing this unknown sender's chat
let isViewingThisChat = selectedPrivateChatPeer == tempPeerID
// Check if message is recent (less than 30 seconds old)
let messageAgeSeconds = Date().timeIntervalSince(timestamp)
let isRecentMessage = messageAgeSeconds < 30
// Determine if we should mark as unread BEFORE adding to chats
// During startup phase, only block OLD messages from being marked as unread
// Recent messages should always be marked as unread if not previously read
let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && (isRecentMessage || !isStartupPhase)
// Use provided nickname or try to extract from previous messages
var finalSenderNickname = senderNickname ?? "Unknown"
// If no nickname provided, check if we have any previous messages from this Nostr key
if senderNickname == nil {
for (_, messages) in privateChats {
if let previousMessage = messages.first(where: {
$0.senderPeerID == tempPeerID
}) {
finalSenderNickname = previousMessage.sender
break
}
}
}
// Create the message
let message = BitchatMessage(
id: messageId,
sender: finalSenderNickname,
content: content,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: nickname,
senderPeerID: tempPeerID,
mentions: nil,
deliveryStatus: .delivered(to: nickname, at: Date())
)
// Store in private chats
if privateChats[tempPeerID] == nil {
privateChats[tempPeerID] = []
}
privateChats[tempPeerID]?.append(message)
// For unknown senders (no Noise key), skip sending Nostr ACKs
// Handle based on read status
if wasReadBefore {
// Message was read in a previous session - don't mark as unread or notify
// Not marking previously-read message as unread
} else if isViewingThisChat {
// Viewing this chat - mark as read
// No read ACKs for unknown senders
} else {
// Not viewing and not previously read
// Use pre-calculated shouldMarkAsUnread to avoid UI flicker
if shouldMarkAsUnread {
unreadPrivateMessages.insert(tempPeerID)
// Only notify if it's a recent message
if isRecentMessage {
NotificationService.shared.sendPrivateMessageNotification(
from: finalSenderNickname,
message: content,
peerID: tempPeerID.id
)
} else {
// Not notifying for old message
}
}
// Not notifying for old message
}
SecureLogger.info("📬 Stored Nostr message from unknown sender \(finalSenderNickname) in temporary peer \(tempPeerID)", category: .session)
}
@MainActor
private func findNoiseKey(for nostrPubkey: String) -> Data? {
// Convert hex to npub if needed for comparison
@@ -6134,7 +6463,7 @@ private func checkForMentions(_ message: BitchatMessage) {
let d = String(id.publicKeyHex.suffix(4))
tokens.append(nickname + "#" + d)
}
default:
case .mesh:
break
}
#endif
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -599,7 +599,7 @@ extension LocationChannelsSheet {
switch level {
case .region:
return ""
default:
case .building, .block, .neighborhood, .city, .province:
return "~"
}
}
+1 -1
View File
@@ -141,7 +141,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
)
}
@@ -0,0 +1,191 @@
import SwiftUI
#if os(iOS)
import UIKit
private typealias PlatformImage = UIImage
#else
import AppKit
private typealias PlatformImage = NSImage
#endif
struct BlockRevealImageView: View {
private let url: URL
private let revealProgress: Double?
private let isSending: Bool
private let onCancel: (() -> Void)?
private let initiallyBlurred: Bool
private let onOpen: (() -> Void)?
private let onDelete: (() -> Void)?
@State private var platformImage: PlatformImage?
@State private var aspectRatio: CGFloat = 1
@State private var isBlurred: Bool = false
init(
url: URL,
revealProgress: Double?,
isSending: Bool,
onCancel: (() -> Void)?,
initiallyBlurred: Bool = false,
onOpen: (() -> Void)? = nil,
onDelete: (() -> Void)? = nil
) {
self.url = url
self.revealProgress = revealProgress
self.isSending = isSending
self.onCancel = onCancel
self.initiallyBlurred = initiallyBlurred
self.onOpen = onOpen
self.onDelete = onDelete
}
private var fraction: Double {
guard let revealProgress = revealProgress else { return 1 }
return max(0, min(1, revealProgress))
}
var body: some View {
ZStack(alignment: .topTrailing) {
if let image = platformImage {
Image(platformImage: image)
.resizable()
.aspectRatio(aspectRatio, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(Color.gray.opacity(0.2), lineWidth: 1)
)
.mask(
BlockRevealMask(
fraction: fraction,
columns: 24,
rows: 16
)
.animation(.easeOut(duration: 0.2), value: fraction)
)
.blur(radius: isBlurred ? 20 : 0)
.overlay {
if isBlurred {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color.black.opacity(0.35))
.overlay(
Image(systemName: "eye.slash.fill")
.font(.bitchatSystem(size: 24, weight: .semibold))
.foregroundColor(.white.opacity(0.85))
)
}
}
} else {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color.gray.opacity(0.2))
.frame(height: 200)
.overlay(
ProgressView()
.progressViewStyle(.circular)
)
}
if let onCancel = onCancel, isSending {
Button(action: onCancel) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 12, weight: .bold))
.padding(8)
.background(Circle().fill(Color.black.opacity(0.7)))
.foregroundColor(.white)
.padding(8)
}
.buttonStyle(.plain)
}
}
.onAppear {
isBlurred = initiallyBlurred
loadImage()
}
.onChange(of: url) { _ in
isBlurred = initiallyBlurred
loadImage()
}
.gesture(mainGesture)
}
private func loadImage() {
DispatchQueue.global(qos: .userInitiated).async {
#if os(iOS)
guard let image = UIImage(contentsOfFile: url.path) else { return }
#else
guard let image = NSImage(contentsOf: url) else { return }
#endif
let ratio = image.size.height > 0 ? image.size.width / image.size.height : 1
DispatchQueue.main.async {
self.platformImage = image
self.aspectRatio = ratio
}
}
}
private var mainGesture: some Gesture {
let doubleTap = TapGesture(count: 2).onEnded {
guard !isSending else { return }
onDelete?()
}
let singleTap = TapGesture().onEnded {
guard !isSending else { return }
if isBlurred {
withAnimation(.easeOut(duration: 0.2)) {
isBlurred = false
}
} else {
onOpen?()
}
}
let swipe = DragGesture(minimumDistance: 20, coordinateSpace: .local).onEnded { value in
guard !isSending else { return }
let horizontal = value.translation.width
let vertical = value.translation.height
guard abs(horizontal) > abs(vertical), abs(horizontal) > 40 else { return }
if !isBlurred {
withAnimation(.easeInOut(duration: 0.2)) {
isBlurred = true
}
}
}
return doubleTap.exclusively(before: singleTap).simultaneously(with: swipe)
}
}
private struct BlockRevealMask: Shape {
let fraction: Double
let columns: Int
let rows: Int
func path(in rect: CGRect) -> Path {
var path = Path()
guard fraction > 0, columns > 0, rows > 0 else { return path }
let totalBlocks = columns * rows
let revealCount = max(0, min(totalBlocks, Int(ceil(fraction * Double(totalBlocks)))))
guard revealCount > 0 else { return path }
let blockWidth = rect.width / CGFloat(columns)
let blockHeight = rect.height / CGFloat(rows)
var remaining = revealCount
for row in 0..<rows {
for column in 0..<columns {
if remaining <= 0 { return path }
let x = CGFloat(column) * blockWidth
let y = CGFloat(row) * blockHeight
path.addRect(CGRect(x: x, y: y, width: blockWidth, height: blockHeight))
remaining -= 1
}
}
return path
}
}
private extension Image {
init(platformImage: PlatformImage) {
#if os(iOS)
self.init(uiImage: platformImage)
#else
self.init(nsImage: platformImage)
#endif
}
}
+123
View File
@@ -0,0 +1,123 @@
import SwiftUI
import AVFoundation
struct VoiceNoteView: View {
private let url: URL
private let isSending: Bool
private let sendProgress: Double?
private let onCancel: (() -> Void)?
@Environment(\.colorScheme) private var colorScheme
@StateObject private var playback: VoiceNotePlaybackController
@State private var waveform: [Float] = []
init(url: URL, isSending: Bool, sendProgress: Double?, onCancel: (() -> Void)?) {
self.url = url
self.isSending = isSending
self.sendProgress = sendProgress
self.onCancel = onCancel
_playback = StateObject(wrappedValue: VoiceNotePlaybackController(url: url))
}
private var samples: [Float] {
if waveform.isEmpty {
return Array(repeating: 0.25, count: 64)
}
return waveform
}
private var backgroundColor: Color {
colorScheme == .dark ? Color.black.opacity(0.6) : Color.white
}
private var borderColor: Color {
colorScheme == .dark ? Color.green.opacity(0.3) : Color.green.opacity(0.2)
}
private var durationText: String {
let duration = playback.duration
guard duration.isFinite, duration > 0 else { return "--:--" }
let minutes = Int(duration) / 60
let seconds = Int(duration) % 60
return String(format: "%02d:%02d", minutes, seconds)
}
private var currentText: String {
let current = playback.currentTime
guard current.isFinite, current > 0 else { return "00:00" }
let minutes = Int(current) / 60
let seconds = Int(current) % 60
return String(format: "%02d:%02d", minutes, seconds)
}
private var playbackLabel: String {
playback.isPlaying ? currentText + "/" + durationText : durationText
}
var body: some View {
HStack(spacing: 12) {
Button(action: playback.togglePlayback) {
Image(systemName: playback.isPlaying ? "pause.fill" : "play.fill")
.foregroundColor(.white)
.frame(width: 36, height: 36)
.background(Circle().fill(Color.green))
}
.buttonStyle(.plain)
WaveformView(
samples: samples,
playbackProgress: playback.progress,
sendProgress: sendProgress,
onSeek: { fraction in
playback.seek(to: fraction)
},
isInteractive: playback.isPlaying
)
Text(playbackLabel)
.font(.bitchatSystem(size: 13, design: .monospaced))
.foregroundColor(Color.secondary)
if let onCancel = onCancel, isSending {
Button(action: onCancel) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 12, weight: .bold))
.frame(width: 28, height: 28)
.background(Circle().fill(Color.red.opacity(0.9)))
.foregroundColor(.white)
}
.buttonStyle(.plain)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(backgroundColor)
.shadow(color: Color.black.opacity(colorScheme == .dark ? 0.3 : 0.1), radius: 6, x: 0, y: 2)
)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(borderColor, lineWidth: 1)
)
.task {
// Defer loading to let UI settle after view appears
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s
playback.loadDuration()
await withCheckedContinuation { continuation in
WaveformCache.shared.waveform(for: url, completion: { bins in
waveform = bins
continuation.resume()
})
}
}
.onChange(of: url) { newValue in
WaveformCache.shared.waveform(for: newValue, completion: { bins in
self.waveform = bins
})
playback.replaceURL(newValue)
}
.onDisappear {
playback.stop()
}
}
}
+67
View File
@@ -0,0 +1,67 @@
import SwiftUI
struct WaveformView: View {
let samples: [Float]
let playbackProgress: Double
let sendProgress: Double?
let onSeek: ((Double) -> Void)?
let isInteractive: Bool
private var clampedPlayback: Double {
max(0, min(1, playbackProgress))
}
private var clampedSend: Double? {
guard let sendProgress = sendProgress else { return nil }
return max(0, min(1, sendProgress))
}
var body: some View {
GeometryReader { geometry in
ZStack {
Canvas { context, size in
guard !samples.isEmpty else { return }
let width = max(size.width, 1)
let height = max(size.height, 1)
let barWidth = max(width / CGFloat(samples.count), 1)
for (index, sample) in samples.enumerated() {
let normalized = max(0, min(sample, 1))
let barHeight = CGFloat(normalized) * height
let originX = CGFloat(index) * barWidth
let rect = CGRect(
x: originX,
y: (height - barHeight) / 2,
width: max(barWidth * 0.7, 1),
height: barHeight
)
let binPosition = Double(index) / Double(samples.count)
let color: Color
if binPosition <= clampedPlayback {
color = Color.green
} else if let send = clampedSend, binPosition <= send {
color = Color.blue
} else {
color = Color.gray.opacity(0.35)
}
context.fill(Path(rect), with: .color(color))
}
}
.frame(width: geometry.size.width, height: geometry.size.height)
if isInteractive, let onSeek = onSeek {
Color.clear
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onEnded { value in
guard geometry.size.width > 0 else { return }
let fraction = max(0, min(1, value.location.x / geometry.size.width))
onSeek(fraction)
}
)
}
}
}
.frame(height: 48)
}
}
+8
View File
@@ -10,11 +10,19 @@
</array>
<key>com.apple.security.device.bluetooth</key>
<true/>
<key>com.apple.security.device.microphone</key>
<true/>
<key>com.apple.security.personal-information.location</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.assets.pictures.read-only</key>
<true/>
</dict>
</plist>
@@ -93,6 +93,62 @@ struct FragmentationTests {
#expect(capture.publicMessages.first?.content.count == 2048)
}
@Test("Max-sized file transfer survives reassembly")
func maxSizedFileTransferSurvivesReassembly() async throws {
let ble = BLEService(
keychain: mockKeychain,
idBridge: idBridge,
identityManager: mockIdentityManager
)
let capture = CaptureDelegate()
ble.delegate = capture
let remoteID = PeerID(str: "CAFEBABECAFEBABE")
let fileContent = Data(repeating: 0x42, count: FileTransferLimits.maxPayloadBytes)
let filePacket = BitchatFilePacket(
fileName: "limit.bin",
fileSize: UInt64(fileContent.count),
mimeType: "application/octet-stream",
content: fileContent
)
let encoded = try #require(filePacket.encode(), "File packet encoding failed")
let packet = BitchatPacket(
type: MessageType.fileTransfer.rawValue,
senderID: Data(hexString: remoteID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encoded,
signature: nil,
ttl: 7,
version: 2
)
let fragments = fragmentPacket(packet, fragmentSize: 4096, pad: false)
#expect(!fragments.isEmpty)
for (i, fragment) in fragments.enumerated() {
let delay = 5 * Double(i) * 0.001
Task {
try await sleep(delay)
ble._test_handlePacket(fragment, fromPeerID: remoteID)
}
}
try await sleep(1.0)
let message = try #require(capture.receivedMessages.first, "Expected file transfer message")
#expect(message.content.hasPrefix("[file]"))
if let fileName = message.content.split(separator: " ").last {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let filesRoot = base.appendingPathComponent("files", isDirectory: true)
let incoming = filesRoot.appendingPathComponent("files/incoming", isDirectory: true)
let url = incoming.appendingPathComponent(String(fileName))
try? FileManager.default.removeItem(at: url)
}
}
@Test("Invalid fragment header is ignored")
func invalidFragmentHeaderIsIgnored() async throws {
let ble = BLEService(
@@ -142,7 +198,10 @@ struct FragmentationTests {
extension FragmentationTests {
private final class CaptureDelegate: BitchatDelegate {
var publicMessages: [(peerID: PeerID, nickname: String, content: String)] = []
func didReceiveMessage(_ message: BitchatMessage) {}
var receivedMessages: [BitchatMessage] = []
func didReceiveMessage(_ message: BitchatMessage) {
receivedMessages.append(message)
}
func didConnectToPeer(_ peerID: PeerID) {}
func didDisconnectFromPeer(_ peerID: PeerID) {}
func didUpdatePeerList(_ peers: [PeerID]) {}
@@ -173,8 +232,8 @@ extension FragmentationTests {
}
// Helper: fragment a packet using the same header format BLEService expects
private func fragmentPacket(_ packet: BitchatPacket, fragmentSize: Int, fragmentID: Data? = nil) -> [BitchatPacket] {
let fullData = packet.toBinaryData() ?? Data()
private func fragmentPacket(_ packet: BitchatPacket, fragmentSize: Int, fragmentID: Data? = nil, pad: Bool = true) -> [BitchatPacket] {
guard let fullData = packet.toBinaryData(padding: pad) else { return [] }
let fid = fragmentID ?? Data((0..<8).map { _ in UInt8.random(in: 0...255) })
let chunks: [Data] = stride(from: 0, to: fullData.count, by: fragmentSize).map { off in
Data(fullData[off..<min(off + fragmentSize, fullData.count)])
@@ -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)
}
}
+8
View File
@@ -154,6 +154,14 @@ final class MockBLEService: NSObject {
}
}
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
// Tests currently ignore file transfer flows; keep stub for protocol conformance.
}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
// Tests currently ignore file transfer flows; keep stub for protocol conformance.
}
func sendPrivateMessage(_ content: String, to recipientPeerID: PeerID, recipientNickname: String, messageID: String) {
let message = BitchatMessage(
id: messageID,
@@ -95,4 +95,57 @@ struct NotificationStreamAssemblerTests {
let decoded = try #require(BinaryProtocol.decode(result.frames[0]), "Failed to decode frame after drop")
#expect(decoded.timestamp == packet.timestamp)
}
func testAssemblesCompressedLargeFrame() throws {
var assembler = NotificationStreamAssembler()
// Keep the fixture below FileTransferLimits.maxPayloadBytes so encoding succeeds while still exercising compression.
let largeContent = Data(repeating: 0x41, count: 600_000)
let filePacket = BitchatFilePacket(
fileName: "large.bin",
fileSize: UInt64(largeContent.count),
mimeType: "application/octet-stream",
content: largeContent
)
let tlvPayload = try #require(filePacket.encode(), "Failed to encode file packet")
let senderID = Data(repeating: 0xAA, count: BinaryProtocol.senderIDSize)
let packet = BitchatPacket(
type: MessageType.fileTransfer.rawValue,
senderID: senderID,
recipientID: nil,
timestamp: 0x010203040506,
payload: tlvPayload,
signature: nil,
ttl: 3,
version: 2
)
let frame = try #require(packet.toBinaryData(padding: false), "Failed to encode packet frame")
#expect(BinaryProtocol.Offsets.flags < frame.count)
let flags = frame[frame.startIndex + BinaryProtocol.Offsets.flags]
#expect((flags & BinaryProtocol.Flags.isCompressed) != 0, "Frame should be compressed for large payloads")
let splitIndex = min(4096, frame.count / 2)
var result = assembler.append(frame.prefix(splitIndex))
#expect(result.frames.isEmpty)
result = assembler.append(frame.suffix(from: splitIndex))
#expect(result.frames.count == 1)
#expect(result.droppedPrefixes.isEmpty)
#expect(result.reset == false)
let assembled = try #require(result.frames.first, "Missing assembled frame")
#expect(assembled.count == frame.count)
let decodedPacket = try #require(BinaryProtocol.decode(assembled), "Failed to decode compressed frame")
#expect(decodedPacket.payload.count == tlvPayload.count)
let decodedFile = try #require(BitchatFilePacket.decode(decodedPacket.payload), "Failed to decode TLV payload")
#expect(decodedFile.fileName == filePacket.fileName)
#expect(decodedFile.mimeType == filePacket.mimeType)
#expect(decodedFile.content.count == largeContent.count)
#expect(decodedFile.content.prefix(32) == largeContent.prefix(32))
}
}
@@ -68,8 +68,9 @@ struct BinaryProtocolTests {
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with large payload")
// The encoded size should be smaller than uncompressed due to compression
let uncompressedSize = BinaryProtocol.headerSize + BinaryProtocol.senderIDSize + largePayload.count
#expect(encodedData.count < uncompressedSize)
let headerSize = try #require(BinaryProtocol.headerSize(for: packet.version), "Invalid packet version")
let uncompressedSize = headerSize + BinaryProtocol.senderIDSize + largePayload.count
#expect(encodedData.count < uncompressedSize, "Compressed packet should be smaller than uncompressed form")
// Decode and verify
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode compressed packet")
@@ -0,0 +1,47 @@
import XCTest
@testable import bitchat
final class BitchatFilePacketTests: XCTestCase {
func testRoundTripPreservesFields() throws {
let content = Data((0..<4096).map { UInt8($0 % 251) })
let packet = BitchatFilePacket(
fileName: "sample.jpg",
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
)
guard let encoded = packet.encode() else {
return XCTFail("Failed to encode file packet")
}
guard let decoded = BitchatFilePacket.decode(encoded) else {
return XCTFail("Failed to decode file packet")
}
XCTAssertEqual(decoded.fileName, packet.fileName)
XCTAssertEqual(decoded.fileSize, packet.fileSize)
XCTAssertEqual(decoded.mimeType, packet.mimeType)
XCTAssertEqual(decoded.content, packet.content)
}
func testDecodeFallsBackToContentSizeWhenFileSizeMissing() throws {
let content = Data(repeating: 0x7F, count: 1024)
let packet = BitchatFilePacket(
fileName: nil,
fileSize: nil,
mimeType: nil,
content: content
)
guard let encoded = packet.encode() else {
return XCTFail("Failed to encode file packet")
}
guard let decoded = BitchatFilePacket.decode(encoded) else {
return XCTFail("Failed to decode file packet")
}
XCTAssertEqual(decoded.fileSize, UInt64(content.count))
XCTAssertEqual(decoded.content, content)
}
}
@@ -6,7 +6,9 @@
// For more information, see <https://unlicense.org>
//
#if canImport(os.log)
import os.log
#endif
public extension OSLog {
private static let subsystem = "chat.bitchat"
@@ -7,7 +7,53 @@
//
import Foundation
#if canImport(os.log)
import os.log
#else
public struct OSLog {
public let subsystem: String
public let category: String
public init(subsystem: String, category: String) {
self.subsystem = subsystem
self.category = category
}
}
public struct OSLogType: CustomStringConvertible {
private let label: String
private init(_ label: String) {
self.label = label
}
public var description: String { label }
public static let debug = OSLogType("debug")
public static let info = OSLogType("info")
public static let `default` = OSLogType("default")
public static let error = OSLogType("error")
public static let fault = OSLogType("fault")
}
@usableFromInline
let secureLoggerFallbackFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
@usableFromInline
func os_log(_ message: StaticString, log: OSLog, type: OSLogType, _ args: CVarArg...) {
let rawFormat = String(describing: message)
let format = rawFormat
.replacingOccurrences(of: "%{public}@", with: "%@")
.replacingOccurrences(of: "%{private}@", with: "%@")
let formatted = String(format: format, arguments: args)
let timestamp = secureLoggerFallbackFormatter.string(from: Date())
print("[\(timestamp)] [\(log.subsystem)::\(log.category)] [\(type.description)] \(formatted)")
}
#endif
/// Centralized security-aware logging framework
/// Provides safe logging that filters sensitive data and security events
+15 -151
View File
@@ -1,11 +1,23 @@
import BitLogger
import Foundation
#if canImport(Network)
import Network
#endif
#if canImport(Darwin)
import Darwin
#elseif canImport(Glibc)
import Glibc
#endif
// Declare C entrypoint for Tor when statically linked from an xcframework.
@_silgen_name("tor_main")
private func tor_main_c(_ argc: Int32, _ argv: UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>?) -> Int32
#if !canImport(Network)
private final class NWPathMonitor {
var pathUpdateHandler: ((Any) -> Void)?
func start(queue: DispatchQueue) {
// Path monitoring is unavailable on this platform; nothing to do.
}
}
#endif
// Preferred: tiny C glue that uses Tor's embedding API (tor_api.h)
@_silgen_name("tor_host_start")
@@ -286,150 +298,6 @@ public final class TorManager: ObservableObject {
}
}
// MARK: - Dynamic loader path (no Swift module required)
/// Attempt to locate an embedded tor framework binary and launch Tor via `tor_run_main`.
/// Returns true if the attempt started and port probing was scheduled.
private func startTorViaDlopen() -> Bool {
guard let fwURL = frameworkBinaryURL() else {
SecureLogger.warning("TorManager: no embedded tor framework found", category: .session)
return false
}
// Load the library
let mode = RTLD_NOW | RTLD_LOCAL
SecureLogger.info("TorManager: dlopen(\(fwURL.lastPathComponent))…", category: .session)
guard let handle = dlopen(fwURL.path, mode) else {
let err = String(cString: dlerror())
self.lastError = NSError(domain: "TorManager", code: -10, userInfo: [NSLocalizedDescriptionKey: "dlopen failed: \(err)"])
self.isStarting = false
return false
}
// Resolve tor_main(argc, argv)
typealias TorMainType = @convention(c) (Int32, UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>?) -> Int32
guard let sym = dlsym(handle, "tor_main") else {
// Keep handle open but report error
let err = String(cString: dlerror())
self.lastError = NSError(domain: "TorManager", code: -11, userInfo: [NSLocalizedDescriptionKey: "dlsym tor_main failed: \(err)"])
self.isStarting = false
return false
}
let torMain = unsafeBitCast(sym, to: TorMainType.self)
self._dlHandle = handle
// Prepare args: tor -f <torrc>
var argv: [String] = ["tor"]
if let torrc = torrcURL()?.path {
argv.append(contentsOf: ["-f", torrc])
}
// Run Tor on a background thread to avoid blocking the main actor
SecureLogger.info("TorManager: launching tor_main with torrc", category: .session)
let argc = Int32(argv.count)
DispatchQueue.global(qos: .utility).async {
// Build stable C argv in this thread
let cStrings: [UnsafeMutablePointer<CChar>?] = argv.map { strdup($0) }
let cArgv = UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>.allocate(capacity: cStrings.count + 1)
for i in 0..<cStrings.count { cArgv[i] = cStrings[i] }
cArgv[cStrings.count] = nil
_ = torMain(argc, cArgv)
// Free args after exit (Tor usually never returns)
for ptr in cStrings.compactMap({ $0 }) { free(ptr) }
cArgv.deallocate()
}
// Start control-port monitor and probe readiness asynchronously
startControlMonitorIfNeeded()
Task.detached(priority: .userInitiated) { [weak self] in
guard let self else { return }
let ready = await self.waitForSocksReady(timeout: 60.0)
await MainActor.run {
self.socksReady = ready
if !ready {
self.lastError = NSError(domain: "TorManager", code: -12, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after dlopen start"])
SecureLogger.error("TorManager: SOCKS not reachable (timeout)", category: .session)
} else {
SecureLogger.info("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: .session)
}
// isStarting will be cleared when bootstrap reaches 100%
}
}
return true
}
private var _dlHandle: UnsafeMutableRawPointer?
private func frameworkBinaryURL() -> URL? {
// Try common embedded locations for the framework binary name
let candidates = [
"tor-nolzma.framework/tor-nolzma",
"Tor.framework/Tor",
]
if let base = Bundle.main.privateFrameworksURL {
for rel in candidates {
let url = base.appendingPathComponent(rel)
if FileManager.default.fileExists(atPath: url.path) { return url }
}
}
// For macOS apps, also try Contents/Frameworks explicitly
#if os(macOS)
if let appURL = Bundle.main.bundleURL as URL?,
let frameworksURL = Optional(appURL.appendingPathComponent("Contents/Frameworks", isDirectory: true)) {
for rel in candidates {
let url = frameworksURL.appendingPathComponent(rel)
if FileManager.default.fileExists(atPath: url.path) { return url }
}
}
#endif
return nil
}
// MARK: - Static-link path (no module import)
private func startTorViaLinkedSymbol() -> Bool {
// Attempt to start tor_run_main directly (statically linked). If the
// symbol is not present at link-time, builds will fail which is
// expected when the xcframework is absent.
var argv: [String] = ["tor"]
if let torrc = torrcURL()?.path { argv.append(contentsOf: ["-f", torrc]) }
SecureLogger.info("TorManager: starting tor_main (static)", category: .session)
let argc = Int32(argv.count)
DispatchQueue.global(qos: .utility).async {
// Build stable C argv in this thread
let cStrings: [UnsafeMutablePointer<CChar>?] = argv.map { strdup($0) }
let cArgv = UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>.allocate(capacity: cStrings.count + 1)
for i in 0..<cStrings.count { cArgv[i] = cStrings[i] }
cArgv[cStrings.count] = nil
_ = tor_main_c(argc, cArgv)
// If tor_main ever returns, free memory
for ptr in cStrings.compactMap({ $0 }) { free(ptr) }
cArgv.deallocate()
}
// Start control monitor early
startControlMonitorIfNeeded()
Task.detached(priority: .userInitiated) { [weak self] in
guard let self else { return }
let ready = await self.waitForSocksReady(timeout: 60.0)
await MainActor.run {
self.socksReady = ready
if ready {
SecureLogger.info("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: .session)
} else {
self.lastError = NSError(domain: "TorManager", code: -13, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after static start"])
SecureLogger.error("TorManager: SOCKS not reachable (timeout)", category: .session)
}
// isStarting will be cleared when bootstrap reaches 100%
}
}
return true
}
// MARK: - ControlPort monitoring (bootstrap progress)
private func startControlMonitorIfNeeded() {
guard !controlMonitorStarted else { return }
@@ -440,10 +308,6 @@ public final class TorManager: ObservableObject {
}
}
private func controlMonitorLoop() async {}
private func tryControlSessionOnce() async -> Bool { false }
// iOS: Poll GETINFO periodically to track bootstrap progress without long-lived control readers.
private func bootstrapPollLoop() async {
let deadline = Date().addingTimeInterval(75)