mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 21:45:21 +00:00
Wifi aware refactor mesh core (#545)
* Fix notification user try 2 (#541) * count peers in notification * cleanup * Automated update of relay data - Sun Jan 4 06:08:02 UTC 2026 * Fix: Add @ConscryptMode annotation to FileTransferTest to resolve UnsatisfiedLinkError (#543) Robolectric 4.9+ uses Conscrypt as the default security provider, which requires native libraries that aren't available in the test environment. Adding @ConscryptMode(Mode.OFF) disables Conscrypt and uses BouncyCastle instead, which resolves the test failures. This fixes the build failures in FileTransferTest where all 9 tests were failing with UnsatisfiedLinkError when Conscrypt tried to load native libraries. Fixes #542 * Fix small-screen header clipping (#519) (#532) * Fix small-screen header clipping: reserve status-bar space and center-constrain titles (fixes #519) * Remove redundant Gradle configuration lines * refactor mesh core * tests * QR and Verification feature (#529) * Automated update of relay data - Sun Sep 21 06:21:05 UTC 2025 * Automated update of relay data - Sun Sep 28 06:20:40 UTC 2025 * refactor: new close button like ios(but not liquid glass) * Automated update of relay data - Sun Oct 5 06:20:09 UTC 2025 * Automated update of relay data - Sun Oct 12 06:20:12 UTC 2025 * Automated update of relay data - Sun Oct 19 06:21:51 UTC 2025 * Automated update of relay data - Sun Oct 26 06:21:31 UTC 2025 * Automated update of relay data - Sun Nov 2 06:22:16 UTC 2025 * Automated update of relay data - Sun Nov 9 06:21:43 UTC 2025 * Automated update of relay data - Sun Nov 16 06:22:37 UTC 2025 * Automated update of relay data - Sun Nov 23 06:22:51 UTC 2025 * Automated update of relay data - Sun Nov 30 06:24:08 UTC 2025 * Automated update of relay data - Sun Dec 7 06:22:59 UTC 2025 * Automated update of relay data - Sun Dec 14 06:24:33 UTC 2025 * Automated update of relay data - Sun Dec 21 06:24:49 UTC 2025 * Automated update of relay data - Sun Dec 28 06:25:38 UTC 2025 * feat: Add ZXing dependency for QR code scanning * feat: Request camera permission for QR verification * Add QR verification payloads and mesh wiring * Wire verification state, system messages, and notifications * Add verification sheets and UI affordances * Show verified badges in sidebar and add strings * Persist fingerprint caches for offline verification * Handle bitchat://verify deep links * feat: Replace zxing-android-embedded with ML Kit and CameraX * Refactor(Verification): Replace zxing with MLKit for QR scanning * Replace `AndroidView` with `CameraXViewfinder` for camera preview * Refactor QR verification: Extract VerificationHandler and fix concurrency issues * Extract and translate strings for QR verification feature * Fix build errors: Escape ampersands in strings and restore missing methods in ChatViewModel * return to main * return to main 2 --------- Co-authored-by: GitHub Action <action@github.com> Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com> * Enforce mandatory packet signature verification (#546) * security: enforce mandatory packet signature verification * tests: add comprehensive unit tests for packet signature verification * Revert "Fix small-screen header clipping (#519) (#532)" (#548) This reverts commitd73976537d. * fix: location channel manager (#516) * location channel manager fix * fix: use interval arg in beginLiveRefresh & remove unused code * fix: sync notification peer count with sidebar (#547) * share gossip * refresh peer list more often * bump versioncode to 28 (#551) * verift signature only for ANNOUNCE, MESSAGE, and FILE_TRANSFER like ios (#552) * bump to 29 (#553) * patches: wifi aware mesh refactor core (#549) * Refactor WifiAware memory management and lifecycle handling * Fix Wi-Fi Aware reconnection by ensuring callback unregistration and session watchdog * fix(wifiaware): allow WifiAwareController to restart service if session drops * Enhance WiFi Aware logging with network request timeouts and detailed callback status * Ensure explicit release of WiFi Aware network callbacks on failure or disconnection --------- Co-authored-by: aidenvalue <> * Fix/foreground permissions scanning (#556) * try catch the foreground location service if not available * ask for background permissions * background permissions in onboarding * small improvements * drop duplicate announces based on TTL (#558) * scan duration fix (#557) * feat: Build and release per-architecture APKs (#550) * feat: Build and release per-architecture APKs This commit modifies the build process to generate separate APKs for different CPU architectures (arm64-v8a, x86_64) and a universal APK. This allows for smaller, optimized downloads for users and provides specific builds for platforms like Chromebooks. Key changes: - In `app/build.gradle.kts`, enables ABI splits to create `arm64-v8a`, `x86_64`, and `universal` APKs during the `assembleRelease` task. - Updates the `release.yml` GitHub Actions workflow to rename and upload each of these APKs as distinct release assets. - Removes the previous `arm64-v8a` only filter to allow for multi-architecture builds. * feat: Allow manual triggering of Android CI workflow This adds the `workflow_dispatch` event to the `android-build.yml` GitHub Actions workflow. This change enables the Android CI pipeline to be run manually from the GitHub UI, in addition to the existing triggers for pushes and pull requests. * bump vc to 30 (#559) * fix: r8 exception for LocationManager (#566) * fix: MessageRouter uses stale BluetoothMeshService reference (#571) * fix: MessageRouter uses stale BluetoothMeshService reference * fix: unconditionally update mesh reference in MessageRouter.getInstance * fix camera button (#572) * fix camera button * fix * Fix: Add runtime camera permission check for image picker * fix: wifi aware mesh private dms (#561) * fix(wifi-aware): restore peer check and remove aggressive session restarts * synchronized access to sockets. * clear old peer socket `onClientConnected` if one was found. * address second round of review * fix: Restart publish session on termination in WifiAwareMeshService * fix: cleanup connection tracker resources when handling peer disconnection without active socket --------- Co-authored-by: aidenvalu3 <> * fix(mesh): address memory leaks and background persistence issues in Wi-Fi Aware - Move message persistence and background notifications for Wi-Fi Aware into the service layer via MeshCore hooks. - Fix memory leak in MainActivity by properly detaching WifiAwareMeshDelegate on pause. - Ensure BluetoothMeshService is initialized before WifiAwareMeshService to guarantee shared GossipSyncManager instance. - Deduplicate persistence logic from MainActivity delegate. * Automated update of relay data - Sun Jan 11 06:07:53 UTC 2026 * Fix scroll bug(#568) (#570) * Automated update of relay data - Sun Sep 21 06:21:05 UTC 2025 * Automated update of relay data - Sun Sep 28 06:20:40 UTC 2025 * refactor: new close button like ios(but not liquid glass) * Automated update of relay data - Sun Oct 5 06:20:09 UTC 2025 * Automated update of relay data - Sun Oct 12 06:20:12 UTC 2025 * Automated update of relay data - Sun Oct 19 06:21:51 UTC 2025 * Automated update of relay data - Sun Oct 26 06:21:31 UTC 2025 * Automated update of relay data - Sun Nov 2 06:22:16 UTC 2025 * Automated update of relay data - Sun Nov 9 06:21:43 UTC 2025 * Automated update of relay data - Sun Nov 16 06:22:37 UTC 2025 * Automated update of relay data - Sun Nov 23 06:22:51 UTC 2025 * Automated update of relay data - Sun Nov 30 06:24:08 UTC 2025 * Automated update of relay data - Sun Dec 7 06:22:59 UTC 2025 * Automated update of relay data - Sun Dec 14 06:24:33 UTC 2025 * Automated update of relay data - Sun Dec 21 06:24:49 UTC 2025 * Automated update of relay data - Sun Dec 28 06:25:38 UTC 2025 * Automated update of relay data - Sun Jan 4 06:26:28 UTC 2026 * fix bug(568): Improve scroll-to-bottom logic Replaced the "smart scroll" mechanism with a simpler "follow" behavior. The message list now automatically scrolls to the latest message unless the user has scrolled up. - A new `followIncomingMessages` state tracks whether to auto-scroll. - Scrolling now uses `scrollToItem` instead of `animateScrollToItem` for immediate updates. --------- Co-authored-by: GitHub Action <action@github.com> * fix nostr private messages processing (#563) * fix nostr private messages processing * persisted geohash registry --------- Co-authored-by: a1denvalu3 <> * New MeshPeerListSheet as Ios like (#498) * Automated update of relay data - Sun Sep 21 06:21:05 UTC 2025 * Automated update of relay data - Sun Sep 28 06:20:40 UTC 2025 * refactor: new close button like ios(but not liquid glass) * Automated update of relay data - Sun Oct 5 06:20:09 UTC 2025 * Automated update of relay data - Sun Oct 12 06:20:12 UTC 2025 * Automated update of relay data - Sun Oct 19 06:21:51 UTC 2025 * Refactor: Redesign peer and channel list as a bottom sheet Replaced the right-hand sidebar with a Material 3 `ModalBottomSheet` for displaying mesh peers and channels. This modernizes the UI and improves usability. - Renamed `SidebarComponents.kt` to `MeshPeerListSheet.kt`. - Replaced the custom sidebar implementation with `ModalBottomSheet`. - Added a floating top bar to the sheet that appears on scroll, displaying the title and a close button. - Updated the row layouts for both channels and peers to use `Surface` for better visual grouping and selection state handling. - Added a checkmark icon to indicate the currently selected channel or private chat peer. - Improved styling for section headers, unread badges, and empty-state text. - Removed the `SignalStrengthIndicator` as it was no longer used. * Refactor: Remove sidebar state management from ViewModel This commit removes the state management for the sidebar's visibility from `ChatViewModel` and `ChatState`. The sidebar's visibility is now a purely UI-level concern and is no longer coupled with the ViewModel's logic. This change simplifies the ViewModel by removing unnecessary LiveData and related methods (`showSidebar`, `hideSidebar`). The back navigation handler has also been updated to remove the case for closing the sidebar. * Refactor: Replace Sidebar with MeshPeerList Bottom Sheet * feat: Add nested private chat sheet * Feat: Enhance UI/UX of LocationNotesSheet This commit refactors the `LocationNotesSheet` to more closely align with its iOS counterpart, improving both its appearance and user experience. The layout has been updated to use a `Box` with aligned elements instead of a single `Column`, allowing for a floating input section at the bottom and a floating close button at the top right. **Key Changes:** - **Floating Top Bar and Input:** - The main content is now a `LazyColumn` that scrolls underneath a new floating top bar and a floating input section at the bottom. - The top bar's background animates from transparent to semi-opaque as the user scrolls, providing a "blur" effect. - **iOS-Style Close Button:** - The close button is moved from the header row to the top-right corner of the sheet, where it remains fixed. - **Structural Refinements:** - Replaced the main `Column` with a `Box` to manage the layout of the scrollable content, top bar, and input section. - Removed the `onClose` parameter from `LocationNotesHeader` as the close button is now managed separately. - Added `statusBarsPadding` to the `ModalBottomSheet` to prevent content from rendering under the system status bar. - Adjusted spacing and padding for better visual consistency. * refactor: Use collectAsStateWithLifecycle Migrates LiveData observation from `observeAsState` to `collectAsStateWithLifecycle` for improved lifecycle-aware state collection in `MeshPeerListSheet`. This also includes the following related changes: * Removes an unused `showSidebar` state flow from `ChatViewModel`. * Replaces fully qualified `com.bitchat.android.ui.splitSuffix` calls with a direct `splitSuffix` call. * Updates resource string access to use the `R` import. * feat: Add verification status indicators - Add peer verification status icons to the peer list. - Add a button to the channel list to show the verification QR code. - Remove the unused `SidebarComponents.kt` file. * Refactor: Add state for mesh peer list visibility * Refactor: Move mesh peer list state to ViewModel * refactor: Move QR code icon from channel items to footer The verification (QR code) icon is relocated from being repeated on each channel list item to a single, centralized position in the sheet's footer. This icon is now only displayed when not in a location-based channel. The `onShowVerification` parameter has been removed from `ChannelListItem` as it's no longer needed there. * feat: Show verified status for peers --------- Co-authored-by: GitHub Action <action@github.com> * UI Refactor (#562) * Automated update of relay data - Sun Sep 21 06:21:05 UTC 2025 * Automated update of relay data - Sun Sep 28 06:20:40 UTC 2025 * refactor: new close button like ios(but not liquid glass) * Automated update of relay data - Sun Oct 5 06:20:09 UTC 2025 * Automated update of relay data - Sun Oct 12 06:20:12 UTC 2025 * Automated update of relay data - Sun Oct 19 06:21:51 UTC 2025 * Automated update of relay data - Sun Oct 26 06:21:31 UTC 2025 * Automated update of relay data - Sun Nov 2 06:22:16 UTC 2025 * Automated update of relay data - Sun Nov 9 06:21:43 UTC 2025 * Automated update of relay data - Sun Nov 16 06:22:37 UTC 2025 * Automated update of relay data - Sun Nov 23 06:22:51 UTC 2025 * Automated update of relay data - Sun Nov 30 06:24:08 UTC 2025 * Automated update of relay data - Sun Dec 7 06:22:59 UTC 2025 * Automated update of relay data - Sun Dec 14 06:24:33 UTC 2025 * Automated update of relay data - Sun Dec 21 06:24:49 UTC 2025 * Automated update of relay data - Sun Dec 28 06:25:38 UTC 2025 * Automated update of relay data - Sun Jan 4 06:26:28 UTC 2026 * refactor: Extract CloseButton to core UI components * feat: Add BitchatBottomSheet component * refactor: Use BitchatBottomSheet component and minor ui change(CloseButton, colors) * Refactor: Use BitchatBottomSheet in MeshPeerListSheet --------- Co-authored-by: GitHub Action <action@github.com> * Implement Geohash Presence (Heartbeats) (#576) * geohash announce * only for some geohashes * global presence right away * jitter delays * show ? people for high-precision geohashes * 1000 events * Gossip mesh topology + source-based routing (#445) * wip mesh graph * gossip fix * gossip works * source-based routing wip * log * update spec to be explicit about intermediate hops only * drop duplicate hops * add to spec * test * forgot comma * source routing v2 * add compression bomb protection * v2 source routing * fragmented packets inherit route * update spec * Gossip routing tmp with connection limit fixed (#569) * fix: r8 exception for LocationManager (#566) * fragmented packets inherit route * update spec * fix connection limits * fix: deserialization issue with routed packets * log * dynamic fragment size * fragment size * add tests * feat(gossip): implement two-way handshake for source routing edges - Update MeshGraphService to track directed announcements - Require bidirectional announcements for a 'confirmed' edge - Update RoutePlanner to strictly use confirmed edges - Update Mesh Topology debug view to show confirmed vs unconfirmed edges (solid vs dotted) * docs: update SOURCE_ROUTING.md with two-way handshake requirement * evict stale peers from mesh graph service * better logging * fix announce spe * fix: empty route * fix spec * fix: compile error in DebugSettingsSheet and potential NPE in RoutePlanner * revert * try again * Feat/graph force (#581) * mesh topology wip * graph * graph animated * visual logger refactor (#583) * Update arti to 1.9.0 and produce 32 bit builds (#585) * x86 * update arti to 1.9.0 * Move PrivateChatSheet hosting into ChatDialogs and unify sheet state (#586) * Automated update of relay data - Sun Sep 21 06:21:05 UTC 2025 * Automated update of relay data - Sun Sep 28 06:20:40 UTC 2025 * refactor: new close button like ios(but not liquid glass) * Automated update of relay data - Sun Oct 5 06:20:09 UTC 2025 * Automated update of relay data - Sun Oct 12 06:20:12 UTC 2025 * Automated update of relay data - Sun Oct 19 06:21:51 UTC 2025 * Automated update of relay data - Sun Oct 26 06:21:31 UTC 2025 * Automated update of relay data - Sun Nov 2 06:22:16 UTC 2025 * Automated update of relay data - Sun Nov 9 06:21:43 UTC 2025 * Automated update of relay data - Sun Nov 16 06:22:37 UTC 2025 * Automated update of relay data - Sun Nov 23 06:22:51 UTC 2025 * Automated update of relay data - Sun Nov 30 06:24:08 UTC 2025 * Automated update of relay data - Sun Dec 7 06:22:59 UTC 2025 * Automated update of relay data - Sun Dec 14 06:24:33 UTC 2025 * Automated update of relay data - Sun Dec 21 06:24:49 UTC 2025 * Automated update of relay data - Sun Dec 28 06:25:38 UTC 2025 * Automated update of relay data - Sun Jan 4 06:26:28 UTC 2026 * Automated update of relay data - Sun Jan 11 06:26:19 UTC 2026 * feat: Show private chat in sheet from notification * Refactor: Hoist private chat sheet state to ChatViewModel * remove icon * remove old bottom sheet --------- Co-authored-by: GitHub Action <action@github.com> Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com> * use bluetooth icon (#597) * Fix/nostr dm bottom sheet (#598) * nostr in new dm sheets * open sheet for nostr peers * Fix and Improve LocationManager logic (#599) * location manager improvements * fix: resolve compilation errors and improve location state handling - Fix missing imports and stray braces in LocationChannelManager - Implement checkSystemLocationEnabled and BroadcastReceiver for location state - Order class members for correct initialization - Consolidate UI refresh logic in LocationChannelsSheet * fix: guard against stale geocoding results on legacy devices - Add isActive check after blocking Geocoder.getFromLocation call - Prevent stale results from overwriting state after job cancellation * OSM fallback for geocoding (#611) * OSM fallback for geocoding * fallback to city for missing province * security: prevent storage exhaustion by saving incoming files to cache (#606) Closes #592 - Changed FileUtils.saveIncomingFile to use context.cacheDir instead of context.filesDir. - This ensures incoming files are treated as temporary and can be cleared by the OS if space is needed. Co-authored-by: a1denvalu3 <> * announce peer ID in servicedata to prevent duplicate connections (#613) * Refactor: Implement Hybrid Location Provider (System/Fused) (#612) * refactor: abstract LocationProvider, use FusedLocationProvider * Refactor: Sync permission state on check Replaces manual updatePermissionState calls with a unified checkAndSyncPermission method. This ensures that _permissionState flow always reflects the actual system permission status whenever it is checked (e.g. in requestOneShotLocation), preventing desync issues when permissions are revoked at runtime. * Refactor: Add timeout and tracking to SystemLocationProvider Implements robust cleanup and timeout logic for location requests. - SystemLocationProvider: Adds 30s timeout and listener tracking for legacy one-shot requests to prevent memory leaks on pre-Android 11 devices. - FusedLocationProvider: Adds 30s duration to requests. - LocationProvider: Adds cancel() method for full resource cleanup. - LocationChannelManager: Ensures cancel() is called during cleanup. * try catch * fix(security): Clear in-memory keys during panic mode (#596) * fix(security): Clear in-memory keys during panic mode #588 * feat: Recreate mesh service after panic clear (#602) * feat: Recreate mesh service after panic clear This commit refactors the panic clear process to ensure a new mesh identity is immediately created and applied. Previously, the `ChatViewModel` would clear sensitive data, but the recreation of the `BluetoothMeshService` was handled externally. This could lead to a delay or failure in adopting the new identity. Key changes: - Introduces `MeshServiceHolder` to manage the lifecycle of the `BluetoothMeshService` instance. - Adds `recreateMeshServiceAfterPanic()` to `ChatViewModel`, which now explicitly clears the old service instance and creates a new one with a regenerated identity. - The `meshService` property in `ChatViewModel` is now a `var` to allow it to be replaced with the fresh instance post-panic. - The new service is started, and a broadcast announcement is sent immediately, ensuring the new peer ID is used on the network. * fix: Ensure mesh service is properly managed in foreground service * refactor: Decouple handlers from direct service reference This commit updates the `VerificationHandler` and `MediaSendingManager` to receive the `meshService` via a lambda function (`getMeshService`) instead of a direct reference. This change decouples the handlers from the service instance, preventing them from holding a stale reference if the service reconnects or changes. By invoking the lambda to get the current service instance when needed, it ensures they always interact with the active `meshService`. * fix: restart bluetooth --------- Co-authored-by: Moe Hamade <69801237+moehamade@users.noreply.github.com> * improve verification sheet (#615) * Implement iOS-compatible Direct Peer Detection (TTL Logic) (#574) * Clean up direct peer detection logic (Max TTL) and remove unrelated routing/media changes * cleanup fix * fix: centralize and strictly enforce connection limits (#618) * Fix: Correctly parse recipient/sender IDs in broadcaster to enable unicast (#619) * Fix: Unicast source-routed packets at origin instead of broadcasting * Fix: Correctly parse recipient/sender IDs as hex strings in broadcaster * security: ensure media files are deleted during panic mode (#607) Closes #591 - Added FileUtils.clearAllMedia() to recursively delete media directories and cache. - Called clearAllMedia() in ChatViewModel.panicClearAllData(). fix: correct voice notes directory path for cleanup - Updated FileUtils.clearAllMedia to use 'voicenotes' instead of 'voice_notes' to match VoiceRecorder.kt fix: update media cleanup to include cache directories - Updated FileUtils.clearAllMedia to explicitly clean 'files/incoming' and 'images/incoming' from context.cacheDir, reflecting the storage location change from issue #592. - Maintained legacy cleanup for context.filesDir. Co-authored-by: a1denvalu3 <> * fix: Ensure empty neighbor lists in newer announcements clear mesh edges (#620) * fix: Ensure empty neighbor lists in newer announcements clear mesh edges - Refactored MeshGraphService.updateFromAnnouncement to prioritize timestamp checks. - Treated null neighbor lists (omitted TLV) as empty lists to allow peer disconnection/isolation updates to propagate. - Added MeshGraphServiceTest to verify timestamp logic and edge eviction. * fix(test): Use TestOnly API to reset singleton instead of reflection - Added MeshGraphService.resetForTesting() - Updated MeshGraphServiceTest to use the new API, avoiding fragile reflection on companion object fields. * fix(ui): Show acquiring location state instead of unavailable (#621) * fix(ui): Show acquiring location state instead of unavailable This change updates the LocationNotesSheetPresenter to display an 'Acquiring Location' sheet when location permissions are granted but the location is still loading. This prevents the misleading 'Location Unavailable' error on devices with slow GPS start-up (e.g., GrapheneOS). Fixes #578 * Adjust text --------- Co-authored-by: a1denvalu3 <> * Fix/590-Private-key-stored-as-Plain-text (#603) * Private Key Stored in Plaintext(SharedPreference) in EncryptionService migrated to EncryptedSharedPreferences. * Fix: Safe migration to EncryptedSharedPreferences and restore testability - Use distinct filename for encrypted prefs to avoid collision with legacy plaintext file - Move Keystore setup to initialize() to support Robolectric test mocks - Implement safe read-old/write-new migration logic --------- Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com> * Refactored BottomSheetTopBar to BitchatSheetTopBar (#601) * Automated update of relay data - Sun Sep 21 06:21:05 UTC 2025 * Automated update of relay data - Sun Sep 28 06:20:40 UTC 2025 * refactor: new close button like ios(but not liquid glass) * Automated update of relay data - Sun Oct 5 06:20:09 UTC 2025 * Automated update of relay data - Sun Oct 12 06:20:12 UTC 2025 * Automated update of relay data - Sun Oct 19 06:21:51 UTC 2025 * Automated update of relay data - Sun Oct 26 06:21:31 UTC 2025 * Automated update of relay data - Sun Nov 2 06:22:16 UTC 2025 * Automated update of relay data - Sun Nov 9 06:21:43 UTC 2025 * Automated update of relay data - Sun Nov 16 06:22:37 UTC 2025 * Automated update of relay data - Sun Nov 23 06:22:51 UTC 2025 * Automated update of relay data - Sun Nov 30 06:24:08 UTC 2025 * Automated update of relay data - Sun Dec 7 06:22:59 UTC 2025 * Automated update of relay data - Sun Dec 14 06:24:33 UTC 2025 * Automated update of relay data - Sun Dec 21 06:24:49 UTC 2025 * Automated update of relay data - Sun Dec 28 06:25:38 UTC 2025 * Automated update of relay data - Sun Jan 4 06:26:28 UTC 2026 * Automated update of relay data - Sun Jan 11 06:26:19 UTC 2026 * feat: Add BitchatSheetTopBar component * Refactor: Use BitchatSheetTopBar in bottom sheets This commit refactors several bottom sheet components to use the new reusable `BitchatSheetTopBar` composable. This change provides a consistent look and feel for top bars across the following sheets: - DebugSettingsSheet - MeshPeerListSheet - LocationNotesSheet - LocationChannelsSheet - LocationNotesSheetPresenter (for the location unavailable state) The `BitchatSheetCenterTopBar` has been removed as its functionality is now covered by the more flexible `BitchatSheetTopBar`. * Refactor: Use BitchatSheetTopBar in MeshPeerListSheet --------- Co-authored-by: GitHub Action <action@github.com> * spacing for geohash sheet text (#623) * feat: Introduce Gradle property to control APK splits (#560) * feat: Introduce Gradle property to control APK splits This commit introduces a new Gradle project property, `buildSplitApks`, to conditionally enable or disable the generation of ABI-specific (arm64, x86_64) and universal APKs. Key changes: - In `app/build.gradle.kts`, the `splits.abi.isEnable` flag is now dynamically set based on the `buildSplitApks` property. - APK splitting is disabled by default to support standard Android App Bundle (`bundleRelease`) builds. - The release workflow (`release.yml`) is updated to pass `-PbuildSplitApks=true` when building release APKs for GitHub. - The general Android build workflow (`android-build.yml`) is also modified to enable splits only for the `Release` variant, ensuring debug builds are not affected. * chore: Simplify and automate APK split builds This commit simplifies the build process by automatically enabling ABI splits for APKs (`assemble`) and disabling them for AABs (`bundle`). This removes the need to manually pass the `-PbuildSplitApks=true` property. The build script now intelligently determines whether to create architecture-specific APKs based on the task being executed (e.g., `assembleRelease` vs. `bundleRelease`). The GitHub Actions workflows (`release.yml`, `android-build.yml`) have been updated to remove this now-redundant property, streamlining the CI configuration. * bump to 1.7.0 (#624) * Fix location update in notes sheet and Nostr subscription leaks (#636) - Fixes #634: Trigger location refresh when LocationNotesSheet opens - Fixes #635: Implement proper subscription diffing and cleanup in GeohashViewModel * Automated update of relay data - Sun Jan 18 06:07:42 UTC 2026 * Automated update of relay data - Sun Jan 25 06:07:49 UTC 2026 * fix(geohash): cleanup sampling subscriptions when closing location sheet (#659) - Added DisposableEffect to LocationChannelsSheet to ensure cleanup runs when composable is removed - Fixed ChatViewModel.endGeohashSampling() to correctly delegate to GeohashViewModel instead of being a no-op - Prevents lingering kind 20001 subscriptions after closing the sheet * Automated update of relay data - Sun Feb 1 06:19:30 UTC 2026 * fix: pause geohash heartbeat (kind 20001) sampling when backgrounded (#660) * bump (#661) * edit * Automated update of relay data - Sun Feb 8 06:20:17 UTC 2026 * Automated update of relay data - Sun Feb 15 06:19:54 UTC 2026 * Automated update of relay data - Sun Feb 22 06:17:38 UTC 2026 * chore: change license from MIT to GPLv3 (#674) * Automated update of relay data - Sun Mar 1 06:17:19 UTC 2026 * voice note player ui (#680) * Unit tests for BinaryProtocol (#678) * Comprehensive tests for BinaryProtocol * Test feedback * Automated update of relay data - Sun Mar 8 06:15:21 UTC 2026 * Automated update of relay data - Sun Mar 15 06:23:41 UTC 2026 * Automated update of relay data - Sun Mar 22 06:19:38 UTC 2026 * update input button styling (#682) * fix: use CSPRNG for heartbeat intervals (#685) * Add 'Send private message' action to chat user sheet (#649) * Add 'Send private message' action to chat user sheet * Change 'Send private message' action color to purple * Fix: Resolve short ID against all participants, not just cached nicknames * Fix: Correctly open private chat sheet for mesh peers --------- Co-authored-by: a1denvalu3 <> * fix: add input validation for protocol decoding and fragment reassembly (#666) * fix: add input validation for protocol decoding and fragment reassembly * fix:subtract the old entry's size before adding the new one, so duplicate/retransmitted fragments don't inflate the counter * fix: FragmentManager.handleFragment() can be entered concurrently (e.g., fragments for the same fragmentID arriving from multiple peers/relays). In order for this to happen multiple devices would be needed to connect to the mesh. even with a per-fragmentID byte cap, an attacker could open many fragment IDs at once and force the device to buffer lots of fragment data overall (risking memory pressure/OOM). In this fix we made fragments atomic and thread safe. When a fragment index is retransmitted, we compute the size delta (new - old) so duplicates don’t inflate counters and can’t be used to bypass limits. Under heavy load/attack the app will drop/reject fragment sets earlier instead of growing memory usage without bound to reduce risk of oom. tested on pixel 6 and pixel 8. * fix: add fragmenttest * Automated update of relay data - Sun Mar 29 06:27:23 UTC 2026 * update version (#688) * Revert "update input button styling (#682)" (#689) This reverts commitce86f4ec2f. * Revert "voice note player ui (#680)" (#690) This reverts commitbc8909bab0. * Automated update of relay data - Sun Apr 5 06:29:20 UTC 2026 * Automated update of relay data - Sun Apr 12 06:35:32 UTC 2026 * Automated update of relay data - Sun Apr 19 06:37:36 UTC 2026 * Automated update of relay data - Sun Apr 26 06:41:15 UTC 2026 * Automated update of relay data - Sun May 3 06:54:32 UTC 2026 * Automated update of relay data - Sun May 10 06:58:05 UTC 2026 * Automated update of relay data - Sun May 17 07:01:09 UTC 2026 * Automated update of relay data - Sun May 24 07:09:35 UTC 2026 * Automated update of relay data - Sun May 31 07:22:04 UTC 2026 * Automated update of relay data - Sun Jun 7 07:27:22 UTC 2026 * Fix request sync duplicate public messages (#707) Co-authored-by: CC <cc@ggg.local> * wifi aware working * more robust * fragments work * new files * wifi improvements * handshake works * fix(sync): resolve hash alignment and early break bugs on GCS filter trimming (#708) * fix(sync): resolve hash alignment and early break bugs on GCS filter trimming * fix(sync): filter out zero bucket and deduplicate buckets before encoding * fix(sync): preserve zero-bucket mapping by clamping 0 to 1 during encoding and decoding * improve * wifi mesh more robust * fix review * check for support * wifi aware fixes --------- Co-authored-by: GitHub Action <action@github.com> Co-authored-by: Rebroad <rebroad@users.noreply.github.com> Co-authored-by: kargathara Aakash <97916053+Kargatharaakash@users.noreply.github.com> Co-authored-by: yet300 <96379204+yet300@users.noreply.github.com> Co-authored-by: aidenvalu3 <erendentman@gmail.com> Co-authored-by: Moe Hamade <69801237+moehamade@users.noreply.github.com> Co-authored-by: a1denvalu3 <43107113+a1denvalu3@users.noreply.github.com> Co-authored-by: Anupam Kumar <101698944+anupamjhabbl@users.noreply.github.com> Co-authored-by: Clare Kinery <39098724+kinerycl@users.noreply.github.com> Co-authored-by: rollforsats <59777267+rollforsats@users.noreply.github.com> Co-authored-by: Ovi <137784219+0x0v1@users.noreply.github.com> Co-authored-by: CC <cc@ggg.local>
This commit is contained in:
co-authored by
GitHub Action
Rebroad
kargathara Aakash
yet300
aidenvalu3
Moe Hamade
a1denvalu3
Anupam Kumar
Clare Kinery
rollforsats
Ovi
CC
parent
35030a05d8
commit
f6ac7b59ad
@@ -1,6 +1,7 @@
|
||||
name: Android CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ "main", "develop" ]
|
||||
pull_request:
|
||||
|
||||
@@ -38,17 +38,20 @@ jobs:
|
||||
- name: Grant execute permission for Gradlew
|
||||
run: chmod +x ./gradlew
|
||||
|
||||
- name: Build Release APK (with Tor)
|
||||
- name: Build Release APKs (with architecture splits)
|
||||
run: ./gradlew assembleRelease --no-daemon --stacktrace
|
||||
|
||||
- name: List APK files
|
||||
run: |
|
||||
echo "APK files built:"
|
||||
find app/build/outputs/apk -name "*.apk" -type f
|
||||
find app/build/outputs/apk/release -name "*.apk" -type f -exec ls -lh {} \;
|
||||
|
||||
- name: Rename APK
|
||||
- name: Rename APKs for GitHub Release
|
||||
run: |
|
||||
mv app/build/outputs/apk/release/app-release-unsigned.apk app/build/outputs/apk/release/bitchat-android.apk
|
||||
cd app/build/outputs/apk/release
|
||||
[ -f "app-arm64-v8a-release-unsigned.apk" ] && mv app-arm64-v8a-release-unsigned.apk bitchat-android-arm64.apk
|
||||
[ -f "app-x86_64-release-unsigned.apk" ] && mv app-x86_64-release-unsigned.apk bitchat-android-x86_64.apk
|
||||
[ -f "app-universal-release-unsigned.apk" ] && mv app-universal-release-unsigned.apk bitchat-android-universal.apk
|
||||
|
||||
- name: DEBUG
|
||||
run: |
|
||||
@@ -59,8 +62,8 @@ jobs:
|
||||
ls -all
|
||||
tree || ls -R
|
||||
|
||||
# Optional: Sign APK (requires secrets)
|
||||
# - name: Sign APK
|
||||
# Optional: Sign APKs (uncomment and configure secrets when ready)
|
||||
# - name: Sign APKs
|
||||
# uses: r0adkll/sign-android-release@v1
|
||||
# with:
|
||||
# releaseDirectory: app/build/outputs/apk/release
|
||||
@@ -69,10 +72,10 @@ jobs:
|
||||
# keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
||||
# keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
|
||||
- name: Upload APK as artifact
|
||||
- name: Upload APKs as artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bitchat-android-apk-${{ github.ref_name }}
|
||||
name: bitchat-android-release-${{ github.ref_name }}
|
||||
path: app/build/outputs/apk/release/*.apk
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
@@ -82,25 +85,23 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Download APK artifact
|
||||
- name: Download release artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: bitchat-android-apk-${{ github.ref_name }}
|
||||
name: bitchat-android-release-${{ github.ref_name }}
|
||||
path: release
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
release/bitchat-android.apk
|
||||
release/bitchat-android-arm64.apk
|
||||
release/bitchat-android-x86_64.apk
|
||||
release/bitchat-android-universal.apk
|
||||
name: Release ${{ github.ref_name }}
|
||||
body: |
|
||||
## bitchat Android Release
|
||||
|
||||
**bitchat-android.apk** (~15MB)
|
||||
- Secure P2P messaging over Bluetooth mesh and Nostr
|
||||
- Built-in Tor support (custom Arti build with 16KB page size)
|
||||
- Compatible with Google Play requirements (Nov 2025+)
|
||||
- Cross-platform compatible with iOS version
|
||||
**bitchat-android-arm64.apk** - ARM64 (most phones)
|
||||
**bitchat-android-x86_64.apk** - x86_64 (Chromebooks, tablets)
|
||||
**bitchat-android-universal.apk** - All architectures (fallback)
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Bitchat Android - Agent Guide
|
||||
|
||||
This document provides context, architectural insights, and development standards for AI agents working on the Bitchat Android codebase.
|
||||
|
||||
## 1. Project Overview
|
||||
**Bitchat** is a decentralized, off-grid communication application focused on privacy and censorship resistance. It utilizes mesh networking (primarily Bluetooth LE and Tor/Arti) to enable peer-to-peer messaging without centralized servers.
|
||||
|
||||
**Key Technologies:**
|
||||
- **Language:** Kotlin (JVM Target 1.8)
|
||||
- **UI Framework:** Jetpack Compose (Material 3)
|
||||
- **Asynchronous:** Kotlin Coroutines & Flow
|
||||
- **Networking:** Bluetooth Low Energy (BLE), Tor (Arti Rust bridge), OkHttp
|
||||
- **Architecture:** MVVM with Clean Architecture principles
|
||||
- **Build System:** Gradle (Kotlin DSL)
|
||||
|
||||
## 2. Architecture & Directory Structure
|
||||
The application follows a clean architecture pattern, heavily modularized by feature within the `app` module.
|
||||
|
||||
**Root Package:** `com.bitchat.android`
|
||||
|
||||
| Directory | Purpose |
|
||||
|-----------|---------|
|
||||
| `ui/` | **Presentation Layer**: Jetpack Compose screens, themes, and ViewModels. |
|
||||
| `service/` | **Core Service**: Contains `MeshForegroundService`, managing persistent background connectivity. |
|
||||
| `mesh/` | **Mesh Networking**: Logic for peer discovery, advertising, and message routing. |
|
||||
| `protocol/` | **Wire Protocol**: Definitions of messages exchanged between peers. |
|
||||
| `crypto/` | **Security**: Cryptographic primitives and key management. |
|
||||
| `noise/` | **Encryption**: Implementation of the Noise Protocol Framework for secure channels. |
|
||||
| `identity/` | **User Identity**: Management of user profiles and public/private keys. |
|
||||
| `features/` | **App Features**: Sub-modules for `voice`, `file`, and `media` handling. |
|
||||
| `nostr/` | **Relay Integration**: Logic for Nostr protocol integration and relay management. |
|
||||
| `geohash/` | **Location**: Utilities for location-based features and geohashing. |
|
||||
| `net/` | **Networking**: General network utilities and abstractions. |
|
||||
|
||||
## 3. Key Components
|
||||
|
||||
### UI Layer (Jetpack Compose)
|
||||
- **Activity**: Single-Activity architecture (`MainActivity.kt`).
|
||||
- **Navigation**: Jetpack Compose Navigation.
|
||||
- **State Management**: `ViewModel` exposing `StateFlow` to Composables.
|
||||
- **Theme**: Custom theme definitions in `ui/theme`.
|
||||
|
||||
### Networking & Connectivity
|
||||
- **MeshForegroundService**: The critical component that keeps the mesh network alive. It manages the lifecycle of BLE scanning/advertising and other transport layers.
|
||||
- **BLE Stack**: Located in `mesh/` and `net/`, handles the intricacies of Android Bluetooth interactions.
|
||||
- **Tor/Arti**: Integrated via JNI (`jniLibs`) to provide anonymous internet routing where available.
|
||||
|
||||
## 4. Development Standards
|
||||
|
||||
### Code Style
|
||||
- **Kotlin**: Adhere to official Kotlin coding conventions.
|
||||
- **Compose**: Use functional components. Hoist state to ViewModels where possible.
|
||||
- **Coroutines**: Use `suspend` functions for all I/O operations. strictly avoid blocking the main thread.
|
||||
- **Naming**: Clear, descriptive names. Follow standard Android naming patterns (e.g., `*ViewModel`, `*Repository`, `*Screen`).
|
||||
|
||||
### Testing
|
||||
- **Unit Tests**: Located in `app/src/test/`. Use for business logic, protocols, and utility testing.
|
||||
- **Instrumented Tests**: Located in `app/src/androidTest/`. Use for UI and permission integration testing.
|
||||
- **Execution**:
|
||||
- Unit: `./gradlew test`
|
||||
- Instrumented: `./gradlew connectedAndroidTest`
|
||||
|
||||
## 5. Critical Constraints & Gotchas
|
||||
1. **Permissions**: The app relies heavily on dangerous runtime permissions (Location, Bluetooth Scan/Connect/Advertise, Audio Recording). Always verify permission handling patterns in `MainActivity` or permission wrappers before adding new hardware features.
|
||||
2. **Hardware Dependency**: Features like BLE are difficult to emulate. When writing code for these, focus on robust error handling and defensive programming as hardware behavior can be flaky.
|
||||
3. **Background Limits**: Android enforces strict background execution limits. Network operations intended to persist must be tied to the `MeshForegroundService`.
|
||||
|
||||
## 6. Common Tasks
|
||||
- **Build Debug APK**: `./gradlew assembleDebug`
|
||||
- **Lint Check**: `./gradlew lint`
|
||||
- **Clean Build**: `./gradlew clean`
|
||||
|
||||
---
|
||||
*Note: This file is intended to assist AI agents in navigating and modifying the codebase efficiently. Always verify context by reading the actual files before making changes.*
|
||||
+670
-17
@@ -1,21 +1,674 @@
|
||||
MIT License
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (c) 2025
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
Preamble
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
||||
+22
-7
@@ -8,7 +8,10 @@ bitchat is designed with privacy as its foundation. We believe private communica
|
||||
|
||||
## Summary
|
||||
|
||||
**WE DO NOT COLLECT ANY INFORMATION.**
|
||||
|
||||
- **No personal data collection** - We don't collect names, emails, or phone numbers
|
||||
- **No location data collection** - Location is accessed only for local processing (BLE/Geohash) and is never collected or sent to us
|
||||
- **Hybrid Functionality** - bitchat offers two modes of communication:
|
||||
- **Bluetooth Mesh Chat**: This mode is completely offline, using peer-to-peer Bluetooth connections. It does not use any servers or internet connection.
|
||||
- **Geohash Chat**: This mode uses an internet connection to communicate with others in a specific geographic area. It relies on Nostr relays for message transport.
|
||||
@@ -68,7 +71,8 @@ When you join a password-protected room:
|
||||
|
||||
bitchat **never**:
|
||||
- Collects personal information
|
||||
- Tracks your location
|
||||
- Collects location history
|
||||
- Transmits any data to us (the developers)
|
||||
- Stores data on servers
|
||||
- Shares data with third parties
|
||||
- Uses analytics or telemetry
|
||||
@@ -91,13 +95,24 @@ You have complete control:
|
||||
- **No Account**: Nothing to delete from servers because there are none
|
||||
- **Portability**: Your data never leaves your device unless you export it
|
||||
|
||||
## Bluetooth & Permissions
|
||||
## Location Data & Permissions
|
||||
|
||||
bitchat requires Bluetooth permission to function:
|
||||
- Used only for peer-to-peer communication
|
||||
- No location data is accessed or stored
|
||||
- Bluetooth is not used for tracking
|
||||
- You can revoke this permission at any time in system settings
|
||||
To provide the core functionality of bitchat, we access your device's location data. This access is necessary for the following specific purposes:
|
||||
|
||||
### 1. Bluetooth Low Energy (BLE) Scanning
|
||||
- **Why we need it:** The Android operating system requires Location permission to scan for nearby Bluetooth LE devices (especially on Android 11 and lower). This is a system-level requirement because Bluetooth scans can theoretically be used to derive location.
|
||||
- **How we use it:** We use this permission strictly to discover other bitchat peers nearby for the "Bluetooth Mesh Chat" mode.
|
||||
- **Privacy protection:** We do not record or store your location during this process. The data is processed instantaneously by the Android system to facilitate the connection.
|
||||
|
||||
### 2. Geohash Chat Functionality
|
||||
- **Why we need it:** The "Geohash Chat" mode allows you to communicate with others in your approximate geographic area.
|
||||
- **How we use it:** If you enable this mode, we access your location to calculate a "geohash" (a short alphanumeric string representing a geographic region). This geohash is used to find and subscribe to relevant channels on decentralized Nostr relays.
|
||||
- **Privacy protection:**
|
||||
- Your precise GPS coordinates are **never** sent to any server or peer.
|
||||
- Only the coarse geohash (representing an area, not a pinpoint) is shared with the Nostr network.
|
||||
- You can use the "Bluetooth Mesh Chat" mode without this feature if you prefer.
|
||||
|
||||
**We do not collect, store, or share your location history.** Location data is processed locally on your device to enable these specific features.
|
||||
|
||||
## Children's Privacy
|
||||
|
||||
|
||||
+32
-8
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "com.bitchat.droid"
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||
versionCode = 27
|
||||
versionName = "1.6.0"
|
||||
versionCode = 33
|
||||
versionName = "1.7.2"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -33,7 +33,7 @@ android {
|
||||
debug {
|
||||
ndk {
|
||||
// Include x86_64 for emulator support during development
|
||||
abiFilters += listOf("arm64-v8a", "x86_64")
|
||||
abiFilters += listOf("arm64-v8a", "x86_64", "armeabi-v7a", "x86")
|
||||
}
|
||||
}
|
||||
release {
|
||||
@@ -43,13 +43,27 @@ android {
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
ndk {
|
||||
// ARM64-only to minimize APK size (~5.8MB savings)
|
||||
// Excludes x86_64 as emulator not needed for production builds
|
||||
abiFilters += listOf("arm64-v8a")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// APK splits for GitHub releases - creates arm64, x86_64, and universal APKs
|
||||
// AAB for Play Store handles architecture distribution automatically
|
||||
// Auto-detects: splits enabled for assemble tasks, disabled for bundle tasks
|
||||
// Works in Android Studio GUI and CLI without needing extra properties
|
||||
val enableSplits = gradle.startParameter.taskNames.any { taskName ->
|
||||
taskName.contains("assemble", ignoreCase = true) &&
|
||||
!taskName.contains("bundle", ignoreCase = true)
|
||||
}
|
||||
|
||||
splits {
|
||||
abi {
|
||||
isEnable = enableSplits
|
||||
reset()
|
||||
include("arm64-v8a", "x86_64", "armeabi-v7a", "x86")
|
||||
isUniversalApk = true // For F-Droid and fallback
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
@@ -84,12 +98,22 @@ dependencies {
|
||||
|
||||
// Lifecycle
|
||||
implementation(libs.bundles.lifecycle)
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
|
||||
// Navigation
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
|
||||
// Permissions
|
||||
implementation(libs.accompanist.permissions)
|
||||
|
||||
// QR
|
||||
implementation(libs.zxing.core)
|
||||
implementation(libs.mlkit.barcode.scanning)
|
||||
|
||||
// CameraX
|
||||
implementation(libs.androidx.camera.camera2)
|
||||
implementation(libs.androidx.camera.lifecycle)
|
||||
implementation(libs.androidx.camera.compose)
|
||||
|
||||
// Cryptography
|
||||
implementation(libs.bundles.cryptography)
|
||||
|
||||
Vendored
+5
@@ -26,3 +26,8 @@
|
||||
-keepnames class org.torproject.arti.**
|
||||
-dontwarn info.guardianproject.arti.**
|
||||
-dontwarn org.torproject.arti.**
|
||||
|
||||
# Fix for AbstractMethodError on API < 29 where LocationListener methods are abstract
|
||||
-keepclassmembers class * implements android.location.LocationListener {
|
||||
public <methods>;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<!-- Location permission required for BLE scanning -->
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
|
||||
<!-- Notification permissions -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
@@ -34,10 +35,14 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
<!-- Data sync foreground service type (required when declaring dataSync) -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<!-- Location foreground service type required for BLE scanning in background -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<!-- Microphone for voice notes -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<!-- Camera for QR verification -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<!-- Storage permissions for file sharing -->
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
|
||||
@@ -56,6 +61,7 @@
|
||||
<uses-feature android:name="android.hardware.bluetooth" android:required="true" />
|
||||
<!-- Device support hint for Wi‑Fi Aware (optional) -->
|
||||
<uses-feature android:name="android.hardware.wifi.aware" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
<permission
|
||||
android:name="com.bitchat.android.permission.FORCE_FINISH"
|
||||
@@ -98,13 +104,19 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="bitchat" android:host="verify" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Persistent foreground service to run the mesh in background -->
|
||||
<service
|
||||
android:name=".service.MeshForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="connectedDevice|dataSync"
|
||||
android:foregroundServiceType="connectedDevice|dataSync|location"
|
||||
tools:ignore="DataExtractionRules">
|
||||
</service>
|
||||
|
||||
|
||||
@@ -1,295 +1,441 @@
|
||||
Relay URL,Latitude,Longitude
|
||||
strfry.shock.network,39.0438,-77.4874
|
||||
wot.nostr.party,36.1627,-86.7816
|
||||
nostr.blankfors.se,60.1699,24.9384
|
||||
nostr.now,36.55,139.733
|
||||
nostr.simplex.icu,51.5121,-0.0005238
|
||||
relay.threenine.services,51.5524,-0.29686
|
||||
relay.barine.co,43.6532,-79.3832
|
||||
theoutpost.life,64.1476,-21.9392
|
||||
purplerelay.com,50.1109,8.68213
|
||||
nostrue.com,40.8054,-74.0241
|
||||
relay-arg.zombi.cloudrodion.com,1.35208,103.82
|
||||
shu02.shugur.net,21.4902,39.2246
|
||||
relayone.geektank.ai,18.2148,-63.0574
|
||||
relay03.lnfi.network,39.0997,-94.5786
|
||||
soloco.nl,43.6532,-79.3832
|
||||
relay-testnet.k8s.layer3.news,37.3387,-121.885
|
||||
nostr.azzamo.net,52.2633,21.0283
|
||||
nostr.red5d.dev,43.6532,-79.3832
|
||||
nostr.thebiglake.org,32.71,-96.6745
|
||||
nostr.tadryanom.me,43.6532,-79.3832
|
||||
schnorr.me,43.6532,-79.3832
|
||||
ithurtswhenip.ee,51.223,6.78245
|
||||
orly.ft.hn,50.4754,12.3683
|
||||
relay02.lnfi.network,39.0997,-94.5786
|
||||
relay.nosto.re,51.1792,5.89444
|
||||
strfry.openhoofd.nl,51.9229,4.40833
|
||||
relays.diggoo.com,43.6532,-79.3832
|
||||
relay.nostrhub.tech,49.0291,8.35696
|
||||
relay.artx.market,43.652,-79.3633
|
||||
relay.sigit.io,50.4754,12.3683
|
||||
nostr-relay.psfoundation.info,39.0438,-77.4874
|
||||
wot.brightbolt.net,47.6735,-116.781
|
||||
relay.bitcoinartclock.com,50.4754,12.3683
|
||||
khatru.nostrver.se,51.1792,5.89444
|
||||
relay.cosmicbolt.net,37.3986,-121.964
|
||||
adre.su,59.9311,30.3609
|
||||
nostrelay.memory-art.xyz,43.6532,-79.3832
|
||||
temp.iris.to,43.6532,-79.3832
|
||||
nproxy.kristapsk.lv,60.1699,24.9384
|
||||
relay.ngengine.org,43.6532,-79.3832
|
||||
relay.puresignal.news,43.6532,-79.3832
|
||||
nostr.stakey.net,52.3676,4.90414
|
||||
nostr.commonshub.brussels,49.4543,11.0746
|
||||
wot.shaving.kiwi,43.6532,-79.3832
|
||||
espelho.girino.org,43.6532,-79.3832
|
||||
relay.thibautduchene.fr,43.6532,-79.3832
|
||||
nostr-relay.corb.net,38.8353,-104.822
|
||||
wot.soundhsa.com,33.1384,-95.6011
|
||||
relay.jabato.space,52.52,13.405
|
||||
relay.credenso.cafe,43.3601,-80.3127
|
||||
relayone.soundhsa.com,33.1384,-95.6011
|
||||
relay.bitcoinveneto.org,64.1466,-21.9426
|
||||
relay.samt.st,40.8302,-74.1299
|
||||
relay.vrtmrz.net,43.6532,-79.3832
|
||||
nostr.data.haus,50.4754,12.3683
|
||||
relay.npubhaus.com,43.6532,-79.3832
|
||||
strfry.elswa-dev.online,50.1109,8.68213
|
||||
relay.wavefunc.live,34.0362,-118.443
|
||||
wot.sovbit.host,64.1466,-21.9426
|
||||
relay.smies.me,33.7501,-84.3885
|
||||
nostr.bilthon.dev,25.8128,-80.2377
|
||||
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
|
||||
santo.iguanatech.net,40.8302,-74.1299
|
||||
prl.plus,42.6978,23.3246
|
||||
relay-freeharmonypeople.space,38.7223,-9.13934
|
||||
nostream.breadslice.com,1.35208,103.82
|
||||
nostr.notribe.net,40.8302,-74.1299
|
||||
relay.fountain.fm,39.0997,-94.5786
|
||||
orangepiller.org,60.1699,24.9384
|
||||
nostr.21crypto.ch,47.5356,8.73209
|
||||
nostr.camalolo.com,24.1469,120.684
|
||||
okn.czas.top,51.267,6.81738
|
||||
relay.wolfcoil.com,35.6092,139.73
|
||||
nostr.quali.chat,60.1699,24.9384
|
||||
nostr-relay.online,43.6532,-79.3832
|
||||
alienos.libretechsystems.xyz,55.4724,9.87335
|
||||
ribo.eu.nostria.app,52.3676,4.90414
|
||||
wot.yesnostr.net,50.9871,2.12554
|
||||
nostr.overmind.lol,43.6532,-79.3832
|
||||
relay.chakany.systems,43.6532,-79.3832
|
||||
relay.hasenpfeffr.com,39.0438,-77.4874
|
||||
nostrelites.org,41.8781,-87.6298
|
||||
strfry.bonsai.com,37.8715,-122.273
|
||||
relay.agora.social,50.7383,15.0648
|
||||
alien.macneilmediagroup.com,43.6532,-79.3832
|
||||
relay.getsafebox.app,43.6532,-79.3832
|
||||
relay.nuts.cash,34.0362,-118.443
|
||||
notemine.io,52.2026,20.9397
|
||||
relay.nostr.place,32.7767,-96.797
|
||||
relay.21e6.cz,50.7383,15.0648
|
||||
nostr.casa21.space,43.6532,-79.3832
|
||||
premium.primal.net,43.6532,-79.3832
|
||||
relay5.bitransfer.org,43.6532,-79.3832
|
||||
nostr.rblb.it,43.7094,10.6582
|
||||
relay.goodmorningbitcoin.com,43.6532,-79.3832
|
||||
relay.camelus.app,45.5201,-122.99
|
||||
relay.origin.land,35.6673,139.751
|
||||
relay.fr13nd5.com,52.5233,13.3426
|
||||
nos.lol,50.4754,12.3683
|
||||
wot.nostr.net,43.6532,-79.3832
|
||||
relay.javi.space,43.4633,11.8796
|
||||
relay2.ngengine.org,43.6532,-79.3832
|
||||
relay.angor.io,48.1046,11.6002
|
||||
pyramid.treegaze.com,43.6532,-79.3832
|
||||
relay.letsfo.com,52.2633,21.0283
|
||||
relay.nostrzh.org,43.6532,-79.3832
|
||||
nostr.hekster.org,37.3986,-121.964
|
||||
relay.0xchat.com,1.35208,103.82
|
||||
nostr.czas.top,50.1109,8.68213
|
||||
nostr.superfriends.online,43.6532,-79.3832
|
||||
hsuite-nostr-relay.hbarsuite.workers.dev,43.6532,-79.3832
|
||||
relay.mccormick.cx,52.3563,4.95714
|
||||
nostr.88mph.life,51.5072,-0.127586
|
||||
strfry.ymir.cloud,34.0965,-117.585
|
||||
nostr.lkjsxc.com,43.6532,-79.3832
|
||||
yabu.me,35.6092,139.73
|
||||
relay.chorus.community,50.1109,8.68213
|
||||
nostr-02.yakihonne.com,1.32123,103.695
|
||||
neuromancer.nettek.io,39.1429,-94.573
|
||||
relay04.lnfi.network,39.0997,-94.5786
|
||||
nostr-relay.amethyst.name,39.0438,-77.4874
|
||||
nostrcheck.tnsor.network,43.6532,-79.3832
|
||||
wot.nostr.place,32.7767,-96.797
|
||||
cyberspace.nostr1.com,40.7057,-74.0136
|
||||
relay.guggero.org,47.3769,8.54169
|
||||
nostr.calitabby.net,39.9268,-75.0246
|
||||
x.kojira.io,43.6532,-79.3832
|
||||
nostr.agentcampfire.com,52.3676,4.90414
|
||||
relay.bullishbounty.com,43.6532,-79.3832
|
||||
relay.nostrverse.net,43.6532,-79.3832
|
||||
nostr.robosats.org,64.1476,-21.9392
|
||||
nostr-verified.wellorder.net,45.5201,-122.99
|
||||
nostr.huszonegy.world,47.4979,19.0402
|
||||
relay.cypherflow.ai,48.8566,2.35222
|
||||
nostr-relay.xbytez.io,50.6924,3.20113
|
||||
nostr.faultables.net,43.6532,-79.3832
|
||||
relay.libernet.app,43.6532,-79.3832
|
||||
relay.magiccity.live,25.8128,-80.2377
|
||||
relay.wellorder.net,45.5201,-122.99
|
||||
black.nostrcity.club,48.8575,2.35138
|
||||
relay.jeffg.fyi,43.6532,-79.3832
|
||||
freeben666.fr,43.7221,7.15296
|
||||
relay.nostr.wirednet.jp,34.706,135.493
|
||||
nostrelay.circum.space,52.3676,4.90414
|
||||
relay.islandbitcoin.com,12.8498,77.6545
|
||||
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
||||
relay.nostriot.com,41.5695,-83.9786
|
||||
relayrs.notoshi.win,43.6532,-79.3832
|
||||
nostr-pub.wellorder.net,45.5201,-122.99
|
||||
nostr.vulpem.com,49.4543,11.0746
|
||||
nostr.sathoarder.com,48.5734,7.75211
|
||||
wheat.happytavern.co,43.6532,-79.3832
|
||||
relay.nostr.net,43.6532,-79.3832
|
||||
bcast.girino.org,43.6532,-79.3832
|
||||
nostr-02.czas.top,51.2277,6.77346
|
||||
vault.iris.to,43.6532,-79.3832
|
||||
ynostr.yael.at,60.1699,24.9384
|
||||
nostr.nodesmap.com,59.3327,18.0656
|
||||
nostr.n7ekb.net,47.4941,-122.294
|
||||
relayb.uid.ovh,43.6532,-79.3832
|
||||
shu05.shugur.net,48.8566,2.35222
|
||||
dev-relay.lnfi.network,39.0997,-94.5786
|
||||
relay.bitcoindistrict.org,43.6532,-79.3832
|
||||
nostr.spicyz.io,43.6532,-79.3832
|
||||
nostr.0x7e.xyz,47.4988,8.72369
|
||||
relay.dwadziesciajeden.pl,52.2297,21.0122
|
||||
relay.zone667.com,60.1699,24.9384
|
||||
nostr-dev.wellorder.net,45.5201,-122.99
|
||||
nos.xmark.cc,50.6924,3.20113
|
||||
relay.etch.social,41.2619,-95.8608
|
||||
nostr.na.social,43.6532,-79.3832
|
||||
relay.orangepill.ovh,49.1689,-0.358841
|
||||
relay.olas.app,50.4754,12.3683
|
||||
relay.holzeis.me,43.6532,-79.3832
|
||||
relay2.angor.io,48.1046,11.6002
|
||||
relay.degmods.com,50.4754,12.3683
|
||||
vitor.nostr1.com,40.7128,-74.006
|
||||
relay.btcforplebs.com,43.6532,-79.3832
|
||||
nostr.luisschwab.net,43.6532,-79.3832
|
||||
relay.moinsen.com,50.4754,12.3683
|
||||
czas.xyz,48.8566,2.35222
|
||||
nostr.bitcoiner.social,39.1585,-94.5728
|
||||
nostr.mehdibekhtaoui.com,49.4939,-1.54813
|
||||
inbox.azzamo.net,52.2633,21.0283
|
||||
nostr.ovia.to,43.6532,-79.3832
|
||||
nostr.myshosholoza.co.za,52.3676,4.90414
|
||||
fenrir-s.notoshi.win,43.6532,-79.3832
|
||||
relay-rpi.edufeed.org,49.4521,11.0767
|
||||
chat-relay.zap-work.com,43.6532,-79.3832
|
||||
nostr.bond,50.1109,8.68213
|
||||
relay01.lnfi.network,39.0997,-94.5786
|
||||
relay.seq1.net,43.6532,-79.3832
|
||||
nostr.rikmeijer.nl,50.4754,12.3683
|
||||
offchain.pub,47.6743,-117.112
|
||||
nostr.spaceshell.xyz,43.6532,-79.3832
|
||||
nos4smartnkind.tech,40.1872,44.5152
|
||||
kotukonostr.onrender.com,37.7775,-122.397
|
||||
nostr.ps1829.com,33.8851,130.883
|
||||
relay.lightning.pub,39.0438,-77.4874
|
||||
nostr-relay.gateway.in.th,15.2634,100.344
|
||||
relay.thebluepulse.com,49.4521,11.0767
|
||||
relay.malxte.de,52.52,13.405
|
||||
relay.usefusion.ai,38.7134,-78.1591
|
||||
nostr-relay.cbrx.io,43.6532,-79.3832
|
||||
shu01.shugur.net,21.4902,39.2246
|
||||
nostr.jerrynya.fun,31.2304,121.474
|
||||
dev-nostr.bityacht.io,25.0797,121.234
|
||||
bitsat.molonlabe.holdings,51.4012,-1.3147
|
||||
relay.primal.net,43.6532,-79.3832
|
||||
relay.wavlake.com,41.2619,-95.8608
|
||||
nostr-relay.nextblockvending.com,47.2343,-119.853
|
||||
v-relay.d02.vrtmrz.net,34.6937,135.502
|
||||
nostr.mikoshi.de,47.74,12.0917
|
||||
nostr.coincards.com,53.5501,-113.469
|
||||
relay.snort.social,53.3498,-6.26031
|
||||
bitcoiner.social,39.1585,-94.5728
|
||||
nostr.noones.com,50.1109,8.68213
|
||||
wot.dergigi.com,64.1476,-21.9392
|
||||
relay.lumina.rocks,49.0291,8.35695
|
||||
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
|
||||
nostr.snowbla.de,60.1699,24.9384
|
||||
strfry.felixzieger.de,50.1013,8.62643
|
||||
relay.satlantis.io,32.8769,-80.0114
|
||||
relay-dev.satlantis.io,40.8302,-74.1299
|
||||
nostr.davidebtc.me,51.5072,-0.127586
|
||||
relay.mattybs.lol,43.6532,-79.3832
|
||||
relay.fundstr.me,42.3601,-71.0589
|
||||
relay.tagayasu.xyz,43.6715,-79.38
|
||||
nostr-relay.zimage.com,34.0549,-118.243
|
||||
nostrcheck.me,43.6532,-79.3832
|
||||
bucket.coracle.social,37.7775,-122.397
|
||||
relay.siamdev.cc,13.9178,100.424
|
||||
r.bitcoinhold.net,43.6532,-79.3832
|
||||
skeme.vanderwarker.family,40.8218,-74.45
|
||||
articles.layer3.news,37.3387,-121.885
|
||||
no.str.cr,9.92857,-84.0528
|
||||
srtrelay.c-stellar.net,43.6532,-79.3832
|
||||
relay.comcomponent.com,34.7062,135.493
|
||||
relay.illuminodes.com,47.6061,-122.333
|
||||
nostr.openhoofd.nl,51.9229,4.40833
|
||||
nostr.plantroon.com,50.1013,8.62643
|
||||
slick.mjex.me,39.048,-77.4817
|
||||
relay.minibolt.info,43.6532,-79.3832
|
||||
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
|
||||
relay.nostrdice.com,-33.8688,151.209
|
||||
wot.dtonon.com,43.6532,-79.3832
|
||||
shu04.shugur.net,25.2604,55.2989
|
||||
nostr.tavux.tech,48.8575,2.35138
|
||||
nostr-02.uid.ovh,43.6532,-79.3832
|
||||
relay.nostrcheck.me,43.6532,-79.3832
|
||||
relay.nsnip.io,60.1699,24.9384
|
||||
relay.toastr.net,40.8054,-74.0241
|
||||
relay.divine.video,43.6532,-79.3832
|
||||
nostr.4rs.nl,49.0291,8.35696
|
||||
wot.sebastix.social,51.1792,5.89444
|
||||
relay.openfarmtools.org,60.1699,24.9384
|
||||
relay.damus.io,43.6532,-79.3832
|
||||
purpura.cloud,43.6532,-79.3832
|
||||
wot.sudocarlos.com,51.5072,-0.127586
|
||||
relay.arx-ccn.com,50.4754,12.3683
|
||||
nostr.zoracle.org,45.6018,-121.185
|
||||
nostr.tac.lol,47.4748,-122.273
|
||||
relay.coinos.io,43.6532,-79.3832
|
||||
nostr.rtvslawenia.com,49.4543,11.0746
|
||||
relay.davidebtc.me,51.5072,-0.127586
|
||||
ribo.us.nostria.app,41.5868,-93.625
|
||||
relay.upleb.uk,51.9194,19.1451
|
||||
librerelay.aaroniumii.com,43.6532,-79.3832
|
||||
relay.endfiat.money,43.6532,-79.3832
|
||||
relay.nostar.org,43.6532,-79.3832
|
||||
relay.electriclifestyle.com,26.2897,-80.1293
|
||||
relay.nostrhub.fr,48.1045,11.6004
|
||||
relay.agorist.space,52.3734,4.89406
|
||||
freelay.sovbit.host,64.1476,-21.9392
|
||||
nostr.hifish.org,47.4043,8.57398
|
||||
nostr-01.yakihonne.com,1.32123,103.695
|
||||
relay.routstr.com,43.6532,-79.3832
|
||||
nr.yay.so,46.2126,6.1154
|
||||
bcast.seutoba.com.br,43.6532,-79.3832
|
||||
fanfares.nostr1.com,40.7128,-74.006
|
||||
nostr.girino.org,43.6532,-79.3832
|
||||
nostr2.girino.org,43.6532,-79.3832
|
||||
relay.notoshi.win,13.311,101.112
|
||||
nostrja-kari.heguro.com,43.6532,-79.3832
|
||||
relay.mostro.network,40.8302,-74.1299
|
||||
satsage.xyz,37.3986,-121.964
|
||||
relay.evanverma.com,40.8302,-74.1299
|
||||
nostr-2.21crypto.ch,47.5356,8.73209
|
||||
relay.mitchelltribe.com,39.0438,-77.4874
|
||||
relaynostr.breadslice.com,43.6532,-79.3832
|
||||
nostr.chaima.info,51.223,6.78245
|
||||
nostr.mom,50.4754,12.3683
|
||||
relay.nostr-check.me,43.6532,-79.3832
|
||||
relay.ditto.pub,43.6532,-79.3832
|
||||
Relay URL,Latitude,Longitude
|
||||
nostr.bitcoiner.social:443,47.6743,-117.112
|
||||
relay-dev.satlantis.io:443,40.8302,-74.1299
|
||||
relay.lightning.pub:443,39.0438,-77.4874
|
||||
ribo.us.nostria.app:443,43.6532,-79.3832
|
||||
relay.notoshi.win,13.3622,100.983
|
||||
openrelay.ziomc.com,50.0755,14.4378
|
||||
relay.agentry.com:443,42.8864,-78.8784
|
||||
nostr-relay.xbytez.io,50.6924,3.20113
|
||||
relay.gulugulu.moe,43.6532,-79.3832
|
||||
nostr.thebiglake.org:443,32.71,-96.6745
|
||||
relay.fountain.fm,43.6532,-79.3832
|
||||
freelay.sovbit.host,60.1699,24.9384
|
||||
nostr.girino.org:443,43.6532,-79.3832
|
||||
relay.openresist.com,43.6532,-79.3832
|
||||
nas01xanthosnet.synology.me:7778,47.1285,8.74735
|
||||
relay.ohstr.com:443,43.6532,-79.3832
|
||||
nostr-pub.wellorder.net,45.5201,-122.99
|
||||
relay.nostrdice.com,-33.8688,151.209
|
||||
bbw-nostr.xyz,41.5284,-87.4237
|
||||
cs-relay.nostrdev.com:443,50.4754,12.3683
|
||||
top.testrelay.top,43.6532,-79.3832
|
||||
relay.trotters.cc,43.6532,-79.3832
|
||||
blossom.gnostr.cloud,43.6532,-79.3832
|
||||
ribo.eu.nostria.app:443,43.6532,-79.3832
|
||||
public.crostr.com,43.6532,-79.3832
|
||||
relay.nostar.org,43.6532,-79.3832
|
||||
strfry.bonsai.com:443,39.0438,-77.4874
|
||||
nostr.carroarmato0.be,50.914,3.21378
|
||||
relay.endfiat.money,59.3327,18.0656
|
||||
nostr.overmind.lol:443,43.6532,-79.3832
|
||||
nostr.myshosholoza.co.za:443,52.3913,4.66545
|
||||
relay.wellorder.net,45.5201,-122.99
|
||||
nostr.tagomago.me,42.3601,-71.0589
|
||||
relay.internationalright-wing.org,-22.5022,-48.7114
|
||||
relay.fckstate.net,59.3293,18.0686
|
||||
nostr.azzamo.net,52.2633,21.0283
|
||||
relay.0xchat.com,43.6532,-79.3832
|
||||
relayone.geektank.ai,39.1008,-94.5811
|
||||
relay.homeinhk.xyz,35.694,139.754
|
||||
nostr.nodesmap.com,59.3327,18.0656
|
||||
relay.islandbitcoin.com:443,12.8498,77.6545
|
||||
relay.mrmave.work,43.6532,-79.3832
|
||||
relay.arx-ccn.com,50.4754,12.3683
|
||||
fanfares.nostr1.com:443,40.7057,-74.0136
|
||||
wot.shaving.kiwi,43.6532,-79.3832
|
||||
relay.nearhood.co.uk,51.5072,-0.127586
|
||||
relay.fundstr.me,42.3601,-71.0589
|
||||
syb.lol:443,43.6532,-79.3832
|
||||
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
|
||||
relay.nostx.io,43.6532,-79.3832
|
||||
no.str.cr,10.6352,-85.4378
|
||||
relay.jbnco.co,43.6532,-79.3832
|
||||
ribo.nostria.app:443,43.6532,-79.3832
|
||||
us-east.nostr.pikachat.org,39.0438,-77.4874
|
||||
nexus.libernet.app,43.6532,-79.3832
|
||||
nostr.dlcdevkit.com,40.0992,-83.1141
|
||||
nostr.debate.report,50.1109,8.68213
|
||||
str-define-contributing-jackets.trycloudflare.com,43.6532,-79.3832
|
||||
nostr2.girino.org,43.6532,-79.3832
|
||||
nostr.unkn0wn.world,46.8499,9.53287
|
||||
nostr.spicyz.io:443,43.6532,-79.3832
|
||||
relay.layer.systems:443,49.0291,8.35695
|
||||
nostr-relay.amethyst.name,39.0067,-77.4291
|
||||
slick.mjex.me,39.0418,-77.4744
|
||||
nostr.girino.org,43.6532,-79.3832
|
||||
nostr.quali.chat:443,60.1699,24.9384
|
||||
purplerelay.com:443,43.6532,-79.3832
|
||||
nostr.notribe.net,40.8302,-74.1299
|
||||
relay.plebeian.market:443,50.1109,8.68213
|
||||
relay.samt.st,40.8302,-74.1299
|
||||
relay.mitchelltribe.com:443,39.0438,-77.4874
|
||||
nostr.0x7e.xyz,47.4949,8.71954
|
||||
relayone.soundhsa.com:443,39.1008,-94.5811
|
||||
nostr.thalheim.io:443,60.1699,24.9384
|
||||
relay.getsafebox.app:443,43.6532,-79.3832
|
||||
nostr-relay.psfoundation.info,39.0438,-77.4874
|
||||
bucket.coracle.social,37.7775,-122.397
|
||||
nostr.carroarmato0.be:443,50.914,3.21378
|
||||
relay.staging.commonshub.brussels,49.4543,11.0746
|
||||
relay-dev.satlantis.io,40.8302,-74.1299
|
||||
relay.lacompagniemaximus.com:443,45.3147,-73.8785
|
||||
relay-rpi.edufeed.org,49.4521,11.0767
|
||||
nostr.computingcache.com:443,34.0356,-118.442
|
||||
relay.lanavault.space:443,60.1699,24.9384
|
||||
relay.getsafebox.app,43.6532,-79.3832
|
||||
nrs-02.darkcloudarcade.com:443,39.9526,-75.1652
|
||||
nostr.hekster.org,37.3986,-121.964
|
||||
node.kommonzenze.de,49.4521,11.0767
|
||||
0x-nostr-relay.fly.dev,37.7648,-122.432
|
||||
speakeasy.cellar.social,49.4543,11.0746
|
||||
nostr.0x7e.xyz:443,47.4949,8.71954
|
||||
nostr.vulpem.com,49.4543,11.0746
|
||||
relay5.bitransfer.org,43.6532,-79.3832
|
||||
relay.inforsupports.com,43.6532,-79.3832
|
||||
relay.plebeian.market,50.1109,8.68213
|
||||
shu02.shugur.net,21.4902,39.2246
|
||||
nostr.easycryptosend.it,43.6532,-79.3832
|
||||
nostr.spaceshell.xyz,43.6532,-79.3832
|
||||
relay.minibolt.info,43.6532,-79.3832
|
||||
nostr.pbfs.io:443,50.4754,12.3683
|
||||
nos.lol:443,50.4754,12.3683
|
||||
nostr.thebiglake.org,32.71,-96.6745
|
||||
relay.nostriot.com,41.5695,-83.9786
|
||||
strfry.shock.network:443,39.0438,-77.4874
|
||||
relay01.lnfi.network,35.6764,139.65
|
||||
r.0kb.io:443,32.789,-96.7989
|
||||
bcast.girino.org,43.6532,-79.3832
|
||||
nostr-relay.psfoundation.info:443,39.0438,-77.4874
|
||||
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
|
||||
testnet.samt.st,43.6532,-79.3832
|
||||
relay.bornheimer.app,51.5072,-0.127586
|
||||
nrs-02.darkcloudarcade.com,39.9526,-75.1652
|
||||
relay.binaryrobot.com:443,43.6532,-79.3832
|
||||
nostr.computingcache.com,34.0356,-118.442
|
||||
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
|
||||
schnorr.me,43.6532,-79.3832
|
||||
nostr.twinkle.lol,51.902,7.6657
|
||||
nostr.overmind.lol,43.6532,-79.3832
|
||||
relay.mmwaves.de:443,48.8575,2.35138
|
||||
relay2.veganostr.com,60.1699,24.9384
|
||||
nostr.mom,50.4754,12.3683
|
||||
nostr2.girino.org:443,43.6532,-79.3832
|
||||
wot.sudocarlos.com,43.6532,-79.3832
|
||||
dev.relay.stream,43.6532,-79.3832
|
||||
nostr.thalheim.io,60.1699,24.9384
|
||||
relay-dev.gulugulu.moe:443,43.6532,-79.3832
|
||||
conduitl2.fly.dev,37.7648,-122.432
|
||||
relay.bullishbounty.com,43.6532,-79.3832
|
||||
myvoiceourstory.org,37.3598,-121.981
|
||||
relay.angor.io,48.1046,11.6002
|
||||
r.0kb.io,32.789,-96.7989
|
||||
nexus.libernet.app:443,43.6532,-79.3832
|
||||
us-east.nostr.pikachat.org:443,39.0438,-77.4874
|
||||
nostr.bitcoiner.social,47.6743,-117.112
|
||||
nostrelay.circum.space:443,52.6907,4.8181
|
||||
relayone.soundhsa.com,39.1008,-94.5811
|
||||
nostr.snowbla.de,60.1699,24.9384
|
||||
relay1.orangesync.tech,44.7839,-106.941
|
||||
nostr-2.21crypto.ch:443,47.5356,8.73209
|
||||
espelho.girino.org,43.6532,-79.3832
|
||||
nostr.blankfors.se,60.1699,24.9384
|
||||
relay.sigit.io:443,50.4754,12.3683
|
||||
relay.vrtmrz.net:443,43.6532,-79.3832
|
||||
nostr.wecsats.io:443,43.6532,-79.3832
|
||||
nostrcity-club.fly.dev,37.7648,-122.432
|
||||
nostrelay.circum.space,52.6907,4.8181
|
||||
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
|
||||
relay.edufeed.org,49.4521,11.0767
|
||||
nostr.dlcdevkit.com:443,40.0992,-83.1141
|
||||
x.kojira.io,43.6532,-79.3832
|
||||
relay.binaryrobot.com,43.6532,-79.3832
|
||||
relay.nostrhub.fr,48.1045,11.6004
|
||||
nostr.sathoarder.com,48.5734,7.75211
|
||||
prl.plus,55.7628,37.5983
|
||||
relay.cosmicbolt.net:443,37.3986,-121.964
|
||||
relay.nostu.be:443,40.4167,-3.70329
|
||||
cs-relay.nostrdev.com,50.4754,12.3683
|
||||
satsage.xyz,37.3986,-121.964
|
||||
relay.lab.rytswd.com:443,49.4543,11.0746
|
||||
rilo.nostria.app:443,43.6532,-79.3832
|
||||
portal-relay.pareto.space,49.0291,8.35696
|
||||
relay.wavlake.com:443,41.2619,-95.8608
|
||||
relay.beginningend.com,35.2227,-97.4786
|
||||
relay.openresist.com:443,43.6532,-79.3832
|
||||
bitcoiner.social:443,47.6743,-117.112
|
||||
relay.notoshi.win:443,13.3622,100.983
|
||||
dev.relay.edufeed.org:443,49.4521,11.0767
|
||||
relay.cypherflow.ai:443,48.8575,2.35138
|
||||
relay.ru.ac.th,13.7607,100.627
|
||||
shu03.shugur.net,25.2048,55.2708
|
||||
nostr.rtvslawenia.com:443,49.4543,11.0746
|
||||
relay.agorist.space:443,52.3734,4.89406
|
||||
relay02.lnfi.network,35.6764,139.65
|
||||
relay-testnet.k8s.layer3.news:443,37.3387,-121.885
|
||||
nostr.notribe.net:443,40.8302,-74.1299
|
||||
relay.ditto.pub,43.6532,-79.3832
|
||||
nostr.rikmeijer.nl,51.7111,5.36809
|
||||
blossom.gnostr.cloud:443,43.6532,-79.3832
|
||||
nostr.oxtr.dev,50.4754,12.3683
|
||||
cache.trustr.ing,43.6548,-79.3885
|
||||
nostr.bitczat.pl,60.1699,24.9384
|
||||
relay.nostrverse.net,43.6532,-79.3832
|
||||
shu04.shugur.net,25.2048,55.2708
|
||||
eu.nostr.pikachat.org:443,49.4543,11.0746
|
||||
ribo.us.nostria.app,43.6532,-79.3832
|
||||
nostr-relay.cbrx.io,43.6532,-79.3832
|
||||
nostr.bitczat.pl:443,60.1699,24.9384
|
||||
nostr-relay.corb.net:443,38.8353,-104.822
|
||||
relay.libernet.app,43.6532,-79.3832
|
||||
nostr-verified.wellorder.net,45.5201,-122.99
|
||||
relay.nostu.be,40.4167,-3.70329
|
||||
rele.speyhard.fi,51.5072,-0.127586
|
||||
relay.staging.plebeian.market,51.5072,-0.127586
|
||||
nostr.spicyz.io,43.6532,-79.3832
|
||||
nostrride.io,37.3986,-121.964
|
||||
x.kojira.io:443,43.6532,-79.3832
|
||||
relay.staging.plebeian.market:443,51.5072,-0.127586
|
||||
relay.sigit.io,50.4754,12.3683
|
||||
nostr.stakey.net:443,52.3676,4.90414
|
||||
relay.nostr.blockhenge.com,39.0438,-77.4874
|
||||
relay.comcomponent.com,43.6532,-79.3832
|
||||
wot.nostr.place,43.6532,-79.3832
|
||||
relay.mostr.pub:443,43.6532,-79.3832
|
||||
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
|
||||
no.str.cr:443,10.6352,-85.4378
|
||||
relay.chorus.community:443,48.5333,10.7
|
||||
relay.aarpia.com,37.3986,-121.964
|
||||
relay.nostrmap.net,60.1699,24.9384
|
||||
relay.snotr.nl:49999,52.0195,4.42946
|
||||
relay.bebond.net,43.6532,-79.3832
|
||||
relay.illuminodes.com,43.6532,-79.3832
|
||||
chat-relay.zap-work.com:443,43.6532,-79.3832
|
||||
testr.nymble.world,40.8054,-74.0241
|
||||
relay.underorion.se,50.1109,8.68213
|
||||
fanfares.nostr1.com,40.7057,-74.0136
|
||||
relay.damus.io,43.6532,-79.3832
|
||||
relay.nostriches.club,43.6532,-79.3832
|
||||
nostr-dev.wellorder.net,45.5201,-122.99
|
||||
relay2.angor.io,48.1046,11.6002
|
||||
relay.openfarmtools.org,60.1699,24.9384
|
||||
wot.makenomistakes.ca,43.7064,-79.3986
|
||||
relay.thecryptosquid.com,50.4754,12.3683
|
||||
test.thedude.cloud,50.1109,8.68213
|
||||
nostr.rtvslawenia.com,49.4543,11.0746
|
||||
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
||||
relay.olas.app:443,60.1699,24.9384
|
||||
strfry.bonsai.com,39.0438,-77.4874
|
||||
relayrs.notoshi.win,43.6532,-79.3832
|
||||
articles.layer3.news,37.3387,-121.885
|
||||
wot.utxo.one,43.6532,-79.3832
|
||||
relay.angor.io:443,48.1046,11.6002
|
||||
relay.dwadziesciajeden.pl,52.2297,21.0122
|
||||
soloco.nl,43.6532,-79.3832
|
||||
armada.sharegap.net,43.6532,-79.3832
|
||||
dm-test-strfry-generic.samt.st,43.6532,-79.3832
|
||||
nostr.4rs.nl,49.0291,8.35696
|
||||
nrs-01.darkcloudarcade.com,39.1008,-94.5811
|
||||
relay.beginningend.com:443,35.2227,-97.4786
|
||||
relay.nostr.place,43.6532,-79.3832
|
||||
relay.layer.systems,49.0291,8.35695
|
||||
adre.su,59.9311,30.3609
|
||||
relay.0xchat.com:443,43.6532,-79.3832
|
||||
nostr.infero.net,35.6764,139.65
|
||||
relay.typedcypher.com:443,51.5072,-0.127586
|
||||
relay.mostro.network:443,40.8302,-74.1299
|
||||
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
|
||||
relay.mitchelltribe.com,39.0438,-77.4874
|
||||
relay.mostr.pub,43.6532,-79.3832
|
||||
bitcoiner.social,47.6743,-117.112
|
||||
nostr.tac.lol:443,47.4748,-122.273
|
||||
nostr.hekster.org:443,37.3986,-121.964
|
||||
relay.satmaxt.xyz:443,43.6532,-79.3832
|
||||
relay.cypherflow.ai,48.8575,2.35138
|
||||
relay.zone667.com:443,60.1699,24.9384
|
||||
relay.jeffg.fyi:443,43.6532,-79.3832
|
||||
relay.agorist.space,52.3734,4.89406
|
||||
nostr.spaceshell.xyz:443,43.6532,-79.3832
|
||||
top.testrelay.top:443,43.6532,-79.3832
|
||||
insta-relay.apps3.slidestr.net,40.4167,-3.70329
|
||||
relay.nostrian-conquest.com:443,41.223,-111.974
|
||||
relay.laantungir.net:443,-19.4692,-42.5315
|
||||
relay.islandbitcoin.com,12.8498,77.6545
|
||||
purplerelay.com,43.6532,-79.3832
|
||||
nostr.tac.lol,47.4748,-122.273
|
||||
nostr.wecsats.io,43.6532,-79.3832
|
||||
nostr.2b9t.xyz,34.0549,-118.243
|
||||
nostr.quali.chat,60.1699,24.9384
|
||||
relay.dreamith.to,43.6532,-79.3832
|
||||
nostr.islandarea.net:443,35.4669,-97.6473
|
||||
relay.primal.net,43.6532,-79.3832
|
||||
eu.nostr.pikachat.org,49.4543,11.0746
|
||||
relay.nostr.net,43.6532,-79.3832
|
||||
nostr.n7ekb.net,47.4941,-122.294
|
||||
relay.mulatta.io,37.5665,126.978
|
||||
nittom.nostr1.com,40.7057,-74.0136
|
||||
relay2.orangesync.tech,40.7128,-74.006
|
||||
relay.artx.market,43.6548,-79.3885
|
||||
relay.wavefunc.live,41.8781,-87.6298
|
||||
bitchat.nostr1.com,40.7057,-74.0136
|
||||
relay.ditto.pub:443,43.6532,-79.3832
|
||||
relay.wisp.talk,49.4543,11.0746
|
||||
nostrelites.org,41.8781,-87.6298
|
||||
relay.goodmorningbitcoin.com,43.6532,-79.3832
|
||||
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
|
||||
relay.chorus.community,48.5333,10.7
|
||||
relay.plebchain.club,43.6532,-79.3832
|
||||
nostr-relay.amethyst.name:443,39.0067,-77.4291
|
||||
relay.lanacoin-eternity.com,40.8302,-74.1299
|
||||
relay.edufeed.org:443,49.4521,11.0767
|
||||
relay.lacompagniemaximus.com,45.3147,-73.8785
|
||||
relay.dreamith.to:443,43.6532,-79.3832
|
||||
nostr.stakey.net,52.3676,4.90414
|
||||
nostr.n7ekb.net:443,47.4941,-122.294
|
||||
relay.dyne.org,49.0291,8.35705
|
||||
nostr.janx.com,43.6532,-79.3832
|
||||
relay.trustr.ing,43.6548,-79.3885
|
||||
relay.paulstephenborile.com:443,49.4543,11.0746
|
||||
relay.wisp.talk:443,49.4543,11.0746
|
||||
yabu.me,35.6092,139.73
|
||||
bcast.seutoba.com.br,43.6532,-79.3832
|
||||
nos.xmark.cc,50.6924,3.20113
|
||||
nos.lol,50.4754,12.3683
|
||||
relay.satmaxt.xyz,43.6532,-79.3832
|
||||
relay.endfiat.money:443,59.3327,18.0656
|
||||
relay.tapestry.ninja,40.8054,-74.0241
|
||||
relay.lab.rytswd.com,49.4543,11.0746
|
||||
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
|
||||
relay.damus.io:443,43.6532,-79.3832
|
||||
treuzkas.branruz.com,48.8575,2.35138
|
||||
nostr-01.yakihonne.com,1.32123,103.695
|
||||
relay.nostriot.com:443,41.5695,-83.9786
|
||||
nostr-relay.nextblockvending.com,47.2343,-119.853
|
||||
nostr.aruku.ovh,1.27994,103.849
|
||||
nostr.chaima.info,50.1109,8.68213
|
||||
ynostr.yael.at,60.1699,24.9384
|
||||
ynostr.yael.at:443,60.1699,24.9384
|
||||
nostr-01.yakihonne.com:443,1.32123,103.695
|
||||
spookstr2.nostr1.com,40.7057,-74.0136
|
||||
relay.trustr.ing:443,43.6548,-79.3885
|
||||
dev.relay.edufeed.org,49.4521,11.0767
|
||||
relay2.angor.io:443,48.1046,11.6002
|
||||
nostr.azzamo.net:443,52.2633,21.0283
|
||||
offchain.bostr.online,43.6532,-79.3832
|
||||
relay.zone667.com,60.1699,24.9384
|
||||
relay.veganostr.com,60.1699,24.9384
|
||||
vault.iris.to:443,43.6532,-79.3832
|
||||
relay.artx.market:443,43.6548,-79.3885
|
||||
wot.rejecttheframe.xyz,43.6532,-79.3832
|
||||
nostr.pbfs.io,50.4754,12.3683
|
||||
relay.mostro.network,40.8302,-74.1299
|
||||
strfry.shock.network,39.0438,-77.4874
|
||||
relay.klabo.world,47.2343,-119.853
|
||||
relay.fountain.fm:443,43.6532,-79.3832
|
||||
nostrja-kari.heguro.com,43.6532,-79.3832
|
||||
relaisnostr.trivaco.fr,48.5734,7.75211
|
||||
offchain.pub,39.1585,-94.5728
|
||||
relay.degmods.com,50.4754,12.3683
|
||||
nostr-relay.xbytez.io:443,50.6924,3.20113
|
||||
relay.paulstephenborile.com,49.4543,11.0746
|
||||
nittom.nostr1.com:443,40.7057,-74.0136
|
||||
dev-relay.nostreon.com,60.1699,24.9384
|
||||
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
|
||||
relay.jeffg.fyi,43.6532,-79.3832
|
||||
relay.laantungir.net,-19.4692,-42.5315
|
||||
nostr.data.haus:443,50.4754,12.3683
|
||||
wot.codingarena.top,50.4754,12.3683
|
||||
nostr.2b9t.xyz:443,34.0549,-118.243
|
||||
relay.libernet.app:443,43.6532,-79.3832
|
||||
nostr.ps1829.com,33.8851,130.883
|
||||
wot.dergigi.com,64.1476,-21.9392
|
||||
relay.olas.app,60.1699,24.9384
|
||||
relay.lanacoin-eternity.com:443,40.8302,-74.1299
|
||||
nostr.data.haus,50.4754,12.3683
|
||||
relay-dev.gulugulu.moe,43.6532,-79.3832
|
||||
relay.ohstr.com,43.6532,-79.3832
|
||||
relay.lightning.pub,39.0438,-77.4874
|
||||
relay.guggero.org,46.5971,9.59652
|
||||
testnet-relay.samt.st:443,40.8302,-74.1299
|
||||
thecitadel.nostr1.com,40.7057,-74.0136
|
||||
nostr.ps1829.com:443,33.8851,130.883
|
||||
nostr-relay.corb.net,38.8353,-104.822
|
||||
relay.npubhaus.com,43.6532,-79.3832
|
||||
nostr.21crypto.ch,47.5356,8.73209
|
||||
nostr.tadryanom.me,43.6532,-79.3832
|
||||
nostr.myshosholoza.co.za,52.3913,4.66545
|
||||
relay.bitmacro.cloud,43.6532,-79.3832
|
||||
ribo.nostria.app,43.6532,-79.3832
|
||||
relay.vrtmrz.net,43.6532,-79.3832
|
||||
syb.lol,43.6532,-79.3832
|
||||
relay.bebond.net:443,43.6532,-79.3832
|
||||
relay.agentry.com,42.8864,-78.8784
|
||||
nostr-2.21crypto.ch,47.5356,8.73209
|
||||
nostrcity-club.fly.dev:443,37.7648,-122.432
|
||||
articles.layer3.news:443,37.3387,-121.885
|
||||
relay.internationalright-wing.org:443,-22.5022,-48.7114
|
||||
nostr-relay.zimage.com,34.0549,-118.243
|
||||
chat-relay.zap-work.com,43.6532,-79.3832
|
||||
relay.mwaters.net,50.9871,2.12554
|
||||
nostr.liberty.fans,36.9104,-89.5875
|
||||
spookstr2.nostr1.com:443,40.7057,-74.0136
|
||||
nostr.chaima.info:443,50.1109,8.68213
|
||||
schnorr.me:443,43.6532,-79.3832
|
||||
ribo.eu.nostria.app,43.6532,-79.3832
|
||||
nostr.bond,50.1109,8.68213
|
||||
wot.nostr.party,36.1659,-86.7844
|
||||
temp.iris.to,43.6532,-79.3832
|
||||
relay.lotek-distro.com,43.6532,-79.3832
|
||||
relay.minibolt.info:443,43.6532,-79.3832
|
||||
relay.lanavault.space,60.1699,24.9384
|
||||
relay.mmwaves.de,48.8575,2.35138
|
||||
social.amanah.eblessing.co,48.1046,11.6002
|
||||
nostr2.thalheim.io,49.4543,11.0746
|
||||
relay.typedcypher.com,51.5072,-0.127586
|
||||
relay.cosmicbolt.net,37.3986,-121.964
|
||||
relayrs.notoshi.win:443,43.6532,-79.3832
|
||||
nostr-relay-1.trustlessenterprise.com:443,43.6532,-79.3832
|
||||
nostr.islandarea.net,35.4669,-97.6473
|
||||
relay.nostrmap.net:443,60.1699,24.9384
|
||||
relay.bullishbounty.com:443,43.6532,-79.3832
|
||||
nostr.oxtr.dev:443,50.4754,12.3683
|
||||
srtrelay.c-stellar.net,43.6532,-79.3832
|
||||
relay.mccormick.cx,52.3563,4.95714
|
||||
vault.iris.to,43.6532,-79.3832
|
||||
relay.mccormick.cx:443,52.3563,4.95714
|
||||
relay.toastr.net,40.8054,-74.0241
|
||||
nostr.hifish.org,47.4244,8.57658
|
||||
speakeasy.cellar.social:443,49.4543,11.0746
|
||||
relay.wavlake.com,41.2619,-95.8608
|
||||
testnet-relay.samt.st,40.8302,-74.1299
|
||||
aeon.libretechsystems.xyz,55.486,9.86577
|
||||
mostro-p2p.tech,50.1109,8.68213
|
||||
nostr.wild-vibes.ts.net,48.8566,2.35222
|
||||
nostr.88mph.life,52.1941,-2.21905
|
||||
relay.nostrcheck.me,43.6532,-79.3832
|
||||
rilo.nostria.app,43.6532,-79.3832
|
||||
relay.bowlafterbowl.com,32.9483,-96.7299
|
||||
relay.nostr.place:443,43.6532,-79.3832
|
||||
nostr.mom:443,50.4754,12.3683
|
||||
relay.nostrian-conquest.com,41.223,-111.974
|
||||
herbstmeister.com,34.0549,-118.243
|
||||
nrs-01.darkcloudarcade.com:443,39.1008,-94.5811
|
||||
nostr.red5d.dev,43.6532,-79.3832
|
||||
nostrbtc.com,43.6532,-79.3832
|
||||
strfry.apps3.slidestr.net,40.4167,-3.70329
|
||||
relay.sharegap.net,43.6532,-79.3832
|
||||
reraw.pbla2fish.cc,43.6532,-79.3832
|
||||
relay-testnet.k8s.layer3.news,37.3387,-121.885
|
||||
nostr.sathoarder.com:443,48.5734,7.75211
|
||||
relay.veganostr.com:443,60.1699,24.9384
|
||||
relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222
|
||||
premium.primal.net,43.6532,-79.3832
|
||||
relay.wavefunc.live:443,41.8781,-87.6298
|
||||
relay-rpi.edufeed.org:443,49.4521,11.0767
|
||||
kotukonostr.onrender.com,37.7775,-122.397
|
||||
bridge.tagomago.me,42.3601,-71.0589
|
||||
nostr.tadryanom.me:443,43.6532,-79.3832
|
||||
relay.gulugulu.moe:443,43.6532,-79.3832
|
||||
offchain.pub:443,39.1585,-94.5728
|
||||
relay.directsponsor.net,42.8864,-78.8784
|
||||
nostr.snowbla.de:443,60.1699,24.9384
|
||||
|
||||
|
@@ -43,9 +43,16 @@ class BitchatApplication : Application() {
|
||||
|
||||
// Initialize Wi‑Fi Aware controller with persisted default
|
||||
try {
|
||||
val enabled = com.bitchat.android.ui.debug.DebugPreferenceManager.getWifiAwareEnabled(false)
|
||||
val enabled = com.bitchat.android.ui.debug.DebugPreferenceManager.getWifiAwareEnabled(true)
|
||||
com.bitchat.android.wifiaware.WifiAwareController.initialize(this, enabled)
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Initialize Geohash Registries for persistence
|
||||
try {
|
||||
com.bitchat.android.nostr.GeohashAliasRegistry.initialize(this)
|
||||
com.bitchat.android.nostr.GeohashConversationRegistry.initialize(this)
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Initialize mesh service preferences
|
||||
try { com.bitchat.android.service.MeshServicePreferences.init(this) } catch (_: Exception) { }
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.onboarding.BluetoothCheckScreen
|
||||
import com.bitchat.android.onboarding.BluetoothStatus
|
||||
import com.bitchat.android.onboarding.BluetoothStatusManager
|
||||
@@ -26,6 +27,7 @@ import com.bitchat.android.onboarding.BatteryOptimizationManager
|
||||
import com.bitchat.android.onboarding.BatteryOptimizationPreferenceManager
|
||||
import com.bitchat.android.onboarding.BatteryOptimizationScreen
|
||||
import com.bitchat.android.onboarding.BatteryOptimizationStatus
|
||||
import com.bitchat.android.onboarding.BackgroundLocationPermissionScreen
|
||||
import com.bitchat.android.onboarding.InitializationErrorScreen
|
||||
import com.bitchat.android.onboarding.InitializingScreen
|
||||
import com.bitchat.android.onboarding.LocationCheckScreen
|
||||
@@ -39,7 +41,9 @@ import com.bitchat.android.ui.ChatScreen
|
||||
import com.bitchat.android.ui.ChatViewModel
|
||||
import com.bitchat.android.ui.OrientationAwareActivity
|
||||
import com.bitchat.android.ui.theme.BitchatTheme
|
||||
import com.bitchat.android.wifiaware.WifiAwareController
|
||||
import com.bitchat.android.nostr.PoWPreferenceManager
|
||||
import com.bitchat.android.services.VerificationService
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -53,12 +57,13 @@ class MainActivity : OrientationAwareActivity() {
|
||||
|
||||
// Core mesh service - provided by the foreground service holder
|
||||
private lateinit var meshService: BluetoothMeshService
|
||||
private lateinit var unifiedMeshService: MeshService
|
||||
private val mainViewModel: MainViewModel by viewModels()
|
||||
private val chatViewModel: ChatViewModel by viewModels {
|
||||
object : ViewModelProvider.Factory {
|
||||
override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return ChatViewModel(application, meshService) as T
|
||||
return ChatViewModel(application, meshService, unifiedMeshService) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,6 +117,7 @@ class MainActivity : OrientationAwareActivity() {
|
||||
// Ensure foreground service is running and get mesh instance from holder
|
||||
try { com.bitchat.android.service.MeshForegroundService.start(applicationContext) } catch (_: Exception) { }
|
||||
meshService = com.bitchat.android.service.MeshServiceHolder.getOrCreate(applicationContext)
|
||||
unifiedMeshService = com.bitchat.android.service.MeshServiceHolder.getUnifiedOrCreate(applicationContext)
|
||||
// Expose BLE mesh to Wi‑Fi Aware controller for cross-transport relays - DEPRECATED
|
||||
// Bridging is now handled by TransportBridgeService automatically
|
||||
|
||||
@@ -137,6 +143,9 @@ class MainActivity : OrientationAwareActivity() {
|
||||
activity = this,
|
||||
permissionManager = permissionManager,
|
||||
onOnboardingComplete = ::handleOnboardingComplete,
|
||||
onBackgroundLocationRequired = {
|
||||
mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION)
|
||||
},
|
||||
onOnboardingFailed = ::handleOnboardingFailed
|
||||
)
|
||||
|
||||
@@ -163,47 +172,12 @@ class MainActivity : OrientationAwareActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// Bridge Wi‑Fi Aware callbacks into ChatViewModel (reusing BLE delegate methods)
|
||||
// Keep the unified mesh delegate attached when Wi-Fi Aware starts after the UI.
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
com.bitchat.android.wifiaware.WifiAwareController.running.collect { running ->
|
||||
val svc = com.bitchat.android.wifiaware.WifiAwareController.getService()
|
||||
if (running && svc != null) {
|
||||
svc.delegate = object : com.bitchat.android.wifiaware.WifiAwareMeshDelegate {
|
||||
override fun didReceiveMessage(message: com.bitchat.android.model.BitchatMessage) {
|
||||
if (message.isPrivate) {
|
||||
message.senderPeerID?.let { pid -> com.bitchat.android.services.AppStateStore.addPrivateMessage(pid, message) }
|
||||
} else if (message.channel != null) {
|
||||
com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel, message)
|
||||
} else {
|
||||
com.bitchat.android.services.AppStateStore.addPublicMessage(message)
|
||||
}
|
||||
chatViewModel.didReceiveMessage(message)
|
||||
}
|
||||
override fun didUpdatePeerList(peers: List<String>) {
|
||||
chatViewModel.onWifiPeersUpdated(peers)
|
||||
}
|
||||
override fun didReceiveChannelLeave(channel: String, fromPeer: String) {
|
||||
chatViewModel.didReceiveChannelLeave(channel, fromPeer)
|
||||
}
|
||||
override fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) {
|
||||
chatViewModel.didReceiveDeliveryAck(messageID, recipientPeerID)
|
||||
}
|
||||
override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) {
|
||||
chatViewModel.didReceiveReadReceipt(messageID, recipientPeerID)
|
||||
}
|
||||
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
|
||||
return chatViewModel.decryptChannelMessage(encryptedContent, channel)
|
||||
}
|
||||
override fun getNickname(): String? {
|
||||
return chatViewModel.getNickname()
|
||||
}
|
||||
override fun isFavorite(peerID: String): Boolean {
|
||||
return try {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.isMutual == true
|
||||
} catch (_: Exception) { false }
|
||||
}
|
||||
}
|
||||
WifiAwareController.running.collect { running ->
|
||||
if (running && lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
|
||||
unifiedMeshService.delegate = chatViewModel
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,6 +293,21 @@ class MainActivity : OrientationAwareActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
OnboardingState.BACKGROUND_LOCATION_EXPLANATION -> {
|
||||
BackgroundLocationPermissionScreen(
|
||||
modifier = modifier,
|
||||
onContinue = {
|
||||
onboardingCoordinator.requestBackgroundLocation()
|
||||
},
|
||||
onRetry = {
|
||||
onboardingCoordinator.checkBackgroundLocationAndProceed()
|
||||
},
|
||||
onSkip = {
|
||||
onboardingCoordinator.skipBackgroundLocation()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
OnboardingState.CHECKING, OnboardingState.INITIALIZING, OnboardingState.COMPLETE -> {
|
||||
// Set up back navigation handling for the chat screen
|
||||
val backCallback = object : OnBackPressedCallback(true) {
|
||||
@@ -445,10 +434,17 @@ class MainActivity : OrientationAwareActivity() {
|
||||
if (permissionManager.isFirstTimeLaunch()) {
|
||||
Log.d("MainActivity", "First time launch, showing permission explanation")
|
||||
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
|
||||
} else if (permissionManager.areAllPermissionsGranted()) {
|
||||
Log.d("MainActivity", "Existing user with permissions, initializing app")
|
||||
mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING)
|
||||
initializeApp()
|
||||
} else if (permissionManager.areRequiredPermissionsGranted()) {
|
||||
Log.d("MainActivity", "Existing user with required permissions")
|
||||
if (permissionManager.needsBackgroundLocationPermission() &&
|
||||
!permissionManager.isBackgroundLocationGranted() &&
|
||||
!com.bitchat.android.onboarding.BackgroundLocationPreferenceManager.isSkipped(this@MainActivity)
|
||||
) {
|
||||
mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION)
|
||||
} else {
|
||||
mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING)
|
||||
initializeApp()
|
||||
}
|
||||
} else {
|
||||
Log.d("MainActivity", "Existing user missing permissions, showing explanation")
|
||||
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
|
||||
@@ -511,6 +507,8 @@ class MainActivity : OrientationAwareActivity() {
|
||||
Log.d("MainActivity", "Location services enabled by user")
|
||||
mainViewModel.updateLocationLoading(false)
|
||||
mainViewModel.updateLocationStatus(LocationStatus.ENABLED)
|
||||
// Ensure Wi-Fi Aware starts now that location is enabled
|
||||
com.bitchat.android.wifiaware.WifiAwareController.startIfPossible()
|
||||
checkBatteryOptimizationAndProceed()
|
||||
}
|
||||
|
||||
@@ -714,14 +712,15 @@ class MainActivity : OrientationAwareActivity() {
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Set up mesh service delegate and start services
|
||||
meshService.delegate = chatViewModel
|
||||
meshService.startServices()
|
||||
// Set up unified mesh delegate and start enabled transports
|
||||
unifiedMeshService.delegate = chatViewModel
|
||||
unifiedMeshService.startServices()
|
||||
|
||||
Log.d("MainActivity", "Mesh service started successfully")
|
||||
|
||||
// Handle any notification intent
|
||||
handleNotificationIntent(intent)
|
||||
handleVerificationIntent(intent)
|
||||
|
||||
// Small delay to ensure mesh service is fully initialized
|
||||
delay(500)
|
||||
@@ -750,6 +749,7 @@ class MainActivity : OrientationAwareActivity() {
|
||||
// Handle notification intents when app is already running
|
||||
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
||||
handleNotificationIntent(intent)
|
||||
handleVerificationIntent(intent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -758,14 +758,12 @@ class MainActivity : OrientationAwareActivity() {
|
||||
// Check Bluetooth and Location status on resume and handle accordingly
|
||||
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
||||
// Reattach mesh delegate to new ChatViewModel instance after Activity recreation
|
||||
try { meshService.delegate = chatViewModel } catch (_: Exception) { }
|
||||
// Set app foreground state
|
||||
meshService.connectionManager.setAppBackgroundState(false)
|
||||
chatViewModel.setAppBackgroundState(false)
|
||||
try { unifiedMeshService.delegate = chatViewModel } catch (_: Exception) { }
|
||||
|
||||
// Check if Bluetooth was disabled while app was backgrounded
|
||||
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
|
||||
if (currentBluetoothStatus != BluetoothStatus.ENABLED && !mainViewModel.isBluetoothCheckSkipped.value) {
|
||||
val bleRequired = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
|
||||
if (bleRequired && currentBluetoothStatus != BluetoothStatus.ENABLED && !mainViewModel.isBluetoothCheckSkipped.value) {
|
||||
Log.w("MainActivity", "Bluetooth disabled while app was backgrounded")
|
||||
mainViewModel.updateBluetoothStatus(currentBluetoothStatus)
|
||||
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
|
||||
@@ -780,6 +778,9 @@ class MainActivity : OrientationAwareActivity() {
|
||||
mainViewModel.updateLocationStatus(currentLocationStatus)
|
||||
mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK)
|
||||
mainViewModel.updateLocationLoading(false)
|
||||
} else {
|
||||
// If location is enabled, ensure Wi-Fi Aware starts if it was blocked by location earlier
|
||||
com.bitchat.android.wifiaware.WifiAwareController.startIfPossible()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -788,11 +789,8 @@ class MainActivity : OrientationAwareActivity() {
|
||||
super.onPause()
|
||||
// Only set background state if app is fully initialized
|
||||
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
||||
// Set app background state
|
||||
meshService.connectionManager.setAppBackgroundState(true)
|
||||
chatViewModel.setAppBackgroundState(true)
|
||||
// Detach UI delegate so the foreground service can own DM notifications while UI is closed
|
||||
try { meshService.delegate = null } catch (_: Exception) { }
|
||||
try { unifiedMeshService.delegate = null } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -818,8 +816,9 @@ class MainActivity : OrientationAwareActivity() {
|
||||
if (peerID != null) {
|
||||
Log.d("MainActivity", "Opening private chat with $senderNickname (peerID: $peerID) from notification")
|
||||
|
||||
// Open the private chat with this peer
|
||||
chatViewModel.startPrivateChat(peerID)
|
||||
// Open the private chat sheet with this peer
|
||||
chatViewModel.showMeshPeerList()
|
||||
chatViewModel.showPrivateChatSheet(peerID)
|
||||
|
||||
// Clear notifications for this sender since user is now viewing the chat
|
||||
chatViewModel.clearNotificationsForSender(peerID)
|
||||
@@ -855,6 +854,17 @@ class MainActivity : OrientationAwareActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleVerificationIntent(intent: Intent) {
|
||||
val uri = intent.data ?: return
|
||||
if (uri.scheme != "bitchat" || uri.host != "verify") return
|
||||
|
||||
chatViewModel.showVerificationSheet()
|
||||
val qr = VerificationService.verifyScannedQR(uri.toString())
|
||||
if (qr != null) {
|
||||
chatViewModel.beginQRVerification(qr)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.bitchat.android.core.ui.component.sheet
|
||||
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BitchatBottomSheet(
|
||||
modifier: Modifier = Modifier,
|
||||
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
onDismissRequest: () -> Unit,
|
||||
content: @Composable (ColumnScope.() -> Unit),
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
modifier = modifier.statusBarsPadding(),
|
||||
onDismissRequest = onDismissRequest,
|
||||
sheetState = sheetState,
|
||||
dragHandle = null,
|
||||
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.bitchat.android.core.ui.component.sheet
|
||||
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitchat.android.core.ui.component.button.CloseButton
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BitchatSheetTopBar(
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
backgroundAlpha: Float = 0.98f,
|
||||
title: @Composable () -> Unit,
|
||||
navigationIcon: (@Composable () -> Unit)? = null,
|
||||
actions: @Composable RowScope.() -> Unit = {}
|
||||
) {
|
||||
TopAppBar(
|
||||
title = title,
|
||||
navigationIcon = { navigationIcon?.invoke() },
|
||||
actions = {
|
||||
actions()
|
||||
CloseButton(
|
||||
onClick = onClose,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background.copy(alpha = backgroundAlpha),
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
actionIconContentColor = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BitchatSheetCenterTopBar(
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
backgroundAlpha: Float = 0.98f,
|
||||
title: @Composable () -> Unit,
|
||||
navigationIcon: (@Composable () -> Unit)? = null,
|
||||
actions: @Composable RowScope.() -> Unit = {}
|
||||
) {
|
||||
CenterAlignedTopAppBar(
|
||||
title = title,
|
||||
navigationIcon = { navigationIcon?.invoke() },
|
||||
actions = {
|
||||
actions()
|
||||
CloseButton(
|
||||
onClick = onClose,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background.copy(alpha = backgroundAlpha),
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
actionIconContentColor = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BitchatSheetTitle(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
package com.bitchat.android.crypto
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import com.bitchat.android.noise.NoiseEncryptionService
|
||||
import org.bouncycastle.crypto.AsymmetricCipherKeyPair
|
||||
import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator
|
||||
@@ -11,6 +15,7 @@ import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
|
||||
import org.bouncycastle.crypto.signers.Ed25519Signer
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import androidx.core.content.edit
|
||||
|
||||
/**
|
||||
* Encryption service that now uses NoiseEncryptionService internally
|
||||
@@ -19,29 +24,55 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
* This is the main interface for all encryption/decryption operations in bitchat.
|
||||
* It now uses the Noise protocol for secure transport encryption with proper session management.
|
||||
*/
|
||||
class EncryptionService(private val context: Context) {
|
||||
open class EncryptionService(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "EncryptionService"
|
||||
private const val ED25519_PRIVATE_KEY_PREF = "ed25519_signing_private_key"
|
||||
private const val OLD_PREFS_NAME = "bitchat_crypto"
|
||||
private const val SECURE_PREFS_NAME = "bitchat_crypto_secure"
|
||||
}
|
||||
|
||||
// Core Noise encryption service
|
||||
private val noiseService: NoiseEncryptionService = NoiseEncryptionService(context)
|
||||
private val noiseService: NoiseEncryptionService by lazy { NoiseEncryptionService(context) }
|
||||
|
||||
// Session tracking for established connections
|
||||
private val establishedSessions = ConcurrentHashMap<String, String>() // peerID -> fingerprint
|
||||
|
||||
// Ed25519 signing keys (separate from Noise static keys)
|
||||
private val ed25519PrivateKey: Ed25519PrivateKeyParameters
|
||||
private val ed25519PublicKey: Ed25519PublicKeyParameters
|
||||
private lateinit var ed25519PrivateKey: Ed25519PrivateKeyParameters
|
||||
private lateinit var ed25519PublicKey: Ed25519PublicKeyParameters
|
||||
|
||||
// Callbacks for UI state updates
|
||||
var onSessionEstablished: ((String) -> Unit)? = null // peerID
|
||||
var onSessionLost: ((String) -> Unit)? = null // peerID
|
||||
var onHandshakeRequired: ((String) -> Unit)? = null // peerID
|
||||
private lateinit var prefs: SharedPreferences
|
||||
|
||||
init {
|
||||
initialize()
|
||||
}
|
||||
|
||||
private fun setUpEncryptedPrefs() {
|
||||
val masterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
// Create encrypted shared preferences
|
||||
prefs = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
SECURE_PREFS_NAME,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialization logic moved to method to allow overriding in tests
|
||||
*/
|
||||
protected open fun initialize() {
|
||||
setUpEncryptedPrefs()
|
||||
// Initialize or load Ed25519 signing keys
|
||||
val keyPair = loadOrCreateEd25519KeyPair()
|
||||
ed25519PrivateKey = keyPair.private as Ed25519PrivateKeyParameters
|
||||
@@ -135,9 +166,14 @@ class EncryptionService(private val context: Context) {
|
||||
|
||||
// Clear Ed25519 signing key from preferences
|
||||
try {
|
||||
val prefs = context.getSharedPreferences("bitchat_crypto", Context.MODE_PRIVATE)
|
||||
prefs.edit().remove(ED25519_PRIVATE_KEY_PREF).apply()
|
||||
prefs.edit { remove(ED25519_PRIVATE_KEY_PREF) }
|
||||
Log.d(TAG, "🗑️ Cleared Ed25519 signing keys from preferences")
|
||||
|
||||
// Generate new keys immediately
|
||||
val keyPair = loadOrCreateEd25519KeyPair()
|
||||
ed25519PrivateKey = keyPair.private as Ed25519PrivateKeyParameters
|
||||
ed25519PublicKey = keyPair.public as Ed25519PublicKeyParameters
|
||||
Log.d(TAG, "✅ Rotated Ed25519 signing keys in memory")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to clear Ed25519 keys: ${e.message}")
|
||||
}
|
||||
@@ -356,7 +392,7 @@ class EncryptionService(private val context: Context) {
|
||||
/**
|
||||
* Verify Ed25519 signature against data using a public key
|
||||
*/
|
||||
fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean {
|
||||
open fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean {
|
||||
return try {
|
||||
val publicKey = Ed25519PublicKeyParameters(publicKeyBytes, 0)
|
||||
val verifier = Ed25519Signer()
|
||||
@@ -377,13 +413,14 @@ class EncryptionService(private val context: Context) {
|
||||
* Load existing Ed25519 key pair from preferences or create a new one
|
||||
*/
|
||||
private fun loadOrCreateEd25519KeyPair(): AsymmetricCipherKeyPair {
|
||||
// Migrate legacy plaintext Ed25519 key to encrypted storage if present
|
||||
migrateOldEd25519KeyIfNeeded()
|
||||
try {
|
||||
val prefs = context.getSharedPreferences("bitchat_crypto", Context.MODE_PRIVATE)
|
||||
val storedKey = prefs.getString(ED25519_PRIVATE_KEY_PREF, null)
|
||||
|
||||
|
||||
if (storedKey != null) {
|
||||
// Load existing key
|
||||
val privateKeyBytes = android.util.Base64.decode(storedKey, android.util.Base64.DEFAULT)
|
||||
val privateKeyBytes = Base64.decode(storedKey, Base64.DEFAULT)
|
||||
val privateKey = Ed25519PrivateKeyParameters(privateKeyBytes, 0)
|
||||
val publicKey = privateKey.generatePublicKey()
|
||||
Log.d(TAG, "✅ Loaded existing Ed25519 signing key pair")
|
||||
@@ -394,18 +431,21 @@ class EncryptionService(private val context: Context) {
|
||||
}
|
||||
|
||||
// Create new key pair
|
||||
return generateAndSaveEd25519KeyPair()
|
||||
}
|
||||
|
||||
fun generateAndSaveEd25519KeyPair(): AsymmetricCipherKeyPair {
|
||||
val keyGen = Ed25519KeyPairGenerator()
|
||||
keyGen.init(Ed25519KeyGenerationParameters(SecureRandom()))
|
||||
val keyPair = keyGen.generateKeyPair()
|
||||
|
||||
|
||||
// Store private key in preferences
|
||||
try {
|
||||
val privateKey = keyPair.private as Ed25519PrivateKeyParameters
|
||||
val privateKeyBytes = privateKey.encoded
|
||||
val encodedKey = android.util.Base64.encodeToString(privateKeyBytes, android.util.Base64.DEFAULT)
|
||||
|
||||
val prefs = context.getSharedPreferences("bitchat_crypto", Context.MODE_PRIVATE)
|
||||
prefs.edit().putString(ED25519_PRIVATE_KEY_PREF, encodedKey).apply()
|
||||
val encodedKey = Base64.encodeToString(privateKeyBytes, Base64.DEFAULT)
|
||||
|
||||
prefs.edit { putString(ED25519_PRIVATE_KEY_PREF, encodedKey) }
|
||||
Log.d(TAG, "✅ Created and stored new Ed25519 signing key pair")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to store Ed25519 private key: ${e.message}")
|
||||
@@ -413,4 +453,25 @@ class EncryptionService(private val context: Context) {
|
||||
|
||||
return keyPair
|
||||
}
|
||||
|
||||
private fun migrateOldEd25519KeyIfNeeded() {
|
||||
try {
|
||||
// old existing plain text preference
|
||||
val oldPrefs = context.getSharedPreferences(OLD_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
val oldKey = oldPrefs.getString(ED25519_PRIVATE_KEY_PREF, null)
|
||||
|
||||
if (oldKey != null && !prefs.contains(ED25519_PRIVATE_KEY_PREF)) {
|
||||
prefs.edit {
|
||||
putString(ED25519_PRIVATE_KEY_PREF, oldKey)
|
||||
}
|
||||
oldPrefs.edit {
|
||||
remove(ED25519_PRIVATE_KEY_PREF)
|
||||
}
|
||||
Log.d(TAG, "🔁 Migrated Ed25519 key to EncryptedSharedPreferences")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "⚠️ Failed to migrate Ed25519 key; generating new identity: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +197,9 @@ object FileUtils {
|
||||
): String {
|
||||
val lowerMime = file.mimeType.lowercase()
|
||||
val isImage = lowerMime.startsWith("image/")
|
||||
val baseDir = context.filesDir
|
||||
// FIX: Use cacheDir instead of filesDir to prevent storage exhaustion attacks (Issue #592)
|
||||
// Files in cacheDir are eligible for automatic system cleanup when space is low
|
||||
val baseDir = context.cacheDir
|
||||
val subdir = if (isImage) "images/incoming" else "files/incoming"
|
||||
val dir = java.io.File(baseDir, subdir).apply { mkdirs() }
|
||||
|
||||
@@ -271,4 +273,54 @@ object FileUtils {
|
||||
else -> com.bitchat.android.model.BitchatMessageType.File
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively delete all media files (incoming and outgoing)
|
||||
* Used for Panic Mode cleanup
|
||||
*/
|
||||
fun clearAllMedia(context: Context) {
|
||||
try {
|
||||
// Clear files dir subdirectories (legacy storage and outgoing)
|
||||
val filesDir = context.filesDir
|
||||
val dirsToClear = listOf(
|
||||
"files/incoming",
|
||||
"files/outgoing",
|
||||
"images/incoming",
|
||||
"images/outgoing",
|
||||
"voicenotes"
|
||||
)
|
||||
|
||||
dirsToClear.forEach { subDir ->
|
||||
val dir = File(filesDir, subDir)
|
||||
if (dir.exists()) {
|
||||
dir.deleteRecursively()
|
||||
Log.d(TAG, "Deleted media directory from filesDir: $subDir")
|
||||
}
|
||||
}
|
||||
|
||||
// Clear cache dir subdirectories (new incoming storage)
|
||||
// Note: cacheDir.deleteRecursively() below would handle this, but being explicit ensures these
|
||||
// specific media folders are targeted even if full cache clear fails or is modified later.
|
||||
val cacheDir = context.cacheDir
|
||||
val cacheDirsToClear = listOf(
|
||||
"files/incoming",
|
||||
"images/incoming"
|
||||
)
|
||||
|
||||
cacheDirsToClear.forEach { subDir ->
|
||||
val dir = File(cacheDir, subDir)
|
||||
if (dir.exists()) {
|
||||
dir.deleteRecursively()
|
||||
Log.d(TAG, "Deleted media directory from cacheDir: $subDir")
|
||||
}
|
||||
}
|
||||
|
||||
// Also clear entire cache dir as a catch-all
|
||||
context.cacheDir.deleteRecursively()
|
||||
Log.d(TAG, "Cleared entire cache directory")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to clear media files", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.bitchat.android.geohash
|
||||
|
||||
import android.content.Context
|
||||
import android.location.Address
|
||||
import android.location.Geocoder
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import java.util.Locale
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
class AndroidGeocoderProvider(context: Context) : GeocoderProvider {
|
||||
private val geocoder = Geocoder(context, Locale.getDefault())
|
||||
private val TAG = "AndroidGeocoderProvider"
|
||||
|
||||
override suspend fun getFromLocation(latitude: Double, longitude: Double, maxResults: Int): List<Address> {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
try {
|
||||
geocoder.getFromLocation(
|
||||
latitude,
|
||||
longitude,
|
||||
maxResults,
|
||||
object : Geocoder.GeocodeListener {
|
||||
override fun onGeocode(addresses: MutableList<Address>) {
|
||||
if (cont.isActive) cont.resume(addresses)
|
||||
}
|
||||
|
||||
override fun onError(errorMessage: String?) {
|
||||
if (cont.isActive) {
|
||||
Log.e(TAG, "Geocode error: $errorMessage")
|
||||
cont.resume(emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (cont.isActive) cont.resumeWithException(e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
try {
|
||||
geocoder.getFromLocation(latitude, longitude, maxResults) ?: emptyList()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Geocode failed", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.bitchat.android.geohash
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.app.ActivityCompat
|
||||
import com.google.android.gms.location.*
|
||||
|
||||
class FusedLocationProvider(private val context: Context) : LocationProvider {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FusedLocationProvider"
|
||||
}
|
||||
|
||||
private val fusedLocationClient: FusedLocationProviderClient = LocationServices.getFusedLocationProviderClient(context)
|
||||
|
||||
// Map to keep track of callbacks to remove them later
|
||||
private val activeCallbacks = mutableMapOf<(Location) -> Unit, LocationCallback>()
|
||||
|
||||
private fun hasLocationPermission(): Boolean {
|
||||
return ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
||||
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun getLastKnownLocation(callback: (Location?) -> Unit) {
|
||||
if (!hasLocationPermission()) {
|
||||
callback(null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
fusedLocationClient.lastLocation
|
||||
.addOnSuccessListener { location ->
|
||||
callback(location)
|
||||
}
|
||||
.addOnFailureListener { e ->
|
||||
Log.e(TAG, "Error getting last known fused location: ${e.message}")
|
||||
callback(null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Exception getting last known fused location: ${e.message}")
|
||||
callback(null)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun requestFreshLocation(callback: (Location?) -> Unit) {
|
||||
if (!hasLocationPermission()) {
|
||||
callback(null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val request = CurrentLocationRequest.Builder()
|
||||
.setPriority(Priority.PRIORITY_HIGH_ACCURACY)
|
||||
.setDurationMillis(30000)
|
||||
.build()
|
||||
|
||||
fusedLocationClient.getCurrentLocation(request, null)
|
||||
.addOnSuccessListener { location ->
|
||||
callback(location)
|
||||
}
|
||||
.addOnFailureListener { e ->
|
||||
Log.e(TAG, "Error getting fresh fused location: ${e.message}")
|
||||
callback(null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Exception getting fresh fused location: ${e.message}")
|
||||
callback(null)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun requestLocationUpdates(
|
||||
intervalMs: Long,
|
||||
minDistanceMeters: Float,
|
||||
callback: (Location) -> Unit
|
||||
) {
|
||||
if (!hasLocationPermission()) return
|
||||
|
||||
try {
|
||||
val request = LocationRequest.Builder(intervalMs)
|
||||
.setMinUpdateDistanceMeters(minDistanceMeters)
|
||||
.setPriority(Priority.PRIORITY_HIGH_ACCURACY)
|
||||
.build()
|
||||
|
||||
val locationCallback = object : LocationCallback() {
|
||||
override fun onLocationResult(result: LocationResult) {
|
||||
result.lastLocation?.let { callback(it) }
|
||||
}
|
||||
}
|
||||
|
||||
synchronized(activeCallbacks) {
|
||||
activeCallbacks[callback] = locationCallback
|
||||
}
|
||||
|
||||
fusedLocationClient.requestLocationUpdates(
|
||||
request,
|
||||
locationCallback,
|
||||
Looper.getMainLooper()
|
||||
)
|
||||
Log.d(TAG, "Registered fused updates")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error requesting fused updates: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun removeLocationUpdates(callback: (Location) -> Unit) {
|
||||
try {
|
||||
val locationCallback = synchronized(activeCallbacks) {
|
||||
activeCallbacks.remove(callback)
|
||||
}
|
||||
|
||||
if (locationCallback != null) {
|
||||
fusedLocationClient.removeLocationUpdates(locationCallback)
|
||||
Log.d(TAG, "Removed fused updates")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error removing fused updates: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
try {
|
||||
synchronized(activeCallbacks) {
|
||||
for ((callback, locationCallback) in activeCallbacks) {
|
||||
fusedLocationClient.removeLocationUpdates(locationCallback)
|
||||
}
|
||||
activeCallbacks.clear()
|
||||
}
|
||||
Log.d(TAG, "Cancelled all fused updates")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error cancelling fused provider: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.bitchat.android.geohash
|
||||
|
||||
import android.content.Context
|
||||
import android.location.Geocoder
|
||||
|
||||
/**
|
||||
* Factory to provide the best available geocoder.
|
||||
*/
|
||||
object GeocoderFactory {
|
||||
fun get(context: Context): GeocoderProvider {
|
||||
// If Google Play Services Geocoder is present, use it.
|
||||
// Otherwise, fall back to OpenStreetMap.
|
||||
return if (Geocoder.isPresent()) {
|
||||
AndroidGeocoderProvider(context)
|
||||
} else {
|
||||
OpenStreetMapGeocoderProvider()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.bitchat.android.geohash
|
||||
|
||||
import android.location.Address
|
||||
|
||||
/**
|
||||
* Interface for reverse geocoding providers.
|
||||
*/
|
||||
interface GeocoderProvider {
|
||||
/**
|
||||
* Get a list of Address objects from latitude and longitude.
|
||||
*/
|
||||
suspend fun getFromLocation(latitude: Double, longitude: Double, maxResults: Int): List<Address>
|
||||
}
|
||||
@@ -167,12 +167,11 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
|
||||
if (gh.isEmpty()) return
|
||||
if (_bookmarkNames.value?.containsKey(gh) == true) return
|
||||
if (resolving.contains(gh)) return
|
||||
if (!Geocoder.isPresent()) return
|
||||
|
||||
resolving.add(gh)
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val geocoder = Geocoder(context, Locale.getDefault())
|
||||
val geocoderProvider = GeocoderFactory.get(context)
|
||||
val name: String? = if (gh.length <= 2) {
|
||||
// Composite admin name from multiple points
|
||||
val b = Geohash.decodeToBounds(gh)
|
||||
@@ -186,9 +185,8 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
|
||||
val admins = linkedSetOf<String>()
|
||||
for (loc in points) {
|
||||
try {
|
||||
@Suppress("DEPRECATION")
|
||||
val list = geocoder.getFromLocation(loc.latitude, loc.longitude, 1)
|
||||
val a = list?.firstOrNull()
|
||||
val list = geocoderProvider.getFromLocation(loc.latitude, loc.longitude, 1)
|
||||
val a = list.firstOrNull()
|
||||
val admin = a?.adminArea?.takeIf { !it.isNullOrEmpty() }
|
||||
val country = a?.countryName?.takeIf { !it.isNullOrEmpty() }
|
||||
if (admin != null) admins.add(admin)
|
||||
@@ -203,9 +201,8 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
|
||||
}
|
||||
} else {
|
||||
val center = Geohash.decodeToCenter(gh)
|
||||
@Suppress("DEPRECATION")
|
||||
val list = geocoder.getFromLocation(center.first, center.second, 1)
|
||||
val a = list?.firstOrNull()
|
||||
val list = geocoderProvider.getFromLocation(center.first, center.second, 1)
|
||||
val a = list.firstOrNull()
|
||||
pickNameForLength(gh.length, a)
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,18 @@ data class GeohashChannel(
|
||||
*/
|
||||
sealed class ChannelID {
|
||||
object Mesh : ChannelID()
|
||||
data class Location(val channel: GeohashChannel) : ChannelID()
|
||||
data class Location(val channel: GeohashChannel) : ChannelID() {
|
||||
companion object {
|
||||
fun fromPersisted(levelName: String, geohash: String): Location? {
|
||||
return try {
|
||||
val level = GeohashChannelLevel.valueOf(levelName)
|
||||
Location(GeohashChannel(level, geohash))
|
||||
} catch (_: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Human readable name for UI.
|
||||
|
||||
@@ -2,20 +2,25 @@ package com.bitchat.android.geohash
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Geocoder
|
||||
import android.location.Location
|
||||
import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.core.app.ActivityCompat
|
||||
import com.google.android.gms.common.ConnectionResult
|
||||
import com.google.android.gms.common.GoogleApiAvailability
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.*
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonSyntaxException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/**
|
||||
* Manages location permissions, one-shot location retrieval, and computing geohash channels.
|
||||
@@ -38,22 +43,45 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
|
||||
// State enum matching iOS
|
||||
enum class PermissionState {
|
||||
NOT_DETERMINED,
|
||||
DENIED,
|
||||
RESTRICTED,
|
||||
AUTHORIZED
|
||||
}
|
||||
|
||||
private val locationManager: LocationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
private val geocoder: Geocoder = Geocoder(context, Locale.getDefault())
|
||||
private val locationProvider: LocationProvider
|
||||
private val geocoderProvider: GeocoderProvider = GeocoderFactory.get(context)
|
||||
private var lastLocation: Location? = null
|
||||
private var refreshTimer: Job? = null
|
||||
private var isGeocoding: Boolean = false
|
||||
private var geocodingJob: Job? = null
|
||||
private val gson = Gson()
|
||||
private var dataManager: com.bitchat.android.ui.DataManager? = null
|
||||
|
||||
private fun checkSystemLocationEnabled(): Boolean {
|
||||
return try {
|
||||
locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) ||
|
||||
locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private val locationStateReceiver = object : android.content.BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: android.content.Intent?) {
|
||||
if (intent?.action == LocationManager.PROVIDERS_CHANGED_ACTION) {
|
||||
val isEnabled = checkSystemLocationEnabled()
|
||||
Log.d(TAG, "System location state changed: $isEnabled")
|
||||
_systemLocationEnabled.value = isEnabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val locationUpdateCallback: (Location) -> Unit = { location ->
|
||||
onLocationUpdated(location)
|
||||
}
|
||||
|
||||
// Published state for UI bindings (matching iOS @Published properties)
|
||||
private val _permissionState = MutableStateFlow(PermissionState.NOT_DETERMINED)
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
|
||||
private val _permissionState = MutableStateFlow(PermissionState.DENIED)
|
||||
val permissionState: StateFlow<PermissionState> = _permissionState
|
||||
|
||||
private val _availableChannels = MutableStateFlow<List<GeohashChannel>>(emptyList())
|
||||
@@ -74,12 +102,40 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
private val _locationServicesEnabled = MutableStateFlow(false)
|
||||
val locationServicesEnabled: StateFlow<Boolean> = _locationServicesEnabled
|
||||
|
||||
private val _systemLocationEnabled = MutableStateFlow(checkSystemLocationEnabled())
|
||||
val systemLocationEnabled: StateFlow<Boolean> = _systemLocationEnabled
|
||||
|
||||
val effectiveLocationEnabled: StateFlow<Boolean> = combine(
|
||||
locationServicesEnabled,
|
||||
systemLocationEnabled
|
||||
) { appToggle, systemToggle ->
|
||||
appToggle && systemToggle
|
||||
}.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
false
|
||||
)
|
||||
|
||||
|
||||
init {
|
||||
updatePermissionState()
|
||||
// Choose the best location provider
|
||||
val availability = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context)
|
||||
locationProvider = if (availability == ConnectionResult.SUCCESS) {
|
||||
Log.i(TAG, "Using FusedLocationProvider (Google Play Services)")
|
||||
FusedLocationProvider(context)
|
||||
} else {
|
||||
Log.i(TAG, "Using SystemLocationProvider (Native LocationManager)")
|
||||
SystemLocationProvider(context)
|
||||
}
|
||||
|
||||
checkAndSyncPermission()
|
||||
// Initialize DataManager and load persisted settings
|
||||
dataManager = com.bitchat.android.ui.DataManager(context)
|
||||
loadPersistedChannelSelection()
|
||||
loadLocationServicesState()
|
||||
|
||||
// Register for system location changes
|
||||
context.registerReceiver(locationStateReceiver, IntentFilter(LocationManager.PROVIDERS_CHANGED_ACTION))
|
||||
}
|
||||
|
||||
// MARK: - Public API (matching iOS interface)
|
||||
@@ -91,26 +147,19 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
fun enableLocationChannels() {
|
||||
Log.d(TAG, "enableLocationChannels() called")
|
||||
|
||||
// UNIFIED FIX: Check if location services are enabled by user
|
||||
if (!isLocationServicesEnabled()) {
|
||||
Log.w(TAG, "Location services disabled by user - not requesting location")
|
||||
if (!_locationServicesEnabled.value || !_systemLocationEnabled.value) {
|
||||
Log.w(TAG, "Location services disabled (app or system) - not requesting location")
|
||||
return
|
||||
}
|
||||
|
||||
when (getCurrentPermissionStatus()) {
|
||||
PermissionState.NOT_DETERMINED -> {
|
||||
Log.d(TAG, "Permission not determined - user needs to grant in app settings")
|
||||
_permissionState.value = PermissionState.NOT_DETERMINED
|
||||
}
|
||||
PermissionState.DENIED, PermissionState.RESTRICTED -> {
|
||||
Log.d(TAG, "Permission denied or restricted")
|
||||
_permissionState.value = PermissionState.DENIED
|
||||
}
|
||||
PermissionState.AUTHORIZED -> {
|
||||
Log.d(TAG, "Permission authorized - requesting location")
|
||||
_permissionState.value = PermissionState.AUTHORIZED
|
||||
requestOneShotLocation()
|
||||
}
|
||||
|
||||
if (getCurrentPermissionStatus() == PermissionState.AUTHORIZED) {
|
||||
Log.d(TAG, "Permission authorized - requesting location")
|
||||
_permissionState.value = PermissionState.AUTHORIZED
|
||||
requestOneShotLocation()
|
||||
} else {
|
||||
Log.d(TAG, "Permission not granted")
|
||||
_permissionState.value = PermissionState.DENIED
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,35 +173,30 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin periodic one-shot location refreshes while a selector UI is visible
|
||||
* Begin real-time location updates while a selector UI is visible
|
||||
* Uses requestLocationUpdates for continuous updates, plus a one-shot to prime state immediately
|
||||
*/
|
||||
fun beginLiveRefresh(interval: Long = 5000L) {
|
||||
Log.d(TAG, "Beginning live refresh with interval ${interval}ms")
|
||||
|
||||
Log.d(TAG, "Beginning live refresh (continuous updates)")
|
||||
|
||||
if (_permissionState.value != PermissionState.AUTHORIZED) {
|
||||
Log.w(TAG, "Cannot start live refresh - permission not authorized")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (!isLocationServicesEnabled()) {
|
||||
Log.w(TAG, "Cannot start live refresh - location services disabled by user")
|
||||
Log.w(TAG, "Cannot start live refresh - location services disabled")
|
||||
return
|
||||
}
|
||||
|
||||
// Cancel existing timer
|
||||
refreshTimer?.cancel()
|
||||
// Register for continuous updates from available provider
|
||||
locationProvider.requestLocationUpdates(
|
||||
intervalMs = interval,
|
||||
minDistanceMeters = 5f,
|
||||
callback = locationUpdateCallback
|
||||
)
|
||||
|
||||
// Start new timer with coroutines
|
||||
refreshTimer = CoroutineScope(Dispatchers.IO).launch {
|
||||
while (isActive) {
|
||||
if (isLocationServicesEnabled()) {
|
||||
requestOneShotLocation()
|
||||
}
|
||||
delay(interval)
|
||||
}
|
||||
}
|
||||
|
||||
// Kick off immediately
|
||||
// Prime state immediately with last known / current location
|
||||
requestOneShotLocation()
|
||||
}
|
||||
|
||||
@@ -161,8 +205,7 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
*/
|
||||
fun endLiveRefresh() {
|
||||
Log.d(TAG, "Ending live refresh")
|
||||
refreshTimer?.cancel()
|
||||
refreshTimer = null
|
||||
locationProvider.removeLocationUpdates(locationUpdateCallback)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,8 +253,8 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
_locationServicesEnabled.value = true
|
||||
saveLocationServicesState(true)
|
||||
|
||||
// If we have permission, start location operations
|
||||
if (_permissionState.value == PermissionState.AUTHORIZED) {
|
||||
// If we have permission and system location is on, start location operations
|
||||
if (_permissionState.value == PermissionState.AUTHORIZED && systemLocationEnabled.value) {
|
||||
requestOneShotLocation()
|
||||
}
|
||||
}
|
||||
@@ -240,178 +283,76 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
/**
|
||||
* Check if location services are enabled by the user
|
||||
*/
|
||||
/**
|
||||
* Check if both the app toggle and system location are enabled
|
||||
*/
|
||||
fun isLocationServicesEnabled(): Boolean {
|
||||
return _locationServicesEnabled.value
|
||||
return _locationServicesEnabled.value && _systemLocationEnabled.value
|
||||
}
|
||||
|
||||
// MARK: - Location Operations
|
||||
|
||||
private fun requestOneShotLocation() {
|
||||
if (!hasLocationPermission()) {
|
||||
if (!checkAndSyncPermission()) {
|
||||
Log.w(TAG, "No location permission for one-shot request")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Requesting one-shot location")
|
||||
// Set loading state initially
|
||||
_isLoadingLocation.value = true
|
||||
|
||||
try {
|
||||
// Try to get last known location from all available providers
|
||||
var lastKnownLocation: Location? = null
|
||||
|
||||
// Get all available providers and try each one
|
||||
val providers = locationManager.getProviders(true)
|
||||
for (provider in providers) {
|
||||
val location = locationManager.getLastKnownLocation(provider)
|
||||
if (location != null) {
|
||||
// If we find a location, check if it's more recent than what we have
|
||||
if (lastKnownLocation == null || location.time > lastKnownLocation.time) {
|
||||
lastKnownLocation = location
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastKnownLocation == null) {
|
||||
lastKnownLocation = lastLocation;
|
||||
}
|
||||
|
||||
// Use last known location if we have one
|
||||
if (lastKnownLocation != null) {
|
||||
Log.d(TAG, "Using last known location: ${lastKnownLocation.latitude}, ${lastKnownLocation.longitude}")
|
||||
lastLocation = lastKnownLocation
|
||||
_isLoadingLocation.value = false // Make sure loading state is off
|
||||
computeChannels(lastKnownLocation)
|
||||
reverseGeocodeIfNeeded(lastKnownLocation)
|
||||
locationProvider.getLastKnownLocation { cached ->
|
||||
// If we have a cached location and it's reasonably recent (e.g. < 5 mins), use it
|
||||
// For now, we just use it if it exists, similar to previous logic
|
||||
if (cached != null) {
|
||||
Log.d(TAG, "Using last known location: ${cached.latitude}, ${cached.longitude}")
|
||||
onLocationUpdated(cached)
|
||||
} else {
|
||||
Log.d(TAG, "No last known location available")
|
||||
// Set loading state to true so UI can show a spinner
|
||||
_isLoadingLocation.value = true
|
||||
|
||||
// Request a fresh location only when we don't have a last known location
|
||||
Log.d(TAG, "Requesting fresh location...")
|
||||
requestFreshLocation()
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "Security exception requesting location: ${e.message}")
|
||||
_isLoadingLocation.value = false // Turn off loading state on error
|
||||
updatePermissionState()
|
||||
}
|
||||
}
|
||||
|
||||
// One-time location listener to get a fresh location update
|
||||
private val oneShotLocationListener = object : LocationListener {
|
||||
override fun onLocationChanged(location: Location) {
|
||||
Log.d(TAG, "Fresh location received: ${location.latitude}, ${location.longitude}")
|
||||
lastLocation = location
|
||||
computeChannels(location)
|
||||
reverseGeocodeIfNeeded(location)
|
||||
|
||||
// Update loading state to indicate we have a location now
|
||||
_isLoadingLocation.value = false
|
||||
|
||||
// Remove this listener after getting the update
|
||||
try {
|
||||
locationManager.removeUpdates(this)
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "Error removing location listener: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Request a fresh location update using getCurrentLocation instead of continuous updates
|
||||
private fun requestFreshLocation() {
|
||||
if (!hasLocationPermission()) {
|
||||
_isLoadingLocation.value = false // Turn off loading state if no permission
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Set loading state to true to indicate we're actively trying to get a location
|
||||
_isLoadingLocation.value = true
|
||||
|
||||
// Try common providers in order of preference
|
||||
val providers = listOf(
|
||||
LocationManager.GPS_PROVIDER,
|
||||
LocationManager.NETWORK_PROVIDER,
|
||||
LocationManager.PASSIVE_PROVIDER
|
||||
)
|
||||
|
||||
var providerFound = false
|
||||
for (provider in providers) {
|
||||
if (locationManager.isProviderEnabled(provider)) {
|
||||
Log.d(TAG, "Getting current location from $provider")
|
||||
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
|
||||
// For Android 11+ (API 30+), use getCurrentLocation
|
||||
locationManager.getCurrentLocation(
|
||||
provider,
|
||||
null, // No cancellation signal
|
||||
context.mainExecutor,
|
||||
{ location ->
|
||||
if (location != null) {
|
||||
Log.d(TAG, "Fresh location received: ${location.latitude}, ${location.longitude}")
|
||||
lastLocation = location
|
||||
computeChannels(location)
|
||||
reverseGeocodeIfNeeded(location)
|
||||
} else {
|
||||
Log.w(TAG, "Received null location from getCurrentLocation")
|
||||
}
|
||||
// Update loading state to indicate we have a location now
|
||||
_isLoadingLocation.value = false
|
||||
}
|
||||
)
|
||||
Log.d(TAG, "No last known location available, requesting fresh...")
|
||||
locationProvider.requestFreshLocation { fresh ->
|
||||
if (fresh != null) {
|
||||
Log.d(TAG, "Fresh location received: ${fresh.latitude}, ${fresh.longitude}")
|
||||
onLocationUpdated(fresh)
|
||||
} else {
|
||||
// For older versions, fall back to one-shot requestSingleUpdate
|
||||
locationManager.requestSingleUpdate(
|
||||
provider,
|
||||
oneShotLocationListener,
|
||||
null // Looper - null uses the main thread
|
||||
)
|
||||
Log.w(TAG, "Failed to get fresh location")
|
||||
_isLoadingLocation.value = false
|
||||
}
|
||||
|
||||
providerFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If no provider was available, turn off loading state
|
||||
if (!providerFound) {
|
||||
Log.w(TAG, "No location providers available")
|
||||
_isLoadingLocation.value = false
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "Security exception requesting location: ${e.message}")
|
||||
_isLoadingLocation.value = false // Turn off loading state on error
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error requesting location: ${e.message}")
|
||||
_isLoadingLocation.value = false // Turn off loading state on error
|
||||
}
|
||||
}
|
||||
|
||||
private fun onLocationUpdated(location: Location) {
|
||||
lastLocation = location
|
||||
_isLoadingLocation.value = false
|
||||
computeChannels(location)
|
||||
reverseGeocodeIfNeeded(location)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private fun getCurrentPermissionStatus(): PermissionState {
|
||||
return when {
|
||||
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED -> {
|
||||
PermissionState.AUTHORIZED
|
||||
}
|
||||
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED -> {
|
||||
PermissionState.AUTHORIZED
|
||||
}
|
||||
else -> {
|
||||
PermissionState.DENIED // In Android, we can't distinguish between denied and not determined after first ask
|
||||
}
|
||||
return if (checkAndSyncPermission()) {
|
||||
PermissionState.AUTHORIZED
|
||||
} else {
|
||||
PermissionState.DENIED
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePermissionState() {
|
||||
val newState = getCurrentPermissionStatus()
|
||||
Log.d(TAG, "Permission state updated to: $newState")
|
||||
_permissionState.value = newState
|
||||
}
|
||||
|
||||
private fun hasLocationPermission(): Boolean {
|
||||
return ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
||||
private fun checkAndSyncPermission(): Boolean {
|
||||
val hasPermission = ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
||||
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
val newState = if (hasPermission) PermissionState.AUTHORIZED else PermissionState.DENIED
|
||||
|
||||
if (_permissionState.value != newState) {
|
||||
Log.d(TAG, "Permission state updated to: $newState")
|
||||
_permissionState.value = newState
|
||||
}
|
||||
|
||||
return hasPermission
|
||||
}
|
||||
|
||||
private fun computeChannels(location: Location) {
|
||||
@@ -453,38 +394,29 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
}
|
||||
|
||||
private fun reverseGeocodeIfNeeded(location: Location) {
|
||||
if (!Geocoder.isPresent()) {
|
||||
Log.w(TAG, "Geocoder not present on this device")
|
||||
return
|
||||
}
|
||||
|
||||
if (isGeocoding) {
|
||||
Log.d(TAG, "Already geocoding, skipping")
|
||||
return
|
||||
}
|
||||
// Cancel any pending geocoding job to avoid race conditions
|
||||
geocodingJob?.cancel()
|
||||
|
||||
isGeocoding = true
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
geocodingJob = scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Log.d(TAG, "Starting reverse geocoding")
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val addresses = geocoder.getFromLocation(location.latitude, location.longitude, 1)
|
||||
|
||||
if (!addresses.isNullOrEmpty()) {
|
||||
val addresses = geocoderProvider.getFromLocation(location.latitude, location.longitude, 1)
|
||||
|
||||
if (!isActive) return@launch
|
||||
|
||||
if (addresses.isNotEmpty()) {
|
||||
val address = addresses[0]
|
||||
val names = namesByLevel(address)
|
||||
|
||||
Log.d(TAG, "Reverse geocoding result: $names")
|
||||
_locationNames.value = names
|
||||
} else {
|
||||
Log.w(TAG, "No reverse geocoding results")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Reverse geocoding failed: ${e.message}")
|
||||
} finally {
|
||||
isGeocoding = false
|
||||
if (e !is CancellationException) {
|
||||
Log.e(TAG, "Reverse geocoding failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -497,11 +429,13 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
dict[GeohashChannelLevel.REGION] = it
|
||||
}
|
||||
|
||||
// Province (state/province or county)
|
||||
// Province (state/province or county or city)
|
||||
address.adminArea?.takeIf { it.isNotEmpty() }?.let {
|
||||
dict[GeohashChannelLevel.PROVINCE] = it
|
||||
} ?: address.subAdminArea?.takeIf { it.isNotEmpty() }?.let {
|
||||
dict[GeohashChannelLevel.PROVINCE] = it
|
||||
} ?: address.locality?.takeIf { it.isNotEmpty() }?.let {
|
||||
dict[GeohashChannelLevel.PROVINCE] = it
|
||||
}
|
||||
|
||||
// City (locality)
|
||||
@@ -538,18 +472,14 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
private fun saveChannelSelection(channel: ChannelID) {
|
||||
try {
|
||||
val channelData = when (channel) {
|
||||
is ChannelID.Mesh -> {
|
||||
gson.toJson(mapOf("type" to "mesh"))
|
||||
}
|
||||
is ChannelID.Location -> {
|
||||
gson.toJson(mapOf(
|
||||
"type" to "location",
|
||||
"level" to channel.channel.level.name,
|
||||
"precision" to channel.channel.level.precision,
|
||||
"geohash" to channel.channel.geohash,
|
||||
"displayName" to channel.channel.level.displayName
|
||||
))
|
||||
}
|
||||
is ChannelID.Mesh -> gson.toJson(PersistedChannel(mesh = true))
|
||||
is ChannelID.Location -> gson.toJson(
|
||||
PersistedChannel(
|
||||
mesh = false,
|
||||
level = channel.channel.level.name,
|
||||
geohash = channel.channel.geohash
|
||||
)
|
||||
)
|
||||
}
|
||||
dataManager?.saveLastGeohashChannel(channelData)
|
||||
Log.d(TAG, "Saved channel selection: ${channel.displayName}")
|
||||
@@ -564,46 +494,14 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
private fun loadPersistedChannelSelection() {
|
||||
try {
|
||||
val channelData = dataManager?.loadLastGeohashChannel()
|
||||
if (channelData != null) {
|
||||
val channelMap = gson.fromJson(channelData, Map::class.java) as? Map<String, Any>
|
||||
if (channelMap != null) {
|
||||
val channel = when (channelMap["type"] as? String) {
|
||||
"mesh" -> ChannelID.Mesh
|
||||
"location" -> {
|
||||
val levelName = channelMap["level"] as? String
|
||||
val precision = (channelMap["precision"] as? Double)?.toInt()
|
||||
val geohash = channelMap["geohash"] as? String
|
||||
val displayName = channelMap["displayName"] as? String
|
||||
|
||||
if (levelName != null && precision != null && geohash != null && displayName != null) {
|
||||
try {
|
||||
val level = GeohashChannelLevel.valueOf(levelName)
|
||||
val geohashChannel = GeohashChannel(level, geohash)
|
||||
ChannelID.Location(geohashChannel)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.w(TAG, "Invalid geohash level in persisted data: $levelName")
|
||||
null
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Incomplete location channel data in persistence")
|
||||
null
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Unknown channel type in persisted data: ${channelMap["type"]}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (channel != null) {
|
||||
_selectedChannel.value = channel
|
||||
Log.d(TAG, "Restored persisted channel: ${channel.displayName}")
|
||||
} else {
|
||||
Log.d(TAG, "Could not restore persisted channel, defaulting to Mesh")
|
||||
_selectedChannel.value = ChannelID.Mesh
|
||||
}
|
||||
if (!channelData.isNullOrBlank()) {
|
||||
val persisted = gson.fromJson(channelData, PersistedChannel::class.java)
|
||||
val channel = persisted?.toChannel()
|
||||
if (channel != null) {
|
||||
_selectedChannel.value = channel
|
||||
Log.d(TAG, "Restored persisted channel: ${channel.displayName}")
|
||||
} else {
|
||||
Log.w(TAG, "Invalid channel data format in persistence")
|
||||
Log.d(TAG, "Could not restore persisted channel, defaulting to Mesh")
|
||||
_selectedChannel.value = ChannelID.Mesh
|
||||
}
|
||||
} else {
|
||||
@@ -618,7 +516,23 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
_selectedChannel.value = ChannelID.Mesh
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
data class PersistedChannel(
|
||||
val mesh: Boolean,
|
||||
val level: String? = null,
|
||||
val geohash: String? = null
|
||||
) {
|
||||
fun toChannel(): ChannelID? {
|
||||
return if (mesh) {
|
||||
ChannelID.Mesh
|
||||
} else {
|
||||
val levelName = level ?: return null
|
||||
val gh = geohash ?: return null
|
||||
ChannelID.Location.fromPersisted(levelName, gh)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear persisted channel selection (useful for testing or reset)
|
||||
*/
|
||||
@@ -662,17 +576,12 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
fun cleanup() {
|
||||
Log.d(TAG, "Cleaning up LocationChannelManager")
|
||||
endLiveRefresh()
|
||||
locationProvider.cancel()
|
||||
|
||||
// For older Android versions, remove any remaining location listener to prevent memory leaks
|
||||
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.R) {
|
||||
try {
|
||||
locationManager.removeUpdates(oneShotLocationListener)
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "Error removing location listener during cleanup: ${e.message}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error during cleanup: ${e.message}")
|
||||
}
|
||||
}
|
||||
// For Android 11+, getCurrentLocation doesn't need explicit cleanup as it's a one-time operation
|
||||
geocodingJob?.cancel()
|
||||
geocodingJob = null
|
||||
|
||||
// Unregister receiver
|
||||
try { context.unregisterReceiver(locationStateReceiver) } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.bitchat.android.geohash
|
||||
|
||||
import android.location.Location
|
||||
|
||||
/**
|
||||
* Abstraction for location providers to support both
|
||||
* System (LocationManager) and Google Play Services (FusedLocationProvider).
|
||||
*/
|
||||
interface LocationProvider {
|
||||
/**
|
||||
* Get the last known location from cache.
|
||||
* @param callback Called with the location or null if not found/error.
|
||||
*/
|
||||
fun getLastKnownLocation(callback: (Location?) -> Unit)
|
||||
|
||||
/**
|
||||
* Request a single, fresh location update.
|
||||
* @param callback Called with the location or null if failed.
|
||||
*/
|
||||
fun requestFreshLocation(callback: (Location?) -> Unit)
|
||||
|
||||
/**
|
||||
* Request continuous location updates.
|
||||
* @param intervalMs Desired interval in milliseconds.
|
||||
* @param minDistanceMeters Minimum distance in meters.
|
||||
* @param callback Called when location updates.
|
||||
*/
|
||||
fun requestLocationUpdates(intervalMs: Long, minDistanceMeters: Float, callback: (Location) -> Unit)
|
||||
|
||||
/**
|
||||
* Stop location updates.
|
||||
* @param callback The same callback instance passed to requestLocationUpdates.
|
||||
*/
|
||||
fun removeLocationUpdates(callback: (Location) -> Unit)
|
||||
|
||||
/**
|
||||
* Cancel any pending one-shot location requests and cleanup resources.
|
||||
*/
|
||||
fun cancel()
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.bitchat.android.geohash
|
||||
|
||||
import android.location.Address
|
||||
import android.util.Log
|
||||
import com.bitchat.android.net.OkHttpProvider
|
||||
import com.google.gson.Gson
|
||||
import okhttp3.Request
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class OpenStreetMapGeocoderProvider : GeocoderProvider {
|
||||
private val TAG = "OSMGeocoderProvider"
|
||||
private val gson = Gson()
|
||||
private val userAgent = "Bitchat-Android/1.0"
|
||||
|
||||
override suspend fun getFromLocation(latitude: Double, longitude: Double, maxResults: Int): List<Address> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val lang = Locale.getDefault().toLanguageTag()
|
||||
// Using format=jsonv2 for structured address breakdown
|
||||
val url = "https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=$latitude&lon=$longitude&zoom=18&addressdetails=1&accept-language=$lang"
|
||||
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("User-Agent", userAgent)
|
||||
.build()
|
||||
|
||||
val response = OkHttpProvider.httpClient().newCall(request).execute()
|
||||
if (!response.isSuccessful) {
|
||||
Log.e(TAG, "OSM Request failed: ${response.code}")
|
||||
response.close()
|
||||
return@withContext emptyList<Address>()
|
||||
}
|
||||
|
||||
val body = response.body?.string()
|
||||
response.close()
|
||||
|
||||
if (body.isNullOrEmpty()) return@withContext emptyList<Address>()
|
||||
|
||||
try {
|
||||
val osmResponse = gson.fromJson(body, OsmResponse::class.java)
|
||||
if (osmResponse?.address == null) return@withContext emptyList<Address>()
|
||||
|
||||
val address = mapToAddress(osmResponse, latitude, longitude)
|
||||
listOf(address)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "OSM Parse failed: ${e.message}")
|
||||
emptyList<Address>()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "OSM Geocoding failed", e)
|
||||
emptyList<Address>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapToAddress(res: OsmResponse, lat: Double, lon: Double): Address {
|
||||
val address = Address(Locale.getDefault())
|
||||
address.latitude = lat
|
||||
address.longitude = lon
|
||||
|
||||
val a = res.address ?: return address
|
||||
|
||||
address.countryName = a.country
|
||||
address.adminArea = a.state
|
||||
address.subAdminArea = a.county
|
||||
|
||||
// City logic similar to Google's mapping
|
||||
address.locality = a.city ?: a.town ?: a.village ?: a.hamlet
|
||||
|
||||
// Neighborhood logic
|
||||
address.subLocality = a.suburb ?: a.neighbourhood ?: a.residential ?: a.quarter
|
||||
|
||||
address.postalCode = a.postcode
|
||||
address.thoroughfare = a.road
|
||||
|
||||
// Feature name
|
||||
address.featureName = res.name
|
||||
|
||||
return address
|
||||
}
|
||||
|
||||
// Data classes for JSON parsing
|
||||
private data class OsmResponse(
|
||||
val name: String?,
|
||||
val display_name: String?,
|
||||
val address: OsmAddress?
|
||||
)
|
||||
|
||||
private data class OsmAddress(
|
||||
val country: String?,
|
||||
val state: String?,
|
||||
val county: String?,
|
||||
val city: String?,
|
||||
val town: String?,
|
||||
val village: String?,
|
||||
val hamlet: String?,
|
||||
val suburb: String?,
|
||||
val neighbourhood: String?,
|
||||
val residential: String?,
|
||||
val quarter: String?,
|
||||
val postcode: String?,
|
||||
val road: String?
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package com.bitchat.android.geohash
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.core.app.ActivityCompat
|
||||
|
||||
class SystemLocationProvider(private val context: Context) : LocationProvider {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SystemLocationProvider"
|
||||
}
|
||||
|
||||
private val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
private val handler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||
|
||||
// Map to keep track of listeners to unregister them later
|
||||
private val activeListeners = mutableMapOf<(Location) -> Unit, LocationListener>()
|
||||
private val activeOneShotListeners = mutableMapOf<(Location?) -> Unit, LocationListener>()
|
||||
private val activeOneShotRunnables = mutableMapOf<(Location?) -> Unit, Runnable>()
|
||||
|
||||
private fun hasLocationPermission(): Boolean {
|
||||
return ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
||||
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun getLastKnownLocation(callback: (Location?) -> Unit) {
|
||||
if (!hasLocationPermission()) {
|
||||
callback(null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
var bestLocation: Location? = null
|
||||
val providers = locationManager.getProviders(true)
|
||||
for (provider in providers) {
|
||||
val location = locationManager.getLastKnownLocation(provider)
|
||||
if (location != null) {
|
||||
if (bestLocation == null || location.time > bestLocation.time) {
|
||||
bestLocation = location
|
||||
}
|
||||
}
|
||||
}
|
||||
callback(bestLocation)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error getting last known location: ${e.message}")
|
||||
callback(null)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun requestFreshLocation(callback: (Location?) -> Unit) {
|
||||
if (!hasLocationPermission()) {
|
||||
callback(null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val providers = listOf(
|
||||
LocationManager.GPS_PROVIDER,
|
||||
LocationManager.NETWORK_PROVIDER,
|
||||
LocationManager.PASSIVE_PROVIDER
|
||||
)
|
||||
|
||||
var providerFound = false
|
||||
for (provider in providers) {
|
||||
if (locationManager.isProviderEnabled(provider)) {
|
||||
Log.d(TAG, "Requesting fresh location from $provider")
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
locationManager.getCurrentLocation(
|
||||
provider,
|
||||
null,
|
||||
context.mainExecutor
|
||||
) { location ->
|
||||
callback(location)
|
||||
}
|
||||
} else {
|
||||
// For older versions, use requestSingleUpdate with timeout mechanism
|
||||
val timeoutRunnable = Runnable {
|
||||
Log.w(TAG, "Location request timed out")
|
||||
synchronized(activeOneShotListeners) {
|
||||
val listener = activeOneShotListeners.remove(callback)
|
||||
activeOneShotRunnables.remove(callback)
|
||||
if (listener != null) {
|
||||
try {
|
||||
locationManager.removeUpdates(listener)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error removing timed out listener: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
callback(null)
|
||||
}
|
||||
|
||||
val listener = object : LocationListener {
|
||||
override fun onLocationChanged(location: Location) {
|
||||
synchronized(activeOneShotListeners) {
|
||||
activeOneShotListeners.remove(callback)
|
||||
val runnable = activeOneShotRunnables.remove(callback)
|
||||
if (runnable != null) {
|
||||
handler.removeCallbacks(runnable)
|
||||
}
|
||||
}
|
||||
try {
|
||||
locationManager.removeUpdates(this)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error removing updates in callback: ${e.message}")
|
||||
}
|
||||
callback(location)
|
||||
}
|
||||
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
|
||||
override fun onProviderEnabled(provider: String) {}
|
||||
override fun onProviderDisabled(provider: String) {}
|
||||
}
|
||||
|
||||
synchronized(activeOneShotListeners) {
|
||||
activeOneShotListeners[callback] = listener
|
||||
activeOneShotRunnables[callback] = timeoutRunnable
|
||||
}
|
||||
|
||||
locationManager.requestSingleUpdate(provider, listener, null)
|
||||
handler.postDelayed(timeoutRunnable, 30000L) // 30s timeout
|
||||
}
|
||||
providerFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!providerFound) {
|
||||
Log.w(TAG, "No location providers available for fresh location")
|
||||
callback(null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error requesting fresh location: ${e.message}")
|
||||
callback(null)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun requestLocationUpdates(
|
||||
intervalMs: Long,
|
||||
minDistanceMeters: Float,
|
||||
callback: (Location) -> Unit
|
||||
) {
|
||||
if (!hasLocationPermission()) return
|
||||
|
||||
try {
|
||||
val listener = object : LocationListener {
|
||||
override fun onLocationChanged(location: Location) {
|
||||
callback(location)
|
||||
}
|
||||
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
|
||||
override fun onProviderEnabled(provider: String) {}
|
||||
override fun onProviderDisabled(provider: String) {}
|
||||
}
|
||||
|
||||
// Store the listener so we can remove it later
|
||||
synchronized(activeListeners) {
|
||||
activeListeners[callback] = listener
|
||||
}
|
||||
|
||||
val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
|
||||
var registered = false
|
||||
|
||||
for (provider in providers) {
|
||||
if (locationManager.isProviderEnabled(provider)) {
|
||||
locationManager.requestLocationUpdates(
|
||||
provider,
|
||||
intervalMs,
|
||||
minDistanceMeters,
|
||||
listener
|
||||
)
|
||||
registered = true
|
||||
Log.d(TAG, "Registered updates for $provider")
|
||||
}
|
||||
}
|
||||
|
||||
if (!registered) {
|
||||
Log.w(TAG, "No providers enabled for continuous updates")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error requesting location updates: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun removeLocationUpdates(callback: (Location) -> Unit) {
|
||||
try {
|
||||
val listener = synchronized(activeListeners) {
|
||||
activeListeners.remove(callback)
|
||||
}
|
||||
|
||||
if (listener != null) {
|
||||
locationManager.removeUpdates(listener)
|
||||
Log.d(TAG, "Removed location updates")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error removing updates: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
try {
|
||||
// Cancel continuous updates
|
||||
synchronized(activeListeners) {
|
||||
for ((_, listener) in activeListeners) {
|
||||
try { locationManager.removeUpdates(listener) } catch (_: Exception) {}
|
||||
}
|
||||
activeListeners.clear()
|
||||
}
|
||||
|
||||
// Cancel one-shot requests
|
||||
synchronized(activeOneShotListeners) {
|
||||
for ((_, listener) in activeOneShotListeners) {
|
||||
try { locationManager.removeUpdates(listener) } catch (_: Exception) {}
|
||||
}
|
||||
activeOneShotListeners.clear()
|
||||
|
||||
for ((_, runnable) in activeOneShotRunnables) {
|
||||
handler.removeCallbacks(runnable)
|
||||
}
|
||||
activeOneShotRunnables.clear()
|
||||
}
|
||||
Log.d(TAG, "Cancelled all system location requests")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error cancelling system provider: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,10 @@ import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import java.security.MessageDigest
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.bitchat.android.util.hexEncodedString
|
||||
import androidx.core.content.edit
|
||||
|
||||
/**
|
||||
* Manages persistent identity storage and peer ID rotation - 100% compatible with iOS implementation
|
||||
@@ -24,9 +27,15 @@ class SecureIdentityStateManager(private val context: Context) {
|
||||
private const val KEY_STATIC_PUBLIC_KEY = "static_public_key"
|
||||
private const val KEY_SIGNING_PRIVATE_KEY = "signing_private_key"
|
||||
private const val KEY_SIGNING_PUBLIC_KEY = "signing_public_key"
|
||||
private const val KEY_VERIFIED_FINGERPRINTS = "verified_fingerprints"
|
||||
private const val KEY_CACHED_PEER_FINGERPRINTS = "cached_peer_fingerprints"
|
||||
private const val KEY_CACHED_PEER_NOISE_KEYS = "cached_peer_noise_keys"
|
||||
private const val KEY_CACHED_NOISE_FINGERPRINTS = "cached_noise_fingerprints"
|
||||
private const val KEY_CACHED_FINGERPRINT_NICKNAMES = "cached_fingerprint_nicknames"
|
||||
}
|
||||
|
||||
private val prefs: SharedPreferences
|
||||
private val lock = Any()
|
||||
|
||||
init {
|
||||
// Create master key for encryption
|
||||
@@ -168,7 +177,7 @@ class SecureIdentityStateManager(private val context: Context) {
|
||||
fun generateFingerprint(publicKeyData: ByteArray): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val hash = digest.digest(publicKeyData)
|
||||
return hash.joinToString("") { "%02x".format(it) }
|
||||
return hash.hexEncodedString()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,6 +187,112 @@ class SecureIdentityStateManager(private val context: Context) {
|
||||
// SHA-256 fingerprint should be 64 hex characters
|
||||
return fingerprint.matches(Regex("^[a-fA-F0-9]{64}$"))
|
||||
}
|
||||
|
||||
// MARK: - Verified Fingerprints
|
||||
|
||||
fun getVerifiedFingerprints(): Set<String> {
|
||||
return prefs.getStringSet(KEY_VERIFIED_FINGERPRINTS, emptySet())?.toSet() ?: emptySet()
|
||||
}
|
||||
|
||||
fun isVerifiedFingerprint(fingerprint: String): Boolean {
|
||||
return getVerifiedFingerprints().contains(fingerprint)
|
||||
}
|
||||
|
||||
fun setVerifiedFingerprint(fingerprint: String, verified: Boolean) {
|
||||
if (!isValidFingerprint(fingerprint)) return
|
||||
synchronized(lock) {
|
||||
val current = prefs.getStringSet(KEY_VERIFIED_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf()
|
||||
if (verified) {
|
||||
current.add(fingerprint)
|
||||
} else {
|
||||
current.remove(fingerprint)
|
||||
}
|
||||
prefs.edit { putStringSet(KEY_VERIFIED_FINGERPRINTS, current) }
|
||||
}
|
||||
}
|
||||
|
||||
fun getCachedPeerFingerprint(peerID: String): String? {
|
||||
val pid = peerID.lowercase()
|
||||
// Reading is safe without lock for SharedPreferences, but synchronizing ensures memory visibility
|
||||
// if we are paranoid, but SharedPreferences is generally thread-safe for reads.
|
||||
// However, to ensure we don't read a partial update (unlikely with SP), we can leave it.
|
||||
// The critical part is the write.
|
||||
val entries = prefs.getStringSet(KEY_CACHED_PEER_FINGERPRINTS, emptySet()) ?: return null
|
||||
val entry = entries.firstOrNull { it.startsWith("$pid:") } ?: return null
|
||||
return entry.substringAfter(':').takeIf { isValidFingerprint(it) }
|
||||
}
|
||||
|
||||
fun cachePeerFingerprint(peerID: String, fingerprint: String) {
|
||||
if (!isValidFingerprint(fingerprint)) return
|
||||
val pid = peerID.lowercase()
|
||||
synchronized(lock) {
|
||||
val current = prefs.getStringSet(KEY_CACHED_PEER_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf()
|
||||
current.removeAll { it.startsWith("$pid:") }
|
||||
current.add("$pid:$fingerprint")
|
||||
prefs.edit { putStringSet(KEY_CACHED_PEER_FINGERPRINTS, current) }
|
||||
}
|
||||
}
|
||||
|
||||
fun getCachedNoiseKey(peerID: String): String? {
|
||||
val pid = peerID.lowercase()
|
||||
val entries = prefs.getStringSet(KEY_CACHED_PEER_NOISE_KEYS, emptySet()) ?: return null
|
||||
val entry = entries.firstOrNull { it.startsWith("$pid=") } ?: return null
|
||||
return entry.substringAfter('=').takeIf { it.matches(Regex("^[a-fA-F0-9]{64}$")) }
|
||||
}
|
||||
|
||||
fun cachePeerNoiseKey(peerID: String, noiseKeyHex: String) {
|
||||
if (!noiseKeyHex.matches(Regex("^[a-fA-F0-9]{64}$"))) return
|
||||
val pid = peerID.lowercase()
|
||||
synchronized(lock) {
|
||||
val current = prefs.getStringSet(KEY_CACHED_PEER_NOISE_KEYS, emptySet())?.toMutableSet() ?: mutableSetOf()
|
||||
current.removeAll { it.startsWith("$pid=") }
|
||||
current.add("$pid=${noiseKeyHex.lowercase()}")
|
||||
prefs.edit { putStringSet(KEY_CACHED_PEER_NOISE_KEYS, current) }
|
||||
}
|
||||
}
|
||||
|
||||
fun getCachedNoiseFingerprint(noiseKeyHex: String): String? {
|
||||
val key = noiseKeyHex.lowercase()
|
||||
val entries = prefs.getStringSet(KEY_CACHED_NOISE_FINGERPRINTS, emptySet()) ?: return null
|
||||
val entry = entries.firstOrNull { it.startsWith("$key=") } ?: return null
|
||||
return entry.substringAfter('=').takeIf { isValidFingerprint(it) }
|
||||
}
|
||||
|
||||
fun cacheNoiseFingerprint(noiseKeyHex: String, fingerprint: String) {
|
||||
if (!isValidFingerprint(fingerprint)) return
|
||||
if (!noiseKeyHex.matches(Regex("^[a-fA-F0-9]{64}$"))) return
|
||||
val key = noiseKeyHex.lowercase()
|
||||
synchronized(lock) {
|
||||
val current = prefs.getStringSet(KEY_CACHED_NOISE_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf()
|
||||
current.removeAll { it.startsWith("$key=") }
|
||||
current.add("$key=$fingerprint")
|
||||
prefs.edit { putStringSet(KEY_CACHED_NOISE_FINGERPRINTS, current) }
|
||||
}
|
||||
}
|
||||
|
||||
fun getCachedFingerprintNickname(fingerprint: String): String? {
|
||||
if (!isValidFingerprint(fingerprint)) return null
|
||||
val key = fingerprint.lowercase()
|
||||
val entries = prefs.getStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, emptySet()) ?: return null
|
||||
val entry = entries.firstOrNull { it.startsWith("$key=") } ?: return null
|
||||
val encoded = entry.substringAfter('=')
|
||||
return runCatching {
|
||||
val bytes = Base64.decode(encoded, Base64.NO_WRAP)
|
||||
String(bytes, Charsets.UTF_8)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun cacheFingerprintNickname(fingerprint: String, nickname: String) {
|
||||
if (!isValidFingerprint(fingerprint)) return
|
||||
val key = fingerprint.lowercase()
|
||||
val encoded = Base64.encodeToString(nickname.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
|
||||
synchronized(lock) {
|
||||
val current = prefs.getStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, emptySet())?.toMutableSet() ?: mutableSetOf()
|
||||
current.removeAll { it.startsWith("$key=") }
|
||||
current.add("$key=$encoded")
|
||||
prefs.edit { putStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, current) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Peer ID Rotation Management (removed)
|
||||
// Android now derives peer ID from the persisted Noise identity fingerprint.
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.combine
|
||||
|
||||
/**
|
||||
* Power-optimized Bluetooth connection manager with comprehensive memory management
|
||||
@@ -37,7 +38,7 @@ class BluetoothConnectionManager(
|
||||
// Component managers
|
||||
private val permissionManager = BluetoothPermissionManager(context)
|
||||
private val connectionTracker = BluetoothConnectionTracker(connectionScope, powerManager)
|
||||
private val packetBroadcaster = BluetoothPacketBroadcaster(connectionScope, connectionTracker, fragmentManager)
|
||||
private val packetBroadcaster = BluetoothPacketBroadcaster(connectionScope, connectionTracker, fragmentManager, myPeerID)
|
||||
|
||||
// Delegate for component managers to call back to main manager
|
||||
private val componentDelegate = object : BluetoothConnectionManagerDelegate {
|
||||
@@ -57,6 +58,8 @@ class BluetoothConnectionManager(
|
||||
}
|
||||
|
||||
override fun onDeviceConnected(device: BluetoothDevice) {
|
||||
// Trigger limit enforcement immediately upon any new connection
|
||||
enforceStrictLimits()
|
||||
delegate?.onDeviceConnected(device)
|
||||
}
|
||||
|
||||
@@ -70,7 +73,7 @@ class BluetoothConnectionManager(
|
||||
}
|
||||
|
||||
private val serverManager = BluetoothGattServerManager(
|
||||
context, connectionScope, connectionTracker, permissionManager, powerManager, componentDelegate
|
||||
context, connectionScope, connectionTracker, permissionManager, powerManager, componentDelegate, myPeerID
|
||||
)
|
||||
private val clientManager = BluetoothGattClientManager(
|
||||
context, connectionScope, connectionTracker, permissionManager, powerManager, componentDelegate
|
||||
@@ -85,57 +88,114 @@ class BluetoothConnectionManager(
|
||||
// Public property for address-peer mapping
|
||||
val addressPeerMap get() = connectionTracker.addressPeerMap
|
||||
|
||||
// Expose first-announce helpers to higher layers
|
||||
fun noteAnnounceReceived(address: String) { connectionTracker.noteAnnounceReceived(address) }
|
||||
fun hasSeenFirstAnnounce(address: String): Boolean = connectionTracker.hasSeenFirstAnnounce(address)
|
||||
|
||||
private fun isBleTransportEnabled(): Boolean {
|
||||
return try {
|
||||
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
|
||||
} catch (_: Exception) {
|
||||
try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
|
||||
}
|
||||
}
|
||||
|
||||
private fun isGattServerEnabled(): Boolean {
|
||||
return isBleTransportEnabled() &&
|
||||
(try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true })
|
||||
}
|
||||
|
||||
private fun isGattClientEnabled(): Boolean {
|
||||
return isBleTransportEnabled() &&
|
||||
(try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true })
|
||||
}
|
||||
|
||||
init {
|
||||
powerManager.delegate = this
|
||||
// Observe debug settings to enforce role state while active
|
||||
try {
|
||||
val dbg = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance()
|
||||
// Master transport enable/disable
|
||||
connectionScope.launch {
|
||||
dbg.bleEnabled.collect { enabled ->
|
||||
if (enabled) return@collect
|
||||
if (isActive) {
|
||||
disableTransport()
|
||||
}
|
||||
}
|
||||
}
|
||||
// Role enable/disable
|
||||
connectionScope.launch {
|
||||
dbg.gattServerEnabled.collect { enabled ->
|
||||
if (!isActive) return@collect
|
||||
if (enabled) startServer() else stopServer()
|
||||
if (enabled && isBleTransportEnabled()) startServer() else stopServer()
|
||||
}
|
||||
}
|
||||
connectionScope.launch {
|
||||
dbg.gattClientEnabled.collect { enabled ->
|
||||
if (!isActive) return@collect
|
||||
if (enabled) startClient() else stopClient()
|
||||
if (enabled && isBleTransportEnabled()) startClient() else stopClient()
|
||||
}
|
||||
}
|
||||
// Connection caps: enforce on change
|
||||
|
||||
// Centralized limit enforcement on any setting change
|
||||
connectionScope.launch {
|
||||
dbg.maxConnectionsOverall.collect {
|
||||
if (!isActive) return@collect
|
||||
connectionTracker.enforceConnectionLimits()
|
||||
// Also enforce server side best-effort
|
||||
serverManager.enforceServerLimit(dbg.maxServerConnections.value)
|
||||
}
|
||||
}
|
||||
connectionScope.launch {
|
||||
dbg.maxClientConnections.collect {
|
||||
if (!isActive) return@collect
|
||||
connectionTracker.enforceConnectionLimits()
|
||||
}
|
||||
}
|
||||
connectionScope.launch {
|
||||
dbg.maxServerConnections.collect {
|
||||
if (!isActive) return@collect
|
||||
serverManager.enforceServerLimit(dbg.maxServerConnections.value)
|
||||
combine(
|
||||
dbg.maxConnectionsOverall,
|
||||
dbg.maxServerConnections,
|
||||
dbg.maxClientConnections
|
||||
) { _, _, _ ->
|
||||
// We don't need the values here, we just need to trigger enforcement
|
||||
Unit
|
||||
}.collect {
|
||||
if (isActive) {
|
||||
enforceStrictLimits()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized connection limit enforcement
|
||||
*/
|
||||
private fun enforceStrictLimits() {
|
||||
if (!isActive) return
|
||||
|
||||
try {
|
||||
val dbg = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance()
|
||||
val maxOverall = dbg.maxConnectionsOverall.value
|
||||
val maxServer = dbg.maxServerConnections.value
|
||||
val maxClient = dbg.maxClientConnections.value
|
||||
|
||||
// Get list of connections to evict to satisfy all constraints
|
||||
val toEvict = connectionTracker.getConnectionsToEvict(maxOverall, maxServer, maxClient)
|
||||
|
||||
if (toEvict.isNotEmpty()) {
|
||||
Log.i(TAG, "Enforcing limits (max: $maxOverall, s: $maxServer, c: $maxClient) - evicting ${toEvict.size} connections")
|
||||
|
||||
toEvict.forEach { conn ->
|
||||
if (conn.isClient) {
|
||||
Log.d(TAG, "Evicting client ${conn.device.address}")
|
||||
try { conn.gatt?.disconnect() } catch (_: Exception) { }
|
||||
} else {
|
||||
Log.d(TAG, "Evicting server ${conn.device.address}")
|
||||
serverManager.disconnectDevice(conn.device)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error enforcing limits: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start all Bluetooth services with power optimization
|
||||
*/
|
||||
fun startServices(): Boolean {
|
||||
Log.i(TAG, "Starting power-optimized Bluetooth services...")
|
||||
|
||||
if (!isBleTransportEnabled()) {
|
||||
Log.i(TAG, "BLE transport disabled by debug settings; not starting Bluetooth services")
|
||||
disableTransport()
|
||||
return false
|
||||
}
|
||||
|
||||
if (!permissionManager.hasBluetoothPermissions()) {
|
||||
Log.e(TAG, "Missing Bluetooth permissions")
|
||||
@@ -170,9 +230,8 @@ class BluetoothConnectionManager(
|
||||
powerManager.start()
|
||||
|
||||
// Start server/client based on debug settings
|
||||
val dbg = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (_: Exception) { null }
|
||||
val startServer = dbg?.gattServerEnabled?.value != false
|
||||
val startClient = dbg?.gattClientEnabled?.value != false
|
||||
val startServer = isGattServerEnabled()
|
||||
val startClient = isGattClientEnabled()
|
||||
|
||||
if (startServer) {
|
||||
if (!serverManager.start()) {
|
||||
@@ -207,6 +266,19 @@ class BluetoothConnectionManager(
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable BLE without cancelling this manager's coroutine scope, so it can be re-enabled.
|
||||
*/
|
||||
fun disableTransport() {
|
||||
Log.i(TAG, "Disabling BLE transport")
|
||||
isActive = false
|
||||
connectionScope.launch {
|
||||
clientManager.stop()
|
||||
serverManager.stop()
|
||||
connectionTracker.stop()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all Bluetooth services with proper cleanup
|
||||
@@ -247,19 +319,12 @@ class BluetoothConnectionManager(
|
||||
return active
|
||||
}
|
||||
|
||||
/**
|
||||
* Set app background state for power optimization
|
||||
*/
|
||||
fun setAppBackgroundState(inBackground: Boolean) {
|
||||
powerManager.setAppBackgroundState(inBackground)
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast packet to connected devices with connection limit enforcement
|
||||
* Automatically fragments large packets to fit within BLE MTU limits
|
||||
*/
|
||||
fun broadcastPacket(routed: RoutedPacket) {
|
||||
if (!isActive) return
|
||||
if (!isActive || !isBleTransportEnabled()) return
|
||||
|
||||
packetBroadcaster.broadcastPacket(
|
||||
routed,
|
||||
@@ -268,6 +333,16 @@ class BluetoothConnectionManager(
|
||||
)
|
||||
}
|
||||
|
||||
fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
|
||||
if (!isActive || !isBleTransportEnabled()) return false
|
||||
return packetBroadcaster.sendToPeer(
|
||||
peerID,
|
||||
routed,
|
||||
serverManager.getGattServer(),
|
||||
serverManager.getCharacteristic()
|
||||
)
|
||||
}
|
||||
|
||||
fun cancelTransfer(transferId: String): Boolean {
|
||||
return packetBroadcaster.cancelTransfer(transferId)
|
||||
}
|
||||
@@ -276,7 +351,7 @@ class BluetoothConnectionManager(
|
||||
* Send a packet directly to a specific peer, without broadcasting to others.
|
||||
*/
|
||||
fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean {
|
||||
if (!isActive) return false
|
||||
if (!isActive || !isBleTransportEnabled()) return false
|
||||
return packetBroadcaster.sendPacketToPeer(
|
||||
RoutedPacket(packet),
|
||||
peerID,
|
||||
@@ -287,9 +362,15 @@ class BluetoothConnectionManager(
|
||||
|
||||
|
||||
// Expose role controls for debug UI
|
||||
fun startServer() { connectionScope.launch { serverManager.start() } }
|
||||
fun startServer() {
|
||||
if (!isActive || !isBleTransportEnabled()) return
|
||||
connectionScope.launch { if (isGattServerEnabled()) serverManager.start() }
|
||||
}
|
||||
fun stopServer() { connectionScope.launch { serverManager.stop() } }
|
||||
fun startClient() { connectionScope.launch { clientManager.start() } }
|
||||
fun startClient() {
|
||||
if (!isActive || !isBleTransportEnabled()) return
|
||||
connectionScope.launch { if (isGattClientEnabled()) clientManager.start() }
|
||||
}
|
||||
fun stopClient() { connectionScope.launch { clientManager.stop() } }
|
||||
|
||||
// Inject nickname resolver for broadcaster logs
|
||||
@@ -317,7 +398,10 @@ class BluetoothConnectionManager(
|
||||
/**
|
||||
* Public: connect/disconnect helpers for debug UI
|
||||
*/
|
||||
fun connectToAddress(address: String): Boolean = clientManager.connectToAddress(address)
|
||||
fun connectToAddress(address: String): Boolean {
|
||||
if (!isActive || !isBleTransportEnabled()) return false
|
||||
return clientManager.connectToAddress(address)
|
||||
}
|
||||
fun disconnectAddress(address: String) { connectionTracker.disconnectDevice(address) }
|
||||
|
||||
|
||||
@@ -328,10 +412,10 @@ class BluetoothConnectionManager(
|
||||
clientManager.stop()
|
||||
serverManager.stop()
|
||||
delay(200)
|
||||
if (isActive) {
|
||||
if (isActive && isBleTransportEnabled()) {
|
||||
// Restart managers if service is active
|
||||
serverManager.start()
|
||||
clientManager.start()
|
||||
if (isGattServerEnabled()) serverManager.start()
|
||||
if (isGattClientEnabled()) clientManager.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -366,11 +450,17 @@ class BluetoothConnectionManager(
|
||||
Log.i(TAG, "Power mode changed to: $newMode")
|
||||
|
||||
connectionScope.launch {
|
||||
if (!isActive || !isBleTransportEnabled()) {
|
||||
serverManager.stop()
|
||||
clientManager.stop()
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Avoid rapid scan restarts by checking if we need to change scan behavior
|
||||
val wasUsingDutyCycle = powerManager.shouldUseDutyCycle()
|
||||
|
||||
// Update advertising with new power settings if server enabled
|
||||
val serverEnabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true }
|
||||
val serverEnabled = isGattServerEnabled()
|
||||
if (serverEnabled) {
|
||||
serverManager.restartAdvertising()
|
||||
} else {
|
||||
@@ -381,7 +471,7 @@ class BluetoothConnectionManager(
|
||||
val nowUsingDutyCycle = powerManager.shouldUseDutyCycle()
|
||||
if (wasUsingDutyCycle != nowUsingDutyCycle) {
|
||||
Log.d(TAG, "Duty cycle behavior changed (${wasUsingDutyCycle} -> ${nowUsingDutyCycle}), restarting scan")
|
||||
val clientEnabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }
|
||||
val clientEnabled = isGattClientEnabled()
|
||||
if (clientEnabled) {
|
||||
clientManager.restartScanning()
|
||||
} else {
|
||||
@@ -392,16 +482,15 @@ class BluetoothConnectionManager(
|
||||
}
|
||||
|
||||
// Enforce connection limits
|
||||
connectionTracker.enforceConnectionLimits()
|
||||
// Best-effort server cap
|
||||
try {
|
||||
val maxServer = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().maxServerConnections.value
|
||||
serverManager.enforceServerLimit(maxServer)
|
||||
} catch (_: Exception) { }
|
||||
enforceStrictLimits()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onScanStateChanged(shouldScan: Boolean) {
|
||||
if (!isActive || !isBleTransportEnabled()) {
|
||||
clientManager.onScanStateChanged(false)
|
||||
return
|
||||
}
|
||||
clientManager.onScanStateChanged(shouldScan)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ class BluetoothConnectionTracker(
|
||||
val addressPeerMap = ConcurrentHashMap<String, String>()
|
||||
// Track whether we have seen the first ANNOUNCE on a given device connection
|
||||
private val firstAnnounceSeen = ConcurrentHashMap<String, Boolean>()
|
||||
|
||||
// RSSI tracking from scan results (for devices we discover but may connect as servers)
|
||||
private val scanRSSI = ConcurrentHashMap<String, Int>()
|
||||
|
||||
@@ -42,7 +41,8 @@ class BluetoothConnectionTracker(
|
||||
val characteristic: BluetoothGattCharacteristic? = null,
|
||||
val rssi: Int = Int.MIN_VALUE,
|
||||
val isClient: Boolean = false,
|
||||
val connectedAt: Long = System.currentTimeMillis()
|
||||
val connectedAt: Long = System.currentTimeMillis(),
|
||||
val peerID: String? = null
|
||||
)
|
||||
|
||||
override fun start() {
|
||||
@@ -150,6 +150,14 @@ class BluetoothConnectionTracker(
|
||||
* Check if device is already connected
|
||||
*/
|
||||
fun isDeviceConnected(deviceAddress: String): Boolean = isConnected(deviceAddress)
|
||||
|
||||
/**
|
||||
* Check if a peer is already connected (by PeerID)
|
||||
*/
|
||||
fun isPeerConnected(peerID: String): Boolean {
|
||||
// Only consider actual connected devices that have identified themselves
|
||||
return connectedDevices.values.any { it.peerID == peerID }
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect a specific device (by MAC address)
|
||||
@@ -164,48 +172,61 @@ class BluetoothConnectionTracker(
|
||||
/**
|
||||
* Check if connection limit is reached
|
||||
*/
|
||||
fun isConnectionLimitReached(): Boolean {
|
||||
return connectedDevices.size >= powerManager.getMaxConnections()
|
||||
/**
|
||||
* Check if a new client connection is allowed based on limits
|
||||
*/
|
||||
fun canConnectAsClient(maxOverall: Int, maxClient: Int): Boolean {
|
||||
val total = connectedDevices.size
|
||||
val clients = connectedDevices.values.count { it.isClient }
|
||||
return total < maxOverall && clients < maxClient
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce connection limits by disconnecting oldest connections
|
||||
* Calculate which connections should be evicted to satisfy limits.
|
||||
* Logic:
|
||||
* 1. Enforce strict role limits (maxClient, maxServer) - evict oldest excess.
|
||||
* 2. Enforce overall limit (maxOverall) - evict oldest remaining, preferring clients.
|
||||
*/
|
||||
fun enforceConnectionLimits() {
|
||||
// Read debug overrides if available
|
||||
val dbg = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (_: Exception) { null }
|
||||
val maxOverall = dbg?.maxConnectionsOverall?.value ?: powerManager.getMaxConnections()
|
||||
val maxClient = dbg?.maxClientConnections?.value ?: maxOverall
|
||||
val maxServer = dbg?.maxServerConnections?.value ?: maxOverall
|
||||
|
||||
val clients = connectedDevices.values.filter { it.isClient }
|
||||
val servers = connectedDevices.values.filter { !it.isClient }
|
||||
|
||||
// Enforce client cap first (we can actively disconnect)
|
||||
fun getConnectionsToEvict(maxOverall: Int, maxServer: Int, maxClient: Int): List<DeviceConnection> {
|
||||
val toEvict = mutableSetOf<DeviceConnection>()
|
||||
val currentDevices = connectedDevices.values.toList()
|
||||
|
||||
// 1. Enforce Role Limits
|
||||
val clients = currentDevices.filter { it.isClient }.sortedBy { it.connectedAt }
|
||||
if (clients.size > maxClient) {
|
||||
Log.i(TAG, "Enforcing client cap: ${clients.size} > $maxClient")
|
||||
val toDisconnect = clients.sortedBy { it.connectedAt }.take(clients.size - maxClient)
|
||||
toDisconnect.forEach { dc ->
|
||||
Log.d(TAG, "Disconnecting client ${dc.device.address} due to client cap")
|
||||
dc.gatt?.disconnect()
|
||||
}
|
||||
toEvict.addAll(clients.take(clients.size - maxClient))
|
||||
}
|
||||
|
||||
// Note: server cap enforced in GattServerManager (we don't have server handle here)
|
||||
|
||||
// Enforce overall cap by disconnecting oldest client connections
|
||||
if (connectedDevices.size > maxOverall) {
|
||||
Log.i(TAG, "Enforcing overall cap: ${connectedDevices.size} > $maxOverall")
|
||||
val excess = connectedDevices.size - maxOverall
|
||||
val toDisconnect = connectedDevices.values
|
||||
.filter { it.isClient } // only clients from here
|
||||
.sortedBy { it.connectedAt }
|
||||
.take(excess)
|
||||
toDisconnect.forEach { dc ->
|
||||
Log.d(TAG, "Disconnecting client ${dc.device.address} due to overall cap")
|
||||
dc.gatt?.disconnect()
|
||||
|
||||
val servers = currentDevices.filter { !it.isClient }.sortedBy { it.connectedAt }
|
||||
if (servers.size > maxServer) {
|
||||
toEvict.addAll(servers.take(servers.size - maxServer))
|
||||
}
|
||||
|
||||
// 2. Enforce Overall Limit
|
||||
// Count how many would remain after the above evictions
|
||||
val remaining = currentDevices.filter { !toEvict.contains(it) }
|
||||
if (remaining.size > maxOverall) {
|
||||
val excessCount = remaining.size - maxOverall
|
||||
|
||||
// Explicitly prefer evicting clients first
|
||||
val clientCandidates = remaining.filter { it.isClient }.sortedBy { it.connectedAt }
|
||||
val serverCandidates = remaining.filter { !it.isClient }.sortedBy { it.connectedAt }
|
||||
|
||||
var needed = excessCount
|
||||
|
||||
// Take from clients first
|
||||
val fromClients = clientCandidates.take(needed)
|
||||
toEvict.addAll(fromClients)
|
||||
needed -= fromClients.size
|
||||
|
||||
// If still need more, take from servers
|
||||
if (needed > 0) {
|
||||
val fromServers = serverCandidates.take(needed)
|
||||
toEvict.addAll(fromServers)
|
||||
}
|
||||
}
|
||||
|
||||
return toEvict.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -32,6 +32,11 @@ class BluetoothGattClientManager(
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BluetoothGattClientManager"
|
||||
// Self-healing scan recovery tuning
|
||||
private const val SCAN_RETRY_BASE_MS = 3_000L // base backoff for transient scan failures
|
||||
private const val SCAN_MAX_RETRY_DELAY_MS = 30_000L // cap on backoff delay
|
||||
private const val SCAN_WATCHDOG_INTERVAL_MS = 30_000L // how often to verify the scanner is alive
|
||||
private const val SCAN_STALE_RESULT_MS = 120_000L // force a scan restart if no results for this long
|
||||
}
|
||||
|
||||
// Core Bluetooth components
|
||||
@@ -39,11 +44,28 @@ class BluetoothGattClientManager(
|
||||
context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
|
||||
private val bleScanner: BluetoothLeScanner? = bluetoothAdapter?.bluetoothLeScanner
|
||||
|
||||
private fun isBleTransportEnabled(): Boolean {
|
||||
return try {
|
||||
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
|
||||
} catch (_: Exception) {
|
||||
try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
|
||||
}
|
||||
}
|
||||
|
||||
private fun isClientRoleEnabled(): Boolean {
|
||||
return isBleTransportEnabled() &&
|
||||
(try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Public: Connect to a device by MAC address (for debug UI)
|
||||
*/
|
||||
fun connectToAddress(deviceAddress: String): Boolean {
|
||||
if (!isClientRoleEnabled()) {
|
||||
Log.i(TAG, "connectToAddress skipped: BLE client disabled")
|
||||
return false
|
||||
}
|
||||
val device = bluetoothAdapter?.getRemoteDevice(deviceAddress)
|
||||
return if (device != null) {
|
||||
val rssi = connectionTracker.getBestRSSI(deviceAddress) ?: -50
|
||||
@@ -61,8 +83,16 @@ class BluetoothGattClientManager(
|
||||
// Scan rate limiting to prevent "scanning too frequently" errors
|
||||
private var lastScanStartTime = 0L
|
||||
private var lastScanStopTime = 0L
|
||||
private var isCurrentlyScanning = false
|
||||
@Volatile private var isCurrentlyScanning = false
|
||||
private val scanRateLimit = 5000L // Minimum 5 seconds between scan start attempts
|
||||
|
||||
// Self-healing scan state.
|
||||
// scanningDesired distinguishes "we want to be scanning but it isn't running" (a fault to recover
|
||||
// from) from "scanning is intentionally off" (e.g. duty-cycle OFF window or client disabled).
|
||||
@Volatile private var scanningDesired = false
|
||||
@Volatile private var lastScanResultTime = 0L
|
||||
private var scanRetryCount = 0
|
||||
private var scanWatchdogJob: Job? = null
|
||||
|
||||
// RSSI monitoring state
|
||||
private var rssiMonitoringJob: Job? = null
|
||||
@@ -75,12 +105,10 @@ class BluetoothGattClientManager(
|
||||
*/
|
||||
fun start(): Boolean {
|
||||
// Respect debug setting
|
||||
try {
|
||||
if (!com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value) {
|
||||
Log.i(TAG, "Client start skipped: GATT Client disabled in debug settings")
|
||||
return false
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
if (!isClientRoleEnabled()) {
|
||||
Log.i(TAG, "Client start skipped: BLE/GATT Client disabled in debug settings")
|
||||
return false
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
Log.d(TAG, "GATT client already active; start is a no-op")
|
||||
@@ -106,12 +134,16 @@ class BluetoothGattClientManager(
|
||||
connectionScope.launch {
|
||||
if (powerManager.shouldUseDutyCycle()) {
|
||||
Log.i(TAG, "Using power-aware duty cycling")
|
||||
// Duty cycle drives onScanStateChanged(true/false); scanningDesired follows that.
|
||||
} else {
|
||||
scanningDesired = true
|
||||
startScanning()
|
||||
}
|
||||
|
||||
// Start RSSI monitoring
|
||||
startRSSIMonitoring()
|
||||
// Start the scan watchdog so a silently-dead or wedged scanner self-heals.
|
||||
startScanWatchdog()
|
||||
}
|
||||
|
||||
return true
|
||||
@@ -121,6 +153,8 @@ class BluetoothGattClientManager(
|
||||
* Stop client manager
|
||||
*/
|
||||
fun stop() {
|
||||
scanningDesired = false
|
||||
stopScanWatchdog()
|
||||
if (!isActive) {
|
||||
// Idempotent stop
|
||||
stopScanning()
|
||||
@@ -150,7 +184,8 @@ class BluetoothGattClientManager(
|
||||
* Handle scan state changes from power manager
|
||||
*/
|
||||
fun onScanStateChanged(shouldScan: Boolean) {
|
||||
val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }
|
||||
val enabled = isClientRoleEnabled()
|
||||
scanningDesired = shouldScan && enabled
|
||||
if (shouldScan && enabled) {
|
||||
startScanning()
|
||||
} else {
|
||||
@@ -199,7 +234,7 @@ class BluetoothGattClientManager(
|
||||
@Suppress("DEPRECATION")
|
||||
private fun startScanning() {
|
||||
// Respect debug setting
|
||||
val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }
|
||||
val enabled = isClientRoleEnabled()
|
||||
if (!permissionManager.hasBluetoothPermissions() || bleScanner == null || !isActive || !enabled) return
|
||||
|
||||
// Rate limit scan starts to prevent "scanning too frequently" errors
|
||||
@@ -217,7 +252,7 @@ class BluetoothGattClientManager(
|
||||
// Schedule delayed scan start
|
||||
connectionScope.launch {
|
||||
delay(remainingWait)
|
||||
if (isActive && !isCurrentlyScanning) {
|
||||
if (isActive && !isCurrentlyScanning && isClientRoleEnabled()) {
|
||||
startScanning()
|
||||
}
|
||||
}
|
||||
@@ -251,22 +286,39 @@ class BluetoothGattClientManager(
|
||||
lastScanStopTime = System.currentTimeMillis()
|
||||
|
||||
when (errorCode) {
|
||||
1 -> Log.e(TAG, "SCAN_FAILED_ALREADY_STARTED")
|
||||
2 -> Log.e(TAG, "SCAN_FAILED_APPLICATION_REGISTRATION_FAILED")
|
||||
3 -> Log.e(TAG, "SCAN_FAILED_INTERNAL_ERROR")
|
||||
4 -> Log.e(TAG, "SCAN_FAILED_FEATURE_UNSUPPORTED")
|
||||
5 -> Log.e(TAG, "SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES")
|
||||
1 -> {
|
||||
// Already started: the stack thinks a scan is running. Re-arm from a clean
|
||||
// state so we don't stay wedged (stop then restart with backoff).
|
||||
Log.e(TAG, "SCAN_FAILED_ALREADY_STARTED")
|
||||
stopScanning()
|
||||
scheduleScanRestart("already-started", SCAN_RETRY_BASE_MS)
|
||||
}
|
||||
2 -> {
|
||||
// App registration failed: common transient stack fault. Previously had NO
|
||||
// retry, which left discovery dead until a manual BLE toggle.
|
||||
Log.e(TAG, "SCAN_FAILED_APPLICATION_REGISTRATION_FAILED")
|
||||
scheduleScanRestart("registration-failed", SCAN_RETRY_BASE_MS)
|
||||
}
|
||||
3 -> {
|
||||
Log.e(TAG, "SCAN_FAILED_INTERNAL_ERROR")
|
||||
scheduleScanRestart("internal-error", SCAN_RETRY_BASE_MS)
|
||||
}
|
||||
4 -> Log.e(TAG, "SCAN_FAILED_FEATURE_UNSUPPORTED") // permanent: don't retry
|
||||
5 -> {
|
||||
// Out of hardware resources: back off longer so other scanners/connections
|
||||
// can free up before we try again.
|
||||
Log.e(TAG, "SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES")
|
||||
scheduleScanRestart("out-of-resources", SCAN_RETRY_BASE_MS * 3)
|
||||
}
|
||||
6 -> {
|
||||
Log.e(TAG, "SCAN_FAILED_SCANNING_TOO_FREQUENTLY")
|
||||
Log.w(TAG, "Scan failed due to rate limiting - will retry after delay")
|
||||
connectionScope.launch {
|
||||
delay(10000) // Wait 10 seconds before retrying
|
||||
if (isActive) {
|
||||
startScanning()
|
||||
}
|
||||
}
|
||||
scheduleScanRestart("too-frequently", 10_000L)
|
||||
}
|
||||
else -> {
|
||||
Log.e(TAG, "Unknown scan failure code: $errorCode")
|
||||
scheduleScanRestart("unknown-$errorCode", SCAN_RETRY_BASE_MS)
|
||||
}
|
||||
else -> Log.e(TAG, "Unknown scan failure code: $errorCode")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -304,6 +356,76 @@ class BluetoothGattClientManager(
|
||||
lastScanStopTime = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a scan restart with incremental backoff. Used to recover from transient scan
|
||||
* failures that previously had no retry path (codes 2/3/5), leaving discovery dead until a
|
||||
* manual BLE toggle.
|
||||
*/
|
||||
private fun scheduleScanRestart(reason: String, baseDelayMs: Long) {
|
||||
scanRetryCount++
|
||||
val delayMs = (baseDelayMs * scanRetryCount).coerceAtMost(SCAN_MAX_RETRY_DELAY_MS)
|
||||
Log.w(TAG, "Scheduling scan restart in ${delayMs}ms (attempt $scanRetryCount, reason=$reason)")
|
||||
connectionScope.launch {
|
||||
delay(delayMs)
|
||||
if (isActive && scanningDesired && isClientRoleEnabled() && !isCurrentlyScanning) {
|
||||
startScanning()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodic watchdog that self-heals the scanner. Android can stop a scan without ever invoking
|
||||
* onScanFailed (internal stack reset, Doze, background throttling), which leaves the app
|
||||
* believing it is scanning while it is not. This re-arms the scanner in those cases.
|
||||
*/
|
||||
private fun startScanWatchdog() {
|
||||
scanWatchdogJob?.cancel()
|
||||
scanWatchdogJob = connectionScope.launch {
|
||||
while (isActive) {
|
||||
delay(SCAN_WATCHDOG_INTERVAL_MS)
|
||||
try {
|
||||
// Only act when we are supposed to be scanning. Honors duty-cycle OFF windows
|
||||
// and the client-disabled state via scanningDesired.
|
||||
if (!isActive || !scanningDesired || !isClientRoleEnabled()) continue
|
||||
if (!permissionManager.hasBluetoothPermissions() || bluetoothAdapter?.isEnabled != true) continue
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
if (!isCurrentlyScanning) {
|
||||
Log.w(TAG, "Watchdog: scan desired but not running -> restarting scan")
|
||||
startScanning()
|
||||
} else if (lastScanResultTime > 0L &&
|
||||
now - lastScanResultTime > SCAN_STALE_RESULT_MS &&
|
||||
now - lastScanStartTime > SCAN_STALE_RESULT_MS) {
|
||||
// We think we're scanning but haven't seen anything for a long time. The scan
|
||||
// may have silently died (flag wedged true). Force a clean re-arm.
|
||||
Log.w(TAG, "Watchdog: no scan results for ${(now - lastScanResultTime) / 1000}s -> forcing scan restart")
|
||||
forceRestartScan()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Scan watchdog error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopScanWatchdog() {
|
||||
scanWatchdogJob?.cancel()
|
||||
scanWatchdogJob = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a clean scan restart, clearing a possibly-wedged isCurrentlyScanning flag.
|
||||
*/
|
||||
private fun forceRestartScan() {
|
||||
stopScanning()
|
||||
connectionScope.launch {
|
||||
delay(500)
|
||||
if (isActive && scanningDesired && isClientRoleEnabled() && !isCurrentlyScanning) {
|
||||
startScanning()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle scan result and initiate connection if appropriate
|
||||
@@ -320,6 +442,26 @@ class BluetoothGattClientManager(
|
||||
return
|
||||
}
|
||||
|
||||
// Proof the scanner is alive and finding our network: refresh liveness and clear backoff.
|
||||
lastScanResultTime = System.currentTimeMillis()
|
||||
scanRetryCount = 0
|
||||
|
||||
// Try to extract peerID from Service Data (if available) for stable identity
|
||||
val serviceData = scanRecord?.getServiceData(ParcelUuid(AppConstants.Mesh.Gatt.SERVICE_UUID))
|
||||
val peerID = if (serviceData != null && serviceData.size >= 8) {
|
||||
serviceData.joinToString("") { "%02x".format(it) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (peerID != null) {
|
||||
// Log.v(TAG, "Found peerID $peerID in scan record for $deviceAddress")
|
||||
if (connectionTracker.isPeerConnected(peerID)) {
|
||||
Log.d(TAG, "Deduplication: Peer $peerID is already connected (ignoring $deviceAddress)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Log.d(TAG, "Received scan result from $deviceAddress - already connected: ${connectionTracker.isDeviceConnected(deviceAddress)}")
|
||||
|
||||
// Store RSSI from scan results for later use (especially for server connections)
|
||||
@@ -332,7 +474,7 @@ class BluetoothGattClientManager(
|
||||
deviceName = device.name,
|
||||
deviceAddress = deviceAddress,
|
||||
rssi = rssi,
|
||||
peerID = null // peerID unknown at scan time
|
||||
peerID = peerID // Use the discovered peerID if available
|
||||
)
|
||||
)
|
||||
} catch (_: Exception) { }
|
||||
@@ -342,13 +484,12 @@ class BluetoothGattClientManager(
|
||||
Log.d(TAG, "Skipping device $deviceAddress due to weak signal: $rssi < ${powerManager.getRSSIThreshold()}")
|
||||
// Even if we skip connecting, still publish scan result to debug UI
|
||||
try {
|
||||
val pid: String? = null // We don't know peerID until packet exchange
|
||||
DebugSettingsManager.getInstance().addScanResult(
|
||||
DebugScanResult(
|
||||
deviceName = device.name,
|
||||
deviceAddress = deviceAddress,
|
||||
rssi = rssi,
|
||||
peerID = pid
|
||||
peerID = peerID
|
||||
)
|
||||
)
|
||||
} catch (_: Exception) { }
|
||||
@@ -366,14 +507,19 @@ class BluetoothGattClientManager(
|
||||
return
|
||||
}
|
||||
|
||||
if (connectionTracker.isConnectionLimitReached()) {
|
||||
Log.d(TAG, "Connection limit reached (${powerManager.getMaxConnections()})")
|
||||
// Check if connection limit is reached
|
||||
val dbg = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (_: Exception) { null }
|
||||
val maxOverall = dbg?.maxConnectionsOverall?.value ?: powerManager.getMaxConnections()
|
||||
val maxClient = dbg?.maxClientConnections?.value ?: maxOverall
|
||||
|
||||
if (!connectionTracker.canConnectAsClient(maxOverall, maxClient)) {
|
||||
Log.d(TAG, "Client connection limit reached (overall: $maxOverall, client: $maxClient)")
|
||||
return
|
||||
}
|
||||
|
||||
// Add pending connection and start connection
|
||||
if (connectionTracker.addPendingConnection(deviceAddress)) {
|
||||
connectToDevice(device, rssi)
|
||||
connectToDevice(device, rssi, peerID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,11 +527,12 @@ class BluetoothGattClientManager(
|
||||
* Connect to a device as GATT client
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
private fun connectToDevice(device: BluetoothDevice, rssi: Int) {
|
||||
private fun connectToDevice(device: BluetoothDevice, rssi: Int, peerID: String? = null) {
|
||||
if (!isClientRoleEnabled()) return
|
||||
if (!permissionManager.hasBluetoothPermissions()) return
|
||||
|
||||
val deviceAddress = device.address
|
||||
Log.i(TAG, "Connecting to bitchat device: $deviceAddress")
|
||||
Log.i(TAG, "Connecting to bitchat device: $deviceAddress (peerID: $peerID)")
|
||||
|
||||
val gattCallback = object : BluetoothGattCallback() {
|
||||
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
||||
@@ -435,7 +582,8 @@ class BluetoothGattClientManager(
|
||||
device = gatt.device,
|
||||
gatt = gatt,
|
||||
rssi = rssi,
|
||||
isClient = true
|
||||
isClient = true,
|
||||
peerID = peerID // Store the peerID discovered during scan
|
||||
)
|
||||
connectionTracker.addDeviceConnection(deviceAddress, deviceConn)
|
||||
|
||||
@@ -541,7 +689,7 @@ class BluetoothGattClientManager(
|
||||
*/
|
||||
fun restartScanning() {
|
||||
// Respect debug setting
|
||||
val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }
|
||||
val enabled = isClientRoleEnabled()
|
||||
if (!isActive || !enabled) return
|
||||
|
||||
connectionScope.launch {
|
||||
|
||||
@@ -24,11 +24,15 @@ class BluetoothGattServerManager(
|
||||
private val connectionTracker: BluetoothConnectionTracker,
|
||||
private val permissionManager: BluetoothPermissionManager,
|
||||
private val powerManager: PowerManager,
|
||||
private val delegate: BluetoothConnectionManagerDelegate?
|
||||
private val delegate: BluetoothConnectionManagerDelegate?,
|
||||
private val myPeerID: String
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BluetoothGattServerManager"
|
||||
// Self-healing advertising recovery tuning
|
||||
private const val ADVERTISE_RETRY_BASE_MS = 3_000L // base backoff for transient advertise failures
|
||||
private const val ADVERTISE_MAX_RETRY_DELAY_MS = 30_000L // cap on backoff delay
|
||||
}
|
||||
|
||||
// Core Bluetooth components
|
||||
@@ -41,22 +45,33 @@ class BluetoothGattServerManager(
|
||||
private var gattServer: BluetoothGattServer? = null
|
||||
private var characteristic: BluetoothGattCharacteristic? = null
|
||||
private var advertiseCallback: AdvertiseCallback? = null
|
||||
private var advertiseRetryCount = 0
|
||||
|
||||
// State management
|
||||
private var isActive = false
|
||||
|
||||
// Enforce a server connection limit by canceling the oldest connections (best-effort)
|
||||
fun enforceServerLimit(maxServer: Int) {
|
||||
if (maxServer <= 0) return
|
||||
private fun isBleTransportEnabled(): Boolean {
|
||||
return try {
|
||||
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
|
||||
} catch (_: Exception) {
|
||||
try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
|
||||
}
|
||||
}
|
||||
|
||||
private fun isServerRoleEnabled(): Boolean {
|
||||
return isBleTransportEnabled() &&
|
||||
(try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect a specific device (used by ConnectionManager to enforce overall limits)
|
||||
*/
|
||||
fun disconnectDevice(device: BluetoothDevice) {
|
||||
try {
|
||||
val subs = connectionTracker.getSubscribedDevices()
|
||||
if (subs.size > maxServer) {
|
||||
val excess = subs.size - maxServer
|
||||
subs.take(excess).forEach { d ->
|
||||
try { gattServer?.cancelConnection(d) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
gattServer?.cancelConnection(device)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error disconnecting device ${device.address}: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,12 +79,10 @@ class BluetoothGattServerManager(
|
||||
*/
|
||||
fun start(): Boolean {
|
||||
// Respect debug setting
|
||||
try {
|
||||
if (!com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value) {
|
||||
Log.i(TAG, "Server start skipped: GATT Server disabled in debug settings")
|
||||
return false
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
if (!isServerRoleEnabled()) {
|
||||
Log.i(TAG, "Server start skipped: BLE/GATT Server disabled in debug settings")
|
||||
return false
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
Log.d(TAG, "GATT server already active; start is a no-op")
|
||||
@@ -122,9 +135,10 @@ class BluetoothGattServerManager(
|
||||
|
||||
// Try to cancel any active connections explicitly before closing
|
||||
try {
|
||||
val devices = connectionTracker.getSubscribedDevices()
|
||||
devices.forEach { d ->
|
||||
try { gattServer?.cancelConnection(d) } catch (_: Exception) { }
|
||||
// Disconnect ALL server connections
|
||||
val servers = connectionTracker.getConnectedDevices().values.filter { !it.isClient }
|
||||
servers.forEach { d ->
|
||||
try { gattServer?.cancelConnection(d.device) } catch (_: Exception) { }
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
|
||||
@@ -323,7 +337,7 @@ class BluetoothGattServerManager(
|
||||
@Suppress("DEPRECATION")
|
||||
private fun startAdvertising() {
|
||||
// Respect debug setting
|
||||
val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true }
|
||||
val enabled = isServerRoleEnabled()
|
||||
|
||||
// Guard conditions – never throw here to avoid crashing the app from a background coroutine
|
||||
if (!permissionManager.hasBluetoothPermissions()) {
|
||||
@@ -358,22 +372,59 @@ class BluetoothGattServerManager(
|
||||
.setIncludeTxPowerLevel(false)
|
||||
.setIncludeDeviceName(false)
|
||||
.build()
|
||||
|
||||
// Add stable identity (first 8 bytes of peerID) to Scan Response
|
||||
// This allows scanners to deduplicate devices even if MAC address rotates
|
||||
val peerIDBytes = try {
|
||||
myPeerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray().take(8).toByteArray()
|
||||
} catch (e: Exception) {
|
||||
ByteArray(0)
|
||||
}
|
||||
|
||||
val scanResponse = AdvertiseData.Builder()
|
||||
.addServiceData(ParcelUuid(AppConstants.Mesh.Gatt.SERVICE_UUID), peerIDBytes)
|
||||
.setIncludeTxPowerLevel(false)
|
||||
.setIncludeDeviceName(false)
|
||||
.build()
|
||||
|
||||
advertiseCallback = object : AdvertiseCallback() {
|
||||
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
|
||||
advertiseRetryCount = 0
|
||||
val mode = try {
|
||||
powerManager.getPowerInfo().split("Current Mode: ")[1].split("\n")[0]
|
||||
} catch (_: Exception) { "unknown" }
|
||||
Log.i(TAG, "Advertising started (power mode: $mode)")
|
||||
Log.i(TAG, "Advertising started (power mode: $mode) with stable ID: ${peerIDBytes.joinToString("") { "%02x".format(it) }}")
|
||||
}
|
||||
|
||||
override fun onStartFailure(errorCode: Int) {
|
||||
Log.e(TAG, "Advertising failed: $errorCode")
|
||||
// Previously this only logged, so if advertising failed this device became
|
||||
// undiscoverable until a manual BLE toggle. Retry transient failures with backoff.
|
||||
when (errorCode) {
|
||||
ADVERTISE_FAILED_ALREADY_STARTED ->
|
||||
Log.w(TAG, "ADVERTISE_FAILED_ALREADY_STARTED - already advertising, no retry")
|
||||
ADVERTISE_FAILED_DATA_TOO_LARGE ->
|
||||
Log.e(TAG, "ADVERTISE_FAILED_DATA_TOO_LARGE - config issue, not retrying")
|
||||
ADVERTISE_FAILED_FEATURE_UNSUPPORTED ->
|
||||
Log.e(TAG, "ADVERTISE_FAILED_FEATURE_UNSUPPORTED - unsupported, not retrying")
|
||||
ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> {
|
||||
Log.w(TAG, "ADVERTISE_FAILED_TOO_MANY_ADVERTISERS - will retry after backoff")
|
||||
scheduleAdvertiseRestart("too-many-advertisers")
|
||||
}
|
||||
ADVERTISE_FAILED_INTERNAL_ERROR -> {
|
||||
Log.w(TAG, "ADVERTISE_FAILED_INTERNAL_ERROR - will retry after backoff")
|
||||
scheduleAdvertiseRestart("internal-error")
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Unknown advertise failure $errorCode - will retry after backoff")
|
||||
scheduleAdvertiseRestart("unknown-$errorCode")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
bleAdvertiser.startAdvertising(settings, data, advertiseCallback)
|
||||
bleAdvertiser.startAdvertising(settings, data, scanResponse, advertiseCallback)
|
||||
} catch (se: SecurityException) {
|
||||
Log.e(TAG, "SecurityException starting advertising (missing permission?): ${se.message}")
|
||||
} catch (e: Exception) {
|
||||
@@ -394,12 +445,29 @@ class BluetoothGattServerManager(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule an advertising restart with incremental backoff after a transient failure.
|
||||
*/
|
||||
private fun scheduleAdvertiseRestart(reason: String) {
|
||||
advertiseRetryCount++
|
||||
val delayMs = (ADVERTISE_RETRY_BASE_MS * advertiseRetryCount).coerceAtMost(ADVERTISE_MAX_RETRY_DELAY_MS)
|
||||
Log.w(TAG, "Scheduling advertising restart in ${delayMs}ms (attempt $advertiseRetryCount, reason=$reason)")
|
||||
connectionScope.launch {
|
||||
delay(delayMs)
|
||||
if (isActive && isServerRoleEnabled()) {
|
||||
stopAdvertising()
|
||||
delay(100)
|
||||
startAdvertising()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart advertising (for power mode changes)
|
||||
*/
|
||||
fun restartAdvertising() {
|
||||
// Respect debug setting
|
||||
val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true }
|
||||
val enabled = isServerRoleEnabled()
|
||||
if (!isActive || !enabled) {
|
||||
stopAdvertising()
|
||||
return
|
||||
|
||||
@@ -7,12 +7,15 @@ import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.protocol.MessagePadding
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.model.IdentityAnnouncement
|
||||
import com.bitchat.android.model.NoisePayload
|
||||
import com.bitchat.android.model.NoisePayloadType
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.protocol.SpecialRecipients
|
||||
import com.bitchat.android.model.RequestSyncPacket
|
||||
import com.bitchat.android.sync.GossipSyncManager
|
||||
import com.bitchat.android.util.toHexString
|
||||
import com.bitchat.android.services.VerificationService
|
||||
import com.bitchat.android.service.TransportBridgeService
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.*
|
||||
@@ -68,11 +71,13 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
// Coroutines
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var announceJob: Job? = null
|
||||
// Tracks whether this instance has been terminated via stopServices()
|
||||
private var terminated = false
|
||||
|
||||
init {
|
||||
Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID")
|
||||
VerificationService.configure(encryptionService)
|
||||
setupDelegates()
|
||||
messageHandler.packetProcessor = packetProcessor
|
||||
//startPeriodicDebugLogging()
|
||||
@@ -95,50 +100,46 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
} catch (_: Exception) { 0.01 }
|
||||
}
|
||||
)
|
||||
|
||||
// Register as shared instance for Wi-Fi Aware transport
|
||||
com.bitchat.android.service.MeshServiceHolder.setGossipManager(gossipSyncManager)
|
||||
|
||||
// Wire sync manager delegate
|
||||
gossipSyncManager.delegate = object : GossipSyncManager.Delegate {
|
||||
override fun sendPacket(packet: BitchatPacket) {
|
||||
dispatchGlobal(RoutedPacket(packet))
|
||||
}
|
||||
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
|
||||
// Point-to-point optimization if possible, but for bridge safety
|
||||
// we might want to consider dispatchGlobal if peer is on another transport.
|
||||
// However, sendPacketToPeer in connectionManager is BLE-specific unicast.
|
||||
// If peer is on Wi-Fi, this won't reach.
|
||||
// For now, let's keep unicast as-is (it's mostly for sync)
|
||||
// and assume routing handles the rest via broadcasts if needed.
|
||||
connectionManager.sendPacketToPeer(peerID, packet)
|
||||
}
|
||||
override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket {
|
||||
return signPacketBeforeBroadcast(packet)
|
||||
}
|
||||
com.bitchat.android.service.MeshServiceHolder.setGossipManager(gossipSyncManager) { packet ->
|
||||
signPacketBeforeBroadcast(packet)
|
||||
}
|
||||
if (isBleTransportEnabled()) {
|
||||
TransportBridgeService.register("BLE", this)
|
||||
}
|
||||
Log.d(TAG, "Delegates set up; GossipSyncManager initialized")
|
||||
|
||||
// Register with cross-layer transport bridge
|
||||
TransportBridgeService.register("BLE", this)
|
||||
// Inject dynamic direct connection check into PeerManager
|
||||
// Matches iOS logic: checks if we have an active hardware mapping for this peer
|
||||
peerManager.isPeerDirectlyConnected = { peerID ->
|
||||
connectionManager.addressPeerMap.containsValue(peerID)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Delegates set up; GossipSyncManager initialized")
|
||||
}
|
||||
|
||||
// TransportLayer implementation
|
||||
override fun send(packet: RoutedPacket) {
|
||||
// Received from bridge (e.g. Wi-Fi) -> Send via BLE
|
||||
// Direct injection prevents routing loops (bridge handles source check)
|
||||
if (!isBleTransportEnabled()) return
|
||||
connectionManager.broadcastPacket(packet)
|
||||
}
|
||||
|
||||
/**
|
||||
* unified dispatch: Send to local BLE and bridge to other transports
|
||||
*/
|
||||
private fun dispatchGlobal(routed: RoutedPacket) {
|
||||
// 1. Send to local BLE transport
|
||||
override fun sendToPeer(peerID: String, packet: BitchatPacket) {
|
||||
if (!isBleTransportEnabled()) return
|
||||
connectionManager.sendPacketToPeer(peerID, packet)
|
||||
}
|
||||
|
||||
private fun broadcastRoutedPacket(routed: RoutedPacket) {
|
||||
if (!isBleTransportEnabled()) return
|
||||
connectionManager.broadcastPacket(routed)
|
||||
// 2. Bridge to other transports (e.g. Wi-Fi)
|
||||
TransportBridgeService.broadcast("BLE", routed)
|
||||
}
|
||||
|
||||
private fun isBleTransportEnabled(): Boolean {
|
||||
return try {
|
||||
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
|
||||
} catch (_: Exception) {
|
||||
try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic debug logging every 10 seconds
|
||||
@@ -165,7 +166,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
* Send broadcast announcement every 30 seconds
|
||||
*/
|
||||
private fun sendPeriodicBroadcastAnnounce() {
|
||||
serviceScope.launch {
|
||||
announceJob?.cancel()
|
||||
announceJob = serviceScope.launch {
|
||||
Log.d(TAG, "Starting periodic announce loop")
|
||||
while (isActive) {
|
||||
try {
|
||||
@@ -184,18 +186,25 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
*/
|
||||
private fun setupDelegates() {
|
||||
Log.d(TAG, "Setting up component delegates")
|
||||
// Provide nickname resolver to BLE broadcaster for detailed logs
|
||||
// Provide nickname resolver to BLE broadcaster and debug manager
|
||||
try {
|
||||
connectionManager.setNicknameResolver { pid -> peerManager.getPeerNickname(pid) }
|
||||
val resolver: (String) -> String? = { pid -> peerManager.getPeerNickname(pid) }
|
||||
connectionManager.setNicknameResolver(resolver)
|
||||
debugManager?.setNicknameResolver(resolver)
|
||||
} catch (_: Exception) { }
|
||||
// PeerManager delegates to main mesh service delegate
|
||||
peerManager.delegate = object : PeerManagerDelegate {
|
||||
override fun onPeerListUpdated(peerIDs: List<String>) {
|
||||
// Update process-wide state first
|
||||
try { com.bitchat.android.services.AppStateStore.setTransportPeers("BLE", peerIDs) } catch (_: Exception) { }
|
||||
// Then notify UI delegate if attached
|
||||
delegate?.didUpdatePeerList(peerIDs)
|
||||
}
|
||||
override fun onPeerRemoved(peerID: String) {
|
||||
try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { }
|
||||
// Remove from mesh graph topology to prevent routing through stale peers
|
||||
try { com.bitchat.android.services.meshgraph.MeshGraphService.getInstance().removePeer(peerID) } catch (_: Exception) { }
|
||||
|
||||
// Also drop any Noise session state for this peer when they go offline
|
||||
try {
|
||||
encryptionService.removePeer(peerID)
|
||||
@@ -233,7 +242,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
)
|
||||
// Sign the handshake response
|
||||
val signedPacket = signPacketBeforeBroadcast(responsePacket)
|
||||
dispatchGlobal(RoutedPacket(signedPacket))
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
Log.d(TAG, "Sent Noise handshake response to $peerID (${response.size} bytes)")
|
||||
}
|
||||
|
||||
@@ -253,7 +262,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
|
||||
override fun sendPacket(packet: BitchatPacket) {
|
||||
dispatchGlobal(RoutedPacket(packet))
|
||||
broadcastRoutedPacket(RoutedPacket(packet))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,12 +305,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
override fun sendPacket(packet: BitchatPacket) {
|
||||
// Sign the packet before broadcasting
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
val routed = RoutedPacket(signedPacket)
|
||||
dispatchGlobal(routed)
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
}
|
||||
|
||||
override fun relayPacket(routed: RoutedPacket) {
|
||||
dispatchGlobal(routed)
|
||||
broadcastRoutedPacket(routed)
|
||||
}
|
||||
|
||||
override fun getBroadcastRecipient(): ByteArray {
|
||||
@@ -348,7 +356,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
// Sign the handshake packet before broadcasting
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
dispatchGlobal(RoutedPacket(signedPacket))
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
Log.d(TAG, "Initiated Noise handshake with $peerID (${handshakeData.size} bytes)")
|
||||
} else {
|
||||
Log.w(TAG, "Failed to generate Noise handshake data for $peerID")
|
||||
@@ -443,6 +451,14 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
override fun onReadReceiptReceived(messageID: String, peerID: String) {
|
||||
delegate?.didReceiveReadReceipt(messageID, peerID)
|
||||
}
|
||||
|
||||
override fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
delegate?.didReceiveVerifyChallenge(peerID, payload, timestampMs)
|
||||
}
|
||||
|
||||
override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
|
||||
}
|
||||
}
|
||||
|
||||
// PacketProcessor delegates
|
||||
@@ -481,21 +497,24 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
// Process the announce
|
||||
val isFirst = messageHandler.handleAnnounce(routed)
|
||||
|
||||
// Map device address -> peerID on first announce seen over this device connection
|
||||
// Map device address -> peerID based on TTL (max TTL = direct neighbor)
|
||||
// Matches iOS logic: any announce with max TTL on a link defines the direct peer
|
||||
val deviceAddress = routed.relayAddress
|
||||
val pid = routed.peerID
|
||||
if (deviceAddress != null && pid != null) {
|
||||
// First ANNOUNCE over a device connection defines a direct neighbor.
|
||||
if (!connectionManager.hasSeenFirstAnnounce(deviceAddress)) {
|
||||
// Check if this is a direct connection (MAX TTL)
|
||||
// Note: packet.ttl is UByte, compare with AppConstants.MESSAGE_TTL_HOPS
|
||||
val isDirect = routed.packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
|
||||
|
||||
if (isDirect) {
|
||||
// Bind or rebind this device address to the announcing peer
|
||||
connectionManager.addressPeerMap[deviceAddress] = pid
|
||||
connectionManager.noteAnnounceReceived(deviceAddress)
|
||||
Log.d(TAG, "Mapped device $deviceAddress to peer $pid on FIRST-ANNOUNCE for this connection")
|
||||
Log.d(TAG, "Mapped device $deviceAddress to peer $pid (TTL=${routed.packet.ttl})")
|
||||
|
||||
// Mark as directly connected (upgrades from routed if needed)
|
||||
try { peerManager.setDirectConnection(pid, true) } catch (_: Exception) { }
|
||||
// Mark as directly connected - refresh UI state
|
||||
try { peerManager.refreshPeerList() } catch (_: Exception) { }
|
||||
|
||||
// Initial sync for this newly direct peer
|
||||
// Initial sync for this direct peer
|
||||
try { gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
@@ -540,9 +559,15 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
|
||||
override fun relayPacket(routed: RoutedPacket) {
|
||||
dispatchGlobal(routed)
|
||||
broadcastRoutedPacket(routed)
|
||||
}
|
||||
|
||||
override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
|
||||
val sentOverBle = connectionManager.sendToPeer(peerID, routed)
|
||||
TransportBridgeService.sendToPeer("BLE", peerID, routed.packet)
|
||||
return sentOverBle
|
||||
}
|
||||
|
||||
override fun handleRequestSync(routed: RoutedPacket) {
|
||||
// Decode request and respond with missing packets
|
||||
val fromPeer = routed.peerID ?: return
|
||||
@@ -553,19 +578,19 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
// BluetoothConnectionManager delegates
|
||||
connectionManager.delegate = object : BluetoothConnectionManagerDelegate {
|
||||
override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) {
|
||||
// Log incoming for debug graphs (do not double-count anywhere else)
|
||||
try {
|
||||
val nick = getPeerNicknames()[peerID]
|
||||
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming(
|
||||
packetType = packet.type.toString(),
|
||||
fromPeerID = peerID,
|
||||
fromNickname = nick,
|
||||
fromDeviceAddress = device?.address
|
||||
)
|
||||
} catch (_: Exception) { }
|
||||
packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address))
|
||||
}
|
||||
override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) {
|
||||
// Log incoming for debug graphs (do not double-count anywhere else)
|
||||
try {
|
||||
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming(
|
||||
packet = packet,
|
||||
fromPeerID = peerID,
|
||||
fromNickname = null,
|
||||
fromDeviceAddress = device?.address,
|
||||
myPeerID = myPeerID
|
||||
)
|
||||
} catch (_: Exception) { }
|
||||
packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address))
|
||||
}
|
||||
|
||||
override fun onDeviceConnected(device: android.bluetooth.BluetoothDevice) {
|
||||
// Send initial announcements after services are ready
|
||||
@@ -591,12 +616,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
val peer = connectionManager.addressPeerMap[addr]
|
||||
// ConnectionTracker has already removed the address mapping; be defensive either way
|
||||
connectionManager.addressPeerMap.remove(addr)
|
||||
|
||||
// refresh peer list on disconnect.
|
||||
try { peerManager.refreshPeerList() } catch (_: Exception) { }
|
||||
|
||||
if (peer != null) {
|
||||
val stillMapped = connectionManager.addressPeerMap.values.any { it == peer }
|
||||
if (!stillMapped) {
|
||||
// Peer might still be reachable indirectly; mark as not-direct
|
||||
try { peerManager.setDirectConnection(peer, false) } catch (_: Exception) { }
|
||||
}
|
||||
// Verbose debug: device disconnected
|
||||
try {
|
||||
val nick = peerManager.getPeerNickname(peer) ?: "unknown"
|
||||
@@ -624,6 +648,15 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
Log.w(TAG, "Mesh service already active, ignoring duplicate start request")
|
||||
return
|
||||
}
|
||||
if (!isBleTransportEnabled()) {
|
||||
Log.i(TAG, "BLE transport disabled by debug settings; not starting mesh service")
|
||||
connectionManager.disableTransport()
|
||||
TransportBridgeService.unregister("BLE")
|
||||
com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("BLE")
|
||||
try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { }
|
||||
try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("BLE") } catch (_: Exception) { }
|
||||
return
|
||||
}
|
||||
if (terminated) {
|
||||
// This instance's scope was cancelled previously; refuse to start to avoid using dead scopes.
|
||||
Log.e(TAG, "Mesh service instance was terminated; create a new instance instead of restarting")
|
||||
@@ -634,17 +667,42 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
if (connectionManager.startServices()) {
|
||||
isActive = true
|
||||
TransportBridgeService.register("BLE", this)
|
||||
|
||||
// Start periodic announcements for peer discovery and connectivity
|
||||
sendPeriodicBroadcastAnnounce()
|
||||
Log.d(TAG, "Started periodic broadcast announcements (every 30 seconds)")
|
||||
// Start periodic syncs
|
||||
gossipSyncManager.start()
|
||||
com.bitchat.android.service.MeshServiceHolder.startSharedGossip("BLE")
|
||||
Log.d(TAG, "GossipSyncManager started")
|
||||
} else {
|
||||
Log.e(TAG, "Failed to start Bluetooth services")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the debug master transport toggle without destroying this mesh instance.
|
||||
*/
|
||||
fun setBleTransportEnabled(enabled: Boolean) {
|
||||
if (enabled) {
|
||||
startServices()
|
||||
} else {
|
||||
pauseServicesForTransportDisable()
|
||||
}
|
||||
}
|
||||
|
||||
private fun pauseServicesForTransportDisable() {
|
||||
Log.i(TAG, "Disabling BLE mesh transport")
|
||||
isActive = false
|
||||
announceJob?.cancel()
|
||||
announceJob = null
|
||||
com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("BLE")
|
||||
TransportBridgeService.unregister("BLE")
|
||||
try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { }
|
||||
try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("BLE") } catch (_: Exception) { }
|
||||
connectionManager.disableTransport()
|
||||
try { peerManager.refreshPeerList() } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all mesh services
|
||||
@@ -657,9 +715,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
Log.i(TAG, "Stopping Bluetooth mesh service")
|
||||
isActive = false
|
||||
|
||||
// Unregister from bridge
|
||||
announceJob?.cancel()
|
||||
announceJob = null
|
||||
TransportBridgeService.unregister("BLE")
|
||||
try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { }
|
||||
try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("BLE") } catch (_: Exception) { }
|
||||
|
||||
// Send leave announcement
|
||||
sendLeaveAnnouncement()
|
||||
@@ -669,7 +729,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
delay(200) // Give leave message time to send
|
||||
|
||||
// Stop all components
|
||||
gossipSyncManager.stop()
|
||||
com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("BLE")
|
||||
Log.d(TAG, "GossipSyncManager stopped")
|
||||
connectionManager.stopServices()
|
||||
Log.d(TAG, "BluetoothConnectionManager stop requested")
|
||||
@@ -719,7 +779,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
// Sign the packet before broadcasting
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
dispatchGlobal(RoutedPacket(signedPacket))
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
// Track our own broadcast message for sync
|
||||
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
|
||||
}
|
||||
@@ -751,7 +811,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
val signed = signPacketBeforeBroadcast(packet)
|
||||
// Use a stable transferId based on the file TLV payload for progress tracking
|
||||
val transferId = sha256Hex(payload)
|
||||
dispatchGlobal(RoutedPacket(signed, transferId = transferId))
|
||||
broadcastRoutedPacket(RoutedPacket(signed, transferId = transferId))
|
||||
try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -795,7 +855,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
// Create NOISE_ENCRYPTED packet (not FILE_TRANSFER!)
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
version = if (encrypted.size > 0xFFFF) 2u else 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = hexStringToByteArray(recipientPeerID),
|
||||
@@ -809,7 +869,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
val signed = signPacketBeforeBroadcast(packet)
|
||||
// Use a stable transferId based on the unencrypted file TLV payload for progress tracking
|
||||
val transferId = sha256Hex(filePayload)
|
||||
dispatchGlobal(RoutedPacket(signed, transferId = transferId))
|
||||
broadcastRoutedPacket(RoutedPacket(signed, transferId = transferId))
|
||||
Log.d(TAG, "✅ Sent encrypted file to $recipientPeerID")
|
||||
|
||||
} catch (e: Exception) {
|
||||
@@ -889,7 +949,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
// Sign the packet before broadcasting
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
dispatchGlobal(RoutedPacket(signedPacket))
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
Log.d(TAG, "📤 Sent encrypted private message to $recipientPeerID (${encrypted.size} bytes)")
|
||||
|
||||
// FIXED: Don't send didReceiveMessage for our own sent messages
|
||||
@@ -960,7 +1020,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
// Sign the packet before broadcasting
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
dispatchGlobal(RoutedPacket(signedPacket))
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID")
|
||||
|
||||
// Persist as read after successful send
|
||||
@@ -971,6 +1031,50 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: QR Verification over Noise
|
||||
|
||||
fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
|
||||
val tlv = VerificationService.buildVerifyChallenge(noiseKeyHex, nonceA)
|
||||
val payload = NoisePayload(
|
||||
type = NoisePayloadType.VERIFY_CHALLENGE,
|
||||
data = tlv
|
||||
)
|
||||
sendNoisePayloadToPeer(payload, peerID, "verify challenge")
|
||||
}
|
||||
|
||||
fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
|
||||
val tlv = VerificationService.buildVerifyResponse(noiseKeyHex, nonceA) ?: return
|
||||
val payload = NoisePayload(
|
||||
type = NoisePayloadType.VERIFY_RESPONSE,
|
||||
data = tlv
|
||||
)
|
||||
sendNoisePayloadToPeer(payload, peerID, "verify response")
|
||||
}
|
||||
|
||||
private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String, label: String) {
|
||||
serviceScope.launch {
|
||||
try {
|
||||
val encrypted = encryptionService.encrypt(payload.encode(), recipientPeerID)
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = hexStringToByteArray(recipientPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = encrypted,
|
||||
signature = null,
|
||||
ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
|
||||
)
|
||||
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
Log.d(TAG, "📤 Sent $label to $recipientPeerID (${payload.data.size} bytes)")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send $label to $recipientPeerID: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send broadcast announce with TLV-encoded identity announcement - exactly like iOS
|
||||
@@ -996,11 +1100,25 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
// Create iOS-compatible IdentityAnnouncement with TLV encoding
|
||||
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
|
||||
val tlvPayload = announcement.encode()
|
||||
var tlvPayload = announcement.encode()
|
||||
if (tlvPayload == null) {
|
||||
Log.e(TAG, "Failed to encode announcement as TLV")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Append gossip TLV containing up to 10 direct neighbors (compact IDs)
|
||||
try {
|
||||
val directPeers = getDirectPeerIDsForGossip()
|
||||
if (directPeers.isNotEmpty()) {
|
||||
val gossip = com.bitchat.android.services.meshgraph.GossipTLV.encodeNeighbors(directPeers)
|
||||
tlvPayload = tlvPayload + gossip
|
||||
}
|
||||
// Always update our own node in the mesh graph with the neighbor list we used
|
||||
try {
|
||||
com.bitchat.android.services.meshgraph.MeshGraphService.getInstance()
|
||||
.updateFromAnnouncement(myPeerID, nickname, directPeers, System.currentTimeMillis().toULong())
|
||||
} catch (_: Exception) { }
|
||||
} catch (_: Exception) { }
|
||||
|
||||
val announcePacket = BitchatPacket(
|
||||
type = MessageType.ANNOUNCE.value,
|
||||
@@ -1014,7 +1132,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
announcePacket.copy(signature = signature)
|
||||
} ?: announcePacket
|
||||
|
||||
dispatchGlobal(RoutedPacket(signedPacket))
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
Log.d(TAG, "Sent iOS-compatible signed TLV announce (${tlvPayload.size} bytes)")
|
||||
// Track announce for sync
|
||||
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
|
||||
@@ -1045,11 +1163,25 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
// Create iOS-compatible IdentityAnnouncement with TLV encoding
|
||||
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
|
||||
val tlvPayload = announcement.encode()
|
||||
var tlvPayload = announcement.encode()
|
||||
if (tlvPayload == null) {
|
||||
Log.e(TAG, "Failed to encode peer announcement as TLV")
|
||||
return
|
||||
}
|
||||
|
||||
// Append gossip TLV containing up to 10 direct neighbors (compact IDs)
|
||||
try {
|
||||
val directPeers = getDirectPeerIDsForGossip()
|
||||
if (directPeers.isNotEmpty()) {
|
||||
val gossip = com.bitchat.android.services.meshgraph.GossipTLV.encodeNeighbors(directPeers)
|
||||
tlvPayload = tlvPayload + gossip
|
||||
}
|
||||
// Always update our own node in the mesh graph with the neighbor list we used
|
||||
try {
|
||||
com.bitchat.android.services.meshgraph.MeshGraphService.getInstance()
|
||||
.updateFromAnnouncement(myPeerID, nickname, directPeers, System.currentTimeMillis().toULong())
|
||||
} catch (_: Exception) { }
|
||||
} catch (_: Exception) { }
|
||||
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.ANNOUNCE.value,
|
||||
@@ -1063,7 +1195,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
packet.copy(signature = signature)
|
||||
} ?: packet
|
||||
|
||||
dispatchGlobal(RoutedPacket(signedPacket))
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
peerManager.markPeerAsAnnouncedTo(peerID)
|
||||
Log.d(TAG, "Sent iOS-compatible signed TLV peer announce to $peerID (${tlvPayload.size} bytes)")
|
||||
|
||||
@@ -1071,6 +1203,26 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect up to 10 direct neighbors for gossip TLV.
|
||||
*/
|
||||
private fun getDirectPeerIDsForGossip(): List<String> {
|
||||
return try {
|
||||
// Prefer verified peers that are currently marked as direct
|
||||
val verified = peerManager.getVerifiedPeers()
|
||||
val direct = verified.filter { it.value.isDirectConnection }.keys.toSet()
|
||||
// Publish this transport's direct peers and gossip the cross-transport union so a
|
||||
// node connected via multiple transports advertises a complete neighbor list.
|
||||
try { com.bitchat.android.services.AppStateStore.setTransportDirectPeers("BLE", direct) } catch (_: Exception) { }
|
||||
val union = try {
|
||||
com.bitchat.android.services.AppStateStore.getDirectPeers().ifEmpty { direct }
|
||||
} catch (_: Exception) { direct }
|
||||
union.distinct().take(10)
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send leave announcement
|
||||
*/
|
||||
@@ -1084,7 +1236,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
// Sign the packet before broadcasting
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
dispatchGlobal(RoutedPacket(signedPacket))
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1159,6 +1311,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
fun getIdentityFingerprint(): String {
|
||||
return encryptionService.getIdentityFingerprint()
|
||||
}
|
||||
|
||||
fun getStaticNoisePublicKey(): ByteArray? {
|
||||
return encryptionService.getStaticPublicKey()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if encryption icon should be shown for a peer
|
||||
@@ -1249,21 +1405,38 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
*/
|
||||
private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket {
|
||||
return try {
|
||||
// Optionally compute and attach a source route for addressed packets
|
||||
val withRoute = try {
|
||||
val rec = packet.recipientID
|
||||
if (rec != null && !rec.contentEquals(SpecialRecipients.BROADCAST)) {
|
||||
val dest = rec.joinToString("") { b -> "%02x".format(b) }
|
||||
val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath(myPeerID, dest)
|
||||
if (path != null && path.size >= 3) {
|
||||
// Exclude first (sender) and last (recipient); only intermediates
|
||||
val intermediates = path.subList(1, path.size - 1)
|
||||
val hopsBytes = intermediates.map { hexStringToByteArray(it) }
|
||||
Log.d(TAG, "✅ Signed packet type ${packet.type} (route ${hopsBytes.size} hops: $intermediates)")
|
||||
// Attach route and upgrade to v2 (required for HAS_ROUTE flag)
|
||||
packet.copy(route = hopsBytes, version = 2u)
|
||||
} else packet.copy(route = null)
|
||||
} else packet
|
||||
} catch (_: Exception) { packet }
|
||||
|
||||
// Get the canonical packet data for signing (without signature)
|
||||
val packetDataForSigning = packet.toBinaryDataForSigning()
|
||||
val packetDataForSigning = withRoute.toBinaryDataForSigning()
|
||||
if (packetDataForSigning == null) {
|
||||
Log.w(TAG, "Failed to encode packet type ${packet.type} for signing, sending unsigned")
|
||||
return packet
|
||||
return withRoute
|
||||
}
|
||||
|
||||
// Sign the packet data using our signing key
|
||||
val signature = encryptionService.signData(packetDataForSigning)
|
||||
if (signature != null) {
|
||||
Log.d(TAG, "✅ Signed packet type ${packet.type} (signature ${signature.size} bytes)")
|
||||
packet.copy(signature = signature)
|
||||
withRoute.copy(signature = signature)
|
||||
} else {
|
||||
Log.w(TAG, "Failed to sign packet type ${packet.type}, sending unsigned")
|
||||
packet
|
||||
withRoute
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error signing packet type ${packet.type}: ${e.message}, sending unsigned")
|
||||
@@ -1279,6 +1452,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
fun clearAllInternalData() {
|
||||
Log.w(TAG, "🚨 Clearing all mesh service internal data")
|
||||
try {
|
||||
// Stop services to cease broadcasting old ID immediately
|
||||
stopServices()
|
||||
|
||||
// Clear all managers
|
||||
fragmentManager.clearAllFragments()
|
||||
storeForwardManager.clearAllCache()
|
||||
@@ -1307,16 +1483,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegate interface for mesh service callbacks (maintains exact same interface)
|
||||
* Delegate interface for BLE mesh callbacks. Extends the shared mesh delegate so
|
||||
* transport-agnostic facades can receive the same callback stream.
|
||||
*/
|
||||
interface BluetoothMeshDelegate {
|
||||
fun didReceiveMessage(message: BitchatMessage)
|
||||
fun didUpdatePeerList(peers: List<String>)
|
||||
fun didReceiveChannelLeave(channel: String, fromPeer: String)
|
||||
fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String)
|
||||
fun didReceiveReadReceipt(messageID: String, recipientPeerID: String)
|
||||
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
|
||||
fun getNickname(): String?
|
||||
fun isFavorite(peerID: String): Boolean
|
||||
// registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager
|
||||
interface BluetoothMeshDelegate : MeshDelegate {
|
||||
override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long)
|
||||
override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long)
|
||||
}
|
||||
|
||||
@@ -18,8 +18,6 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.Job
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.channels.actor
|
||||
|
||||
/**
|
||||
@@ -42,7 +40,8 @@ import kotlinx.coroutines.channels.actor
|
||||
class BluetoothPacketBroadcaster(
|
||||
private val connectionScope: CoroutineScope,
|
||||
private val connectionTracker: BluetoothConnectionTracker,
|
||||
private val fragmentManager: FragmentManager?
|
||||
private val fragmentManager: FragmentManager?,
|
||||
private val myPeerID: String
|
||||
) {
|
||||
|
||||
companion object {
|
||||
@@ -68,7 +67,9 @@ class BluetoothPacketBroadcaster(
|
||||
incomingAddr: String?,
|
||||
toPeer: String?,
|
||||
toDeviceAddress: String,
|
||||
ttl: UByte
|
||||
ttl: UByte,
|
||||
packetVersion: UByte = 1u,
|
||||
routeInfo: String? = null
|
||||
) {
|
||||
try {
|
||||
val fromNick = incomingPeer?.let { nicknameResolver?.invoke(it) }
|
||||
@@ -80,7 +81,9 @@ class BluetoothPacketBroadcaster(
|
||||
toPeerID = toPeer,
|
||||
toNickname = toNick,
|
||||
toDeviceAddress = toDeviceAddress,
|
||||
previousHopPeerID = incomingPeer
|
||||
previousHopPeerID = incomingPeer,
|
||||
packetVersion = packetVersion,
|
||||
routeInfo = routeInfo
|
||||
)
|
||||
// Keep the verbose relay message for human readability
|
||||
manager.logPacketRelayDetailed(
|
||||
@@ -94,7 +97,9 @@ class BluetoothPacketBroadcaster(
|
||||
toNickname = toNick,
|
||||
toDeviceAddress = toDeviceAddress,
|
||||
ttl = ttl,
|
||||
isRelay = true
|
||||
isRelay = true,
|
||||
packetVersion = packetVersion,
|
||||
routeInfo = routeInfo
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
// Silently ignore debug logging failures
|
||||
@@ -110,7 +115,7 @@ class BluetoothPacketBroadcaster(
|
||||
|
||||
// Actor scope for the broadcaster
|
||||
private val broadcasterScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val transferJobs = ConcurrentHashMap<String, Job>()
|
||||
private val fragmentingSender = FragmentingPacketSender(connectionScope, fragmentManager, TAG)
|
||||
|
||||
// SERIALIZATION: Actor to serialize all broadcast operations
|
||||
@OptIn(kotlinx.coroutines.ObsoleteCoroutinesApi::class)
|
||||
@@ -132,71 +137,14 @@ class BluetoothPacketBroadcaster(
|
||||
gattServer: BluetoothGattServer?,
|
||||
characteristic: BluetoothGattCharacteristic?
|
||||
) {
|
||||
val packet = routed.packet
|
||||
val isFile = packet.type == MessageType.FILE_TRANSFER.value
|
||||
if (isFile) {
|
||||
Log.d(TAG, "📤 Broadcasting FILE_TRANSFER: ${packet.payload.size} bytes")
|
||||
}
|
||||
// Prefer caller-provided transferId (e.g., for encrypted media), else derive for FILE_TRANSFER
|
||||
val transferId = routed.transferId ?: (if (isFile) sha256Hex(packet.payload) else null)
|
||||
// Check if we need to fragment
|
||||
if (fragmentManager != null) {
|
||||
val fragments = try {
|
||||
fragmentManager.createFragments(packet)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Fragment creation failed: ${e.message}", e)
|
||||
if (isFile) {
|
||||
Log.e(TAG, "❌ File fragmentation failed for ${packet.payload.size} byte file")
|
||||
}
|
||||
return
|
||||
}
|
||||
if (fragments.size > 1) {
|
||||
if (isFile) {
|
||||
Log.d(TAG, "🔀 File needs ${fragments.size} fragments")
|
||||
}
|
||||
Log.d(TAG, "Fragmenting packet into ${fragments.size} fragments")
|
||||
if (transferId != null) {
|
||||
TransferProgressManager.start(transferId, fragments.size)
|
||||
}
|
||||
val job = connectionScope.launch {
|
||||
var sent = 0
|
||||
fragments.forEach { fragment ->
|
||||
if (!isActive) return@launch
|
||||
// If cancelled, stop sending remaining fragments
|
||||
if (transferId != null && transferJobs[transferId]?.isCancelled == true) return@launch
|
||||
broadcastSinglePacket(RoutedPacket(fragment, transferId = transferId), gattServer, characteristic)
|
||||
// 20ms delay between fragments
|
||||
delay(20)
|
||||
if (transferId != null) {
|
||||
sent += 1
|
||||
TransferProgressManager.progress(transferId, sent, fragments.size)
|
||||
if (sent == fragments.size) TransferProgressManager.complete(transferId, fragments.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (transferId != null) {
|
||||
transferJobs[transferId] = job
|
||||
job.invokeOnCompletion { transferJobs.remove(transferId) }
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Send single packet if no fragmentation needed
|
||||
if (transferId != null) {
|
||||
TransferProgressManager.start(transferId, 1)
|
||||
}
|
||||
broadcastSinglePacket(routed, gattServer, characteristic)
|
||||
if (transferId != null) {
|
||||
TransferProgressManager.progress(transferId, 1, 1)
|
||||
TransferProgressManager.complete(transferId, 1)
|
||||
fragmentingSender.send(routed, "BLE broadcast") { packet ->
|
||||
broadcastSinglePacket(packet, gattServer, characteristic)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelTransfer(transferId: String): Boolean {
|
||||
val job = transferJobs.remove(transferId) ?: return false
|
||||
job.cancel()
|
||||
return true
|
||||
return fragmentingSender.cancelTransfer(transferId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,6 +156,18 @@ class BluetoothPacketBroadcaster(
|
||||
targetPeerID: String,
|
||||
gattServer: BluetoothGattServer?,
|
||||
characteristic: BluetoothGattCharacteristic?
|
||||
): Boolean {
|
||||
if (!hasPeerConnection(targetPeerID)) return false
|
||||
return fragmentingSender.send(routed, "BLE peer ${targetPeerID.take(8)}") { packet ->
|
||||
sendSinglePacketToPeer(packet, targetPeerID, gattServer, characteristic)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendSinglePacketToPeer(
|
||||
routed: RoutedPacket,
|
||||
targetPeerID: String,
|
||||
gattServer: BluetoothGattServer?,
|
||||
characteristic: BluetoothGattCharacteristic?
|
||||
): Boolean {
|
||||
val packet = routed.packet
|
||||
val data = packet.toBinaryData() ?: return false
|
||||
@@ -215,27 +175,20 @@ class BluetoothPacketBroadcaster(
|
||||
if (isFile) {
|
||||
Log.d(TAG, "📤 Broadcasting FILE_TRANSFER: ${packet.payload.size} bytes")
|
||||
}
|
||||
// Prefer caller-provided transferId (e.g., for encrypted media), else derive for FILE_TRANSFER
|
||||
val transferId = routed.transferId ?: (if (isFile) sha256Hex(packet.payload) else null)
|
||||
if (transferId != null) {
|
||||
TransferProgressManager.start(transferId, 1)
|
||||
}
|
||||
val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString()
|
||||
val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
|
||||
val incomingAddr = routed.relayAddress
|
||||
val incomingPeer = incomingAddr?.let { connectionTracker.addressPeerMap[it] }
|
||||
val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
|
||||
val senderNick = senderPeerID.let { pid -> nicknameResolver?.invoke(pid) }
|
||||
val route = packet.route
|
||||
val routeInfo = if (!route.isNullOrEmpty()) "routed: ${route.size} hops" else null
|
||||
|
||||
// Prefer server-side subscriptions
|
||||
val serverTarget = connectionTracker.getSubscribedDevices()
|
||||
.firstOrNull { connectionTracker.addressPeerMap[it.address] == targetPeerID }
|
||||
if (serverTarget != null) {
|
||||
if (notifyDevice(serverTarget, data, gattServer, characteristic)) {
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, serverTarget.address, packet.ttl)
|
||||
if (transferId != null) {
|
||||
TransferProgressManager.progress(transferId, 1, 1)
|
||||
TransferProgressManager.complete(transferId, 1)
|
||||
}
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, serverTarget.address, packet.ttl, packet.version, routeInfo)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -245,11 +198,7 @@ class BluetoothPacketBroadcaster(
|
||||
.firstOrNull { connectionTracker.addressPeerMap[it.device.address] == targetPeerID }
|
||||
if (clientTarget != null) {
|
||||
if (writeToDeviceConn(clientTarget, data)) {
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, clientTarget.device.address, packet.ttl)
|
||||
if (transferId != null) {
|
||||
TransferProgressManager.progress(transferId, 1, 1)
|
||||
TransferProgressManager.complete(transferId, 1)
|
||||
}
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, clientTarget.device.address, packet.ttl, packet.version, routeInfo)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -257,12 +206,6 @@ class BluetoothPacketBroadcaster(
|
||||
return false
|
||||
}
|
||||
|
||||
private fun sha256Hex(bytes: ByteArray): String = try {
|
||||
val md = java.security.MessageDigest.getInstance("SHA-256")
|
||||
md.update(bytes)
|
||||
md.digest().joinToString("") { "%02x".format(it) }
|
||||
} catch (_: Exception) { bytes.size.toString(16) }
|
||||
|
||||
|
||||
/**
|
||||
* Public entry point for broadcasting - submits request to actor for serialization
|
||||
@@ -283,6 +226,31 @@ class BluetoothPacketBroadcaster(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Targeted send to a specific peer (by peerID) if directly connected.
|
||||
* Returns true if sent to at least one matching connection.
|
||||
*/
|
||||
fun sendToPeer(
|
||||
targetPeerID: String,
|
||||
routed: RoutedPacket,
|
||||
gattServer: BluetoothGattServer?,
|
||||
characteristic: BluetoothGattCharacteristic?
|
||||
): Boolean {
|
||||
if (!hasPeerConnection(targetPeerID)) return false
|
||||
return fragmentingSender.send(routed, "BLE peer ${targetPeerID.take(8)}") { packet ->
|
||||
sendSinglePacketToPeer(packet, targetPeerID, gattServer, characteristic)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasPeerConnection(targetPeerID: String): Boolean {
|
||||
val hasServerTarget = connectionTracker.getSubscribedDevices()
|
||||
.any { connectionTracker.addressPeerMap[it.address] == targetPeerID }
|
||||
if (hasServerTarget) return true
|
||||
|
||||
return connectionTracker.getConnectedDevices().values
|
||||
.any { connectionTracker.addressPeerMap[it.device.address] == targetPeerID }
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal broadcast implementation - runs in serialized actor context
|
||||
@@ -299,11 +267,52 @@ class BluetoothPacketBroadcaster(
|
||||
val incomingAddr = routed.relayAddress
|
||||
val incomingPeer = incomingAddr?.let { connectionTracker.addressPeerMap[it] }
|
||||
val senderNick = senderPeerID.let { pid -> nicknameResolver?.invoke(pid) }
|
||||
val route = packet.route
|
||||
val routeInfo = if (!route.isNullOrEmpty()) "routed: ${route.size} hops" else null
|
||||
|
||||
// Source Routing for Originating Packets
|
||||
// If we are the sender and a source route is defined, we must send ONLY to the first hop.
|
||||
if (packet.senderID.toHexString() == myPeerID && !packet.route.isNullOrEmpty()) {
|
||||
val firstHop = packet.route!![0].toHexString()
|
||||
Log.d(TAG, "Source Routing: Packet has explicit route, attempting to send to first hop: $firstHop")
|
||||
|
||||
var sent = false
|
||||
|
||||
// Try to find first hop in server connections (subscribedDevices)
|
||||
val serverTarget = connectionTracker.getSubscribedDevices()
|
||||
.firstOrNull { connectionTracker.addressPeerMap[it.address] == firstHop }
|
||||
|
||||
if (serverTarget != null) {
|
||||
Log.d(TAG, "Source Routing: sending directly to first hop (server conn) $firstHop: ${serverTarget.address}")
|
||||
if (notifyDevice(serverTarget, data, gattServer, characteristic)) {
|
||||
val toPeer = connectionTracker.addressPeerMap[serverTarget.address]
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, serverTarget.address, packet.ttl, packet.version, routeInfo)
|
||||
sent = true
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find first hop in client connections if not sent yet
|
||||
if (!sent) {
|
||||
val clientTarget = connectionTracker.getConnectedDevices().values
|
||||
.firstOrNull { connectionTracker.addressPeerMap[it.device.address] == firstHop }
|
||||
|
||||
if (clientTarget != null) {
|
||||
Log.d(TAG, "Source Routing: sending directly to first hop (client conn) $firstHop: ${clientTarget.device.address}")
|
||||
if (writeToDeviceConn(clientTarget, data)) {
|
||||
val toPeer = connectionTracker.addressPeerMap[clientTarget.device.address]
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, clientTarget.device.address, packet.ttl, packet.version, routeInfo)
|
||||
sent = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sent) return
|
||||
|
||||
Log.w(TAG, "Source Routing: First hop $firstHop not connected. Falling back to standard broadcast logic.")
|
||||
}
|
||||
|
||||
if (packet.recipientID != SpecialRecipients.BROADCAST) {
|
||||
val recipientID = packet.recipientID?.let {
|
||||
String(it).replace("\u0000", "").trim()
|
||||
} ?: ""
|
||||
val recipientID = packet.recipientID?.toHexString() ?: ""
|
||||
|
||||
// Try to find the recipient in server connections (subscribedDevices)
|
||||
val targetDevice = connectionTracker.getSubscribedDevices()
|
||||
@@ -314,7 +323,7 @@ class BluetoothPacketBroadcaster(
|
||||
Log.d(TAG, "Send packet type ${packet.type} directly to target device for recipient $recipientID: ${targetDevice.address}")
|
||||
if (notifyDevice(targetDevice, data, gattServer, characteristic)) {
|
||||
val toPeer = connectionTracker.addressPeerMap[targetDevice.address]
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDevice.address, packet.ttl)
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDevice.address, packet.ttl, packet.version, routeInfo)
|
||||
return // Sent, no need to continue
|
||||
}
|
||||
}
|
||||
@@ -328,7 +337,7 @@ class BluetoothPacketBroadcaster(
|
||||
Log.d(TAG, "Send packet type ${packet.type} directly to target client connection for recipient $recipientID: ${targetDeviceConn.device.address}")
|
||||
if (writeToDeviceConn(targetDeviceConn, data)) {
|
||||
val toPeer = connectionTracker.addressPeerMap[targetDeviceConn.device.address]
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDeviceConn.device.address, packet.ttl)
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDeviceConn.device.address, packet.ttl, packet.version, routeInfo)
|
||||
return // Sent, no need to continue
|
||||
}
|
||||
}
|
||||
@@ -338,9 +347,9 @@ class BluetoothPacketBroadcaster(
|
||||
val subscribedDevices = connectionTracker.getSubscribedDevices()
|
||||
val connectedDevices = connectionTracker.getConnectedDevices()
|
||||
|
||||
Log.i(TAG, "Broadcasting packet type ${packet.type} to ${subscribedDevices.size} server + ${connectedDevices.size} client connections")
|
||||
Log.i(TAG, "Broadcasting packet v${packet.version} type ${packet.type} to ${subscribedDevices.size} server + ${connectedDevices.size} client connections")
|
||||
|
||||
val senderID = String(packet.senderID).replace("\u0000", "")
|
||||
val senderID = packet.senderID.toHexString()
|
||||
|
||||
// Send to server connections (devices connected to our GATT server)
|
||||
subscribedDevices.forEach { device ->
|
||||
@@ -355,7 +364,7 @@ class BluetoothPacketBroadcaster(
|
||||
val sent = notifyDevice(device, data, gattServer, characteristic)
|
||||
if (sent) {
|
||||
val toPeer = connectionTracker.addressPeerMap[device.address]
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, device.address, packet.ttl)
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, device.address, packet.ttl, packet.version, routeInfo)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,7 +382,7 @@ class BluetoothPacketBroadcaster(
|
||||
val sent = writeToDeviceConn(deviceConn, data)
|
||||
if (sent) {
|
||||
val toPeer = connectionTracker.addressPeerMap[deviceConn.device.address]
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, deviceConn.device.address, packet.ttl)
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, deviceConn.device.address, packet.ttl, packet.version, routeInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,9 +33,9 @@ class BluetoothPermissionManager(private val context: Context) {
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION
|
||||
))
|
||||
|
||||
|
||||
return permissions.all {
|
||||
ActivityCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,11 @@ class FragmentManager {
|
||||
private val incomingFragments = ConcurrentHashMap<String, MutableMap<Int, ByteArray>>()
|
||||
// iOS equivalent: fragmentMetadata: [String: (type: UInt8, total: Int, timestamp: Date)]
|
||||
private val fragmentMetadata = ConcurrentHashMap<String, Triple<UByte, Int, Long>>() // originalType, totalFragments, timestamp
|
||||
|
||||
private val fragmentCumulativeSize = ConcurrentHashMap<String, Int>()
|
||||
|
||||
private val fragmentStateLock = Any()
|
||||
private var globalBufferedBytes: Long = 0L
|
||||
|
||||
// Delegate for callbacks
|
||||
var delegate: FragmentManagerDelegate? = null
|
||||
|
||||
@@ -77,8 +81,31 @@ class FragmentManager {
|
||||
val fragmentID = FragmentPayload.generateFragmentID()
|
||||
|
||||
// iOS: stride(from: 0, to: fullData.count, by: maxFragmentSize)
|
||||
val fragmentChunks = stride(0, fullData.size, MAX_FRAGMENT_SIZE) { offset ->
|
||||
val endOffset = minOf(offset + MAX_FRAGMENT_SIZE, fullData.size)
|
||||
// Calculate dynamic fragment size to fit in MTU (512)
|
||||
// Packet = Header + Sender + Recipient + Route + FragmentHeader + Payload + PaddingBuffer
|
||||
val hasRoute = packet.route != null
|
||||
val version = if (hasRoute) 2 else 1
|
||||
val headerSize = if (version == 2) 15 else 13
|
||||
val senderSize = 8
|
||||
val recipientSize = if (packet.recipientID != null) 8 else 0
|
||||
// Route: 1 byte count + 8 bytes per hop
|
||||
val routeSize = if (hasRoute) (1 + (packet.route?.size ?: 0) * 8) else 0
|
||||
val fragmentHeaderSize = 13 // FragmentPayload header
|
||||
val paddingBuffer = 16 // MessagePadding.optimalBlockSize adds 16 bytes overhead
|
||||
|
||||
// 512 - Overhead
|
||||
val packetOverhead = headerSize + senderSize + recipientSize + routeSize + fragmentHeaderSize + paddingBuffer
|
||||
val maxDataSize = (512 - packetOverhead).coerceAtMost(MAX_FRAGMENT_SIZE)
|
||||
|
||||
if (maxDataSize <= 0) {
|
||||
Log.e(TAG, "❌ Calculated maxDataSize is non-positive ($maxDataSize). Route too large?")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
Log.d(TAG, "📏 Dynamic fragment size: $maxDataSize (MAX: $MAX_FRAGMENT_SIZE, Overhead: $packetOverhead)")
|
||||
|
||||
val fragmentChunks = stride(0, fullData.size, maxDataSize) { offset ->
|
||||
val endOffset = minOf(offset + maxDataSize, fullData.size)
|
||||
fullData.sliceArray(offset..<endOffset)
|
||||
}
|
||||
|
||||
@@ -98,13 +125,16 @@ class FragmentManager {
|
||||
)
|
||||
|
||||
// iOS: MessageType.fragment.rawValue (single fragment type)
|
||||
// Fix: Fragments must inherit source route and use v2 if routed
|
||||
val fragmentPacket = BitchatPacket(
|
||||
version = if (packet.route != null) 2u else 1u,
|
||||
type = MessageType.FRAGMENT.value,
|
||||
ttl = packet.ttl,
|
||||
senderID = packet.senderID,
|
||||
recipientID = packet.recipientID,
|
||||
timestamp = packet.timestamp,
|
||||
payload = fragmentPayload.encode(),
|
||||
route = packet.route,
|
||||
signature = null // iOS: signature: nil
|
||||
)
|
||||
|
||||
@@ -146,53 +176,110 @@ class FragmentManager {
|
||||
val fragmentIDString = fragmentPayload.getFragmentIDString()
|
||||
|
||||
Log.d(TAG, "Received fragment ${fragmentPayload.index}/${fragmentPayload.total} for fragmentID: $fragmentIDString, originalType: ${fragmentPayload.originalType}")
|
||||
|
||||
// iOS: if incomingFragments[fragmentID] == nil
|
||||
if (!incomingFragments.containsKey(fragmentIDString)) {
|
||||
incomingFragments[fragmentIDString] = mutableMapOf()
|
||||
fragmentMetadata[fragmentIDString] = Triple(
|
||||
fragmentPayload.originalType,
|
||||
fragmentPayload.total,
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
|
||||
val maxFragments = com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID
|
||||
if (fragmentPayload.total > maxFragments) {
|
||||
Log.w(TAG, "Rejecting fragment with excessive total count: ${fragmentPayload.total} > $maxFragments")
|
||||
return null
|
||||
}
|
||||
|
||||
// iOS: incomingFragments[fragmentID]?[index] = Data(fragmentData)
|
||||
incomingFragments[fragmentIDString]?.put(fragmentPayload.index, fragmentPayload.data)
|
||||
|
||||
// iOS: if let fragments = incomingFragments[fragmentID], fragments.count == total
|
||||
val fragmentMap = incomingFragments[fragmentIDString]
|
||||
if (fragmentMap != null && fragmentMap.size == fragmentPayload.total) {
|
||||
Log.d(TAG, "All fragments received for $fragmentIDString, reassembling...")
|
||||
|
||||
// iOS reassembly logic: for i in 0..<total { if let fragment = fragments[i] { reassembled.append(fragment) } }
|
||||
val reassembledData = mutableListOf<Byte>()
|
||||
for (i in 0 until fragmentPayload.total) {
|
||||
fragmentMap[i]?.let { data ->
|
||||
reassembledData.addAll(data.asIterable())
|
||||
|
||||
synchronized(fragmentStateLock) {
|
||||
fragmentMetadata[fragmentIDString]?.let { (expectedType, expectedTotal, _) ->
|
||||
if (expectedTotal != fragmentPayload.total || expectedType != fragmentPayload.originalType) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Rejecting fragment for $fragmentIDString: inconsistent metadata " +
|
||||
"(expected type=$expectedType total=$expectedTotal, got type=${fragmentPayload.originalType} total=${fragmentPayload.total})"
|
||||
)
|
||||
removeFragmentSetLocked(fragmentIDString)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Decode the original packet bytes we reassembled, so flags/compression are preserved - iOS fix
|
||||
val originalPacket = BitchatPacket.fromBinaryData(reassembledData.toByteArray())
|
||||
if (originalPacket != null) {
|
||||
// iOS cleanup: incomingFragments.removeValue(forKey: fragmentID)
|
||||
incomingFragments.remove(fragmentIDString)
|
||||
fragmentMetadata.remove(fragmentIDString)
|
||||
|
||||
// Suppress re-broadcast of the reassembled packet by zeroing TTL.
|
||||
// We already relayed the incoming fragments; setting TTL=0 ensures
|
||||
// PacketRelayManager will skip relaying this reconstructed packet.
|
||||
val suppressedTtlPacket = originalPacket.copy(ttl = 0u.toUByte())
|
||||
Log.d(TAG, "Successfully reassembled original (${reassembledData.size} bytes); set TTL=0 to suppress relay")
|
||||
return suppressedTtlPacket
|
||||
|
||||
val isNewSet = !incomingFragments.containsKey(fragmentIDString)
|
||||
if (isNewSet) {
|
||||
val maxActive = com.bitchat.android.util.AppConstants.Fragmentation.MAX_ACTIVE_FRAGMENT_SETS
|
||||
if (incomingFragments.size >= maxActive) {
|
||||
Log.w(TAG, "Rejecting new fragment set $fragmentIDString: active fragment sets ${incomingFragments.size} >= $maxActive")
|
||||
return null
|
||||
}
|
||||
|
||||
incomingFragments[fragmentIDString] = mutableMapOf()
|
||||
fragmentMetadata[fragmentIDString] = Triple(
|
||||
fragmentPayload.originalType,
|
||||
fragmentPayload.total,
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
fragmentCumulativeSize[fragmentIDString] = 0
|
||||
}
|
||||
|
||||
val fragmentMap = incomingFragments[fragmentIDString]
|
||||
if (fragmentMap == null) {
|
||||
Log.w(TAG, "Dropping fragment set $fragmentIDString due to missing fragment map")
|
||||
removeFragmentSetLocked(fragmentIDString)
|
||||
return null
|
||||
}
|
||||
|
||||
val currentSize = fragmentCumulativeSize[fragmentIDString]
|
||||
if (currentSize == null) {
|
||||
Log.w(TAG, "Dropping fragment set $fragmentIDString due to missing size tracker")
|
||||
removeFragmentSetLocked(fragmentIDString)
|
||||
return null
|
||||
}
|
||||
|
||||
val oldEntrySize = fragmentMap[fragmentPayload.index]?.size ?: 0
|
||||
val newSize = currentSize - oldEntrySize + fragmentPayload.data.size
|
||||
val maxTotalBytes = com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENT_TOTAL_BYTES
|
||||
if (newSize > maxTotalBytes) {
|
||||
Log.w(TAG, "Rejecting fragment for $fragmentIDString: cumulative size $newSize exceeds cap $maxTotalBytes")
|
||||
removeFragmentSetLocked(fragmentIDString)
|
||||
return null
|
||||
}
|
||||
|
||||
val delta = (fragmentPayload.data.size - oldEntrySize).toLong()
|
||||
val maxGlobalBytes = com.bitchat.android.util.AppConstants.Fragmentation.MAX_GLOBAL_FRAGMENT_TOTAL_BYTES
|
||||
if (globalBufferedBytes + delta > maxGlobalBytes) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Rejecting fragment for $fragmentIDString: global buffered bytes ${(globalBufferedBytes + delta)} exceeds cap $maxGlobalBytes"
|
||||
)
|
||||
if (isNewSet) {
|
||||
removeFragmentSetLocked(fragmentIDString)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fragmentMap[fragmentPayload.index] = fragmentPayload.data
|
||||
fragmentCumulativeSize[fragmentIDString] = newSize
|
||||
globalBufferedBytes += delta
|
||||
|
||||
val expectedTotal = fragmentMetadata[fragmentIDString]?.second ?: fragmentPayload.total
|
||||
if (fragmentMap.size == expectedTotal) {
|
||||
Log.d(TAG, "All fragments received for $fragmentIDString, reassembling...")
|
||||
|
||||
// iOS reassembly logic: for i in 0..<total { if let fragment = fragments[i] { reassembled.append(fragment) } }
|
||||
val reassembledData = mutableListOf<Byte>()
|
||||
for (i in 0 until expectedTotal) {
|
||||
fragmentMap[i]?.let { data ->
|
||||
reassembledData.addAll(data.asIterable())
|
||||
}
|
||||
}
|
||||
|
||||
val originalPacket = BitchatPacket.fromBinaryData(reassembledData.toByteArray())
|
||||
if (originalPacket != null) {
|
||||
removeFragmentSetLocked(fragmentIDString)
|
||||
|
||||
val suppressedTtlPacket = originalPacket.copy(ttl = 0u.toUByte())
|
||||
Log.d(TAG, "Successfully reassembled original (${reassembledData.size} bytes); set TTL=0 to suppress relay")
|
||||
return suppressedTtlPacket
|
||||
} else {
|
||||
val metadata = fragmentMetadata[fragmentIDString]
|
||||
Log.e(TAG, "Failed to decode reassembled packet (type=${metadata?.first}, total=${metadata?.second})")
|
||||
}
|
||||
} else {
|
||||
val metadata = fragmentMetadata[fragmentIDString]
|
||||
Log.e(TAG, "Failed to decode reassembled packet (type=${metadata?.first}, total=${metadata?.second})")
|
||||
val received = fragmentMap.size
|
||||
Log.d(TAG, "Fragment ${fragmentPayload.index} stored, have $received/$expectedTotal fragments for $fragmentIDString")
|
||||
}
|
||||
} else {
|
||||
val received = fragmentMap?.size ?: 0
|
||||
Log.d(TAG, "Fragment ${fragmentPayload.index} stored, have $received/${fragmentPayload.total} fragments for $fragmentIDString")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
@@ -201,6 +288,15 @@ class FragmentManager {
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun removeFragmentSetLocked(fragmentIDString: String) {
|
||||
incomingFragments.remove(fragmentIDString)
|
||||
fragmentMetadata.remove(fragmentIDString)
|
||||
val bytes = fragmentCumulativeSize.remove(fragmentIDString)?.toLong() ?: 0L
|
||||
if (bytes != 0L) {
|
||||
globalBufferedBytes = (globalBufferedBytes - bytes).coerceAtLeast(0L)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to match iOS stride functionality
|
||||
@@ -221,20 +317,20 @@ class FragmentManager {
|
||||
* Clean old fragments (> 30 seconds old)
|
||||
*/
|
||||
private fun cleanupOldFragments() {
|
||||
val now = System.currentTimeMillis()
|
||||
val cutoff = now - FRAGMENT_TIMEOUT
|
||||
|
||||
// iOS: let oldFragments = fragmentMetadata.filter { $0.value.timestamp < cutoff }.map { $0.key }
|
||||
val oldFragments = fragmentMetadata.filter { it.value.third < cutoff }.map { it.key }
|
||||
|
||||
// iOS: for fragmentID in oldFragments { incomingFragments.removeValue(forKey: fragmentID) }
|
||||
for (fragmentID in oldFragments) {
|
||||
incomingFragments.remove(fragmentID)
|
||||
fragmentMetadata.remove(fragmentID)
|
||||
}
|
||||
|
||||
if (oldFragments.isNotEmpty()) {
|
||||
Log.d(TAG, "Cleaned up ${oldFragments.size} old fragment sets (iOS compatible)")
|
||||
synchronized(fragmentStateLock) {
|
||||
val now = System.currentTimeMillis()
|
||||
val cutoff = now - FRAGMENT_TIMEOUT
|
||||
|
||||
// iOS: let oldFragments = fragmentMetadata.filter { $0.value.timestamp < cutoff }.map { $0.key }
|
||||
val oldFragments = fragmentMetadata.filter { it.value.third < cutoff }.map { it.key }
|
||||
|
||||
for (fragmentID in oldFragments) {
|
||||
removeFragmentSetLocked(fragmentID)
|
||||
}
|
||||
|
||||
if (oldFragments.isNotEmpty()) {
|
||||
Log.d(TAG, "Cleaned up ${oldFragments.size} old fragment sets (iOS compatible)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,17 +338,21 @@ class FragmentManager {
|
||||
* Get debug information - matches iOS debugging
|
||||
*/
|
||||
fun getDebugInfo(): String {
|
||||
return buildString {
|
||||
appendLine("=== Fragment Manager Debug Info (iOS Compatible) ===")
|
||||
appendLine("Active Fragment Sets: ${incomingFragments.size}")
|
||||
appendLine("Fragment Size Threshold: $FRAGMENT_SIZE_THRESHOLD bytes")
|
||||
appendLine("Max Fragment Size: $MAX_FRAGMENT_SIZE bytes")
|
||||
|
||||
fragmentMetadata.forEach { (fragmentID, metadata) ->
|
||||
val (originalType, totalFragments, timestamp) = metadata
|
||||
val received = incomingFragments[fragmentID]?.size ?: 0
|
||||
val ageSeconds = (System.currentTimeMillis() - timestamp) / 1000
|
||||
appendLine(" - $fragmentID: $received/$totalFragments fragments, type: $originalType, age: ${ageSeconds}s")
|
||||
synchronized(fragmentStateLock) {
|
||||
return buildString {
|
||||
appendLine("=== Fragment Manager Debug Info (iOS Compatible) ===")
|
||||
appendLine("Active Fragment Sets: ${incomingFragments.size}")
|
||||
appendLine("Fragment Size Threshold: $FRAGMENT_SIZE_THRESHOLD bytes")
|
||||
appendLine("Max Fragment Size: $MAX_FRAGMENT_SIZE bytes")
|
||||
appendLine("Global Buffered Bytes: $globalBufferedBytes")
|
||||
|
||||
fragmentMetadata.forEach { (fragmentID, metadata) ->
|
||||
val (originalType, totalFragments, timestamp) = metadata
|
||||
val received = incomingFragments[fragmentID]?.size ?: 0
|
||||
val ageSeconds = (System.currentTimeMillis() - timestamp) / 1000
|
||||
val bytes = fragmentCumulativeSize[fragmentID] ?: 0
|
||||
appendLine(" - $fragmentID: $received/$totalFragments fragments, bytes=$bytes, type: $originalType, age: ${ageSeconds}s")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,8 +373,12 @@ class FragmentManager {
|
||||
* Clear all fragments
|
||||
*/
|
||||
fun clearAllFragments() {
|
||||
incomingFragments.clear()
|
||||
fragmentMetadata.clear()
|
||||
synchronized(fragmentStateLock) {
|
||||
incomingFragments.clear()
|
||||
fragmentMetadata.clear()
|
||||
fragmentCumulativeSize.clear()
|
||||
globalBufferedBytes = 0L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import android.util.Log
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Shared transport send wrapper that applies bitchat packet fragmentation and
|
||||
* transfer progress before a transport writes packets to its concrete medium.
|
||||
*/
|
||||
class FragmentingPacketSender(
|
||||
private val scope: CoroutineScope,
|
||||
private val fragmentManager: FragmentManager?,
|
||||
private val logTag: String,
|
||||
private val interFragmentDelayMs: Long = 20L
|
||||
) {
|
||||
private val transferJobs = ConcurrentHashMap<String, Job>()
|
||||
|
||||
fun send(
|
||||
routed: RoutedPacket,
|
||||
description: String,
|
||||
sendSingle: (RoutedPacket) -> Boolean
|
||||
): Boolean {
|
||||
val transferId = transferIdFor(routed)
|
||||
val packets = packetsForTransport(routed.packet) ?: return false
|
||||
val total = packets.size
|
||||
|
||||
if (total <= 1) {
|
||||
if (transferId != null) {
|
||||
TransferProgressManager.start(transferId, 1)
|
||||
}
|
||||
val sent = sendSingle(routed.copy(packet = packets.first(), transferId = transferId))
|
||||
if (sent && transferId != null) {
|
||||
TransferProgressManager.progress(transferId, 1, 1)
|
||||
TransferProgressManager.complete(transferId, 1)
|
||||
}
|
||||
return sent
|
||||
}
|
||||
|
||||
Log.d(logTag, "Fragmenting packet type ${routed.packet.type} into $total fragments for $description")
|
||||
if (transferId != null) {
|
||||
TransferProgressManager.start(transferId, total)
|
||||
}
|
||||
|
||||
val job = scope.launch(start = CoroutineStart.LAZY) {
|
||||
var sent = 0
|
||||
for (packet in packets) {
|
||||
if (!isActive) return@launch
|
||||
if (transferId != null && transferJobs[transferId]?.isCancelled == true) return@launch
|
||||
|
||||
val fragment = routed.copy(packet = packet, transferId = transferId)
|
||||
val delivered = try {
|
||||
sendSingle(fragment)
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Fragment send failed for $description: ${e.message}", e)
|
||||
false
|
||||
}
|
||||
|
||||
if (!delivered) {
|
||||
Log.w(logTag, "Stopping fragmented send for $description after $sent/$total fragments")
|
||||
return@launch
|
||||
}
|
||||
|
||||
sent += 1
|
||||
if (transferId != null) {
|
||||
TransferProgressManager.progress(transferId, sent, total)
|
||||
}
|
||||
if (sent < total) {
|
||||
delay(interFragmentDelayMs)
|
||||
}
|
||||
}
|
||||
|
||||
if (transferId != null) {
|
||||
TransferProgressManager.complete(transferId, total)
|
||||
}
|
||||
}
|
||||
|
||||
if (transferId != null) {
|
||||
transferJobs[transferId] = job
|
||||
job.invokeOnCompletion { transferJobs.remove(transferId, job) }
|
||||
}
|
||||
job.start()
|
||||
return true
|
||||
}
|
||||
|
||||
fun cancelTransfer(transferId: String): Boolean {
|
||||
val job = transferJobs.remove(transferId) ?: return false
|
||||
job.cancel()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun packetsForTransport(packet: BitchatPacket): List<BitchatPacket>? {
|
||||
if (packet.type == MessageType.FRAGMENT.value) {
|
||||
return listOf(packet)
|
||||
}
|
||||
|
||||
val manager = fragmentManager ?: return listOf(packet)
|
||||
return try {
|
||||
val fragments = manager.createFragments(packet)
|
||||
if (fragments.isEmpty()) {
|
||||
Log.e(logTag, "Fragment manager returned no packets for packet type ${packet.type}")
|
||||
null
|
||||
} else {
|
||||
fragments
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Fragment creation failed for packet type ${packet.type}: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun transferIdFor(routed: RoutedPacket): String? {
|
||||
routed.transferId?.let { return it }
|
||||
val packet = routed.packet
|
||||
return if (packet.type == MessageType.FILE_TRANSFER.value) {
|
||||
sha256Hex(packet.payload)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun sha256Hex(bytes: ByteArray): String = try {
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
md.update(bytes)
|
||||
md.digest().joinToString("") { "%02x".format(it) }
|
||||
} catch (_: Exception) {
|
||||
bytes.size.toString(16)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -131,6 +132,8 @@ abstract class MeshConnectionTracker(
|
||||
if (expired.isNotEmpty()) {
|
||||
Log.d(tag, "Cleaned up ${expired.size} expired connection attempts")
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
Log.w(tag, "Error in periodic cleanup: ${e.message}")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,843 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bitchat.android.crypto.EncryptionService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
import com.bitchat.android.model.IdentityAnnouncement
|
||||
import com.bitchat.android.model.NoisePayload
|
||||
import com.bitchat.android.model.NoisePayloadType
|
||||
import com.bitchat.android.model.PrivateMessagePacket
|
||||
import com.bitchat.android.model.RequestSyncPacket
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.protocol.SpecialRecipients
|
||||
import com.bitchat.android.service.TransportBridgeService
|
||||
import com.bitchat.android.sync.GossipSyncManager
|
||||
import com.bitchat.android.util.toHexString
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Shared mesh coordinator that wires all mesh-layer components and provides common APIs
|
||||
* for send/receive operations across transports.
|
||||
*/
|
||||
class MeshCore(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val transport: MeshTransport,
|
||||
private val encryptionService: EncryptionService,
|
||||
val myPeerID: String,
|
||||
private val maxTtl: UByte,
|
||||
sharedGossipManager: GossipSyncManager?,
|
||||
gossipConfigProvider: GossipSyncManager.ConfigProvider,
|
||||
private val hooks: Hooks = Hooks()
|
||||
) {
|
||||
data class Hooks(
|
||||
val onMessageReceived: ((BitchatMessage) -> Unit)? = null,
|
||||
val onPeerIdBindingUpdated: ((String, String, ByteArray, String?) -> Unit)? = null,
|
||||
val onAnnounceProcessed: ((RoutedPacket, Boolean) -> Unit)? = null,
|
||||
val readReceiptInterceptor: ((String, String) -> Boolean)? = null,
|
||||
val onReadReceiptSent: ((String) -> Unit)? = null,
|
||||
val announcementNicknameProvider: (() -> String?)? = null,
|
||||
val leavePayloadProvider: (() -> ByteArray)? = null
|
||||
)
|
||||
|
||||
private val peerManager = PeerManager()
|
||||
val fragmentManager = FragmentManager()
|
||||
private val securityManager = SecurityManager(encryptionService, myPeerID)
|
||||
private val storeForwardManager = StoreForwardManager()
|
||||
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
|
||||
private val packetProcessor = PacketProcessor(myPeerID)
|
||||
private val directPeers = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
val gossipSyncManager: GossipSyncManager =
|
||||
sharedGossipManager ?: GossipSyncManager(myPeerID = myPeerID, scope = scope, configProvider = gossipConfigProvider)
|
||||
private val ownsGossipManager: Boolean = sharedGossipManager == null
|
||||
|
||||
var delegate: MeshDelegate? = null
|
||||
|
||||
private var announceJob: Job? = null
|
||||
private var isActive = false
|
||||
|
||||
init {
|
||||
messageHandler.packetProcessor = packetProcessor
|
||||
peerManager.isPeerDirectlyConnected = { peerID -> directPeers.contains(peerID) }
|
||||
setupDelegates()
|
||||
|
||||
if (sharedGossipManager == null) {
|
||||
gossipSyncManager.delegate = object : GossipSyncManager.Delegate {
|
||||
override fun sendPacket(packet: BitchatPacket) {
|
||||
dispatchGlobal(RoutedPacket(packet))
|
||||
}
|
||||
|
||||
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
|
||||
transport.sendPacketToPeer(peerID, packet)
|
||||
TransportBridgeService.sendToPeer(transport.id, peerID, packet)
|
||||
}
|
||||
|
||||
override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket {
|
||||
return signPacketBeforeBroadcast(packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startCore() {
|
||||
if (isActive) return
|
||||
isActive = true
|
||||
startPeriodicBroadcastAnnounce()
|
||||
if (ownsGossipManager) {
|
||||
gossipSyncManager.start()
|
||||
}
|
||||
}
|
||||
|
||||
fun stopCore() {
|
||||
if (!isActive) return
|
||||
isActive = false
|
||||
announceJob?.cancel()
|
||||
announceJob = null
|
||||
if (ownsGossipManager) {
|
||||
gossipSyncManager.stop()
|
||||
}
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
peerManager.shutdown()
|
||||
fragmentManager.shutdown()
|
||||
securityManager.shutdown()
|
||||
storeForwardManager.shutdown()
|
||||
messageHandler.shutdown()
|
||||
packetProcessor.shutdown()
|
||||
}
|
||||
|
||||
fun processIncoming(packet: BitchatPacket, peerID: String?, relayAddress: String?) {
|
||||
packetProcessor.processPacket(RoutedPacket(packet, peerID, relayAddress))
|
||||
}
|
||||
|
||||
fun sendFromBridge(packet: RoutedPacket) {
|
||||
transport.broadcastPacket(packet)
|
||||
}
|
||||
|
||||
private fun dispatchGlobal(routed: RoutedPacket) {
|
||||
transport.broadcastPacket(routed)
|
||||
TransportBridgeService.broadcast(transport.id, routed)
|
||||
}
|
||||
|
||||
private fun startPeriodicBroadcastAnnounce() {
|
||||
announceJob?.cancel()
|
||||
announceJob = scope.launch {
|
||||
while (isActive) {
|
||||
try {
|
||||
delay(30_000)
|
||||
sendBroadcastAnnounce()
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupDelegates() {
|
||||
peerManager.delegate = object : PeerManagerDelegate {
|
||||
override fun onPeerListUpdated(peerIDs: List<String>) {
|
||||
try { com.bitchat.android.services.AppStateStore.setTransportPeers(transport.id, peerIDs) } catch (_: Exception) { }
|
||||
delegate?.didUpdatePeerList(peerIDs)
|
||||
}
|
||||
|
||||
override fun onPeerRemoved(peerID: String) {
|
||||
try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { }
|
||||
try { encryptionService.removePeer(peerID) } catch (_: Exception) { }
|
||||
try { peerManager.refreshPeerList() } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
securityManager.delegate = object : SecurityManagerDelegate {
|
||||
override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) {
|
||||
scope.launch {
|
||||
delay(100)
|
||||
sendAnnouncementToPeer(peerID)
|
||||
delay(1000)
|
||||
storeForwardManager.sendCachedMessages(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendHandshakeResponse(peerID: String, response: ByteArray) {
|
||||
val responsePacket = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_HANDSHAKE.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = MeshPacketUtils.hexStringToByteArray(peerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = response,
|
||||
ttl = maxTtl
|
||||
)
|
||||
dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(responsePacket)))
|
||||
}
|
||||
|
||||
override fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID)
|
||||
}
|
||||
|
||||
storeForwardManager.delegate = object : StoreForwardManagerDelegate {
|
||||
override fun isFavorite(peerID: String): Boolean {
|
||||
return delegate?.isFavorite(peerID) ?: false
|
||||
}
|
||||
|
||||
override fun isPeerOnline(peerID: String): Boolean {
|
||||
return peerManager.isPeerActive(peerID)
|
||||
}
|
||||
|
||||
override fun sendPacket(packet: BitchatPacket) {
|
||||
dispatchGlobal(RoutedPacket(packet))
|
||||
}
|
||||
}
|
||||
|
||||
messageHandler.delegate = object : MessageHandlerDelegate {
|
||||
override fun addOrUpdatePeer(peerID: String, nickname: String): Boolean {
|
||||
return peerManager.addOrUpdatePeer(peerID, nickname)
|
||||
}
|
||||
|
||||
override fun removePeer(peerID: String) {
|
||||
peerManager.removePeer(peerID)
|
||||
}
|
||||
|
||||
override fun updatePeerNickname(peerID: String, nickname: String) {
|
||||
peerManager.addOrUpdatePeer(peerID, nickname)
|
||||
}
|
||||
|
||||
override fun getPeerNickname(peerID: String): String? {
|
||||
return peerManager.getPeerNickname(peerID)
|
||||
}
|
||||
|
||||
override fun getNetworkSize(): Int {
|
||||
return peerManager.getActivePeerCount()
|
||||
}
|
||||
|
||||
override fun getMyNickname(): String? {
|
||||
return delegate?.getNickname()
|
||||
}
|
||||
|
||||
override fun getPeerInfo(peerID: String): PeerInfo? {
|
||||
return peerManager.getPeerInfo(peerID)
|
||||
}
|
||||
|
||||
override fun updatePeerInfo(
|
||||
peerID: String,
|
||||
nickname: String,
|
||||
noisePublicKey: ByteArray,
|
||||
signingPublicKey: ByteArray,
|
||||
isVerified: Boolean
|
||||
): Boolean {
|
||||
return peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified)
|
||||
}
|
||||
|
||||
override fun sendPacket(packet: BitchatPacket) {
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
dispatchGlobal(RoutedPacket(signedPacket))
|
||||
}
|
||||
|
||||
override fun relayPacket(routed: RoutedPacket) {
|
||||
dispatchGlobal(routed)
|
||||
}
|
||||
|
||||
override fun getBroadcastRecipient(): ByteArray {
|
||||
return SpecialRecipients.BROADCAST
|
||||
}
|
||||
|
||||
override fun verifySignature(packet: BitchatPacket, peerID: String): Boolean {
|
||||
return securityManager.verifySignature(packet, peerID)
|
||||
}
|
||||
|
||||
override fun encryptForPeer(data: ByteArray, recipientPeerID: String): ByteArray? {
|
||||
return securityManager.encryptForPeer(data, recipientPeerID)
|
||||
}
|
||||
|
||||
override fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray? {
|
||||
return securityManager.decryptFromPeer(encryptedData, senderPeerID)
|
||||
}
|
||||
|
||||
override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean {
|
||||
return encryptionService.verifyEd25519Signature(signature, data, publicKey)
|
||||
}
|
||||
|
||||
override fun hasNoiseSession(peerID: String): Boolean {
|
||||
return encryptionService.hasEstablishedSession(peerID)
|
||||
}
|
||||
|
||||
override fun initiateNoiseHandshake(peerID: String) {
|
||||
this@MeshCore.initiateNoiseHandshake(peerID)
|
||||
}
|
||||
|
||||
override fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray? {
|
||||
return try {
|
||||
encryptionService.processHandshakeMessage(payload, peerID)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun updatePeerIDBinding(
|
||||
newPeerID: String,
|
||||
nickname: String,
|
||||
publicKey: ByteArray,
|
||||
previousPeerID: String?
|
||||
) {
|
||||
peerManager.addOrUpdatePeer(newPeerID, nickname)
|
||||
val fingerprint = peerManager.storeFingerprintForPeer(newPeerID, publicKey)
|
||||
previousPeerID?.let { peerManager.removePeer(it) }
|
||||
Log.d("MeshCore", "Updated peer ID binding: $newPeerID fp=${fingerprint.take(16)}")
|
||||
hooks.onPeerIdBindingUpdated?.invoke(newPeerID, nickname, publicKey, previousPeerID)
|
||||
}
|
||||
|
||||
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
|
||||
return delegate?.decryptChannelMessage(encryptedContent, channel)
|
||||
}
|
||||
|
||||
override fun onMessageReceived(message: BitchatMessage) {
|
||||
hooks.onMessageReceived?.invoke(message)
|
||||
delegate?.didReceiveMessage(message)
|
||||
}
|
||||
|
||||
override fun onChannelLeave(channel: String, fromPeer: String) {
|
||||
delegate?.didReceiveChannelLeave(channel, fromPeer)
|
||||
}
|
||||
|
||||
override fun onDeliveryAckReceived(messageID: String, peerID: String) {
|
||||
delegate?.didReceiveDeliveryAck(messageID, peerID)
|
||||
}
|
||||
|
||||
override fun onReadReceiptReceived(messageID: String, peerID: String) {
|
||||
delegate?.didReceiveReadReceipt(messageID, peerID)
|
||||
}
|
||||
|
||||
override fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
delegate?.didReceiveVerifyChallenge(peerID, payload, timestampMs)
|
||||
}
|
||||
|
||||
override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
|
||||
}
|
||||
}
|
||||
|
||||
packetProcessor.delegate = object : PacketProcessorDelegate {
|
||||
override fun validatePacketSecurity(packet: BitchatPacket, peerID: String): Boolean {
|
||||
return securityManager.validatePacket(packet, peerID)
|
||||
}
|
||||
|
||||
override fun updatePeerLastSeen(peerID: String) {
|
||||
peerManager.updatePeerLastSeen(peerID)
|
||||
}
|
||||
|
||||
override fun getPeerNickname(peerID: String): String? {
|
||||
return peerManager.getPeerNickname(peerID)
|
||||
}
|
||||
|
||||
override fun getNetworkSize(): Int {
|
||||
return peerManager.getActivePeerCount()
|
||||
}
|
||||
|
||||
override fun getBroadcastRecipient(): ByteArray {
|
||||
return SpecialRecipients.BROADCAST
|
||||
}
|
||||
|
||||
override fun handleNoiseHandshake(routed: RoutedPacket): Boolean {
|
||||
return runBlocking { securityManager.handleNoiseHandshake(routed) }
|
||||
}
|
||||
|
||||
override fun handleNoiseEncrypted(routed: RoutedPacket) {
|
||||
scope.launch { messageHandler.handleNoiseEncrypted(routed) }
|
||||
}
|
||||
|
||||
override fun handleAnnounce(routed: RoutedPacket) {
|
||||
scope.launch {
|
||||
val isFirst = messageHandler.handleAnnounce(routed)
|
||||
hooks.onAnnounceProcessed?.invoke(routed, isFirst)
|
||||
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleMessage(routed: RoutedPacket) {
|
||||
scope.launch { messageHandler.handleMessage(routed) }
|
||||
try {
|
||||
val pkt = routed.packet
|
||||
val isBroadcast = (pkt.recipientID == null || pkt.recipientID.contentEquals(SpecialRecipients.BROADCAST))
|
||||
if (isBroadcast && pkt.type == MessageType.MESSAGE.value) {
|
||||
gossipSyncManager.onPublicPacketSeen(pkt)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override fun handleLeave(routed: RoutedPacket) {
|
||||
scope.launch { messageHandler.handleLeave(routed) }
|
||||
}
|
||||
|
||||
override fun handleFragment(packet: BitchatPacket): BitchatPacket? {
|
||||
try {
|
||||
val isBroadcast = (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST))
|
||||
if (isBroadcast && packet.type == MessageType.FRAGMENT.value) {
|
||||
gossipSyncManager.onPublicPacketSeen(packet)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
return fragmentManager.handleFragment(packet)
|
||||
}
|
||||
|
||||
override fun sendAnnouncementToPeer(peerID: String) {
|
||||
this@MeshCore.sendAnnouncementToPeer(peerID)
|
||||
}
|
||||
|
||||
override fun sendCachedMessages(peerID: String) {
|
||||
storeForwardManager.sendCachedMessages(peerID)
|
||||
}
|
||||
|
||||
override fun relayPacket(routed: RoutedPacket) {
|
||||
dispatchGlobal(routed)
|
||||
}
|
||||
|
||||
override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
|
||||
val sent = transport.sendPacketToPeer(peerID, routed.packet)
|
||||
TransportBridgeService.sendToPeer(transport.id, peerID, routed.packet)
|
||||
return sent
|
||||
}
|
||||
|
||||
override fun handleRequestSync(routed: RoutedPacket) {
|
||||
val fromPeer = routed.peerID ?: return
|
||||
val req = RequestSyncPacket.decode(routed.packet.payload) ?: return
|
||||
gossipSyncManager.handleRequestSync(fromPeer, req)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(content: String, mentions: List<String> = emptyList(), channel: String? = null) {
|
||||
if (content.isEmpty()) return
|
||||
scope.launch {
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.MESSAGE.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = SpecialRecipients.BROADCAST,
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = content.toByteArray(Charsets.UTF_8),
|
||||
signature = null,
|
||||
ttl = maxTtl
|
||||
)
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
dispatchGlobal(RoutedPacket(signedPacket))
|
||||
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
fun sendFileBroadcast(file: BitchatFilePacket) {
|
||||
try {
|
||||
val payload = file.encode() ?: return
|
||||
scope.launch {
|
||||
val packet = BitchatPacket(
|
||||
version = 2u,
|
||||
type = MessageType.FILE_TRANSFER.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = SpecialRecipients.BROADCAST,
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
signature = null,
|
||||
ttl = maxTtl
|
||||
)
|
||||
val signed = signPacketBeforeBroadcast(packet)
|
||||
val transferId = MeshPacketUtils.sha256Hex(payload)
|
||||
dispatchGlobal(RoutedPacket(signed, transferId = transferId))
|
||||
try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("MeshCore", "sendFileBroadcast failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) {
|
||||
try {
|
||||
scope.launch {
|
||||
if (!encryptionService.hasEstablishedSession(recipientPeerID)) {
|
||||
initiateNoiseHandshake(recipientPeerID)
|
||||
return@launch
|
||||
}
|
||||
val tlv = file.encode() ?: return@launch
|
||||
val np = NoisePayload(type = NoisePayloadType.FILE_TRANSFER, data = tlv).encode()
|
||||
val enc = encryptionService.encrypt(np, recipientPeerID)
|
||||
val packet = BitchatPacket(
|
||||
version = if (enc.size > 0xFFFF) 2u else 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = enc,
|
||||
signature = null,
|
||||
ttl = maxTtl
|
||||
)
|
||||
val signed = signPacketBeforeBroadcast(packet)
|
||||
val transferId = MeshPacketUtils.sha256Hex(tlv)
|
||||
dispatchGlobal(RoutedPacket(signed, transferId = transferId))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("MeshCore", "sendFilePrivate failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelFileTransfer(transferId: String): Boolean {
|
||||
return transport.cancelTransfer(transferId)
|
||||
}
|
||||
|
||||
fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) {
|
||||
if (content.isEmpty() || recipientPeerID.isEmpty()) return
|
||||
scope.launch {
|
||||
val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString()
|
||||
|
||||
if (encryptionService.hasEstablishedSession(recipientPeerID)) {
|
||||
try {
|
||||
val privateMessage = PrivateMessagePacket(messageID = finalMessageID, content = content)
|
||||
val tlvData = privateMessage.encode() ?: return@launch
|
||||
val messagePayload = NoisePayload(
|
||||
type = NoisePayloadType.PRIVATE_MESSAGE,
|
||||
data = tlvData
|
||||
)
|
||||
val encrypted = encryptionService.encrypt(messagePayload.encode(), recipientPeerID)
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = encrypted,
|
||||
signature = null,
|
||||
ttl = maxTtl
|
||||
)
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
dispatchGlobal(RoutedPacket(signedPacket))
|
||||
} catch (e: Exception) {
|
||||
Log.e("MeshCore", "Failed to encrypt private message: ${e.message}")
|
||||
}
|
||||
} else {
|
||||
initiateNoiseHandshake(recipientPeerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) {
|
||||
scope.launch {
|
||||
if (hooks.readReceiptInterceptor?.invoke(messageID, recipientPeerID) == true) return@launch
|
||||
try {
|
||||
val payload = NoisePayload(
|
||||
type = NoisePayloadType.READ_RECEIPT,
|
||||
data = messageID.toByteArray(Charsets.UTF_8)
|
||||
).encode()
|
||||
val enc = encryptionService.encrypt(payload, recipientPeerID)
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = enc,
|
||||
signature = null,
|
||||
ttl = maxTtl
|
||||
)
|
||||
dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet)))
|
||||
hooks.onReadReceiptSent?.invoke(messageID)
|
||||
} catch (e: Exception) {
|
||||
Log.e("MeshCore", "Failed to send read receipt: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
|
||||
val payload = NoisePayload(
|
||||
type = NoisePayloadType.VERIFY_CHALLENGE,
|
||||
data = com.bitchat.android.services.VerificationService.buildVerifyChallenge(noiseKeyHex, nonceA)
|
||||
)
|
||||
sendNoisePayloadToPeer(payload, peerID)
|
||||
}
|
||||
|
||||
fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
|
||||
val tlv = com.bitchat.android.services.VerificationService.buildVerifyResponse(noiseKeyHex, nonceA) ?: return
|
||||
val payload = NoisePayload(
|
||||
type = NoisePayloadType.VERIFY_RESPONSE,
|
||||
data = tlv
|
||||
)
|
||||
sendNoisePayloadToPeer(payload, peerID)
|
||||
}
|
||||
|
||||
private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String) {
|
||||
scope.launch {
|
||||
try {
|
||||
val encrypted = encryptionService.encrypt(payload.encode(), recipientPeerID)
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = encrypted,
|
||||
signature = null,
|
||||
ttl = maxTtl
|
||||
)
|
||||
dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet)))
|
||||
} catch (e: Exception) {
|
||||
Log.e("MeshCore", "Failed to send Noise payload to $recipientPeerID: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendBroadcastAnnounce() {
|
||||
scope.launch {
|
||||
val nickname = hooks.announcementNicknameProvider?.invoke()
|
||||
?: delegate?.getNickname()
|
||||
?: myPeerID
|
||||
val staticKey = encryptionService.getStaticPublicKey() ?: run {
|
||||
Log.e("MeshCore", "No static public key available for announcement")
|
||||
return@launch
|
||||
}
|
||||
val signingKey = encryptionService.getSigningPublicKey() ?: run {
|
||||
Log.e("MeshCore", "No signing public key available for announcement")
|
||||
return@launch
|
||||
}
|
||||
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
|
||||
val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return@launch
|
||||
val announcePacket = BitchatPacket(
|
||||
type = MessageType.ANNOUNCE.value,
|
||||
ttl = maxTtl,
|
||||
senderID = myPeerID,
|
||||
payload = tlvPayload
|
||||
)
|
||||
val signedPacket = signPacketBeforeBroadcast(announcePacket)
|
||||
dispatchGlobal(RoutedPacket(signedPacket))
|
||||
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
fun sendAnnouncementToPeer(peerID: String) {
|
||||
if (peerManager.hasAnnouncedToPeer(peerID)) return
|
||||
val nickname = hooks.announcementNicknameProvider?.invoke()
|
||||
?: delegate?.getNickname()
|
||||
?: myPeerID
|
||||
val staticKey = encryptionService.getStaticPublicKey() ?: return
|
||||
val signingKey = encryptionService.getSigningPublicKey() ?: return
|
||||
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
|
||||
val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.ANNOUNCE.value,
|
||||
ttl = maxTtl,
|
||||
senderID = myPeerID,
|
||||
payload = tlvPayload
|
||||
)
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
dispatchGlobal(RoutedPacket(signedPacket))
|
||||
peerManager.markPeerAsAnnouncedTo(peerID)
|
||||
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
private fun buildAnnouncementPayload(announcement: IdentityAnnouncement, nickname: String): ByteArray? {
|
||||
var tlvPayload = announcement.encode() ?: return null
|
||||
val directPeersForGossip = getDirectPeerIDsForGossip()
|
||||
try {
|
||||
if (directPeersForGossip.isNotEmpty()) {
|
||||
tlvPayload += com.bitchat.android.services.meshgraph.GossipTLV.encodeNeighbors(directPeersForGossip)
|
||||
}
|
||||
com.bitchat.android.services.meshgraph.MeshGraphService.getInstance()
|
||||
.updateFromAnnouncement(myPeerID, nickname, directPeersForGossip, System.currentTimeMillis().toULong())
|
||||
} catch (_: Exception) { }
|
||||
return tlvPayload
|
||||
}
|
||||
|
||||
private fun getDirectPeerIDsForGossip(): List<String> {
|
||||
return try {
|
||||
val verifiedDirect = peerManager.getVerifiedPeers()
|
||||
.filter { it.value.isDirectConnection }
|
||||
.keys
|
||||
val localDirect = (verifiedDirect + directPeers).toSet()
|
||||
// Publish this transport's direct peers and gossip the cross-transport union so a
|
||||
// node connected via multiple transports advertises a complete neighbor list.
|
||||
try { com.bitchat.android.services.AppStateStore.setTransportDirectPeers(transport.id, localDirect) } catch (_: Exception) { }
|
||||
val union = try {
|
||||
com.bitchat.android.services.AppStateStore.getDirectPeers().ifEmpty { localDirect }
|
||||
} catch (_: Exception) { localDirect }
|
||||
union.distinct().take(10)
|
||||
} catch (_: Exception) {
|
||||
directPeers.toList().take(10)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendLeaveAnnouncement() {
|
||||
val payload = hooks.leavePayloadProvider?.invoke() ?: byteArrayOf()
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.LEAVE.value,
|
||||
ttl = maxTtl,
|
||||
senderID = myPeerID,
|
||||
payload = payload
|
||||
)
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
dispatchGlobal(RoutedPacket(signedPacket))
|
||||
}
|
||||
|
||||
fun getPeerNicknames(): Map<String, String> = peerManager.getAllPeerNicknames()
|
||||
|
||||
fun getPeerRSSI(): Map<String, Int> = peerManager.getAllPeerRSSI()
|
||||
|
||||
fun getPeerNickname(peerID: String): String? = peerManager.getPeerNickname(peerID)
|
||||
|
||||
fun addOrUpdatePeer(peerID: String, nickname: String): Boolean {
|
||||
return peerManager.addOrUpdatePeer(peerID, nickname)
|
||||
}
|
||||
|
||||
fun removePeer(peerID: String) {
|
||||
peerManager.removePeer(peerID)
|
||||
}
|
||||
|
||||
fun setDirectConnection(peerID: String, isDirect: Boolean) {
|
||||
if (isDirect) {
|
||||
directPeers.add(peerID)
|
||||
} else {
|
||||
directPeers.remove(peerID)
|
||||
}
|
||||
peerManager.refreshPeerList()
|
||||
}
|
||||
|
||||
fun updatePeerRSSI(peerID: String, rssi: Int) {
|
||||
peerManager.updatePeerRSSI(peerID, rssi)
|
||||
}
|
||||
|
||||
fun getDebugInfoWithDeviceAddresses(deviceMap: Map<String, String>): String {
|
||||
return peerManager.getDebugInfoWithDeviceAddresses(deviceMap)
|
||||
}
|
||||
|
||||
fun getFingerprintDebugInfo(): String {
|
||||
return peerManager.getFingerprintDebugInfo()
|
||||
}
|
||||
|
||||
fun hasEstablishedSession(peerID: String): Boolean {
|
||||
return encryptionService.hasEstablishedSession(peerID)
|
||||
}
|
||||
|
||||
fun getSessionState(peerID: String): com.bitchat.android.noise.NoiseSession.NoiseSessionState {
|
||||
return encryptionService.getSessionState(peerID)
|
||||
}
|
||||
|
||||
fun initiateNoiseHandshake(peerID: String) {
|
||||
scope.launch {
|
||||
try {
|
||||
val handshakeData = encryptionService.initiateHandshake(peerID) ?: return@launch
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_HANDSHAKE.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = MeshPacketUtils.hexStringToByteArray(peerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = handshakeData,
|
||||
ttl = maxTtl
|
||||
)
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
dispatchGlobal(RoutedPacket(signedPacket))
|
||||
} catch (e: Exception) {
|
||||
Log.e("MeshCore", "Failed to initiate Noise handshake with $peerID: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getPeerFingerprint(peerID: String): String? = peerManager.getFingerprintForPeer(peerID)
|
||||
|
||||
fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID)
|
||||
|
||||
fun updatePeerInfo(
|
||||
peerID: String,
|
||||
nickname: String,
|
||||
noisePublicKey: ByteArray,
|
||||
signingPublicKey: ByteArray,
|
||||
isVerified: Boolean
|
||||
): Boolean = peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified)
|
||||
|
||||
fun getIdentityFingerprint(): String = encryptionService.getIdentityFingerprint()
|
||||
|
||||
fun getStaticNoisePublicKey(): ByteArray? = encryptionService.getStaticPublicKey()
|
||||
|
||||
fun shouldShowEncryptionIcon(peerID: String): Boolean = encryptionService.hasEstablishedSession(peerID)
|
||||
|
||||
fun getEncryptedPeers(): List<String> = emptyList()
|
||||
|
||||
fun getActivePeerCount(): Int = try { peerManager.getActivePeerCount() } catch (_: Exception) { 0 }
|
||||
|
||||
fun refreshPeerList() {
|
||||
try { peerManager.refreshPeerList() } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
fun getDeviceAddressForPeer(peerID: String): String? = transport.getDeviceAddressForPeer(peerID)
|
||||
|
||||
fun getDeviceAddressToPeerMapping(): Map<String, String> = transport.getDeviceAddressToPeerMapping()
|
||||
|
||||
fun getDebugStatus(
|
||||
transportInfo: String,
|
||||
deviceMap: Map<String, String>,
|
||||
extraLines: List<String> = emptyList(),
|
||||
title: String? = null
|
||||
): String {
|
||||
return buildString {
|
||||
appendLine("=== ${title ?: "${transport.id} Mesh Debug Status"} ===")
|
||||
appendLine("My Peer ID: $myPeerID")
|
||||
if (extraLines.isNotEmpty()) {
|
||||
extraLines.forEach { appendLine(it) }
|
||||
}
|
||||
appendLine(transportInfo)
|
||||
appendLine(peerManager.getDebugInfo(deviceMap))
|
||||
appendLine(fragmentManager.getDebugInfo())
|
||||
appendLine(securityManager.getDebugInfo())
|
||||
appendLine(storeForwardManager.getDebugInfo())
|
||||
appendLine(messageHandler.getDebugInfo())
|
||||
appendLine(packetProcessor.getDebugInfo())
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAllInternalData() {
|
||||
fragmentManager.clearAllFragments()
|
||||
storeForwardManager.clearAllCache()
|
||||
securityManager.clearAllData()
|
||||
peerManager.clearAllPeers()
|
||||
peerManager.clearAllFingerprints()
|
||||
}
|
||||
|
||||
fun clearAllEncryptionData() {
|
||||
encryptionService.clearPersistentIdentity()
|
||||
}
|
||||
|
||||
private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket {
|
||||
return try {
|
||||
val withRoute = try {
|
||||
val recipient = packet.recipientID
|
||||
if (recipient != null && !recipient.contentEquals(SpecialRecipients.BROADCAST)) {
|
||||
val destination = recipient.toHexString()
|
||||
val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath(myPeerID, destination)
|
||||
if (path != null && path.size >= 3) {
|
||||
val intermediates = path.subList(1, path.size - 1)
|
||||
packet.copy(
|
||||
route = intermediates.map { MeshPacketUtils.hexStringToByteArray(it) },
|
||||
version = 2u
|
||||
)
|
||||
} else {
|
||||
packet.copy(route = null)
|
||||
}
|
||||
} else {
|
||||
packet
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
packet
|
||||
}
|
||||
|
||||
val packetDataForSigning = withRoute.toBinaryDataForSigning() ?: return withRoute
|
||||
val signature = encryptionService.signData(packetDataForSigning)
|
||||
if (signature != null) {
|
||||
withRoute.copy(signature = signature)
|
||||
} else {
|
||||
withRoute
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
packet
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
|
||||
/**
|
||||
* Shared mesh delegate interface for transport-agnostic callbacks.
|
||||
*/
|
||||
interface MeshDelegate {
|
||||
fun didReceiveMessage(message: BitchatMessage)
|
||||
fun didUpdatePeerList(peers: List<String>)
|
||||
fun didReceiveChannelLeave(channel: String, fromPeer: String)
|
||||
fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String)
|
||||
fun didReceiveReadReceipt(messageID: String, recipientPeerID: String)
|
||||
fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {}
|
||||
fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {}
|
||||
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
|
||||
fun getNickname(): String?
|
||||
fun isFavorite(peerID: String): Boolean
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
/**
|
||||
* Shared helpers for mesh packet handling.
|
||||
*/
|
||||
object MeshPacketUtils {
|
||||
/**
|
||||
* Convert hex string peer ID to binary data (8 bytes), matching iOS behavior.
|
||||
*/
|
||||
fun hexStringToByteArray(hexString: String): ByteArray {
|
||||
val result = ByteArray(8) { 0 }
|
||||
var tempID = hexString
|
||||
var index = 0
|
||||
|
||||
while (tempID.length >= 2 && index < 8) {
|
||||
val hexByte = tempID.substring(0, 2)
|
||||
val byte = hexByte.toIntOrNull(16)?.toByte()
|
||||
if (byte != null) {
|
||||
result[index] = byte
|
||||
}
|
||||
tempID = tempID.substring(2)
|
||||
index++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash payloads to a stable hex ID for transfer tracking.
|
||||
*/
|
||||
fun sha256Hex(bytes: ByteArray): String = try {
|
||||
val md = java.security.MessageDigest.getInstance("SHA-256")
|
||||
md.update(bytes)
|
||||
md.digest().joinToString("") { "%02x".format(it) }
|
||||
} catch (_: Exception) {
|
||||
bytes.size.toString(16)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
|
||||
/**
|
||||
* Transport-agnostic mesh service API for UI and routing layers.
|
||||
*/
|
||||
interface MeshService {
|
||||
val myPeerID: String
|
||||
var delegate: MeshDelegate?
|
||||
|
||||
fun startServices()
|
||||
fun stopServices()
|
||||
|
||||
fun sendMessage(content: String, mentions: List<String> = emptyList(), channel: String? = null)
|
||||
fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null)
|
||||
fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String)
|
||||
fun sendDeliveryAck(messageID: String, recipientPeerID: String) {}
|
||||
fun sendFavoriteNotification(peerID: String, isFavorite: Boolean) {}
|
||||
fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray)
|
||||
fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray)
|
||||
fun sendFileBroadcast(file: BitchatFilePacket)
|
||||
fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket)
|
||||
fun cancelFileTransfer(transferId: String): Boolean
|
||||
|
||||
fun sendBroadcastAnnounce()
|
||||
fun sendAnnouncementToPeer(peerID: String)
|
||||
|
||||
fun getPeerNicknames(): Map<String, String>
|
||||
fun getPeerRSSI(): Map<String, Int>
|
||||
fun getActivePeerCount(): Int
|
||||
fun hasEstablishedSession(peerID: String): Boolean
|
||||
fun getSessionState(peerID: String): com.bitchat.android.noise.NoiseSession.NoiseSessionState
|
||||
fun initiateNoiseHandshake(peerID: String)
|
||||
fun getPeerFingerprint(peerID: String): String?
|
||||
fun getPeerInfo(peerID: String): PeerInfo?
|
||||
fun updatePeerInfo(
|
||||
peerID: String,
|
||||
nickname: String,
|
||||
noisePublicKey: ByteArray,
|
||||
signingPublicKey: ByteArray,
|
||||
isVerified: Boolean
|
||||
): Boolean
|
||||
fun getIdentityFingerprint(): String
|
||||
fun getStaticNoisePublicKey(): ByteArray?
|
||||
fun shouldShowEncryptionIcon(peerID: String): Boolean
|
||||
fun getEncryptedPeers(): List<String>
|
||||
|
||||
fun getDeviceAddressForPeer(peerID: String): String?
|
||||
fun getDeviceAddressToPeerMapping(): Map<String, String>
|
||||
fun printDeviceAddressesForPeers(): String
|
||||
fun getDebugStatus(): String
|
||||
|
||||
fun clearAllInternalData()
|
||||
fun clearAllEncryptionData()
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
|
||||
/**
|
||||
* Transport abstraction used by MeshCore to send packets via a specific medium.
|
||||
*/
|
||||
interface MeshTransport {
|
||||
val id: String
|
||||
|
||||
fun broadcastPacket(routed: RoutedPacket)
|
||||
|
||||
fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean
|
||||
|
||||
fun cancelTransfer(transferId: String): Boolean = false
|
||||
|
||||
fun getDeviceAddressForPeer(peerID: String): String? = null
|
||||
|
||||
fun getDeviceAddressToPeerMapping(): Map<String, String> = emptyMap()
|
||||
|
||||
fun getTransportDebugInfo(): String = ""
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import com.bitchat.android.model.IdentityAnnouncement
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.sync.PacketIdUtil
|
||||
import com.bitchat.android.util.toHexString
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.*
|
||||
@@ -20,6 +21,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MessageHandler"
|
||||
private const val ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS = 10 * 60 * 1000L
|
||||
}
|
||||
|
||||
// Delegate for callbacks
|
||||
@@ -157,6 +159,14 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
// Simplified: Call delegate with messageID and peerID directly
|
||||
delegate?.onReadReceiptReceived(messageID, peerID)
|
||||
}
|
||||
com.bitchat.android.model.NoisePayloadType.VERIFY_CHALLENGE -> {
|
||||
Log.d(TAG, "🔐 Verify challenge received from $peerID (${noisePayload.data.size} bytes)")
|
||||
delegate?.onVerifyChallengeReceived(peerID, noisePayload.data, packet.timestamp.toLong())
|
||||
}
|
||||
com.bitchat.android.model.NoisePayloadType.VERIFY_RESPONSE -> {
|
||||
Log.d(TAG, "🔐 Verify response received from $peerID (${noisePayload.data.size} bytes)")
|
||||
delegate?.onVerifyResponseReceived(peerID, noisePayload.data, packet.timestamp.toLong())
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
@@ -211,12 +221,15 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
|
||||
if (peerID == myPeerID) return false
|
||||
|
||||
// Ignore stale announcements older than STALE_PEER_TIMEOUT
|
||||
// Peers use wall-clock packet timestamps; tolerate moderate device clock skew
|
||||
// during identity learning, or later signed messages cannot be verified.
|
||||
val now = System.currentTimeMillis()
|
||||
val age = now - packet.timestamp.toLong()
|
||||
if (age > com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS) {
|
||||
Log.w(TAG, "Ignoring stale ANNOUNCE from ${peerID.take(8)} (age=${age}ms > ${com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS}ms)")
|
||||
val clockSkewMs = kotlin.math.abs(now - packet.timestamp.toLong())
|
||||
if (clockSkewMs > ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS) {
|
||||
Log.w(TAG, "Ignoring ANNOUNCE from ${peerID.take(8)} with excessive clock skew (${clockSkewMs}ms > ${ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS}ms)")
|
||||
return false
|
||||
} else if (clockSkewMs > com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS) {
|
||||
Log.w(TAG, "Accepting ANNOUNCE from ${peerID.take(8)} within clock skew tolerance (${clockSkewMs}ms)")
|
||||
}
|
||||
|
||||
// Try to decode as iOS-compatible IdentityAnnouncement with TLV format
|
||||
@@ -278,6 +291,13 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
previousPeerID = null
|
||||
)
|
||||
|
||||
// Update mesh graph from gossip neighbors (only if TLV present)
|
||||
try {
|
||||
val neighborsOrNull = com.bitchat.android.services.meshgraph.GossipTLV.decodeNeighborsFromAnnouncementPayload(packet.payload)
|
||||
com.bitchat.android.services.meshgraph.MeshGraphService.getInstance()
|
||||
.updateFromAnnouncement(peerID, nickname, neighborsOrNull, packet.timestamp)
|
||||
} catch (_: Exception) { }
|
||||
|
||||
Log.d(TAG, "✅ Processed verified TLV announce: stored identity for $peerID")
|
||||
return isFirstAnnounce
|
||||
}
|
||||
@@ -385,7 +405,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
}
|
||||
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file)
|
||||
val message = BitchatMessage(
|
||||
id = java.util.UUID.randomUUID().toString().uppercase(),
|
||||
id = PacketIdUtil.computeIdHex(packet).uppercase(),
|
||||
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
|
||||
content = savedPath,
|
||||
type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType),
|
||||
@@ -401,6 +421,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
|
||||
// Fallback: plain text
|
||||
val message = BitchatMessage(
|
||||
id = PacketIdUtil.computeIdHex(packet).uppercase(),
|
||||
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
|
||||
content = String(packet.payload, Charsets.UTF_8),
|
||||
senderPeerID = peerID,
|
||||
@@ -611,4 +632,6 @@ interface MessageHandlerDelegate {
|
||||
fun onChannelLeave(channel: String, fromPeer: String)
|
||||
fun onDeliveryAckReceived(messageID: String, peerID: String)
|
||||
fun onReadReceiptReceived(messageID: String, peerID: String)
|
||||
fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long)
|
||||
fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long)
|
||||
}
|
||||
|
||||
@@ -78,8 +78,6 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
Log.w(TAG, "Received packet with no peer ID, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Get or create actor for this peer
|
||||
val actor = actors.getOrPut(peerID) { getOrCreateActorForPeer(peerID) }
|
||||
@@ -112,6 +110,9 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
override fun broadcastPacket(routed: RoutedPacket) {
|
||||
delegate?.relayPacket(routed)
|
||||
}
|
||||
override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
|
||||
return delegate?.sendToPeer(peerID, routed) ?: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,4 +324,5 @@ interface PacketProcessorDelegate {
|
||||
fun sendAnnouncementToPeer(peerID: String)
|
||||
fun sendCachedMessages(peerID: String)
|
||||
fun relayPacket(routed: RoutedPacket)
|
||||
fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean
|
||||
}
|
||||
|
||||
@@ -65,9 +65,40 @@ class PacketRelayManager(private val myPeerID: String) {
|
||||
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
|
||||
Log.d(TAG, "Decremented TTL from ${packet.ttl} to ${relayPacket.ttl}")
|
||||
|
||||
// Source-based routing: if route is set and includes us, try targeted next-hop forwarding
|
||||
val route = relayPacket.route
|
||||
if (!route.isNullOrEmpty()) {
|
||||
// Check for duplicate hops to prevent routing loops
|
||||
if (route.map { it.toHexString() }.toSet().size < route.size) {
|
||||
Log.w(TAG, "Packet with duplicate hops dropped")
|
||||
return
|
||||
}
|
||||
val myIdBytes = hexStringToPeerBytes(myPeerID)
|
||||
val index = route.indexOfFirst { it.contentEquals(myIdBytes) }
|
||||
if (index >= 0) {
|
||||
val nextHopIdHex: String? = run {
|
||||
val nextIndex = index + 1
|
||||
if (nextIndex < route.size) {
|
||||
route[nextIndex].toHexString()
|
||||
} else {
|
||||
// We are the last intermediate; try final recipient as next hop
|
||||
relayPacket.recipientID?.toHexString()
|
||||
}
|
||||
}
|
||||
if (nextHopIdHex != null) {
|
||||
val success = try { delegate?.sendToPeer(nextHopIdHex, RoutedPacket(relayPacket, peerID, routed.relayAddress)) } catch (_: Exception) { false } ?: false
|
||||
if (success) {
|
||||
Log.i(TAG, "📦 Source-route relay: ${peerID.take(8)} -> ${nextHopIdHex.take(8)} (type ${'$'}{packet.type}, TTL ${'$'}{relayPacket.ttl})")
|
||||
return
|
||||
} else {
|
||||
Log.w(TAG, "Source-route next hop ${nextHopIdHex.take(8)} not directly connected; falling back to broadcast")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply relay logic based on packet type and debug switch
|
||||
val shouldRelay = isRelayEnabled() && shouldRelayPacket(relayPacket, peerID)
|
||||
|
||||
if (shouldRelay) {
|
||||
relayPacket(RoutedPacket(relayPacket, peerID, routed.relayAddress))
|
||||
} else {
|
||||
@@ -170,4 +201,17 @@ interface PacketRelayManagerDelegate {
|
||||
|
||||
// Packet operations
|
||||
fun broadcastPacket(routed: RoutedPacket)
|
||||
fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean
|
||||
}
|
||||
|
||||
private fun hexStringToPeerBytes(hex: String): ByteArray {
|
||||
val result = ByteArray(8)
|
||||
var idx = 0
|
||||
var out = 0
|
||||
while (idx + 1 < hex.length && out < 8) {
|
||||
val b = hex.substring(idx, idx + 2).toIntOrNull(16)?.toByte() ?: 0
|
||||
result[out++] = b
|
||||
idx += 2
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -86,6 +86,9 @@ class PeerManager {
|
||||
// Delegate for callbacks
|
||||
var delegate: PeerManagerDelegate? = null
|
||||
|
||||
// Callback to check if a peer is directly connected (injected by BluetoothMeshService)
|
||||
var isPeerDirectlyConnected: ((String) -> Boolean)? = null
|
||||
|
||||
// Coroutines
|
||||
private val managerScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
@@ -107,10 +110,21 @@ class PeerManager {
|
||||
isVerified: Boolean
|
||||
): Boolean {
|
||||
if (peerID == "unknown") return false
|
||||
|
||||
fun keysMatch(a: ByteArray?, b: ByteArray?): Boolean {
|
||||
if (a == null && b == null) return true
|
||||
if (a == null || b == null) return false
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val existingPeer = peers[peerID]
|
||||
val isNewPeer = existingPeer == null
|
||||
val wasVerified = existingPeer?.isVerifiedNickname == true
|
||||
val nicknameChanged = existingPeer != null && existingPeer.nickname != nickname
|
||||
val noiseKeyChanged = existingPeer != null && !keysMatch(existingPeer.noisePublicKey, noisePublicKey)
|
||||
val signingKeyChanged = existingPeer != null && !keysMatch(existingPeer.signingPublicKey, signingPublicKey)
|
||||
val connectedChanged = existingPeer != null && existingPeer.isConnected != true
|
||||
|
||||
// Update or create peer info
|
||||
val peerInfo = PeerInfo(
|
||||
@@ -130,25 +144,42 @@ class PeerManager {
|
||||
// No legacy maps; peers map is the single source of truth
|
||||
// Maintain announcedPeers for first-time announce semantics
|
||||
|
||||
val shouldNotify = when {
|
||||
isNewPeer && isVerified -> true
|
||||
wasVerified != isVerified -> true
|
||||
nicknameChanged || noiseKeyChanged || signingKeyChanged || connectedChanged -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
if (isNewPeer && isVerified) {
|
||||
announcedPeers.add(peerID)
|
||||
notifyPeerListUpdate()
|
||||
Log.d(TAG, "🆕 New verified peer: $nickname ($peerID)")
|
||||
return true
|
||||
} else if (isVerified) {
|
||||
Log.d(TAG, "🔄 Updated verified peer: $nickname ($peerID)")
|
||||
} else {
|
||||
Log.d(TAG, "⚠️ Unverified peer announcement from: $nickname ($peerID)")
|
||||
}
|
||||
|
||||
if (shouldNotify) {
|
||||
notifyPeerListUpdate()
|
||||
}
|
||||
|
||||
return false
|
||||
return isNewPeer && isVerified
|
||||
}
|
||||
|
||||
/**
|
||||
* Get peer info
|
||||
* Get peer info with dynamic direct connection status
|
||||
*/
|
||||
fun getPeerInfo(peerID: String): PeerInfo? {
|
||||
return peers[peerID]
|
||||
return peers[peerID]?.let { info ->
|
||||
// Dynamically check direct connection status from ConnectionManager
|
||||
val isDirect = isPeerDirectlyConnected?.invoke(peerID) ?: false
|
||||
if (info.isDirectConnection != isDirect) {
|
||||
info.copy(isDirectConnection = isDirect)
|
||||
} else {
|
||||
info
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,27 +190,12 @@ class PeerManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all verified peers
|
||||
* Get all verified peers with dynamic direct connection status
|
||||
*/
|
||||
fun getVerifiedPeers(): Map<String, PeerInfo> {
|
||||
return peers.filterValues { it.isVerifiedNickname }
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether a peer is directly connected over Bluetooth.
|
||||
* Triggers a peer list update to refresh UI badges.
|
||||
*/
|
||||
fun setDirectConnection(peerID: String, isDirect: Boolean) {
|
||||
peers[peerID]?.let { existing ->
|
||||
if (existing.isDirectConnection != isDirect) {
|
||||
peers[peerID] = existing.copy(isDirectConnection = isDirect)
|
||||
notifyPeerListUpdate()
|
||||
// NEW: notify UI state (if available via delegate path) about directness change
|
||||
try {
|
||||
// Best-effort: delegate path flows up to ChatViewModel via didUpdatePeerList
|
||||
// No direct reference to UI layer here by design.
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
return peers.filterValues { it.isVerifiedNickname }.mapValues { (_, info) ->
|
||||
val isDirect = isPeerDirectlyConnected?.invoke(info.id) ?: false
|
||||
if (info.isDirectConnection != isDirect) info.copy(isDirectConnection = isDirect) else info
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,8 +315,7 @@ class PeerManager {
|
||||
*/
|
||||
fun isPeerActive(peerID: String): Boolean {
|
||||
val info = peers[peerID] ?: return false
|
||||
val now = System.currentTimeMillis()
|
||||
return (now - info.lastSeen) <= stalePeerTimeoutMs && info.isConnected
|
||||
return info.isConnected
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,8 +343,7 @@ class PeerManager {
|
||||
* Get list of active peer IDs
|
||||
*/
|
||||
fun getActivePeerIDs(): List<String> {
|
||||
val now = System.currentTimeMillis()
|
||||
return peers.filterValues { (now - it.lastSeen) <= stalePeerTimeoutMs && it.isConnected }
|
||||
return peers.filterValues { it.isConnected }
|
||||
.keys
|
||||
.toList()
|
||||
.sorted()
|
||||
@@ -366,7 +380,11 @@ class PeerManager {
|
||||
return buildString {
|
||||
appendLine("=== Peer Manager Debug Info ===")
|
||||
appendLine("Active Peers: ${activeIds.size}")
|
||||
peers.forEach { (peerID, info) ->
|
||||
peers.forEach { (peerID, storedInfo) ->
|
||||
// Use dynamic direct status for debug log accuracy
|
||||
val isDirect = isPeerDirectlyConnected?.invoke(peerID) ?: false
|
||||
val info = if (storedInfo.isDirectConnection != isDirect) storedInfo.copy(isDirectConnection = isDirect) else storedInfo
|
||||
|
||||
val timeSince = (now - info.lastSeen) / 1000
|
||||
val rssi = peerRSSI[peerID]?.let { "${it} dBm" } ?: "No RSSI"
|
||||
val deviceAddress = addressPeerMap?.entries?.find { it.value == peerID }?.key
|
||||
@@ -408,6 +426,10 @@ class PeerManager {
|
||||
val peerList = getActivePeerIDs()
|
||||
delegate?.onPeerListUpdated(peerList)
|
||||
}
|
||||
|
||||
fun refreshPeerList() {
|
||||
notifyPeerListUpdate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic cleanup of stale peers
|
||||
|
||||
@@ -7,7 +7,13 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.BatteryManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import kotlinx.coroutines.*
|
||||
import kotlin.math.max
|
||||
|
||||
@@ -15,7 +21,7 @@ import kotlin.math.max
|
||||
* Power-aware Bluetooth management for bitchat
|
||||
* Adjusts scanning, advertising, and connection behavior based on battery state
|
||||
*/
|
||||
class PowerManager(private val context: Context) {
|
||||
class PowerManager(private val context: Context) : LifecycleEventObserver {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PowerManager"
|
||||
@@ -49,7 +55,7 @@ class PowerManager(private val context: Context) {
|
||||
private var currentMode = PowerMode.BALANCED
|
||||
private var isCharging = false
|
||||
private var batteryLevel = 100
|
||||
private var isAppInBackground = false
|
||||
private var isAppInBackground = true
|
||||
|
||||
private val powerScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var dutyCycleJob: Job? = null
|
||||
@@ -87,6 +93,16 @@ class PowerManager(private val context: Context) {
|
||||
|
||||
init {
|
||||
registerBatteryReceiver()
|
||||
|
||||
// Register for process lifecycle events on the main thread
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
try {
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to register lifecycle observer: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
updatePowerMode()
|
||||
}
|
||||
|
||||
@@ -99,13 +115,30 @@ class PowerManager(private val context: Context) {
|
||||
Log.i(TAG, "Stopping power management")
|
||||
powerScope.cancel()
|
||||
unregisterBatteryReceiver()
|
||||
|
||||
// Unregister lifecycle observer
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
try {
|
||||
ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to remove lifecycle observer: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAppBackgroundState(inBackground: Boolean) {
|
||||
if (isAppInBackground != inBackground) {
|
||||
isAppInBackground = inBackground
|
||||
Log.d(TAG, "App background state changed: $inBackground")
|
||||
updatePowerMode()
|
||||
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_START -> {
|
||||
Log.d(TAG, "Process lifecycle: ON_START (App coming to foreground)")
|
||||
isAppInBackground = false
|
||||
updatePowerMode()
|
||||
}
|
||||
Lifecycle.Event.ON_STOP -> {
|
||||
Log.d(TAG, "Process lifecycle: ON_STOP (App going to background)")
|
||||
isAppInBackground = true
|
||||
updatePowerMode()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +166,7 @@ class PowerManager(private val context: Context) {
|
||||
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
|
||||
|
||||
PowerMode.ULTRA_LOW_POWER -> builder
|
||||
.setScanMode(ScanSettings.SCAN_MODE_OPPORTUNISTIC)
|
||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
|
||||
.setMatchMode(ScanSettings.MATCH_MODE_STICKY)
|
||||
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
|
||||
}
|
||||
|
||||
@@ -56,21 +56,30 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
|
||||
// Duplicate detection
|
||||
val messageID = generateMessageID(packet, peerID)
|
||||
if (messageType != MessageType.ANNOUNCE) {
|
||||
if (processedMessages.contains(messageID)) {
|
||||
|
||||
if (processedMessages.contains(messageID)) {
|
||||
// Check for ANNOUNCE exception: allow if it looks like a direct neighbor (max TTL)
|
||||
// This ensures we catch the "first announce" on a new connection for binding,
|
||||
// while still dropping looped/relayed duplicates.
|
||||
val isFreshAnnounce = messageType == MessageType.ANNOUNCE &&
|
||||
packet.ttl >= com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
|
||||
|
||||
if (!isFreshAnnounce) {
|
||||
Log.d(TAG, "Dropping duplicate packet: $messageID")
|
||||
return false
|
||||
}
|
||||
// Add to processed messages
|
||||
processedMessages.add(messageID)
|
||||
messageTimestamps[messageID] = currentTime
|
||||
} else {
|
||||
// Do not deduplicate ANNOUNCE at the security layer.
|
||||
// They are signed/idempotent and we need to ensure first-announce per-connection can bind.
|
||||
Log.d(TAG, "Allowing duplicate ANNOUNCE from direct neighbor: $messageID")
|
||||
}
|
||||
|
||||
// Add to processed messages
|
||||
processedMessages.add(messageID)
|
||||
messageTimestamps[messageID] = currentTime
|
||||
|
||||
// NEW: Signature verification logging (not rejecting yet)
|
||||
verifyPacketSignatureWithLogging(packet, peerID)
|
||||
// Enforce mandatory signature verification
|
||||
if (!verifyPacketSignature(packet, peerID)) {
|
||||
Log.w(TAG, "Dropping packet from $peerID due to signature verification failure")
|
||||
return false
|
||||
}
|
||||
|
||||
Log.d(TAG, "Packet validation passed for $peerID, messageID: $messageID")
|
||||
return true
|
||||
@@ -223,34 +232,58 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify packet signature using peer's signing public key and log the result
|
||||
* Verify packet signature using peer's signing public key
|
||||
* Returns true only if signature is present and valid
|
||||
*/
|
||||
private fun verifyPacketSignatureWithLogging(packet: BitchatPacket, peerID: String) {
|
||||
private fun verifyPacketSignature(packet: BitchatPacket, peerID: String): Boolean {
|
||||
try {
|
||||
// Check if packet has a signature
|
||||
// only verify ANNOUNCE, MESSAGE, and FILE_TRANSFER
|
||||
if (MessageType.fromValue(packet.type) !in setOf(
|
||||
MessageType.ANNOUNCE,
|
||||
MessageType.MESSAGE,
|
||||
MessageType.FILE_TRANSFER
|
||||
)) {
|
||||
return true
|
||||
}
|
||||
// 1. Mandatory Signature Check
|
||||
if (packet.signature == null) {
|
||||
Log.d(TAG, "📝 Signature check for $peerID: NO_SIGNATURE (packet type ${packet.type})")
|
||||
return
|
||||
Log.w(TAG, "❌ Signature check for $peerID: NO_SIGNATURE (packet type ${packet.type})")
|
||||
return false
|
||||
}
|
||||
|
||||
// Try to get peer's signing public key from peer info
|
||||
val peerInfo = delegate?.getPeerInfo(peerID)
|
||||
val signingPublicKey = peerInfo?.signingPublicKey
|
||||
// 2. Get Signing Public Key
|
||||
var signingPublicKey: ByteArray? = null
|
||||
|
||||
if (MessageType.fromValue(packet.type) == MessageType.ANNOUNCE) {
|
||||
// Special Case: ANNOUNCE packets carry their own signing key
|
||||
try {
|
||||
val announcement = com.bitchat.android.model.IdentityAnnouncement.decode(packet.payload)
|
||||
signingPublicKey = announcement?.signingPublicKey
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to decode announcement for key extraction: ${e.message}")
|
||||
}
|
||||
} else {
|
||||
// Standard Case: Get key from known peer info
|
||||
val peerInfo = delegate?.getPeerInfo(peerID)
|
||||
signingPublicKey = peerInfo?.signingPublicKey
|
||||
}
|
||||
|
||||
if (signingPublicKey == null) {
|
||||
Log.d(TAG, "📝 Signature check for $peerID: NO_SIGNING_KEY (packet type ${packet.type})")
|
||||
return
|
||||
// If we don't have a key (and it's not an announce), we can't verify.
|
||||
// For security, we must reject packets from unknown peers unless it's an announce.
|
||||
Log.w(TAG, "❌ Signature check for $peerID: NO_SIGNING_KEY_AVAILABLE (packet type ${packet.type})")
|
||||
return false
|
||||
}
|
||||
|
||||
// Get the canonical packet data for signature verification (without signature)
|
||||
// 3. Get Canonical Data
|
||||
val packetDataForSigning = packet.toBinaryDataForSigning()
|
||||
if (packetDataForSigning == null) {
|
||||
Log.w(TAG, "📝 Signature check for $peerID: ENCODING_ERROR (packet type ${packet.type})")
|
||||
return
|
||||
Log.w(TAG, "❌ Signature check for $peerID: ENCODING_ERROR (packet type ${packet.type})")
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify the signature using the peer's signing public key
|
||||
val signature = packet.signature!! // We already checked for null above
|
||||
// 4. Verify Signature
|
||||
val signature = packet.signature!!
|
||||
val isSignatureValid = encryptionService.verifyEd25519Signature(
|
||||
signature,
|
||||
packetDataForSigning,
|
||||
@@ -258,13 +291,16 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
)
|
||||
|
||||
if (isSignatureValid) {
|
||||
Log.d(TAG, "📝 Signature check for $peerID: ✅ VALID (packet type ${packet.type})")
|
||||
// Log.v(TAG, "✅ Signature verified for $peerID (type ${packet.type})")
|
||||
return true
|
||||
} else {
|
||||
Log.w(TAG, "📝 Signature check for $peerID: ❌ INVALID (packet type ${packet.type})")
|
||||
Log.w(TAG, "❌ Signature INVALID for $peerID (type ${packet.type})")
|
||||
return false
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "📝 Signature check for $peerID: ERROR - ${e.message} (packet type ${packet.type})")
|
||||
Log.e(TAG, "❌ Signature verification error for $peerID: ${e.message}")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.noise.NoiseSession
|
||||
import com.bitchat.android.wifiaware.WifiAwareController
|
||||
|
||||
/**
|
||||
* Feature-facing mesh service that hides local transport selection from the rest of the app.
|
||||
*
|
||||
* BLE remains the canonical origin for broadcast packets when it is enabled so existing BLE mesh
|
||||
* behavior and bridge semantics stay intact. Addressed Noise traffic is routed over whichever
|
||||
* local transport already has the peer/session, falling back to a connected transport handshake.
|
||||
*/
|
||||
class UnifiedMeshService(
|
||||
private val context: Context,
|
||||
private val bluetooth: BluetoothMeshService
|
||||
) : MeshService, BluetoothMeshDelegate {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "UnifiedMeshService"
|
||||
}
|
||||
|
||||
override val myPeerID: String
|
||||
get() = bluetooth.myPeerID
|
||||
|
||||
override var delegate: MeshDelegate? = null
|
||||
set(value) {
|
||||
field = value
|
||||
refreshDelegates()
|
||||
}
|
||||
|
||||
fun refreshDelegates() {
|
||||
try { bluetooth.delegate = if (delegate != null) this else null } catch (_: Exception) { }
|
||||
try { wifiService()?.delegate = if (delegate != null) this else null } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override fun startServices() {
|
||||
if (isBleEnabled()) {
|
||||
try { bluetooth.startServices() } catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to start BLE transport: ${e.message}")
|
||||
}
|
||||
} else {
|
||||
try { bluetooth.setBleTransportEnabled(false) } catch (_: Exception) { }
|
||||
}
|
||||
try { WifiAwareController.startIfPossible() } catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to start Wi-Fi Aware transport: ${e.message}")
|
||||
}
|
||||
refreshDelegates()
|
||||
}
|
||||
|
||||
override fun stopServices() {
|
||||
try { bluetooth.stopServices() } catch (_: Exception) { }
|
||||
try { WifiAwareController.stop() } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override fun sendMessage(content: String, mentions: List<String>, channel: String?) {
|
||||
when {
|
||||
isBleEnabled() -> bluetooth.sendMessage(content, mentions, channel)
|
||||
else -> wifiService()?.sendMessage(content, mentions, channel)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendPrivateMessage(
|
||||
content: String,
|
||||
recipientPeerID: String,
|
||||
recipientNickname: String,
|
||||
messageID: String?
|
||||
) {
|
||||
when {
|
||||
isBleReady(recipientPeerID) -> bluetooth.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID)
|
||||
isWifiReady(recipientPeerID) -> wifiService()?.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID)
|
||||
isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) ->
|
||||
bluetooth.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID)
|
||||
else -> wifiService()?.sendPrivateMessage(content, recipientPeerID, recipientNickname, messageID)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) {
|
||||
when {
|
||||
isBleReady(recipientPeerID) -> bluetooth.sendReadReceipt(messageID, recipientPeerID, readerNickname)
|
||||
isWifiReady(recipientPeerID) -> wifiService()?.sendReadReceipt(messageID, recipientPeerID, readerNickname)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendFavoriteNotification(peerID: String, isFavorite: Boolean) {
|
||||
val myNpub = try {
|
||||
com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)?.npub
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
val content = if (isFavorite) "[FAVORITED]:${myNpub ?: ""}" else "[UNFAVORITED]:${myNpub ?: ""}"
|
||||
val nickname = getPeerNicknames()[peerID] ?: peerID
|
||||
if (hasEstablishedSession(peerID)) {
|
||||
sendPrivateMessage(content, peerID, nickname, java.util.UUID.randomUUID().toString())
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
|
||||
when {
|
||||
isBleReady(peerID) -> bluetooth.sendVerifyChallenge(peerID, noiseKeyHex, nonceA)
|
||||
isWifiReady(peerID) -> wifiService()?.sendVerifyChallenge(peerID, noiseKeyHex, nonceA)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray) {
|
||||
when {
|
||||
isBleReady(peerID) -> bluetooth.sendVerifyResponse(peerID, noiseKeyHex, nonceA)
|
||||
isWifiReady(peerID) -> wifiService()?.sendVerifyResponse(peerID, noiseKeyHex, nonceA)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendFileBroadcast(file: BitchatFilePacket) {
|
||||
when {
|
||||
isBleEnabled() -> bluetooth.sendFileBroadcast(file)
|
||||
else -> wifiService()?.sendFileBroadcast(file)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) {
|
||||
when {
|
||||
isBleReady(recipientPeerID) -> bluetooth.sendFilePrivate(recipientPeerID, file)
|
||||
isWifiReady(recipientPeerID) -> wifiService()?.sendFilePrivate(recipientPeerID, file)
|
||||
isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) ->
|
||||
bluetooth.sendFilePrivate(recipientPeerID, file)
|
||||
else -> wifiService()?.sendFilePrivate(recipientPeerID, file)
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancelFileTransfer(transferId: String): Boolean {
|
||||
val bleCancelled = try { bluetooth.cancelFileTransfer(transferId) } catch (_: Exception) { false }
|
||||
val wifiCancelled = try { wifiService()?.cancelFileTransfer(transferId) == true } catch (_: Exception) { false }
|
||||
return bleCancelled || wifiCancelled
|
||||
}
|
||||
|
||||
override fun sendBroadcastAnnounce() {
|
||||
if (isBleEnabled()) {
|
||||
try { bluetooth.sendBroadcastAnnounce() } catch (_: Exception) { }
|
||||
}
|
||||
try { wifiService()?.sendBroadcastAnnounce() } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override fun sendAnnouncementToPeer(peerID: String) {
|
||||
when {
|
||||
isBleConnected(peerID) || (isBleEnabled() && !isWifiConnected(peerID)) -> bluetooth.sendAnnouncementToPeer(peerID)
|
||||
else -> wifiService()?.sendAnnouncementToPeer(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPeerNicknames(): Map<String, String> {
|
||||
val merged = linkedMapOf<String, String>()
|
||||
try { merged.putAll(wifiService()?.getPeerNicknames().orEmpty()) } catch (_: Exception) { }
|
||||
try { merged.putAll(bluetooth.getPeerNicknames()) } catch (_: Exception) { }
|
||||
return merged
|
||||
}
|
||||
|
||||
override fun getPeerRSSI(): Map<String, Int> {
|
||||
val merged = linkedMapOf<String, Int>()
|
||||
try { merged.putAll(wifiService()?.getPeerRSSI().orEmpty()) } catch (_: Exception) { }
|
||||
try { merged.putAll(bluetooth.getPeerRSSI()) } catch (_: Exception) { }
|
||||
return merged
|
||||
}
|
||||
|
||||
override fun getActivePeerCount(): Int {
|
||||
return mergedPeerIDs().filter { it != myPeerID }.distinct().size
|
||||
}
|
||||
|
||||
override fun hasEstablishedSession(peerID: String): Boolean {
|
||||
return isBleReady(peerID) || isWifiReady(peerID)
|
||||
}
|
||||
|
||||
override fun getSessionState(peerID: String): NoiseSession.NoiseSessionState {
|
||||
val bleState = try { bluetooth.getSessionState(peerID) } catch (_: Exception) { NoiseSession.NoiseSessionState.Uninitialized }
|
||||
val wifiState = try { wifiService()?.getSessionState(peerID) } catch (_: Exception) { null }
|
||||
return when {
|
||||
bleState is NoiseSession.NoiseSessionState.Established -> bleState
|
||||
wifiState is NoiseSession.NoiseSessionState.Established -> wifiState
|
||||
bleState is NoiseSession.NoiseSessionState.Handshaking -> bleState
|
||||
wifiState is NoiseSession.NoiseSessionState.Handshaking -> wifiState
|
||||
bleState !is NoiseSession.NoiseSessionState.Uninitialized -> bleState
|
||||
wifiState != null -> wifiState
|
||||
else -> bleState
|
||||
}
|
||||
}
|
||||
|
||||
override fun initiateNoiseHandshake(peerID: String) {
|
||||
when {
|
||||
isBleConnected(peerID) -> bluetooth.initiateNoiseHandshake(peerID)
|
||||
isWifiConnected(peerID) -> wifiService()?.initiateNoiseHandshake(peerID)
|
||||
isBleEnabled() -> bluetooth.initiateNoiseHandshake(peerID)
|
||||
else -> wifiService()?.initiateNoiseHandshake(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPeerFingerprint(peerID: String): String? {
|
||||
return try { bluetooth.getPeerFingerprint(peerID) } catch (_: Exception) { null }
|
||||
?: try { wifiService()?.getPeerFingerprint(peerID) } catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
override fun getPeerInfo(peerID: String): PeerInfo? {
|
||||
val ble = try { bluetooth.getPeerInfo(peerID) } catch (_: Exception) { null }
|
||||
val wifi = try { wifiService()?.getPeerInfo(peerID) } catch (_: Exception) { null }
|
||||
return when {
|
||||
ble?.isConnected == true && hasEstablishedSessionOnBluetooth(peerID) -> ble
|
||||
wifi?.isConnected == true && wifiService()?.hasEstablishedSession(peerID) == true -> wifi
|
||||
ble?.isConnected == true -> ble
|
||||
wifi?.isConnected == true -> wifi
|
||||
else -> ble ?: wifi
|
||||
}
|
||||
}
|
||||
|
||||
override fun updatePeerInfo(
|
||||
peerID: String,
|
||||
nickname: String,
|
||||
noisePublicKey: ByteArray,
|
||||
signingPublicKey: ByteArray,
|
||||
isVerified: Boolean
|
||||
): Boolean {
|
||||
val bleUpdated = try {
|
||||
bluetooth.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
val wifiUpdated = try {
|
||||
wifiService()?.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified) == true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
return bleUpdated || wifiUpdated
|
||||
}
|
||||
|
||||
override fun getIdentityFingerprint(): String = bluetooth.getIdentityFingerprint()
|
||||
|
||||
override fun getStaticNoisePublicKey(): ByteArray? {
|
||||
return bluetooth.getStaticNoisePublicKey() ?: wifiService()?.getStaticNoisePublicKey()
|
||||
}
|
||||
|
||||
override fun shouldShowEncryptionIcon(peerID: String): Boolean {
|
||||
return hasEstablishedSession(peerID)
|
||||
}
|
||||
|
||||
override fun getEncryptedPeers(): List<String> {
|
||||
val encrypted = linkedSetOf<String>()
|
||||
try { encrypted.addAll(bluetooth.getEncryptedPeers()) } catch (_: Exception) { }
|
||||
try { encrypted.addAll(wifiService()?.getEncryptedPeers().orEmpty()) } catch (_: Exception) { }
|
||||
mergedPeerIDs().filterTo(encrypted) { hasEstablishedSession(it) }
|
||||
return encrypted.toList()
|
||||
}
|
||||
|
||||
override fun getDeviceAddressForPeer(peerID: String): String? {
|
||||
return try { bluetooth.getDeviceAddressForPeer(peerID) } catch (_: Exception) { null }
|
||||
?: try { wifiService()?.getDeviceAddressForPeer(peerID) } catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
override fun getDeviceAddressToPeerMapping(): Map<String, String> {
|
||||
val merged = linkedMapOf<String, String>()
|
||||
try { merged.putAll(wifiService()?.getDeviceAddressToPeerMapping().orEmpty()) } catch (_: Exception) { }
|
||||
try { merged.putAll(bluetooth.getDeviceAddressToPeerMapping()) } catch (_: Exception) { }
|
||||
return merged
|
||||
}
|
||||
|
||||
override fun printDeviceAddressesForPeers(): String {
|
||||
return buildString {
|
||||
appendLine(bluetooth.printDeviceAddressesForPeers())
|
||||
wifiService()?.let {
|
||||
appendLine()
|
||||
appendLine(it.printDeviceAddressesForPeers())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getDebugStatus(): String {
|
||||
return buildString {
|
||||
appendLine("=== Unified Mesh Service Debug Status ===")
|
||||
appendLine("My Peer ID: $myPeerID")
|
||||
appendLine("Merged Peers: ${mergedPeerIDs().joinToString(", ")}")
|
||||
appendLine()
|
||||
appendLine(bluetooth.getDebugStatus())
|
||||
wifiService()?.let {
|
||||
appendLine()
|
||||
appendLine(it.getDebugStatus())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun clearAllInternalData() {
|
||||
try { bluetooth.clearAllInternalData() } catch (_: Exception) { }
|
||||
try { wifiService()?.clearAllInternalData() } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override fun clearAllEncryptionData() {
|
||||
try { bluetooth.clearAllEncryptionData() } catch (_: Exception) { }
|
||||
try { wifiService()?.clearAllEncryptionData() } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override fun didReceiveMessage(message: BitchatMessage) {
|
||||
delegate?.didReceiveMessage(message)
|
||||
}
|
||||
|
||||
override fun didUpdatePeerList(peers: List<String>) {
|
||||
delegate?.didUpdatePeerList(mergedPeerIDs().ifEmpty { peers.distinct() })
|
||||
}
|
||||
|
||||
override fun didReceiveChannelLeave(channel: String, fromPeer: String) {
|
||||
delegate?.didReceiveChannelLeave(channel, fromPeer)
|
||||
}
|
||||
|
||||
override fun didReceiveDeliveryAck(messageID: String, recipientPeerID: String) {
|
||||
delegate?.didReceiveDeliveryAck(messageID, recipientPeerID)
|
||||
}
|
||||
|
||||
override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) {
|
||||
delegate?.didReceiveReadReceipt(messageID, recipientPeerID)
|
||||
}
|
||||
|
||||
override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
delegate?.didReceiveVerifyChallenge(peerID, payload, timestampMs)
|
||||
}
|
||||
|
||||
override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
|
||||
}
|
||||
|
||||
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
|
||||
return delegate?.decryptChannelMessage(encryptedContent, channel)
|
||||
}
|
||||
|
||||
override fun getNickname(): String? = delegate?.getNickname()
|
||||
|
||||
override fun isFavorite(peerID: String): Boolean = delegate?.isFavorite(peerID) ?: false
|
||||
|
||||
private fun mergedPeerIDs(): List<String> {
|
||||
val ids = linkedSetOf<String>()
|
||||
try { ids.addAll(com.bitchat.android.services.AppStateStore.peers.value) } catch (_: Exception) { }
|
||||
try { ids.addAll(bluetooth.getPeerNicknames().keys) } catch (_: Exception) { }
|
||||
try { ids.addAll(wifiService()?.getPeerNicknames()?.keys.orEmpty()) } catch (_: Exception) { }
|
||||
return ids.toList()
|
||||
}
|
||||
|
||||
private fun wifiService(): MeshService? {
|
||||
return try {
|
||||
WifiAwareController.getService()?.also { service ->
|
||||
if (delegate != null && service.delegate !== this) {
|
||||
service.delegate = this
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun isBleEnabled(): Boolean {
|
||||
return try {
|
||||
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
|
||||
} catch (_: Exception) {
|
||||
try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
|
||||
}
|
||||
}
|
||||
|
||||
private fun isBleConnected(peerID: String): Boolean {
|
||||
return try { bluetooth.getPeerInfo(peerID)?.isConnected == true } catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
private fun isWifiConnected(peerID: String): Boolean {
|
||||
return try { wifiService()?.getPeerInfo(peerID)?.isConnected == true } catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
private fun isBleReady(peerID: String): Boolean {
|
||||
return isBleConnected(peerID) && hasEstablishedSessionOnBluetooth(peerID)
|
||||
}
|
||||
|
||||
private fun isWifiReady(peerID: String): Boolean {
|
||||
return try {
|
||||
val wifi = wifiService()
|
||||
wifi?.getPeerInfo(peerID)?.isConnected == true && wifi.hasEstablishedSession(peerID)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasEstablishedSessionOnBluetooth(peerID: String): Boolean {
|
||||
return try { bluetooth.hasEstablishedSession(peerID) } catch (_: Exception) { false }
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ enum class NoisePayloadType(val value: UByte) {
|
||||
PRIVATE_MESSAGE(0x01u), // Private chat message with TLV encoding
|
||||
READ_RECEIPT(0x02u), // Message was read
|
||||
DELIVERED(0x03u), // Message was delivered
|
||||
VERIFY_CHALLENGE(0x10u), // Verification challenge
|
||||
VERIFY_RESPONSE(0x11u), // Verification response
|
||||
FILE_TRANSFER(0x20u);
|
||||
|
||||
|
||||
|
||||
@@ -29,15 +29,15 @@ class NoiseEncryptionService(private val context: Context) {
|
||||
}
|
||||
|
||||
// Static identity key (persistent across app restarts) - loaded from secure storage
|
||||
private val staticIdentityPrivateKey: ByteArray
|
||||
private val staticIdentityPublicKey: ByteArray
|
||||
private var staticIdentityPrivateKey: ByteArray
|
||||
private var staticIdentityPublicKey: ByteArray
|
||||
|
||||
// Ed25519 signing key (persistent across app restarts) - loaded from secure storage
|
||||
private val signingPrivateKey: ByteArray
|
||||
private val signingPublicKey: ByteArray
|
||||
private var signingPrivateKey: ByteArray
|
||||
private var signingPublicKey: ByteArray
|
||||
|
||||
// Session management
|
||||
private val sessionManager: NoiseSessionManager
|
||||
private lateinit var sessionManager: NoiseSessionManager
|
||||
|
||||
// Channel encryption for password-protected channels
|
||||
private val channelEncryption = NoiseChannelEncryption()
|
||||
@@ -56,6 +56,33 @@ class NoiseEncryptionService(private val context: Context) {
|
||||
// Initialize identity state manager for persistent storage
|
||||
identityStateManager = SecureIdentityStateManager(context)
|
||||
|
||||
// Load or create keys - temporary placeholders
|
||||
staticIdentityPrivateKey = ByteArray(32)
|
||||
staticIdentityPublicKey = ByteArray(32)
|
||||
signingPrivateKey = ByteArray(32)
|
||||
signingPublicKey = ByteArray(32)
|
||||
|
||||
loadOrGenerateKeys()
|
||||
|
||||
// Initialize session manager
|
||||
initializeSessionManager()
|
||||
}
|
||||
|
||||
private fun initializeSessionManager() {
|
||||
// Create new session manager with current keys
|
||||
val localPeerID = calculateFingerprint(staticIdentityPublicKey).take(16)
|
||||
sessionManager = NoiseSessionManager(staticIdentityPrivateKey, staticIdentityPublicKey, localPeerID)
|
||||
|
||||
// Set up session callbacks
|
||||
sessionManager.onSessionEstablished = { peerID, remoteStaticKey ->
|
||||
handleSessionEstablished(peerID, remoteStaticKey)
|
||||
}
|
||||
|
||||
// Ensure any other callbacks are wired if needed
|
||||
// sessionManager.onSessionFailed could be wired if we exposed it
|
||||
}
|
||||
|
||||
private fun loadOrGenerateKeys() {
|
||||
// Load or create static identity key (persistent across sessions)
|
||||
val loadedKeyPair = identityStateManager.loadStaticKey()
|
||||
if (loadedKeyPair != null) {
|
||||
@@ -89,16 +116,8 @@ class NoiseEncryptionService(private val context: Context) {
|
||||
identityStateManager.saveSigningKey(signingPrivateKey, signingPublicKey)
|
||||
Log.d(TAG, "Generated and saved new Ed25519 signing key")
|
||||
}
|
||||
|
||||
// Initialize session manager
|
||||
sessionManager = NoiseSessionManager(staticIdentityPrivateKey, staticIdentityPublicKey)
|
||||
|
||||
// Set up session callbacks
|
||||
sessionManager.onSessionEstablished = { peerID, remoteStaticKey ->
|
||||
handleSessionEstablished(peerID, remoteStaticKey)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Public Interface
|
||||
|
||||
/**
|
||||
@@ -135,7 +154,23 @@ class NoiseEncryptionService(private val context: Context) {
|
||||
* Clear persistent identity (for panic mode)
|
||||
*/
|
||||
fun clearPersistentIdentity() {
|
||||
Log.w(TAG, "🚨 Panic Mode: Clearing persistent identity and rotating in-memory keys")
|
||||
|
||||
// 1. Clear storage
|
||||
identityStateManager.clearIdentityData()
|
||||
|
||||
// 2. Clear all sessions immediately
|
||||
if (::sessionManager.isInitialized) {
|
||||
sessionManager.shutdown()
|
||||
}
|
||||
|
||||
// 3. Regenerate keys immediately (in-memory rotation)
|
||||
loadOrGenerateKeys()
|
||||
|
||||
// 4. Re-initialize SessionManager with new keys
|
||||
initializeSessionManager()
|
||||
|
||||
Log.d(TAG, "✅ Identity cleared and keys rotated")
|
||||
}
|
||||
|
||||
// MARK: - Handshake Management
|
||||
@@ -478,7 +513,9 @@ class NoiseEncryptionService(private val context: Context) {
|
||||
* Clean shutdown
|
||||
*/
|
||||
fun shutdown() {
|
||||
sessionManager.shutdown()
|
||||
if (::sessionManager.isInitialized) {
|
||||
sessionManager.shutdown()
|
||||
}
|
||||
channelEncryption.clear()
|
||||
// No need to clear fingerprints here - they are managed centrally
|
||||
}
|
||||
|
||||
@@ -153,6 +153,9 @@ class NoiseSession(
|
||||
// Session state
|
||||
private var state: NoiseSessionState = NoiseSessionState.Uninitialized
|
||||
private val creationTime = System.currentTimeMillis()
|
||||
private var handshakeStartMs: Long? = null
|
||||
private var lastHandshakeActivityMs: Long? = null
|
||||
private var handshakeMessage1: ByteArray? = null
|
||||
|
||||
// Session counters
|
||||
private var currentPattern = 0;
|
||||
@@ -195,6 +198,15 @@ class NoiseSession(
|
||||
fun isEstablished(): Boolean = state is NoiseSessionState.Established
|
||||
fun isHandshaking(): Boolean = state is NoiseSessionState.Handshaking
|
||||
fun getCreationTime(): Long = creationTime
|
||||
fun isInitiatorRole(): Boolean = isInitiator
|
||||
fun getHandshakeStartMs(): Long? = handshakeStartMs
|
||||
fun getLastHandshakeActivityMs(): Long? = lastHandshakeActivityMs
|
||||
|
||||
internal fun getHandshakeMessage1(): ByteArray? = handshakeMessage1?.clone()
|
||||
|
||||
internal fun setLastHandshakeActivityForTest(timestampMs: Long) {
|
||||
lastHandshakeActivityMs = timestampMs
|
||||
}
|
||||
|
||||
init {
|
||||
try {
|
||||
@@ -317,19 +329,25 @@ class NoiseSession(
|
||||
// Initialize handshake as initiator
|
||||
initializeNoiseHandshake(HandshakeState.INITIATOR)
|
||||
state = NoiseSessionState.Handshaking
|
||||
if (handshakeStartMs == null) {
|
||||
handshakeStartMs = System.currentTimeMillis()
|
||||
}
|
||||
lastHandshakeActivityMs = System.currentTimeMillis()
|
||||
|
||||
val messageBuffer = ByteArray(XX_MESSAGE_1_SIZE)
|
||||
val handshakeStateLocal = handshakeState ?: throw IllegalStateException("Handshake state is null")
|
||||
val messageLength = handshakeStateLocal.writeMessage(messageBuffer, 0, null, 0, 0)
|
||||
currentPattern++
|
||||
val firstMessage = messageBuffer.copyOf(messageLength)
|
||||
handshakeMessage1 = firstMessage
|
||||
|
||||
// Validate message size matches XX pattern expectations
|
||||
if (firstMessage.size != XX_MESSAGE_1_SIZE) {
|
||||
Log.w(TAG, "Warning: XX message 1 size ${firstMessage.size} != expected $XX_MESSAGE_1_SIZE")
|
||||
}
|
||||
|
||||
Log.d(TAG, "Sending XX handshake message 1 to $peerID (${firstMessage.size} bytes) currentPattern: $currentPattern")
|
||||
val ePrefix = firstMessage.take(4).toByteArray().toHexString()
|
||||
Log.d(TAG, "Sending XX handshake message 1 to $peerID (${firstMessage.size} bytes) e_prefix=$ePrefix currentPattern: $currentPattern")
|
||||
return firstMessage
|
||||
} catch (e: Exception) {
|
||||
state = NoiseSessionState.Failed(e)
|
||||
@@ -344,19 +362,24 @@ class NoiseSession(
|
||||
*/
|
||||
@Synchronized
|
||||
fun processHandshakeMessage(message: ByteArray): ByteArray? {
|
||||
Log.d(TAG, "Processing handshake message from $peerID (${message.size} bytes)")
|
||||
val inputPrefix = message.take(4).toByteArray().toHexString()
|
||||
Log.d(TAG, "Processing handshake message from $peerID (${message.size} bytes) prefix=$inputPrefix")
|
||||
|
||||
try {
|
||||
// Initialize as responder if receiving first message
|
||||
if (state == NoiseSessionState.Uninitialized && !isInitiator) {
|
||||
initializeNoiseHandshake(HandshakeState.RESPONDER)
|
||||
state = NoiseSessionState.Handshaking
|
||||
if (handshakeStartMs == null) {
|
||||
handshakeStartMs = System.currentTimeMillis()
|
||||
}
|
||||
Log.d(TAG, "Initialized as RESPONDER for XX handshake with $peerID")
|
||||
}
|
||||
|
||||
if (state != NoiseSessionState.Handshaking) {
|
||||
throw IllegalStateException("Invalid state for handshake: $state")
|
||||
}
|
||||
lastHandshakeActivityMs = System.currentTimeMillis()
|
||||
|
||||
val handshakeStateLocal = handshakeState ?: throw IllegalStateException("Handshake state is null")
|
||||
|
||||
@@ -366,7 +389,8 @@ class NoiseSession(
|
||||
// Read the incoming message - the Noise library will handle validation
|
||||
val payloadLength = handshakeStateLocal.readMessage(message, 0, message.size, payloadBuffer, 0)
|
||||
currentPattern++
|
||||
Log.d(TAG, "Read handshake message, payload length: $payloadLength currentPattern: $currentPattern")
|
||||
val readPrefix = message.take(4).toByteArray().toHexString()
|
||||
Log.d(TAG, "Read handshake message, payload length: $payloadLength prefix=$readPrefix currentPattern: $currentPattern")
|
||||
|
||||
// Check what action the handshake state wants us to take next
|
||||
val action = handshakeStateLocal.getAction()
|
||||
|
||||
@@ -8,11 +8,14 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
*/
|
||||
class NoiseSessionManager(
|
||||
private val localStaticPrivateKey: ByteArray,
|
||||
private val localStaticPublicKey: ByteArray
|
||||
private val localStaticPublicKey: ByteArray,
|
||||
private val localPeerID: String
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NoiseSessionManager"
|
||||
private const val HANDSHAKE_TIMEOUT_MS = 20_000L
|
||||
private const val HANDSHAKE_MESSAGE_1_SIZE = 32
|
||||
}
|
||||
|
||||
private val sessions = ConcurrentHashMap<String, NoiseSession>()
|
||||
@@ -51,11 +54,30 @@ class NoiseSessionManager(
|
||||
/**
|
||||
* SIMPLIFIED: Initiate handshake - no tie breaker, just start
|
||||
*/
|
||||
fun initiateHandshake(peerID: String): ByteArray {
|
||||
fun initiateHandshake(peerID: String): ByteArray? {
|
||||
Log.d(TAG, "initiateHandshake($peerID)")
|
||||
|
||||
// Remove any existing session first
|
||||
removeSession(peerID)
|
||||
val now = System.currentTimeMillis()
|
||||
val existing = getSession(peerID)
|
||||
if (existing != null) {
|
||||
when {
|
||||
existing.isEstablished() -> {
|
||||
Log.d(TAG, "Handshake already established with $peerID, skipping initiate")
|
||||
return null
|
||||
}
|
||||
existing.isHandshaking() -> {
|
||||
if (!isHandshakeStale(existing, now)) {
|
||||
Log.d(TAG, "Handshake already in progress with $peerID, not restarting")
|
||||
return null
|
||||
}
|
||||
Log.d(TAG, "Handshake with $peerID is stale; restarting")
|
||||
removeSession(peerID)
|
||||
}
|
||||
else -> {
|
||||
removeSession(peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create new session as initiator
|
||||
val session = NoiseSession(
|
||||
@@ -85,6 +107,23 @@ class NoiseSessionManager(
|
||||
|
||||
try {
|
||||
var session = getSession(peerID)
|
||||
|
||||
// Collision handling: both sides initiated and we received message 1
|
||||
if (session != null &&
|
||||
session.isHandshaking() &&
|
||||
session.isInitiatorRole() &&
|
||||
message.size == HANDSHAKE_MESSAGE_1_SIZE
|
||||
) {
|
||||
val shouldYield = localPeerID > peerID
|
||||
if (shouldYield) {
|
||||
Log.d(TAG, "Handshake collision with $peerID; yielding to responder role")
|
||||
removeSession(peerID)
|
||||
session = null
|
||||
} else {
|
||||
Log.d(TAG, "Handshake collision with $peerID; keeping initiator role")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// If no session exists, create one as responder
|
||||
if (session == null) {
|
||||
@@ -119,6 +158,12 @@ class NoiseSessionManager(
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun isHandshakeStale(session: NoiseSession, nowMs: Long): Boolean {
|
||||
val lastActivity = session.getLastHandshakeActivityMs() ?: session.getHandshakeStartMs()
|
||||
if (lastActivity == null) return false
|
||||
return (nowMs - lastActivity) > HANDSHAKE_TIMEOUT_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* SIMPLIFIED: Encrypt data
|
||||
|
||||
@@ -1,17 +1,40 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* GeohashAliasRegistry
|
||||
* - Global, thread-safe registry for alias->Nostr pubkey mappings (e.g., nostr_<pub16> -> pubkeyHex)
|
||||
* - Allows non-UI components (e.g., MessageRouter) to resolve geohash DM aliases without depending on UI ViewModels
|
||||
* - Persisted to SharedPreferences to survive app restarts.
|
||||
*/
|
||||
object GeohashAliasRegistry {
|
||||
private val map: MutableMap<String, String> = ConcurrentHashMap()
|
||||
private const val PREFS_NAME = "geohash_alias_registry"
|
||||
private var prefs: SharedPreferences? = null
|
||||
|
||||
fun initialize(context: Context) {
|
||||
if (prefs == null) {
|
||||
prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
loadFromPrefs()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadFromPrefs() {
|
||||
prefs?.let { p ->
|
||||
val allEntries = p.all
|
||||
for ((key, value) in allEntries) {
|
||||
if (key is String && value is String) {
|
||||
map[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun put(alias: String, pubkeyHex: String) {
|
||||
map[alias] = pubkeyHex
|
||||
prefs?.edit()?.putString(alias, pubkeyHex)?.apply()
|
||||
}
|
||||
|
||||
fun get(alias: String): String? = map[alias]
|
||||
@@ -20,5 +43,8 @@ object GeohashAliasRegistry {
|
||||
|
||||
fun snapshot(): Map<String, String> = HashMap(map)
|
||||
|
||||
fun clear() { map.clear() }
|
||||
fun clear() {
|
||||
map.clear()
|
||||
prefs?.edit()?.clear()?.apply()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,51 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* GeohashConversationRegistry
|
||||
* - Global, thread-safe registry of conversationKey (e.g., "nostr_<pub16>") -> source geohash
|
||||
* - Enables routing geohash DMs from anywhere by providing the correct geohash identity
|
||||
* - Persisted to SharedPreferences to survive app restarts.
|
||||
*/
|
||||
object GeohashConversationRegistry {
|
||||
private val map = ConcurrentHashMap<String, String>()
|
||||
private const val PREFS_NAME = "geohash_conversation_registry"
|
||||
private var prefs: SharedPreferences? = null
|
||||
|
||||
fun initialize(context: Context) {
|
||||
if (prefs == null) {
|
||||
prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
loadFromPrefs()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadFromPrefs() {
|
||||
prefs?.let { p ->
|
||||
val allEntries = p.all
|
||||
for ((key, value) in allEntries) {
|
||||
if (key is String && value is String) {
|
||||
map[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun set(convKey: String, geohash: String) {
|
||||
if (geohash.isNotEmpty()) map[convKey] = geohash
|
||||
if (geohash.isNotEmpty()) {
|
||||
map[convKey] = geohash
|
||||
prefs?.edit()?.putString(convKey, geohash)?.apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun get(convKey: String): String? = map[convKey]
|
||||
|
||||
fun snapshot(): Map<String, String> = map.toMap()
|
||||
|
||||
fun clear() = map.clear()
|
||||
fun clear() {
|
||||
map.clear()
|
||||
prefs?.edit()?.clear()?.apply()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,15 +46,17 @@ class GeohashMessageHandler(
|
||||
fun onEvent(event: NostrEvent, subscribedGeohash: String) {
|
||||
scope.launch(Dispatchers.Default) {
|
||||
try {
|
||||
if (event.kind != 20000) return@launch
|
||||
if (event.kind != NostrKind.EPHEMERAL_EVENT && event.kind != NostrKind.GEOHASH_PRESENCE) return@launch
|
||||
val tagGeo = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }?.getOrNull(1)
|
||||
if (tagGeo == null || !tagGeo.equals(subscribedGeohash, true)) return@launch
|
||||
if (dedupe(event.id)) return@launch
|
||||
|
||||
// PoW validation (if enabled)
|
||||
val pow = PoWPreferenceManager.getCurrentSettings()
|
||||
if (pow.enabled && pow.difficulty > 0) {
|
||||
if (!NostrProofOfWork.validateDifficulty(event, pow.difficulty)) return@launch
|
||||
// PoW validation (if enabled) - apply to chat messages primarily
|
||||
if (event.kind == NostrKind.EPHEMERAL_EVENT) {
|
||||
val pow = PoWPreferenceManager.getCurrentSettings()
|
||||
if (pow.enabled && pow.difficulty > 0) {
|
||||
if (!NostrProofOfWork.validateDifficulty(event, pow.difficulty)) return@launch
|
||||
}
|
||||
}
|
||||
|
||||
// Blocked users check (use injected DataManager which has loaded state)
|
||||
@@ -62,7 +64,12 @@ class GeohashMessageHandler(
|
||||
|
||||
// Update repository (participants, nickname, teleport)
|
||||
// Update repository on a background-safe path; repository will post updates to LiveData
|
||||
repo.updateParticipant(subscribedGeohash, event.pubkey, Date(event.createdAt * 1000L))
|
||||
|
||||
// Update participant count (last seen) on BOTH Presence (20001) and Chat (20000) events
|
||||
if (event.kind == NostrKind.GEOHASH_PRESENCE || event.kind == NostrKind.EPHEMERAL_EVENT) {
|
||||
repo.updateParticipant(subscribedGeohash, event.pubkey, Date(event.createdAt * 1000L))
|
||||
}
|
||||
|
||||
event.tags.find { it.size >= 2 && it[0] == "n" }?.let { repo.cacheNickname(event.pubkey, it[1]) }
|
||||
event.tags.find { it.size >= 2 && it[0] == "t" && it[1] == "teleport" }?.let { repo.markTeleported(event.pubkey) }
|
||||
// Register a geohash DM alias for this participant so MessageRouter can route DMs via Nostr
|
||||
@@ -70,6 +77,9 @@ class GeohashMessageHandler(
|
||||
com.bitchat.android.nostr.GeohashAliasRegistry.put("nostr_${event.pubkey.take(16)}", event.pubkey)
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Stop here for presence events - they don't produce chat messages
|
||||
if (event.kind == NostrKind.GEOHASH_PRESENCE) return@launch
|
||||
|
||||
// Skip our own events for message emission
|
||||
val my = NostrIdentityBridge.deriveIdentity(subscribedGeohash, application)
|
||||
if (my.publicKeyHex.equals(event.pubkey, true)) return@launch
|
||||
|
||||
@@ -43,6 +43,20 @@ class GeohashRepository(
|
||||
}?.key
|
||||
}
|
||||
|
||||
fun findPubkeyByShortId(shortId: String): String? {
|
||||
// First check cached nicknames (fastest)
|
||||
var found = geoNicknames.keys.firstOrNull { it.startsWith(shortId, ignoreCase = true) }
|
||||
if (found != null) return found
|
||||
|
||||
// If not found in nicknames (e.g. anon user), check all known participants across all geohashes
|
||||
for (participants in geohashParticipants.values) {
|
||||
found = participants.keys.firstOrNull { it.startsWith(shortId, ignoreCase = true) }
|
||||
if (found != null) return found
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// peerID alias -> nostr pubkey mapping for geohash DMs and temp aliases
|
||||
private val nostrKeyMapping: MutableMap<String, String> = mutableMapOf()
|
||||
|
||||
|
||||
@@ -2,8 +2,12 @@ package com.bitchat.android.nostr
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.model.NoisePayload
|
||||
import com.bitchat.android.model.NoisePayloadType
|
||||
import com.bitchat.android.model.PrivateMessagePacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.services.SeenMessageStore
|
||||
import com.bitchat.android.ui.ChatState
|
||||
@@ -71,7 +75,7 @@ class NostrDirectMessageHandler(
|
||||
|
||||
if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch
|
||||
|
||||
val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return@launch
|
||||
val noisePayload = NoisePayload.decode(packet.payload) ?: return@launch
|
||||
val messageTimestamp = Date(giftWrap.createdAt * 1000L)
|
||||
val convKey = "nostr_${senderPubkey.take(16)}"
|
||||
repo.putNostrKeyMapping(convKey, senderPubkey)
|
||||
@@ -104,7 +108,7 @@ class NostrDirectMessageHandler(
|
||||
}
|
||||
|
||||
private suspend fun processNoisePayload(
|
||||
payload: com.bitchat.android.model.NoisePayload,
|
||||
payload: NoisePayload,
|
||||
convKey: String,
|
||||
senderNickname: String,
|
||||
timestamp: Date,
|
||||
@@ -112,8 +116,8 @@ class NostrDirectMessageHandler(
|
||||
recipientIdentity: NostrIdentity
|
||||
) {
|
||||
when (payload.type) {
|
||||
com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE -> {
|
||||
val pm = com.bitchat.android.model.PrivateMessagePacket.decode(payload.data) ?: return
|
||||
NoisePayloadType.PRIVATE_MESSAGE -> {
|
||||
val pm = PrivateMessagePacket.decode(payload.data) ?: return
|
||||
val existingMessages = state.getPrivateChatsValue()[convKey] ?: emptyList()
|
||||
if (existingMessages.any { it.id == pm.messageID }) return
|
||||
|
||||
@@ -148,21 +152,21 @@ class NostrDirectMessageHandler(
|
||||
seenStore.markRead(pm.messageID)
|
||||
}
|
||||
}
|
||||
com.bitchat.android.model.NoisePayloadType.DELIVERED -> {
|
||||
NoisePayloadType.DELIVERED -> {
|
||||
val messageId = String(payload.data, Charsets.UTF_8)
|
||||
withContext(Dispatchers.Main) {
|
||||
meshDelegateHandler.didReceiveDeliveryAck(messageId, convKey)
|
||||
}
|
||||
}
|
||||
com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> {
|
||||
NoisePayloadType.READ_RECEIPT -> {
|
||||
val messageId = String(payload.data, Charsets.UTF_8)
|
||||
withContext(Dispatchers.Main) {
|
||||
meshDelegateHandler.didReceiveReadReceipt(messageId, convKey)
|
||||
}
|
||||
}
|
||||
com.bitchat.android.model.NoisePayloadType.FILE_TRANSFER -> {
|
||||
NoisePayloadType.FILE_TRANSFER -> {
|
||||
// Properly handle encrypted file transfer
|
||||
val file = com.bitchat.android.model.BitchatFilePacket.decode(payload.data)
|
||||
val file = BitchatFilePacket.decode(payload.data)
|
||||
if (file != null) {
|
||||
val uniqueMsgId = java.util.UUID.randomUUID().toString().uppercase()
|
||||
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(application, file)
|
||||
@@ -185,6 +189,8 @@ class NostrDirectMessageHandler(
|
||||
Log.w(TAG, "⚠️ Failed to decode Nostr file transfer from $convKey")
|
||||
}
|
||||
}
|
||||
NoisePayloadType.VERIFY_CHALLENGE,
|
||||
NoisePayloadType.VERIFY_RESPONSE -> Unit // Ignore verification payloads in Nostr direct messages
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -215,6 +215,7 @@ object NostrKind {
|
||||
const val SEAL = 13 // NIP-17 sealed event
|
||||
const val GIFT_WRAP = 1059 // NIP-17 gift wrap
|
||||
const val EPHEMERAL_EVENT = 20000 // For geohash channels
|
||||
const val GEOHASH_PRESENCE = 20001 // For geohash presence heartbeat
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -32,11 +32,11 @@ data class NostrFilter(
|
||||
}
|
||||
|
||||
/**
|
||||
* Create filter for geohash-scoped ephemeral events (kind 20000)
|
||||
* Create filter for geohash-scoped ephemeral events (kind 20000 and 20001)
|
||||
*/
|
||||
fun geohashEphemeral(geohash: String, since: Long? = null, limit: Int = 200): NostrFilter {
|
||||
fun geohashEphemeral(geohash: String, since: Long? = null, limit: Int = 1000): NostrFilter {
|
||||
return NostrFilter(
|
||||
kinds = listOf(NostrKind.EPHEMERAL_EVENT),
|
||||
kinds = listOf(NostrKind.EPHEMERAL_EVENT, NostrKind.GEOHASH_PRESENCE),
|
||||
since = since?.let { (it / 1000).toInt() },
|
||||
tagFilters = mapOf("g" to listOf(geohash)),
|
||||
limit = limit
|
||||
|
||||
@@ -117,6 +117,28 @@ object NostrProtocol {
|
||||
|
||||
return@withContext senderIdentity.signEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a geohash-scoped presence event (kind 20001)
|
||||
* Has no content and no nickname, used for participant counting
|
||||
*/
|
||||
suspend fun createGeohashPresenceEvent(
|
||||
geohash: String,
|
||||
senderIdentity: NostrIdentity
|
||||
): NostrEvent = withContext(Dispatchers.Default) {
|
||||
val tags = mutableListOf<List<String>>()
|
||||
tags.add(listOf("g", geohash))
|
||||
|
||||
val event = NostrEvent(
|
||||
pubkey = senderIdentity.publicKeyHex,
|
||||
createdAt = (System.currentTimeMillis() / 1000).toInt(),
|
||||
kind = NostrKind.GEOHASH_PRESENCE,
|
||||
tags = tags,
|
||||
content = ""
|
||||
)
|
||||
|
||||
return@withContext senderIdentity.signEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a geohash-scoped ephemeral public message (kind 20000)
|
||||
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
package com.bitchat.android.onboarding
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.LocationOn
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.R
|
||||
|
||||
/**
|
||||
* Explanation screen shown before requesting background location permission.
|
||||
*/
|
||||
@Composable
|
||||
fun BackgroundLocationPermissionScreen(
|
||||
modifier: Modifier,
|
||||
onContinue: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onSkip: () -> Unit
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Box(modifier = modifier) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 88.dp)
|
||||
.verticalScroll(scrollState),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
HeaderSection(colorScheme)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = colorScheme.surfaceVariant.copy(alpha = 0.25f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.LocationOn,
|
||||
contentDescription = stringResource(R.string.cd_location_services),
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.background_location_required_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.onBackground
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.background_location_explanation),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.8f)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.background_location_settings_tip),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = colorScheme.onBackground.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = colorScheme.surfaceVariant.copy(alpha = 0.25f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Security,
|
||||
contentDescription = stringResource(R.string.cd_privacy_protected),
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.background_location_needs_for),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.onBackground
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.background_location_needs_bullets),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.8f)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.background_location_privacy_note),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium
|
||||
),
|
||||
color = colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth(),
|
||||
color = colorScheme.surface,
|
||||
shadowElevation = 8.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = onContinue,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.grant_background_location),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onRetry,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.check_again),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onSkip,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.battery_optimization_skip),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderSection(colorScheme: ColorScheme) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 32.sp
|
||||
),
|
||||
color = colorScheme.onBackground
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.background_location_required_subtitle),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.bitchat.android.onboarding
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* Preference manager for background location skip choice.
|
||||
*/
|
||||
object BackgroundLocationPreferenceManager {
|
||||
private const val PREFS_NAME = "bitchat_settings"
|
||||
private const val KEY_BACKGROUND_LOCATION_SKIP = "background_location_skipped"
|
||||
|
||||
fun setSkipped(context: Context, skipped: Boolean) {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit().putBoolean(KEY_BACKGROUND_LOCATION_SKIP, skipped).apply()
|
||||
}
|
||||
|
||||
fun isSkipped(context: Context): Boolean {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getBoolean(KEY_BACKGROUND_LOCATION_SKIP, false)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ class OnboardingCoordinator(
|
||||
private val activity: ComponentActivity,
|
||||
private val permissionManager: PermissionManager,
|
||||
private val onOnboardingComplete: () -> Unit,
|
||||
private val onBackgroundLocationRequired: () -> Unit,
|
||||
private val onOnboardingFailed: (String) -> Unit
|
||||
) {
|
||||
|
||||
@@ -27,9 +28,11 @@ class OnboardingCoordinator(
|
||||
}
|
||||
|
||||
private var permissionLauncher: ActivityResultLauncher<Array<String>>? = null
|
||||
private var backgroundLocationLauncher: ActivityResultLauncher<String>? = null
|
||||
|
||||
init {
|
||||
setupPermissionLauncher()
|
||||
setupBackgroundLocationLauncher()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,6 +46,14 @@ class OnboardingCoordinator(
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupBackgroundLocationLauncher() {
|
||||
backgroundLocationLauncher = activity.registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
handleBackgroundLocationResult(granted)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the onboarding process
|
||||
*/
|
||||
@@ -50,9 +61,14 @@ class OnboardingCoordinator(
|
||||
Log.d(TAG, "Starting onboarding process")
|
||||
permissionManager.logPermissionStatus()
|
||||
|
||||
if (permissionManager.areAllPermissionsGranted()) {
|
||||
Log.d(TAG, "All permissions already granted, completing onboarding")
|
||||
completeOnboarding()
|
||||
if (permissionManager.areRequiredPermissionsGranted()) {
|
||||
if (shouldRequestBackgroundLocation()) {
|
||||
Log.d(TAG, "Foreground permissions granted; background location recommended")
|
||||
onBackgroundLocationRequired()
|
||||
} else {
|
||||
Log.d(TAG, "Required permissions already granted, completing onboarding")
|
||||
completeOnboarding()
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Missing permissions, need to start explanation flow")
|
||||
// The explanation screen will be shown by the calling activity
|
||||
@@ -76,7 +92,11 @@ class OnboardingCoordinator(
|
||||
val missingPermissions = (missingRequired + optionalToRequest).distinct()
|
||||
|
||||
if (missingPermissions.isEmpty()) {
|
||||
completeOnboarding()
|
||||
if (shouldRequestBackgroundLocation()) {
|
||||
onBackgroundLocationRequired()
|
||||
} else {
|
||||
completeOnboarding()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -98,13 +118,19 @@ class OnboardingCoordinator(
|
||||
val criticalGranted = criticalPermissions.all { permissions[it] == true }
|
||||
|
||||
when {
|
||||
allGranted -> {
|
||||
Log.d(TAG, "All permissions granted successfully")
|
||||
completeOnboarding()
|
||||
}
|
||||
criticalGranted -> {
|
||||
Log.d(TAG, "Critical permissions granted, can proceed with limited functionality")
|
||||
showPartialPermissionWarning(permissions)
|
||||
if (shouldRequestBackgroundLocation()) {
|
||||
Log.d(TAG, "Foreground permissions granted; requesting background location next")
|
||||
onBackgroundLocationRequired()
|
||||
return
|
||||
}
|
||||
if (allGranted) {
|
||||
Log.d(TAG, "All permissions granted successfully")
|
||||
completeOnboarding()
|
||||
} else {
|
||||
Log.d(TAG, "Critical permissions granted, can proceed with limited functionality")
|
||||
showPartialPermissionWarning(permissions)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Log.d(TAG, "Critical permissions denied")
|
||||
@@ -113,15 +139,50 @@ class OnboardingCoordinator(
|
||||
}
|
||||
}
|
||||
|
||||
fun requestBackgroundLocation() {
|
||||
val permission = permissionManager.getBackgroundLocationPermission()
|
||||
if (permission == null) {
|
||||
completeOnboarding()
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "Requesting background location permission")
|
||||
backgroundLocationLauncher?.launch(permission)
|
||||
}
|
||||
|
||||
private fun handleBackgroundLocationResult(granted: Boolean) {
|
||||
if (granted) {
|
||||
Log.d(TAG, "Background location permission granted")
|
||||
} else {
|
||||
Log.w(TAG, "Background location permission denied; continuing without it")
|
||||
}
|
||||
completeOnboarding()
|
||||
}
|
||||
|
||||
fun skipBackgroundLocation() {
|
||||
Log.d(TAG, "User skipped background location permission")
|
||||
BackgroundLocationPreferenceManager.setSkipped(activity, true)
|
||||
completeOnboarding()
|
||||
}
|
||||
|
||||
fun checkBackgroundLocationAndProceed() {
|
||||
if (!shouldRequestBackgroundLocation()) {
|
||||
completeOnboarding()
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldRequestBackgroundLocation(): Boolean {
|
||||
return permissionManager.needsBackgroundLocationPermission() &&
|
||||
!permissionManager.isBackgroundLocationGranted() &&
|
||||
!BackgroundLocationPreferenceManager.isSkipped(activity)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of critical permissions that are absolutely required
|
||||
*/
|
||||
private fun getCriticalPermissions(): List<String> {
|
||||
// For bitchat, Bluetooth and location permissions are critical
|
||||
// Notifications are nice-to-have but not critical
|
||||
return permissionManager.getRequiredPermissions().filter { permission ->
|
||||
!permission.contains("POST_NOTIFICATIONS")
|
||||
}
|
||||
// Notifications are nice-to-have but not critical and are not included in getRequiredPermissions()
|
||||
return permissionManager.getRequiredPermissions()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,6 +269,7 @@ class OnboardingCoordinator(
|
||||
private fun getPermissionDisplayName(permission: String): String {
|
||||
return when {
|
||||
permission.contains("BLUETOOTH") -> "Bluetooth/Nearby Devices"
|
||||
permission.contains("BACKGROUND") -> "Background Location"
|
||||
permission.contains("LOCATION") -> "Location (for Bluetooth scanning)"
|
||||
permission.contains("NEARBY_WIFI") -> "Nearby Wi‑Fi Devices (for Wi‑Fi Aware)"
|
||||
permission.contains("NOTIFICATION") -> "Notifications"
|
||||
|
||||
@@ -6,8 +6,9 @@ enum class OnboardingState {
|
||||
LOCATION_CHECK,
|
||||
BATTERY_OPTIMIZATION_CHECK,
|
||||
PERMISSION_EXPLANATION,
|
||||
BACKGROUND_LOCATION_EXPLANATION,
|
||||
PERMISSION_REQUESTING,
|
||||
INITIALIZING,
|
||||
COMPLETE,
|
||||
ERROR
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,10 @@ import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material.icons.filled.Wifi
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -210,29 +208,6 @@ private fun PermissionCategoryCard(
|
||||
color = colorScheme.onBackground.copy(alpha = 0.8f)
|
||||
)
|
||||
|
||||
if (category.type == PermissionType.PRECISE_LOCATION) {
|
||||
// Extra emphasis for location permission
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = stringResource(R.string.cd_warning),
|
||||
tint = Color(0xFFFF9800),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.location_tracking_warning),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Color(0xFFFF9800)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,6 +216,7 @@ private fun getPermissionIcon(permissionType: PermissionType): ImageVector {
|
||||
return when (permissionType) {
|
||||
PermissionType.NEARBY_DEVICES -> Icons.Filled.Bluetooth
|
||||
PermissionType.PRECISE_LOCATION -> Icons.Filled.LocationOn
|
||||
PermissionType.BACKGROUND_LOCATION -> Icons.Filled.LocationOn
|
||||
PermissionType.MICROPHONE -> Icons.Filled.Mic
|
||||
PermissionType.NOTIFICATIONS -> Icons.Filled.Notifications
|
||||
PermissionType.WIFI_AWARE -> Icons.Filled.Wifi
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.bitchat.android.R
|
||||
|
||||
/**
|
||||
* Centralized permission management for bitchat app
|
||||
@@ -22,6 +23,22 @@ class PermissionManager(private val context: Context) {
|
||||
|
||||
private val sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
private fun shouldRequireWifiAwarePermission(): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return false
|
||||
val enabled = try {
|
||||
com.bitchat.android.ui.debug.DebugPreferenceManager.getWifiAwareEnabled(true)
|
||||
} catch (_: Exception) {
|
||||
true
|
||||
}
|
||||
if (!enabled) return false
|
||||
|
||||
return try {
|
||||
com.bitchat.android.wifiaware.WifiAwareSupport.isSupported(context)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is the first time the user is launching the app
|
||||
*/
|
||||
@@ -40,7 +57,8 @@ class PermissionManager(private val context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all permissions required by the app
|
||||
* Get required permissions that can be requested together.
|
||||
* Background location is handled separately to ensure correct request order.
|
||||
* Note: Notification permission is optional and not included here,
|
||||
* so the app works without notification access.
|
||||
*/
|
||||
@@ -68,7 +86,7 @@ class PermissionManager(private val context: Context) {
|
||||
))
|
||||
|
||||
// Wi‑Fi Aware: Android 13+ requires NEARBY_WIFI_DEVICES runtime permission
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (shouldRequireWifiAwarePermission()) {
|
||||
permissions.add(Manifest.permission.NEARBY_WIFI_DEVICES)
|
||||
}
|
||||
|
||||
@@ -77,6 +95,27 @@ class PermissionManager(private val context: Context) {
|
||||
return permissions
|
||||
}
|
||||
|
||||
/**
|
||||
* Background location permission is required on Android 10+ for background BLE scanning.
|
||||
* Must be requested after foreground location permissions are granted.
|
||||
*/
|
||||
fun needsBackgroundLocationPermission(): Boolean {
|
||||
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
||||
}
|
||||
|
||||
fun getBackgroundLocationPermission(): String? {
|
||||
return if (needsBackgroundLocationPermission()) {
|
||||
Manifest.permission.ACCESS_BACKGROUND_LOCATION
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun isBackgroundLocationGranted(): Boolean {
|
||||
val permission = getBackgroundLocationPermission() ?: return true
|
||||
return isPermissionGranted(permission)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get optional permissions that improve the experience but aren't required.
|
||||
* Currently includes POST_NOTIFICATIONS on Android 13+.
|
||||
@@ -98,9 +137,13 @@ class PermissionManager(private val context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all required permissions are granted
|
||||
* Check if all required permissions are granted (background location is optional).
|
||||
*/
|
||||
fun areAllPermissionsGranted(): Boolean {
|
||||
return areRequiredPermissionsGranted()
|
||||
}
|
||||
|
||||
fun areRequiredPermissionsGranted(): Boolean {
|
||||
return getRequiredPermissions().all { isPermissionGranted(it) }
|
||||
}
|
||||
|
||||
@@ -136,6 +179,11 @@ class PermissionManager(private val context: Context) {
|
||||
return getRequiredPermissions().filter { !isPermissionGranted(it) }
|
||||
}
|
||||
|
||||
fun getMissingBackgroundLocationPermission(): List<String> {
|
||||
val permission = getBackgroundLocationPermission() ?: return emptyList()
|
||||
return if (isPermissionGranted(permission)) emptyList() else listOf(permission)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get categorized permission information for display
|
||||
*/
|
||||
@@ -183,7 +231,7 @@ class PermissionManager(private val context: Context) {
|
||||
)
|
||||
|
||||
// Wi‑Fi Aware category (Android 13+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (shouldRequireWifiAwarePermission()) {
|
||||
val wifiAwarePermissions = listOf(Manifest.permission.NEARBY_WIFI_DEVICES)
|
||||
categories.add(
|
||||
PermissionCategory(
|
||||
@@ -196,6 +244,19 @@ class PermissionManager(private val context: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
if (needsBackgroundLocationPermission()) {
|
||||
val backgroundPermission = listOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
|
||||
categories.add(
|
||||
PermissionCategory(
|
||||
type = PermissionType.BACKGROUND_LOCATION,
|
||||
description = context.getString(R.string.perm_background_location_desc),
|
||||
permissions = backgroundPermission,
|
||||
isGranted = backgroundPermission.all { isPermissionGranted(it) },
|
||||
systemDescription = context.getString(R.string.perm_background_location_system)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Notifications category (if applicable)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
categories.add(
|
||||
@@ -235,7 +296,7 @@ class PermissionManager(private val context: Context) {
|
||||
appendLine("Permission Diagnostics:")
|
||||
appendLine("Android SDK: ${Build.VERSION.SDK_INT}")
|
||||
appendLine("First time launch: ${isFirstTimeLaunch()}")
|
||||
appendLine("All permissions granted: ${areAllPermissionsGranted()}")
|
||||
appendLine("Required permissions granted: ${areAllPermissionsGranted()}")
|
||||
appendLine()
|
||||
|
||||
getCategorizedPermissions().forEach { category ->
|
||||
@@ -247,7 +308,7 @@ class PermissionManager(private val context: Context) {
|
||||
appendLine()
|
||||
}
|
||||
|
||||
val missing = getMissingPermissions()
|
||||
val missing = getMissingPermissions() + getMissingBackgroundLocationPermission()
|
||||
if (missing.isNotEmpty()) {
|
||||
appendLine("Missing permissions:")
|
||||
missing.forEach { permission ->
|
||||
@@ -279,6 +340,7 @@ data class PermissionCategory(
|
||||
enum class PermissionType(val nameValue: String) {
|
||||
NEARBY_DEVICES("Nearby Devices"),
|
||||
PRECISE_LOCATION("Precise Location"),
|
||||
BACKGROUND_LOCATION("Background Location"),
|
||||
MICROPHONE("Microphone"),
|
||||
NOTIFICATIONS("Notifications"),
|
||||
WIFI_AWARE("Wi‑Fi Aware"),
|
||||
|
||||
@@ -36,7 +36,7 @@ object SpecialRecipients {
|
||||
/**
|
||||
* Binary packet format - 100% backward compatible with iOS version
|
||||
*
|
||||
* Header (13 bytes for v1, 15 bytes for v2):
|
||||
* Header (14 bytes for v1, 16 bytes for v2):
|
||||
* - Version: 1 byte
|
||||
* - Type: 1 byte
|
||||
* - TTL: 1 byte
|
||||
@@ -59,7 +59,8 @@ data class BitchatPacket(
|
||||
val timestamp: ULong,
|
||||
val payload: ByteArray,
|
||||
var signature: ByteArray? = null, // Changed from val to var for packet signing
|
||||
var ttl: UByte
|
||||
var ttl: UByte,
|
||||
var route: List<ByteArray>? = null // Optional source route: ordered list of peerIDs (8 bytes each), not including sender and final recipient
|
||||
) : Parcelable {
|
||||
|
||||
constructor(
|
||||
@@ -97,6 +98,7 @@ data class BitchatPacket(
|
||||
timestamp = timestamp,
|
||||
payload = payload,
|
||||
signature = null, // Remove signature for signing
|
||||
route = route,
|
||||
ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // Use fixed TTL=0 for signing to ensure relay compatibility
|
||||
)
|
||||
return BinaryProtocol.encode(unsignedPacket)
|
||||
@@ -149,6 +151,11 @@ data class BitchatPacket(
|
||||
if (!signature.contentEquals(other.signature)) return false
|
||||
} else if (other.signature != null) return false
|
||||
if (ttl != other.ttl) return false
|
||||
if (route != null || other.route != null) {
|
||||
val a = route?.map { it.toList() } ?: emptyList()
|
||||
val b = other.route?.map { it.toList() } ?: emptyList()
|
||||
if (a != b) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -162,6 +169,7 @@ data class BitchatPacket(
|
||||
result = 31 * result + payload.contentHashCode()
|
||||
result = 31 * result + (signature?.contentHashCode() ?: 0)
|
||||
result = 31 * result + ttl.hashCode()
|
||||
result = 31 * result + (route?.fold(1) { acc, bytes -> 31 * acc + bytes.contentHashCode() } ?: 0)
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -170,8 +178,8 @@ data class BitchatPacket(
|
||||
* Binary Protocol implementation - supports v1 and v2, backward compatible
|
||||
*/
|
||||
object BinaryProtocol {
|
||||
private const val HEADER_SIZE_V1 = 13
|
||||
private const val HEADER_SIZE_V2 = 15
|
||||
private const val HEADER_SIZE_V1 = 14
|
||||
private const val HEADER_SIZE_V2 = 16
|
||||
private const val SENDER_ID_SIZE = 8
|
||||
private const val RECIPIENT_ID_SIZE = 8
|
||||
private const val SIGNATURE_SIZE = 64
|
||||
@@ -180,6 +188,7 @@ object BinaryProtocol {
|
||||
const val HAS_RECIPIENT: UByte = 0x01u
|
||||
const val HAS_SIGNATURE: UByte = 0x02u
|
||||
const val IS_COMPRESSED: UByte = 0x04u
|
||||
const val HAS_ROUTE: UByte = 0x08u
|
||||
}
|
||||
|
||||
private fun getHeaderSize(version: UByte): Int {
|
||||
@@ -193,12 +202,12 @@ object BinaryProtocol {
|
||||
try {
|
||||
// Try to compress payload if beneficial
|
||||
var payload = packet.payload
|
||||
var originalPayloadSize: UShort? = null
|
||||
var originalPayloadSize: Int? = null
|
||||
var isCompressed = false
|
||||
|
||||
if (CompressionUtil.shouldCompress(payload)) {
|
||||
CompressionUtil.compress(payload)?.let { compressedPayload ->
|
||||
originalPayloadSize = payload.size.toUShort()
|
||||
originalPayloadSize = payload.size
|
||||
payload = compressedPayload
|
||||
isCompressed = true
|
||||
}
|
||||
@@ -208,8 +217,12 @@ object BinaryProtocol {
|
||||
val headerSize = getHeaderSize(packet.version)
|
||||
val recipientBytes = if (packet.recipientID != null) RECIPIENT_ID_SIZE else 0
|
||||
val signatureBytes = if (packet.signature != null) SIGNATURE_SIZE else 0
|
||||
val payloadBytes = payload.size + if (isCompressed) 2 else 0
|
||||
val capacity = headerSize + SENDER_ID_SIZE + recipientBytes + payloadBytes + signatureBytes + 16 // small slack
|
||||
val sizeFieldBytes = if (isCompressed) (if (packet.version >= 2u.toUByte()) 4 else 2) else 0
|
||||
val payloadBytes = payload.size + sizeFieldBytes
|
||||
val routeBytes = if (!packet.route.isNullOrEmpty() && packet.version >= 2u.toUByte()) {
|
||||
1 + (packet.route!!.size.coerceAtMost(255) * SENDER_ID_SIZE)
|
||||
} else 0
|
||||
val capacity = headerSize + SENDER_ID_SIZE + recipientBytes + payloadBytes + signatureBytes + routeBytes + 16 // small slack
|
||||
val buffer = ByteBuffer.allocate(capacity.coerceAtLeast(512)).apply { order(ByteOrder.BIG_ENDIAN) }
|
||||
|
||||
// Header
|
||||
@@ -231,13 +244,21 @@ object BinaryProtocol {
|
||||
if (isCompressed) {
|
||||
flags = flags or Flags.IS_COMPRESSED
|
||||
}
|
||||
// HAS_ROUTE is only supported for v2+ packets
|
||||
if (!packet.route.isNullOrEmpty() && packet.version >= 2u.toUByte()) {
|
||||
flags = flags or Flags.HAS_ROUTE
|
||||
}
|
||||
buffer.put(flags.toByte())
|
||||
|
||||
// Payload length (2 or 4 bytes, big-endian) - includes original size if compressed
|
||||
val payloadDataSize = payload.size + if (isCompressed) 2 else 0
|
||||
val payloadDataSize = payload.size + sizeFieldBytes
|
||||
if (packet.version >= 2u.toUByte()) {
|
||||
buffer.putInt(payloadDataSize) // 4 bytes for v2+
|
||||
} else {
|
||||
if (payloadDataSize > 0xFFFF || (originalPayloadSize ?: 0) > 0xFFFF) {
|
||||
Log.w("BinaryProtocol", "Cannot encode oversized v1 packet payload: $payloadDataSize bytes")
|
||||
return null
|
||||
}
|
||||
buffer.putShort(payloadDataSize.toShort()) // 2 bytes for v1
|
||||
}
|
||||
|
||||
@@ -256,12 +277,26 @@ object BinaryProtocol {
|
||||
buffer.put(ByteArray(RECIPIENT_ID_SIZE - recipientBytes.size))
|
||||
}
|
||||
}
|
||||
|
||||
// Route (optional, v2+ only): 1 byte count + N*8 bytes
|
||||
if (packet.version >= 2u.toUByte() && !packet.route.isNullOrEmpty()) {
|
||||
packet.route?.let { routeList ->
|
||||
val cleaned = routeList.map { bytes -> bytes.take(SENDER_ID_SIZE).toByteArray().let { if (it.size < SENDER_ID_SIZE) it + ByteArray(SENDER_ID_SIZE - it.size) else it } }
|
||||
val count = cleaned.size.coerceAtMost(255)
|
||||
buffer.put(count.toByte())
|
||||
cleaned.take(count).forEach { hop -> buffer.put(hop) }
|
||||
}
|
||||
}
|
||||
|
||||
// Payload (with original size prepended if compressed)
|
||||
if (isCompressed) {
|
||||
val originalSize = originalPayloadSize
|
||||
if (originalSize != null) {
|
||||
buffer.putShort(originalSize.toShort())
|
||||
if (packet.version >= 2u.toUByte()) {
|
||||
buffer.putInt(originalSize.toInt())
|
||||
} else {
|
||||
buffer.putShort(originalSize.toShort())
|
||||
}
|
||||
}
|
||||
}
|
||||
buffer.put(payload)
|
||||
@@ -324,6 +359,8 @@ object BinaryProtocol {
|
||||
val hasRecipient = (flags and Flags.HAS_RECIPIENT) != 0u.toUByte()
|
||||
val hasSignature = (flags and Flags.HAS_SIGNATURE) != 0u.toUByte()
|
||||
val isCompressed = (flags and Flags.IS_COMPRESSED) != 0u.toUByte()
|
||||
// HAS_ROUTE is only valid for v2+ packets; ignore the flag for v1
|
||||
val hasRoute = (version >= 2u.toUByte()) && (flags and Flags.HAS_ROUTE) != 0u.toUByte()
|
||||
|
||||
// Payload length - version-dependent (2 or 4 bytes)
|
||||
val payloadLength = if (version >= 2u.toUByte()) {
|
||||
@@ -332,9 +369,29 @@ object BinaryProtocol {
|
||||
buffer.getShort().toUShort().toUInt() // 2 bytes for v1, convert to UInt
|
||||
}
|
||||
|
||||
// Calculate expected total size
|
||||
if (payloadLength > com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH.toUInt()) {
|
||||
Log.w("BinaryProtocol", "Payload length ${payloadLength} exceeds maximum allowed (${com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH})")
|
||||
return null
|
||||
}
|
||||
|
||||
var expectedSize = headerSize + SENDER_ID_SIZE + payloadLength.toInt()
|
||||
if (hasRecipient) expectedSize += RECIPIENT_ID_SIZE
|
||||
var routeCount = 0
|
||||
if (hasRoute) {
|
||||
// Peek count (1 byte) without consuming buffer for now
|
||||
// The buffer is currently positioned at the start of SenderID (after fixed header)
|
||||
// We must skip SenderID and RecipientID (if present) to find the route count
|
||||
val currentPos = buffer.position()
|
||||
var routeOffset = currentPos + SENDER_ID_SIZE
|
||||
if (hasRecipient) {
|
||||
routeOffset += RECIPIENT_ID_SIZE
|
||||
}
|
||||
|
||||
if (raw.size >= routeOffset + 1) {
|
||||
routeCount = raw[routeOffset].toUByte().toInt()
|
||||
}
|
||||
expectedSize += 1 + (routeCount * SENDER_ID_SIZE)
|
||||
}
|
||||
if (hasSignature) expectedSize += SIGNATURE_SIZE
|
||||
|
||||
if (raw.size < expectedSize) return null
|
||||
@@ -350,15 +407,46 @@ object BinaryProtocol {
|
||||
recipientBytes
|
||||
} else null
|
||||
|
||||
// Route (optional)
|
||||
val route: List<ByteArray>? = if (hasRoute) {
|
||||
val count = buffer.get().toUByte().toInt()
|
||||
if (count == 0) {
|
||||
null // Treat empty route list as null to enforce canonical representation
|
||||
} else {
|
||||
val hops = mutableListOf<ByteArray>()
|
||||
repeat(count) {
|
||||
val hop = ByteArray(SENDER_ID_SIZE)
|
||||
buffer.get(hop)
|
||||
hops.add(hop)
|
||||
}
|
||||
hops
|
||||
}
|
||||
} else null
|
||||
|
||||
// Payload
|
||||
val payload = if (isCompressed) {
|
||||
// First 2 bytes are original size
|
||||
if (payloadLength.toInt() < 2) return null
|
||||
val originalSize = buffer.getShort().toInt()
|
||||
val lengthFieldBytes = if (version >= 2u.toUByte()) 4 else 2
|
||||
if (payloadLength.toInt() < lengthFieldBytes) return null
|
||||
|
||||
val originalSize = if (version >= 2u.toUByte()) {
|
||||
buffer.getInt()
|
||||
} else {
|
||||
buffer.getShort().toUShort().toInt()
|
||||
}
|
||||
|
||||
// Compressed payload
|
||||
val compressedPayload = ByteArray(payloadLength.toInt() - 2)
|
||||
val compressedSize = payloadLength.toInt() - lengthFieldBytes
|
||||
val compressedPayload = ByteArray(compressedSize)
|
||||
buffer.get(compressedPayload)
|
||||
|
||||
// Security check: Compression bomb protection
|
||||
if (compressedSize > 0) {
|
||||
val ratio = originalSize.toDouble() / compressedSize.toDouble()
|
||||
if (ratio > 50_000.0) {
|
||||
Log.w("BinaryProtocol", "🚫 Suspicious compression ratio: ${ratio}:1")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Decompress
|
||||
CompressionUtil.decompress(compressedPayload, originalSize) ?: return null
|
||||
@@ -383,10 +471,11 @@ object BinaryProtocol {
|
||||
timestamp = timestamp,
|
||||
payload = payload,
|
||||
signature = signature,
|
||||
ttl = ttl
|
||||
ttl = ttl,
|
||||
route = route
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
} catch (e: Throwable) {
|
||||
Log.e("BinaryProtocol", "Error decoding packet: ${e.message}")
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.bitchat.android.service
|
||||
import android.app.Application
|
||||
import android.os.Process
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.net.ArtiTorManager
|
||||
import com.bitchat.android.net.TorMode
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -39,7 +39,7 @@ object AppShutdownCoordinator {
|
||||
|
||||
fun requestFullShutdownAndKill(
|
||||
app: Application,
|
||||
mesh: BluetoothMeshService?,
|
||||
mesh: MeshService?,
|
||||
notificationManager: NotificationManagerCompat,
|
||||
stopForeground: () -> Unit,
|
||||
stopService: () -> Unit
|
||||
|
||||
@@ -7,8 +7,10 @@ import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.bitchat.android.MainActivity
|
||||
@@ -111,7 +113,10 @@ class MeshForegroundService : Service() {
|
||||
|
||||
private lateinit var notificationManager: NotificationManagerCompat
|
||||
private var updateJob: Job? = null
|
||||
private var meshService: BluetoothMeshService? = null
|
||||
private val meshService: BluetoothMeshService?
|
||||
get() = MeshServiceHolder.meshService
|
||||
private val unifiedMeshService: com.bitchat.android.mesh.MeshService?
|
||||
get() = MeshServiceHolder.unifiedMeshService
|
||||
private val serviceJob = Job()
|
||||
private val scope = CoroutineScope(Dispatchers.Default + serviceJob)
|
||||
private var isInForeground: Boolean = false
|
||||
@@ -122,15 +127,16 @@ class MeshForegroundService : Service() {
|
||||
notificationManager = NotificationManagerCompat.from(this)
|
||||
createChannel()
|
||||
|
||||
// Adopt or create the mesh service
|
||||
// Ensure mesh service exists in holder (create if needed)
|
||||
val existing = MeshServiceHolder.meshService
|
||||
meshService = existing ?: MeshServiceHolder.getOrCreate(applicationContext)
|
||||
if (existing != null) {
|
||||
android.util.Log.d("MeshForegroundService", "Adopted existing BluetoothMeshService from holder")
|
||||
Log.d("MeshForegroundService", "Using existing BluetoothMeshService from holder")
|
||||
} else {
|
||||
android.util.Log.i("MeshForegroundService", "Created/adopted new BluetoothMeshService via holder")
|
||||
val created = MeshServiceHolder.getOrCreate(applicationContext)
|
||||
Log.i("MeshForegroundService", "Created new BluetoothMeshService via holder")
|
||||
MeshServiceHolder.attach(created)
|
||||
}
|
||||
MeshServiceHolder.attach(meshService!!)
|
||||
MeshServiceHolder.getUnifiedOrCreate(applicationContext)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
@@ -144,7 +150,7 @@ class MeshForegroundService : Service() {
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> {
|
||||
// Stop FGS and mesh cleanly
|
||||
try { meshService?.stopServices() } catch (_: Exception) { }
|
||||
try { unifiedMeshService?.stopServices() ?: meshService?.stopServices() } catch (_: Exception) { }
|
||||
try { MeshServiceHolder.clear() } catch (_: Exception) { }
|
||||
try { stopForeground(true) } catch (_: Exception) { }
|
||||
notificationManager.cancel(NOTIFICATION_ID)
|
||||
@@ -162,7 +168,7 @@ class MeshForegroundService : Service() {
|
||||
// Fully stop all background activity, stop Tor (without changing setting), then kill the app
|
||||
AppShutdownCoordinator.requestFullShutdownAndKill(
|
||||
app = application,
|
||||
mesh = meshService,
|
||||
mesh = unifiedMeshService,
|
||||
notificationManager = notificationManager,
|
||||
stopForeground = {
|
||||
try { stopForeground(true) } catch (_: Exception) { }
|
||||
@@ -175,8 +181,8 @@ class MeshForegroundService : Service() {
|
||||
ACTION_UPDATE_NOTIFICATION -> {
|
||||
// If we became eligible and are not in foreground yet, promote once
|
||||
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
|
||||
val n = buildNotification(meshService?.getActivePeerCount() ?: 0)
|
||||
startForeground(NOTIFICATION_ID, n)
|
||||
val n = buildNotification(getUnifiedActivePeerCount())
|
||||
startForegroundCompat(n)
|
||||
isInForeground = true
|
||||
} else {
|
||||
updateNotification(force = true)
|
||||
@@ -190,8 +196,8 @@ class MeshForegroundService : Service() {
|
||||
|
||||
// Promote exactly once when eligible, otherwise stay background (or stop)
|
||||
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
|
||||
val notification = buildNotification(meshService?.getActivePeerCount() ?: 0)
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
val notification = buildNotification(getUnifiedActivePeerCount())
|
||||
startForegroundCompat(notification)
|
||||
isInForeground = true
|
||||
}
|
||||
|
||||
@@ -223,10 +229,26 @@ class MeshForegroundService : Service() {
|
||||
|
||||
private fun ensureMeshStarted() {
|
||||
if (isShuttingDown) return
|
||||
try {
|
||||
com.bitchat.android.wifiaware.WifiAwareController.startIfPossible()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("MeshForegroundService", "Failed to ensure Wi-Fi Aware transport: ${e.message}")
|
||||
}
|
||||
|
||||
val bleEnabled = try {
|
||||
com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true)
|
||||
} catch (_: Exception) {
|
||||
true
|
||||
}
|
||||
if (!bleEnabled) {
|
||||
try { meshService?.setBleTransportEnabled(false) } catch (_: Exception) { }
|
||||
return
|
||||
}
|
||||
if (!hasBluetoothPermissions()) return
|
||||
try {
|
||||
android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started")
|
||||
meshService?.startServices()
|
||||
val service = MeshServiceHolder.getOrCreate(applicationContext)
|
||||
service.startServices()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("MeshForegroundService", "Failed to start mesh service: ${e.message}")
|
||||
}
|
||||
@@ -237,7 +259,7 @@ class MeshForegroundService : Service() {
|
||||
notificationManager.cancel(NOTIFICATION_ID)
|
||||
return
|
||||
}
|
||||
val count = meshService?.getActivePeerCount() ?: 0
|
||||
val count = getUnifiedActivePeerCount()
|
||||
val notification = buildNotification(count)
|
||||
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) {
|
||||
notificationManager.notify(NOTIFICATION_ID, notification)
|
||||
@@ -257,6 +279,14 @@ class MeshForegroundService : Service() {
|
||||
return hasBluetoothPermissions() && hasNotificationPermission()
|
||||
}
|
||||
|
||||
private fun getUnifiedActivePeerCount(): Int {
|
||||
return try {
|
||||
unifiedMeshService?.getActivePeerCount() ?: meshService?.getActivePeerCount() ?: 0
|
||||
} catch (_: Exception) {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasBluetoothPermissions(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_ADVERTISE) == android.content.pm.PackageManager.PERMISSION_GRANTED &&
|
||||
@@ -276,7 +306,7 @@ class MeshForegroundService : Service() {
|
||||
} else true
|
||||
}
|
||||
|
||||
private fun buildNotification(activeUsers: Int): Notification {
|
||||
private fun buildNotification(activePeers: Int): Notification {
|
||||
val openIntent = Intent(this, MainActivity::class.java)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this, 0, openIntent,
|
||||
@@ -291,7 +321,7 @@ class MeshForegroundService : Service() {
|
||||
)
|
||||
|
||||
val title = getString(R.string.app_name)
|
||||
val content = getString(R.string.mesh_service_notification_content, activeUsers)
|
||||
val content = getString(R.string.mesh_service_notification_content, activePeers)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
@@ -326,6 +356,35 @@ class MeshForegroundService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasLocationPermission(): Boolean {
|
||||
val fine = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
val coarse = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_COARSE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
return fine || coarse
|
||||
}
|
||||
|
||||
private fun startForegroundCompat(notification: Notification) {
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
val type = if (hasLocationPermission()) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
|
||||
} else {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
|
||||
}
|
||||
try {
|
||||
startForeground(NOTIFICATION_ID, notification, type)
|
||||
} catch (e: SecurityException) {
|
||||
// Fallback for cases where "While In Use" permission exists but background start is restricted
|
||||
if (type and ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION != 0) {
|
||||
android.util.Log.w("MeshForegroundService", "Failed to start with LOCATION type, falling back to CONNECTED_DEVICE: ${e.message}")
|
||||
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
updateJob?.cancel()
|
||||
updateJob = null
|
||||
|
||||
@@ -2,6 +2,10 @@ package com.bitchat.android.service
|
||||
|
||||
import android.content.Context
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.mesh.UnifiedMeshService
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.sync.GossipSyncManager
|
||||
|
||||
/**
|
||||
* Process-wide holder to share a single BluetoothMeshService instance
|
||||
@@ -10,15 +14,68 @@ import com.bitchat.android.mesh.BluetoothMeshService
|
||||
object MeshServiceHolder {
|
||||
private const val TAG = "MeshServiceHolder"
|
||||
@Volatile
|
||||
var sharedGossipSyncManager: com.bitchat.android.sync.GossipSyncManager? = null
|
||||
var sharedGossipSyncManager: GossipSyncManager? = null
|
||||
private set
|
||||
|
||||
fun setGossipManager(mgr: com.bitchat.android.sync.GossipSyncManager) { sharedGossipSyncManager = mgr }
|
||||
private val activeGossipOwners = mutableSetOf<String>()
|
||||
|
||||
@Synchronized
|
||||
fun setGossipManager(
|
||||
mgr: GossipSyncManager,
|
||||
signer: (BitchatPacket) -> BitchatPacket
|
||||
) {
|
||||
val previous = sharedGossipSyncManager
|
||||
if (previous !== mgr) {
|
||||
try { previous?.stop() } catch (_: Exception) { }
|
||||
}
|
||||
sharedGossipSyncManager = mgr
|
||||
mgr.delegate = TransportGossipDelegate(signer)
|
||||
if (activeGossipOwners.isNotEmpty()) {
|
||||
mgr.start()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun startSharedGossip(owner: String) {
|
||||
val wasIdle = activeGossipOwners.isEmpty()
|
||||
activeGossipOwners.add(owner)
|
||||
if (wasIdle) {
|
||||
sharedGossipSyncManager?.start()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stopSharedGossip(owner: String) {
|
||||
activeGossipOwners.remove(owner)
|
||||
if (activeGossipOwners.isEmpty()) {
|
||||
sharedGossipSyncManager?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private class TransportGossipDelegate(
|
||||
private val signer: (BitchatPacket) -> BitchatPacket
|
||||
) : GossipSyncManager.Delegate {
|
||||
override fun sendPacket(packet: BitchatPacket) {
|
||||
TransportBridgeService.broadcastFromLocal(RoutedPacket(packet))
|
||||
}
|
||||
|
||||
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
|
||||
TransportBridgeService.sendToPeerFromLocal(peerID, packet)
|
||||
}
|
||||
|
||||
override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket {
|
||||
return signer(packet)
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var meshService: BluetoothMeshService? = null
|
||||
private set
|
||||
|
||||
@Volatile
|
||||
var unifiedMeshService: UnifiedMeshService? = null
|
||||
private set
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreate(context: Context): BluetoothMeshService {
|
||||
val existing = meshService
|
||||
@@ -37,18 +94,35 @@ object MeshServiceHolder {
|
||||
val created = BluetoothMeshService(context.applicationContext)
|
||||
android.util.Log.i(TAG, "Created new BluetoothMeshService (replacement)")
|
||||
meshService = created
|
||||
unifiedMeshService = null
|
||||
created
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "Error checking service reusability; creating new instance: ${e.message}")
|
||||
val created = BluetoothMeshService(context.applicationContext)
|
||||
meshService = created
|
||||
unifiedMeshService = null
|
||||
created
|
||||
}
|
||||
}
|
||||
val created = BluetoothMeshService(context.applicationContext)
|
||||
android.util.Log.i(TAG, "Created new BluetoothMeshService (no existing instance)")
|
||||
meshService = created
|
||||
unifiedMeshService = null
|
||||
return created
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getUnifiedOrCreate(context: Context): UnifiedMeshService {
|
||||
val bluetooth = getOrCreate(context)
|
||||
val existing = unifiedMeshService
|
||||
if (existing != null) {
|
||||
existing.refreshDelegates()
|
||||
return existing
|
||||
}
|
||||
val created = UnifiedMeshService(context.applicationContext, bluetooth)
|
||||
unifiedMeshService = created
|
||||
android.util.Log.i(TAG, "Created new UnifiedMeshService")
|
||||
return created
|
||||
}
|
||||
|
||||
@@ -56,11 +130,16 @@ object MeshServiceHolder {
|
||||
fun attach(service: BluetoothMeshService) {
|
||||
android.util.Log.d(TAG, "Attaching BluetoothMeshService to holder")
|
||||
meshService = service
|
||||
unifiedMeshService = null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clear() {
|
||||
android.util.Log.d(TAG, "Clearing BluetoothMeshService from holder")
|
||||
try { sharedGossipSyncManager?.stop() } catch (_: Exception) { }
|
||||
sharedGossipSyncManager = null
|
||||
activeGossipOwners.clear()
|
||||
meshService = null
|
||||
unifiedMeshService = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@ package com.bitchat.android.service
|
||||
|
||||
import android.util.Log
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.util.toHexString
|
||||
import java.security.MessageDigest
|
||||
import java.util.Collections
|
||||
import java.util.LinkedHashMap
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
@@ -13,6 +18,8 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
*/
|
||||
object TransportBridgeService {
|
||||
private const val TAG = "TransportBridgeService"
|
||||
private const val MAX_SEEN_PACKETS = 4096
|
||||
private const val SEEN_PACKET_TTL_MS = 5 * 60 * 1000L
|
||||
|
||||
/**
|
||||
* Interface that any transport layer (BLE, WiFi, Tor, etc.) must implement
|
||||
@@ -23,9 +30,21 @@ object TransportBridgeService {
|
||||
* Send a packet out via this transport.
|
||||
*/
|
||||
fun send(packet: RoutedPacket)
|
||||
|
||||
/**
|
||||
* Send a packet to a specific peer via this transport (optional).
|
||||
*/
|
||||
fun sendToPeer(peerID: String, packet: BitchatPacket) { }
|
||||
}
|
||||
|
||||
private val transports = ConcurrentHashMap<String, TransportLayer>()
|
||||
private val seenPackets = Collections.synchronizedMap(
|
||||
object : LinkedHashMap<String, Long>(MAX_SEEN_PACKETS, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Long>?): Boolean {
|
||||
return size > MAX_SEEN_PACKETS
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Register a transport layer to receive bridged packets.
|
||||
@@ -55,15 +74,111 @@ object TransportBridgeService {
|
||||
fun broadcast(sourceId: String, packet: RoutedPacket) {
|
||||
val targets = transports.filterKeys { it != sourceId }
|
||||
if (targets.isEmpty()) return
|
||||
val forwardedPacket = prepareForwardedPacket("broadcast", packet.packet) ?: return
|
||||
val forwarded = packet.copy(packet = forwardedPacket)
|
||||
|
||||
// Log.v(TAG, "Bridging packet type ${packet.packet.type} from $sourceId to ${targets.keys}")
|
||||
|
||||
targets.forEach { (id, layer) ->
|
||||
try {
|
||||
layer.send(packet)
|
||||
layer.send(forwarded)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to bridge packet to $id: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a packet to a specific peer across all other transports.
|
||||
*/
|
||||
fun sendToPeer(sourceId: String, peerID: String, packet: BitchatPacket) {
|
||||
val targets = transports.filterKeys { it != sourceId }
|
||||
if (targets.isEmpty()) return
|
||||
val forwardedPacket = prepareForwardedPacket("peer:$peerID", packet) ?: return
|
||||
|
||||
targets.forEach { (id, layer) ->
|
||||
try {
|
||||
layer.sendToPeer(peerID, forwardedPacket)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to bridge unicast packet to $id: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a locally originated packet to every active transport without applying relay TTL
|
||||
* handling. This is used for neighbor-only packets such as REQUEST_SYNC whose TTL is
|
||||
* intentionally zero on the first radio hop.
|
||||
*/
|
||||
fun broadcastFromLocal(packet: RoutedPacket) {
|
||||
val targets = transports.toMap()
|
||||
if (targets.isEmpty()) return
|
||||
|
||||
targets.forEach { (id, layer) ->
|
||||
try {
|
||||
layer.send(packet)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send local packet to $id: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a locally originated packet directly to a peer on every active transport.
|
||||
*/
|
||||
fun sendToPeerFromLocal(peerID: String, packet: BitchatPacket) {
|
||||
val targets = transports.toMap()
|
||||
if (targets.isEmpty()) return
|
||||
|
||||
targets.forEach { (id, layer) ->
|
||||
try {
|
||||
layer.sendToPeer(peerID, packet)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send local peer packet to $id: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun prepareForwardedPacket(kind: String, packet: BitchatPacket): BitchatPacket? {
|
||||
if (packet.ttl == 0u.toUByte()) {
|
||||
Log.d(TAG, "Dropping bridged packet type ${packet.type}: TTL expired")
|
||||
return null
|
||||
}
|
||||
|
||||
val key = "$kind:${logicalPacketId(packet)}"
|
||||
val now = System.currentTimeMillis()
|
||||
synchronized(seenPackets) {
|
||||
pruneSeen(now)
|
||||
val previous = seenPackets[key]
|
||||
if (previous != null && now - previous < SEEN_PACKET_TTL_MS) {
|
||||
Log.d(TAG, "Dropping duplicate bridged packet type ${packet.type}")
|
||||
return null
|
||||
}
|
||||
seenPackets[key] = now
|
||||
}
|
||||
|
||||
return packet.copy(ttl = (packet.ttl - 1u).toUByte())
|
||||
}
|
||||
|
||||
private fun pruneSeen(now: Long) {
|
||||
val iterator = seenPackets.entries.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val entry = iterator.next()
|
||||
if (now - entry.value > SEEN_PACKET_TTL_MS) {
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun logicalPacketId(packet: BitchatPacket): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
digest.update(packet.type.toByte())
|
||||
digest.update(packet.senderID)
|
||||
packet.recipientID?.let { digest.update(it) }
|
||||
digest.update(packet.timestamp.toString().toByteArray(Charsets.UTF_8))
|
||||
digest.update(packet.payload)
|
||||
packet.route?.forEach { digest.update(it) }
|
||||
packet.signature?.let { digest.update(it) }
|
||||
return digest.digest().toHexString()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
object AppStateStore {
|
||||
// Global de-dup set by message id to avoid duplicate keys in Compose lists
|
||||
private val seenMessageIds = mutableSetOf<String>()
|
||||
private val seenPublicMessageKeys = mutableSetOf<String>()
|
||||
private val peerIdsByTransport = mutableMapOf<String, Set<String>>()
|
||||
// Direct (single-hop) peer IDs per transport, used to gossip a unified neighbor set.
|
||||
private val directPeerIdsByTransport = mutableMapOf<String, Set<String>>()
|
||||
// Connected peer IDs (mesh ephemeral IDs)
|
||||
private val _peers = MutableStateFlow<List<String>>(emptyList())
|
||||
val peers: StateFlow<List<String>> = _peers.asStateFlow()
|
||||
@@ -30,13 +34,63 @@ object AppStateStore {
|
||||
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = _channelMessages.asStateFlow()
|
||||
|
||||
fun setPeers(ids: List<String>) {
|
||||
_peers.value = ids
|
||||
synchronized(this) {
|
||||
_peers.value = ids.distinct()
|
||||
}
|
||||
}
|
||||
|
||||
fun setTransportPeers(transportId: String, ids: List<String>) {
|
||||
synchronized(this) {
|
||||
peerIdsByTransport[transportId] = ids.toSet()
|
||||
publishTransportPeersLocked()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearTransportPeers(transportId: String) {
|
||||
synchronized(this) {
|
||||
peerIdsByTransport.remove(transportId)
|
||||
publishTransportPeersLocked()
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishTransportPeersLocked() {
|
||||
_peers.value = peerIdsByTransport.values
|
||||
.asSequence()
|
||||
.flatten()
|
||||
.distinct()
|
||||
.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the set of direct (single-hop) peers reachable over a given transport. Each transport
|
||||
* (BLE, Wi-Fi Aware, ...) only knows its own direct peers; [getDirectPeers] unions them so every
|
||||
* transport can gossip the same complete neighbor list under our shared node identity.
|
||||
*/
|
||||
fun setTransportDirectPeers(transportId: String, ids: Collection<String>) {
|
||||
synchronized(this) {
|
||||
directPeerIdsByTransport[transportId] = ids.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearTransportDirectPeers(transportId: String) {
|
||||
synchronized(this) {
|
||||
directPeerIdsByTransport.remove(transportId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Union of direct peers across all transports. */
|
||||
fun getDirectPeers(): Set<String> {
|
||||
synchronized(this) {
|
||||
return directPeerIdsByTransport.values.flatten().toSet()
|
||||
}
|
||||
}
|
||||
|
||||
fun addPublicMessage(msg: BitchatMessage) {
|
||||
synchronized(this) {
|
||||
if (seenMessageIds.contains(msg.id)) return
|
||||
val publicKey = publicMessageKey(msg)
|
||||
if (seenMessageIds.contains(msg.id) || seenPublicMessageKeys.contains(publicKey)) return
|
||||
seenMessageIds.add(msg.id)
|
||||
seenPublicMessageKeys.add(publicKey)
|
||||
_publicMessages.value = _publicMessages.value + msg
|
||||
}
|
||||
}
|
||||
@@ -100,10 +154,24 @@ object AppStateStore {
|
||||
fun clear() {
|
||||
synchronized(this) {
|
||||
seenMessageIds.clear()
|
||||
seenPublicMessageKeys.clear()
|
||||
peerIdsByTransport.clear()
|
||||
directPeerIdsByTransport.clear()
|
||||
_peers.value = emptyList()
|
||||
_publicMessages.value = emptyList()
|
||||
_privateMessages.value = emptyMap()
|
||||
_channelMessages.value = emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
private fun publicMessageKey(msg: BitchatMessage): String {
|
||||
val sender = msg.senderPeerID ?: msg.sender
|
||||
return listOf(
|
||||
sender,
|
||||
msg.timestamp.time.toString(),
|
||||
msg.type.name,
|
||||
msg.channel ?: "",
|
||||
msg.content
|
||||
).joinToString("\u001F")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,12 @@ object ConversationAliasResolver {
|
||||
if (selected != null && keysToMerge.contains(selected)) {
|
||||
state.setSelectedPrivateChatPeer(targetPeerID)
|
||||
}
|
||||
|
||||
// Switch sheet peer if currently viewing an alias that got merged
|
||||
val sheetPeer = state.getPrivateChatSheetPeerValue()
|
||||
if (sheetPeer != null && keysToMerge.contains(sheetPeer)) {
|
||||
state.setPrivateChatSheetPeer(targetPeerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,39 +2,39 @@ package com.bitchat.android.services
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.ReadReceipt
|
||||
import com.bitchat.android.nostr.NostrTransport
|
||||
|
||||
/**
|
||||
* Routes messages between BLE mesh and Nostr transports, matching iOS behavior.
|
||||
* Routes messages between local mesh transports and Nostr, matching iOS behavior.
|
||||
*/
|
||||
class MessageRouter private constructor(
|
||||
private val context: Context,
|
||||
private val mesh: BluetoothMeshService,
|
||||
private var mesh: MeshService,
|
||||
private val nostr: NostrTransport
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "MessageRouter"
|
||||
@Volatile private var INSTANCE: MessageRouter? = null
|
||||
fun tryGetInstance(): MessageRouter? = INSTANCE
|
||||
fun getInstance(context: Context, mesh: BluetoothMeshService): MessageRouter {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
val nostr = NostrTransport.getInstance(context)
|
||||
INSTANCE?.also {
|
||||
// Update mesh reference if needed and keep senderPeerID in sync
|
||||
it.nostr.senderPeerID = mesh.myPeerID
|
||||
return it
|
||||
}
|
||||
MessageRouter(context.applicationContext, mesh, nostr).also { instance ->
|
||||
instance.nostr.senderPeerID = mesh.myPeerID
|
||||
// Register for favorites changes to flush outbox
|
||||
try {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener(instance.favoriteListener)
|
||||
} catch (_: Exception) {}
|
||||
INSTANCE = instance
|
||||
fun getInstance(context: Context, mesh: MeshService): MessageRouter {
|
||||
val instance = INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: run {
|
||||
val nostr = NostrTransport.getInstance(context)
|
||||
MessageRouter(context.applicationContext, mesh, nostr).also { instance ->
|
||||
// Register for favorites changes to flush outbox
|
||||
try {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener(instance.favoriteListener)
|
||||
} catch (_: Exception) {}
|
||||
INSTANCE = instance
|
||||
}
|
||||
}
|
||||
}
|
||||
// Always update mesh reference and sync peer ID
|
||||
instance.mesh = mesh
|
||||
instance.nostr.senderPeerID = mesh.myPeerID
|
||||
return instance
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,17 +70,10 @@ class MessageRouter private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
val hasMesh = mesh.getPeerInfo(toPeerID)?.isConnected == true
|
||||
val hasEstablished = mesh.hasEstablishedSession(toPeerID)
|
||||
// Check Wi‑Fi Aware availability as a secondary transport
|
||||
val aware = try { com.bitchat.android.wifiaware.WifiAwareController.getService() } catch (_: Exception) { null }
|
||||
val hasAware = try { aware?.getPeerInfo(toPeerID)?.isConnected == true && aware.hasEstablishedSession(toPeerID) } catch (_: Exception) { false }
|
||||
if (hasMesh && hasEstablished) {
|
||||
val hasMesh = isConnected(mesh, toPeerID)
|
||||
if (isReady(mesh, toPeerID)) {
|
||||
Log.d(TAG, "Routing PM via mesh to ${toPeerID} msg_id=${messageID.take(8)}…")
|
||||
mesh.sendPrivateMessage(content, toPeerID, recipientNickname, messageID)
|
||||
} else if (hasAware) {
|
||||
Log.d(TAG, "Routing PM via Wi‑Fi Aware to ${toPeerID} msg_id=${messageID.take(8)}…")
|
||||
aware?.sendPrivateMessage(content, toPeerID, recipientNickname, messageID)
|
||||
} else if (canSendViaNostr(toPeerID)) {
|
||||
Log.d(TAG, "Routing PM via Nostr to ${toPeerID.take(32)}… msg_id=${messageID.take(8)}…")
|
||||
nostr.sendPrivateMessage(content, toPeerID, recipientNickname, messageID)
|
||||
@@ -89,21 +82,14 @@ class MessageRouter private constructor(
|
||||
val q = outbox.getOrPut(toPeerID) { mutableListOf() }
|
||||
q.add(Triple(content, recipientNickname, messageID))
|
||||
Log.d(TAG, "Initiating noise handshake after queueing PM for ${toPeerID.take(8)}…")
|
||||
if (hasMesh) mesh.initiateNoiseHandshake(toPeerID) else aware?.initiateNoiseHandshake(toPeerID)
|
||||
if (hasMesh) mesh.initiateNoiseHandshake(toPeerID)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendReadReceipt(receipt: ReadReceipt, toPeerID: String) {
|
||||
val aware = try { com.bitchat.android.wifiaware.WifiAwareController.getService() } catch (_: Exception) { null }
|
||||
val viaMesh = (mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID)
|
||||
val viaAware = try { aware?.getPeerInfo(toPeerID)?.isConnected == true && aware.hasEstablishedSession(toPeerID) } catch (_: Exception) { false }
|
||||
if (viaMesh) {
|
||||
if (isReady(mesh, toPeerID)) {
|
||||
Log.d(TAG, "Routing READ via mesh to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}…")
|
||||
mesh.sendReadReceipt(receipt.originalMessageID, toPeerID, mesh.getPeerNicknames()[toPeerID] ?: mesh.myPeerID)
|
||||
} else if (viaAware) {
|
||||
Log.d(TAG, "Routing READ via Wi‑Fi Aware to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}…")
|
||||
val me = try { aware?.myPeerID } catch (_: Exception) { null }
|
||||
aware?.sendReadReceipt(receipt.originalMessageID, toPeerID, me ?: "")
|
||||
} else {
|
||||
Log.d(TAG, "Routing READ via Nostr to ${toPeerID.take(8)}… id=${receipt.originalMessageID.take(8)}…")
|
||||
nostr.sendReadReceipt(receipt, toPeerID)
|
||||
@@ -126,11 +112,11 @@ class MessageRouter private constructor(
|
||||
}
|
||||
|
||||
fun sendFavoriteNotification(toPeerID: String, isFavorite: Boolean) {
|
||||
if (mesh.getPeerInfo(toPeerID)?.isConnected == true) {
|
||||
if (mesh.getPeerInfo(toPeerID)?.isConnected == true && mesh.hasEstablishedSession(toPeerID)) {
|
||||
val myNpub = try { com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)?.npub } catch (_: Exception) { null }
|
||||
val content = if (isFavorite) "[FAVORITED]:${myNpub ?: ""}" else "[UNFAVORITED]:${myNpub ?: ""}"
|
||||
val nickname = mesh.getPeerNicknames()[toPeerID] ?: toPeerID
|
||||
mesh.sendPrivateMessage(content, toPeerID, nickname)
|
||||
mesh.sendPrivateMessage(content, toPeerID, nickname, null)
|
||||
} else {
|
||||
nostr.sendFavoriteNotification(toPeerID, isFavorite)
|
||||
}
|
||||
@@ -144,11 +130,11 @@ class MessageRouter private constructor(
|
||||
val iterator = queued.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val (content, nickname, messageID) = iterator.next()
|
||||
var hasMesh = mesh.getPeerInfo(peerID)?.isConnected == true && mesh.hasEstablishedSession(peerID)
|
||||
val hasMesh = isReady(mesh, peerID)
|
||||
// If this is a noiseHex key, see if there is a connected mesh peer for this identity
|
||||
if (!hasMesh && peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
||||
val meshPeer = resolveMeshPeerForNoiseHex(peerID)
|
||||
if (meshPeer != null && mesh.getPeerInfo(meshPeer)?.isConnected == true && mesh.hasEstablishedSession(meshPeer)) {
|
||||
val meshPeer = resolvePeerForNoiseHex(peerID, mesh)
|
||||
if (meshPeer != null && isReady(mesh, meshPeer)) {
|
||||
mesh.sendPrivateMessage(content, meshPeer, nickname, messageID)
|
||||
iterator.remove()
|
||||
continue
|
||||
@@ -195,10 +181,27 @@ class MessageRouter private constructor(
|
||||
return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
}
|
||||
|
||||
private fun resolveMeshPeerForNoiseHex(noiseHex: String): String? {
|
||||
private fun isConnected(service: MeshService, peerID: String): Boolean {
|
||||
return try {
|
||||
mesh.getPeerNicknames().keys.firstOrNull { pid ->
|
||||
val info = mesh.getPeerInfo(pid)
|
||||
service.getPeerInfo(peerID)?.isConnected == true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isReady(service: MeshService, peerID: String): Boolean {
|
||||
return try {
|
||||
service.getPeerInfo(peerID)?.isConnected == true &&
|
||||
service.hasEstablishedSession(peerID)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolvePeerForNoiseHex(noiseHex: String, service: MeshService): String? {
|
||||
return try {
|
||||
service.getPeerNicknames().keys.firstOrNull { pid ->
|
||||
val info = service.getPeerInfo(pid)
|
||||
val keyHex = info?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) }
|
||||
keyHex != null && keyHex.equals(noiseHex, ignoreCase = true)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,12 @@ class SeenMessageStore private constructor(private val context: Context) {
|
||||
persist()
|
||||
}
|
||||
|
||||
@Synchronized fun clear() {
|
||||
delivered.clear()
|
||||
read.clear()
|
||||
persist()
|
||||
}
|
||||
|
||||
private fun trim(set: LinkedHashSet<String>) {
|
||||
if (set.size <= MAX_IDS) return
|
||||
val it = set.iterator()
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import com.bitchat.android.crypto.EncryptionService
|
||||
import com.bitchat.android.util.AppConstants
|
||||
import com.bitchat.android.util.dataFromHexString
|
||||
import com.bitchat.android.util.hexEncodedString
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.security.SecureRandom
|
||||
import androidx.core.net.toUri
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
/**
|
||||
* QR verification helpers: schema, signing, and basic challenge/response helpers.
|
||||
*/
|
||||
object VerificationService {
|
||||
private const val CONTEXT = "bitchat-verify-v1"
|
||||
private const val RESPONSE_CONTEXT = "bitchat-verify-resp-v1"
|
||||
|
||||
private var encryptionServiceRef: WeakReference<EncryptionService>? = null
|
||||
|
||||
fun configure(encryptionService: EncryptionService) {
|
||||
this.encryptionServiceRef = WeakReference(encryptionService)
|
||||
}
|
||||
|
||||
data class VerificationQR(
|
||||
val v: Int,
|
||||
val noiseKeyHex: String,
|
||||
val signKeyHex: String,
|
||||
val npub: String?,
|
||||
val nickname: String,
|
||||
val ts: Long,
|
||||
val nonceB64: String,
|
||||
val sigHex: String
|
||||
) {
|
||||
fun canonicalBytes(): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
|
||||
fun appendField(value: String) {
|
||||
val data = value.toByteArray(Charsets.UTF_8)
|
||||
val len = minOf(data.size, 255)
|
||||
out.write(len)
|
||||
out.write(data, 0, len)
|
||||
}
|
||||
|
||||
appendField(CONTEXT)
|
||||
appendField(v.toString())
|
||||
appendField(noiseKeyHex.lowercase())
|
||||
appendField(signKeyHex.lowercase())
|
||||
appendField(npub ?: "")
|
||||
appendField(nickname)
|
||||
appendField(ts.toString())
|
||||
appendField(nonceB64)
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
fun toUrlString(): String {
|
||||
val builder = Uri.Builder()
|
||||
.scheme("bitchat")
|
||||
.authority("verify")
|
||||
.appendQueryParameter("v", v.toString())
|
||||
.appendQueryParameter("noise", noiseKeyHex)
|
||||
.appendQueryParameter("sign", signKeyHex)
|
||||
.appendQueryParameter("nick", nickname)
|
||||
.appendQueryParameter("ts", ts.toString())
|
||||
.appendQueryParameter("nonce", nonceB64)
|
||||
.appendQueryParameter("sig", sigHex)
|
||||
if (npub != null) {
|
||||
builder.appendQueryParameter("npub", npub)
|
||||
}
|
||||
return builder.build().toString()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromUrlString(urlString: String): VerificationQR? {
|
||||
val uri = runCatching { urlString.toUri() }.getOrNull() ?: return null
|
||||
if (uri.scheme != "bitchat" || uri.host != "verify") return null
|
||||
|
||||
val vStr = uri.getQueryParameter("v") ?: return null
|
||||
val v = vStr.toIntOrNull() ?: return null
|
||||
val noise = uri.getQueryParameter("noise") ?: return null
|
||||
val sign = uri.getQueryParameter("sign") ?: return null
|
||||
val nick = uri.getQueryParameter("nick") ?: return null
|
||||
val tsStr = uri.getQueryParameter("ts") ?: return null
|
||||
val ts = tsStr.toLongOrNull() ?: return null
|
||||
val nonce = uri.getQueryParameter("nonce") ?: return null
|
||||
val sig = uri.getQueryParameter("sig") ?: return null
|
||||
val npub = uri.getQueryParameter("npub")
|
||||
|
||||
return VerificationQR(
|
||||
v = v,
|
||||
noiseKeyHex = noise,
|
||||
signKeyHex = sign,
|
||||
npub = npub,
|
||||
nickname = nick,
|
||||
ts = ts,
|
||||
nonceB64 = nonce,
|
||||
sigHex = sig
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun buildMyQRString(nickname: String, npub: String?): String? {
|
||||
val service = encryptionServiceRef?.get() ?: return null
|
||||
val cache = Cache.last
|
||||
if (cache != null && cache.nickname == nickname && cache.npub == npub) {
|
||||
if (System.currentTimeMillis() - cache.builtAtMs < 60_000L) {
|
||||
return cache.value
|
||||
}
|
||||
}
|
||||
|
||||
val noiseKey = service.getStaticPublicKey()?.hexEncodedString() ?: return null
|
||||
val signKey = service.getSigningPublicKey()?.hexEncodedString() ?: return null
|
||||
val ts = System.currentTimeMillis() / 1000L
|
||||
val nonce = ByteArray(16)
|
||||
SecureRandom().nextBytes(nonce)
|
||||
val nonceB64 = Base64.encodeToString(
|
||||
nonce,
|
||||
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
|
||||
)
|
||||
|
||||
val payload = VerificationQR(
|
||||
v = 1,
|
||||
noiseKeyHex = noiseKey,
|
||||
signKeyHex = signKey,
|
||||
npub = npub,
|
||||
nickname = nickname,
|
||||
ts = ts,
|
||||
nonceB64 = nonceB64,
|
||||
sigHex = ""
|
||||
)
|
||||
|
||||
val signature = service.signData(payload.canonicalBytes()) ?: return null
|
||||
val signed = payload.copy(sigHex = signature.hexEncodedString())
|
||||
val out = signed.toUrlString()
|
||||
Cache.last = CacheEntry(nickname, npub, System.currentTimeMillis(), out)
|
||||
return out
|
||||
}
|
||||
|
||||
fun verifyScannedQR(
|
||||
urlString: String,
|
||||
maxAgeSeconds: Long = AppConstants.Verification.QR_MAX_AGE_SECONDS
|
||||
): VerificationQR? {
|
||||
val service = encryptionServiceRef?.get() ?: return null
|
||||
val qr = VerificationQR.fromUrlString(urlString) ?: return null
|
||||
val now = System.currentTimeMillis() / 1000L
|
||||
if (now - qr.ts > maxAgeSeconds) return null
|
||||
|
||||
val sig = qr.sigHex.dataFromHexString() ?: return null
|
||||
val signKey = qr.signKeyHex.dataFromHexString() ?: return null
|
||||
val ok = service.verifyEd25519Signature(sig, qr.canonicalBytes(), signKey)
|
||||
return if (ok) qr else null
|
||||
}
|
||||
|
||||
fun buildVerifyChallenge(noiseKeyHex: String, nonceA: ByteArray): ByteArray {
|
||||
val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8)
|
||||
val out = ByteArrayOutputStream()
|
||||
out.write(0x01)
|
||||
out.write(minOf(noiseData.size, 255))
|
||||
out.write(noiseData, 0, minOf(noiseData.size, 255))
|
||||
out.write(0x02)
|
||||
out.write(minOf(nonceA.size, 255))
|
||||
out.write(nonceA, 0, minOf(nonceA.size, 255))
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
fun buildVerifyResponse(noiseKeyHex: String, nonceA: ByteArray): ByteArray? {
|
||||
val service = encryptionServiceRef?.get() ?: return null
|
||||
val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8)
|
||||
val msg = ByteArrayOutputStream()
|
||||
msg.write(RESPONSE_CONTEXT.toByteArray(Charsets.UTF_8))
|
||||
msg.write(minOf(noiseData.size, 255))
|
||||
msg.write(noiseData, 0, minOf(noiseData.size, 255))
|
||||
msg.write(nonceA)
|
||||
val sig = service.signData(msg.toByteArray()) ?: return null
|
||||
|
||||
val out = ByteArrayOutputStream()
|
||||
out.write(0x01)
|
||||
out.write(minOf(noiseData.size, 255))
|
||||
out.write(noiseData, 0, minOf(noiseData.size, 255))
|
||||
out.write(0x02)
|
||||
out.write(minOf(nonceA.size, 255))
|
||||
out.write(nonceA, 0, minOf(nonceA.size, 255))
|
||||
out.write(0x03)
|
||||
out.write(minOf(sig.size, 255))
|
||||
out.write(sig, 0, minOf(sig.size, 255))
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
fun parseVerifyChallenge(data: ByteArray): Pair<String, ByteArray>? {
|
||||
var idx = 0
|
||||
|
||||
fun take(n: Int): ByteArray? {
|
||||
if (idx + n > data.size) return null
|
||||
val out = data.copyOfRange(idx, idx + n)
|
||||
idx += n
|
||||
return out
|
||||
}
|
||||
|
||||
val t1 = take(1) ?: return null
|
||||
if (t1[0].toInt() != 0x01) return null
|
||||
val l1 = take(1)?.get(0)?.toInt() ?: return null
|
||||
val noiseBytes = take(l1) ?: return null
|
||||
val noise = noiseBytes.toString(Charsets.UTF_8)
|
||||
|
||||
val t2 = take(1) ?: return null
|
||||
if (t2[0].toInt() != 0x02) return null
|
||||
val l2 = take(1)?.get(0)?.toInt() ?: return null
|
||||
val nonce = take(l2) ?: return null
|
||||
|
||||
return noise to nonce
|
||||
}
|
||||
|
||||
data class VerifyResponse(val noiseKeyHex: String, val nonceA: ByteArray, val signature: ByteArray) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as VerifyResponse
|
||||
|
||||
if (noiseKeyHex != other.noiseKeyHex) return false
|
||||
if (!nonceA.contentEquals(other.nonceA)) return false
|
||||
if (!signature.contentEquals(other.signature)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = noiseKeyHex.hashCode()
|
||||
result = 31 * result + nonceA.contentHashCode()
|
||||
result = 31 * result + signature.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
fun parseVerifyResponse(data: ByteArray): VerifyResponse? {
|
||||
var idx = 0
|
||||
|
||||
fun take(n: Int): ByteArray? {
|
||||
if (idx + n > data.size) return null
|
||||
val out = data.copyOfRange(idx, idx + n)
|
||||
idx += n
|
||||
return out
|
||||
}
|
||||
|
||||
val t1 = take(1) ?: return null
|
||||
if (t1[0].toInt() != 0x01) return null
|
||||
val l1 = take(1)?.get(0)?.toInt() ?: return null
|
||||
val noiseBytes = take(l1) ?: return null
|
||||
val noise = noiseBytes.toString(Charsets.UTF_8)
|
||||
|
||||
val t2 = take(1) ?: return null
|
||||
if (t2[0].toInt() != 0x02) return null
|
||||
val l2 = take(1)?.get(0)?.toInt() ?: return null
|
||||
val nonce = take(l2) ?: return null
|
||||
|
||||
val t3 = take(1) ?: return null
|
||||
if (t3[0].toInt() != 0x03) return null
|
||||
val l3 = take(1)?.get(0)?.toInt() ?: return null
|
||||
val sig = take(l3) ?: return null
|
||||
|
||||
return VerifyResponse(noise, nonce, sig)
|
||||
}
|
||||
|
||||
fun verifyResponseSignature(
|
||||
noiseKeyHex: String,
|
||||
nonceA: ByteArray,
|
||||
signature: ByteArray,
|
||||
signerPublicKeyHex: String
|
||||
): Boolean {
|
||||
val service = encryptionServiceRef?.get() ?: return false
|
||||
val noiseData = noiseKeyHex.toByteArray(Charsets.UTF_8)
|
||||
val msg = ByteArrayOutputStream()
|
||||
msg.write(RESPONSE_CONTEXT.toByteArray(Charsets.UTF_8))
|
||||
msg.write(minOf(noiseData.size, 255))
|
||||
msg.write(noiseData, 0, minOf(noiseData.size, 255))
|
||||
msg.write(nonceA)
|
||||
val signerKey = signerPublicKeyHex.dataFromHexString() ?: return false
|
||||
return service.verifyEd25519Signature(signature, msg.toByteArray(), signerKey)
|
||||
}
|
||||
|
||||
private data class CacheEntry(
|
||||
val nickname: String,
|
||||
val npub: String?,
|
||||
val builtAtMs: Long,
|
||||
val value: String
|
||||
)
|
||||
|
||||
private object Cache {
|
||||
var last: CacheEntry? = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.bitchat.android.services.meshgraph
|
||||
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Gossip TLV helpers for embedding direct neighbor peer IDs in ANNOUNCE payloads.
|
||||
* Uses compact TLV: [type=0x04][len=1 byte][value=N*8 bytes of peerIDs]
|
||||
*/
|
||||
object GossipTLV {
|
||||
// TLV type for a compact list of direct neighbor peerIDs (each 8 bytes)
|
||||
const val DIRECT_NEIGHBORS_TYPE: UByte = 0x04u
|
||||
|
||||
/**
|
||||
* Encode up to 10 unique peerIDs (hex string up to 16 chars) as TLV value.
|
||||
*/
|
||||
fun encodeNeighbors(peerIDs: List<String>): ByteArray {
|
||||
val unique = peerIDs.distinct().take(10)
|
||||
val valueBytes = unique.flatMap { id -> hexStringPeerIdTo8Bytes(id).toList() }.toByteArray()
|
||||
if (valueBytes.size > 255) {
|
||||
// Safety check, though 10*8 = 80 bytes, so well under 255
|
||||
Log.w("GossipTLV", "Neighbors value exceeds 255, truncating")
|
||||
}
|
||||
return byteArrayOf(DIRECT_NEIGHBORS_TYPE.toByte(), valueBytes.size.toByte()) + valueBytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a TLV-encoded announce payload and extract neighbor peerIDs.
|
||||
* Returns null if the TLV is not present at all; returns an empty list if present with length 0.
|
||||
*/
|
||||
fun decodeNeighborsFromAnnouncementPayload(payload: ByteArray): List<String>? {
|
||||
val result = mutableListOf<String>()
|
||||
var offset = 0
|
||||
while (offset + 2 <= payload.size) {
|
||||
val type = payload[offset].toUByte()
|
||||
val len = payload[offset + 1].toUByte().toInt()
|
||||
offset += 2
|
||||
if (offset + len > payload.size) break
|
||||
val value = payload.sliceArray(offset until offset + len)
|
||||
offset += len
|
||||
|
||||
if (type == DIRECT_NEIGHBORS_TYPE) {
|
||||
// Value is N*8 bytes of peer IDs
|
||||
var pos = 0
|
||||
while (pos + 8 <= value.size) {
|
||||
val idBytes = value.sliceArray(pos until pos + 8)
|
||||
result.add(bytesToPeerIdHex(idBytes))
|
||||
pos += 8
|
||||
}
|
||||
return result // present (possibly empty)
|
||||
}
|
||||
}
|
||||
// Not present
|
||||
return null
|
||||
}
|
||||
|
||||
private fun hexStringPeerIdTo8Bytes(hexString: String): ByteArray {
|
||||
val clean = hexString.lowercase().take(16)
|
||||
val result = ByteArray(8) { 0 }
|
||||
var idx = 0
|
||||
var out = 0
|
||||
while (idx + 1 < clean.length && out < 8) {
|
||||
val byteStr = clean.substring(idx, idx + 2)
|
||||
val b = byteStr.toIntOrNull(16)?.toByte() ?: 0
|
||||
result[out++] = b
|
||||
idx += 2
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun bytesToPeerIdHex(bytes: ByteArray): String {
|
||||
val sb = StringBuilder()
|
||||
for (b in bytes.take(8)) {
|
||||
sb.append(String.format("%02x", b))
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.bitchat.android.services.meshgraph
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Maintains an internal graph of the mesh based on gossip.
|
||||
* Nodes are peers (peerID), edges are direct connections.
|
||||
*/
|
||||
class MeshGraphService private constructor() {
|
||||
data class GraphNode(val peerID: String, val nickname: String?)
|
||||
data class GraphEdge(val a: String, val b: String, val isConfirmed: Boolean, val confirmedBy: String? = null)
|
||||
data class GraphSnapshot(val nodes: List<GraphNode>, val edges: List<GraphEdge>)
|
||||
|
||||
// Map peerID -> nickname (may be null if unknown)
|
||||
private val nicknames = ConcurrentHashMap<String, String?>()
|
||||
// Announcements: peerID -> set of neighbor peerIDs that *this* peer claims to see
|
||||
private val announcements = ConcurrentHashMap<String, Set<String>>()
|
||||
// Latest announcement timestamp per peer (ULong from packet)
|
||||
private val lastUpdate = ConcurrentHashMap<String, ULong>()
|
||||
|
||||
private val _graphState = MutableStateFlow(GraphSnapshot(emptyList(), emptyList()))
|
||||
val graphState: StateFlow<GraphSnapshot> = _graphState.asStateFlow()
|
||||
|
||||
/**
|
||||
* Update graph from a verified announcement.
|
||||
* Replaces previous neighbors for origin if this is newer (by timestamp).
|
||||
*/
|
||||
fun updateFromAnnouncement(originPeerID: String, originNickname: String?, neighborsOrNull: List<String>?, timestamp: ULong) {
|
||||
synchronized(this) {
|
||||
// Always update nickname if provided
|
||||
if (originNickname != null) nicknames[originPeerID] = originNickname
|
||||
|
||||
// 1. Check timestamp first to ensure this is the latest word from the peer
|
||||
val prevTs = lastUpdate[originPeerID]
|
||||
if (prevTs != null && prevTs >= timestamp) {
|
||||
// Older or equal update: ignore
|
||||
return
|
||||
}
|
||||
lastUpdate[originPeerID] = timestamp
|
||||
|
||||
// 2. Latest announcement determines state.
|
||||
// If neighborsOrNull is null (TLV omitted), it means the peer is not reporting any neighbors (empty list).
|
||||
val neighbors = neighborsOrNull ?: emptyList()
|
||||
|
||||
// Filter out self-loops just in case
|
||||
val newSet = neighbors.distinct().take(10).filter { it != originPeerID }.toSet()
|
||||
announcements[originPeerID] = newSet
|
||||
|
||||
publishSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNickname(peerID: String, nickname: String?) {
|
||||
if (nickname == null) return
|
||||
nicknames[peerID] = nickname
|
||||
publishSnapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a peer from the graph completely (e.g. when stale/offline).
|
||||
*/
|
||||
fun removePeer(peerID: String) {
|
||||
synchronized(this) {
|
||||
nicknames.remove(peerID)
|
||||
announcements.remove(peerID)
|
||||
lastUpdate.remove(peerID)
|
||||
publishSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishSnapshot() {
|
||||
// Collect all known nodes from nicknames and announcements
|
||||
val allNodes = mutableSetOf<String>()
|
||||
allNodes.addAll(nicknames.keys)
|
||||
announcements.forEach { (origin, neighbors) ->
|
||||
allNodes.add(origin)
|
||||
allNodes.addAll(neighbors)
|
||||
}
|
||||
|
||||
val nodeList = allNodes.map { GraphNode(it, nicknames[it]) }.sortedBy { it.peerID }
|
||||
|
||||
val edges = mutableListOf<GraphEdge>()
|
||||
val processedPairs = mutableSetOf<Pair<String, String>>()
|
||||
|
||||
// We only care about connections that exist in at least one direction.
|
||||
// So iterating through all entries in `announcements` covers every declared edge.
|
||||
announcements.forEach { (source, targets) ->
|
||||
targets.forEach { target ->
|
||||
val pair = if (source <= target) source to target else target to source
|
||||
if (processedPairs.add(pair)) {
|
||||
// This is a new pair we haven't evaluated yet
|
||||
val (a, b) = pair
|
||||
val aAnnouncesB = announcements[a]?.contains(b) == true
|
||||
val bAnnouncesA = announcements[b]?.contains(a) == true
|
||||
|
||||
if (aAnnouncesB && bAnnouncesA) {
|
||||
edges.add(GraphEdge(a, b, isConfirmed = true))
|
||||
} else if (aAnnouncesB) {
|
||||
edges.add(GraphEdge(a, b, isConfirmed = false, confirmedBy = a))
|
||||
} else if (bAnnouncesA) {
|
||||
edges.add(GraphEdge(a, b, isConfirmed = false, confirmedBy = b))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val sortedEdges = edges.sortedWith(compareBy({ it.a }, { it.b }))
|
||||
_graphState.value = GraphSnapshot(nodeList, sortedEdges)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Volatile private var INSTANCE: MeshGraphService? = null
|
||||
fun getInstance(): MeshGraphService = INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: MeshGraphService().also { INSTANCE = it }
|
||||
}
|
||||
|
||||
@org.jetbrains.annotations.TestOnly
|
||||
fun resetForTesting() {
|
||||
synchronized(this) {
|
||||
INSTANCE = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.bitchat.android.services.meshgraph
|
||||
|
||||
import android.util.Log
|
||||
import java.util.PriorityQueue
|
||||
|
||||
/**
|
||||
* Computes shortest paths on the current mesh graph snapshot using Dijkstra.
|
||||
* Assumes unit edge weights.
|
||||
*/
|
||||
object RoutePlanner {
|
||||
private const val TAG = "RoutePlanner"
|
||||
|
||||
/**
|
||||
* Return full path [src, ..., dst] if reachable, else null.
|
||||
*/
|
||||
fun shortestPath(src: String, dst: String): List<String>? {
|
||||
if (src == dst) return listOf(src)
|
||||
val snapshot = MeshGraphService.getInstance().graphState.value
|
||||
val neighbors = mutableMapOf<String, MutableSet<String>>()
|
||||
|
||||
// Only consider confirmed edges for routing
|
||||
snapshot.edges.filter { it.isConfirmed }.forEach { e ->
|
||||
neighbors.getOrPut(e.a) { mutableSetOf() }.add(e.b)
|
||||
neighbors.getOrPut(e.b) { mutableSetOf() }.add(e.a)
|
||||
}
|
||||
// Ensure nodes known even if isolated
|
||||
snapshot.nodes.forEach { n -> neighbors.putIfAbsent(n.peerID, mutableSetOf()) }
|
||||
|
||||
if (!neighbors.containsKey(src) || !neighbors.containsKey(dst)) return null
|
||||
|
||||
val dist = mutableMapOf<String, Int>()
|
||||
val prev = mutableMapOf<String, String?>()
|
||||
val pq = PriorityQueue<Pair<String, Int>>(compareBy { it.second })
|
||||
|
||||
neighbors.keys.forEach { v ->
|
||||
dist[v] = if (v == src) 0 else Int.MAX_VALUE
|
||||
prev[v] = null
|
||||
}
|
||||
pq.add(src to 0)
|
||||
|
||||
while (pq.isNotEmpty()) {
|
||||
val top = pq.poll() ?: break
|
||||
val (u, d) = top
|
||||
if (d > (dist[u] ?: Int.MAX_VALUE)) continue
|
||||
if (u == dst) break
|
||||
neighbors[u]?.forEach { v ->
|
||||
val alt = d + 1
|
||||
if (alt < (dist[v] ?: Int.MAX_VALUE)) {
|
||||
dist[v] = alt
|
||||
prev[v] = u
|
||||
pq.add(v to alt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ((dist[dst] ?: Int.MAX_VALUE) == Int.MAX_VALUE) return null
|
||||
|
||||
val path = mutableListOf<String>()
|
||||
var cur: String? = dst
|
||||
while (cur != null) {
|
||||
path.add(cur)
|
||||
cur = prev[cur]
|
||||
}
|
||||
path.reverse()
|
||||
Log.d(TAG, "Computed path $path")
|
||||
return path
|
||||
}
|
||||
}
|
||||
@@ -43,21 +43,29 @@ object GCSFilter {
|
||||
targetFpr: Double
|
||||
): Params {
|
||||
val p = deriveP(targetFpr)
|
||||
var nCap = estimateMaxElementsForSize(maxBytes, p)
|
||||
val n = ids.size.coerceAtMost(nCap)
|
||||
val selected = ids.take(n)
|
||||
// Map to [0, M)
|
||||
val m = (n.toLong() shl p)
|
||||
val mapped = selected.map { id -> (h64(id) % m) }.sorted()
|
||||
val nCap = estimateMaxElementsForSize(maxBytes, p)
|
||||
var trimmedN = ids.size.coerceAtMost(nCap)
|
||||
|
||||
var finalM = (trimmedN.toLong() shl p).coerceAtLeast(1L)
|
||||
var selected = ids.take(trimmedN)
|
||||
var mapped = selected.map { id ->
|
||||
val v = h64(id) % finalM
|
||||
if (v == 0L) 1L else v
|
||||
}.distinct().sorted()
|
||||
var encoded = encode(mapped, p)
|
||||
|
||||
// If estimate was too optimistic, trim until it fits
|
||||
var trimmedN = n
|
||||
while (encoded.size > maxBytes && trimmedN > 0) {
|
||||
trimmedN = (trimmedN * 9) / 10 // drop 10%
|
||||
val mapped2 = mapped.take(trimmedN)
|
||||
encoded = encode(mapped2, p)
|
||||
finalM = (trimmedN.toLong() shl p).coerceAtLeast(1L)
|
||||
selected = ids.take(trimmedN)
|
||||
mapped = selected.map { id ->
|
||||
val v = h64(id) % finalM
|
||||
if (v == 0L) 1L else v
|
||||
}.distinct().sorted()
|
||||
encoded = encode(mapped, p)
|
||||
}
|
||||
val finalM = (trimmedN.toLong() shl p)
|
||||
|
||||
return Params(p = p, m = finalM, data = encoded)
|
||||
}
|
||||
|
||||
@@ -96,7 +104,7 @@ object GCSFilter {
|
||||
return false
|
||||
}
|
||||
|
||||
private fun h64(id16: ByteArray): Long {
|
||||
internal fun h64(id16: ByteArray): Long {
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
md.update(id16)
|
||||
val d = md.digest()
|
||||
|
||||
@@ -169,14 +169,9 @@ class GossipSyncManager(
|
||||
// Decode GCS into sorted set for membership checks
|
||||
val sorted = GCSFilter.decodeToSortedSet(request.p, request.m, request.data)
|
||||
fun mightContain(id: ByteArray): Boolean {
|
||||
val v = (GCSFilter.run {
|
||||
// reuse hashing method from GCSFilter
|
||||
val md = java.security.MessageDigest.getInstance("SHA-256");
|
||||
md.update(id); val d = md.digest();
|
||||
var x = 0L; for (i in 0 until 8) { x = (x shl 8) or (d[i].toLong() and 0xFF) }
|
||||
(x and 0x7fff_ffff_ffff_ffffL) % request.m
|
||||
})
|
||||
return GCSFilter.contains(sorted, v)
|
||||
val v = GCSFilter.h64(id) % request.m
|
||||
val nonZeroV = if (v == 0L) 1L else v
|
||||
return GCSFilter.contains(sorted, nonZeroV)
|
||||
}
|
||||
|
||||
// 1) Announcements: send latest per peerID if remote doesn't have them
|
||||
|
||||
@@ -13,13 +13,10 @@ import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material.icons.filled.NetworkCheck
|
||||
import androidx.compose.material.icons.filled.Speed
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
@@ -27,15 +24,14 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.BaselineShift
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.nostr.NostrProofOfWork
|
||||
import com.bitchat.android.nostr.PoWPreferenceManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.core.ui.component.button.CloseButton
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
import com.bitchat.android.net.TorMode
|
||||
import com.bitchat.android.net.TorPreferenceManager
|
||||
import com.bitchat.android.net.ArtiTorManager
|
||||
@@ -217,10 +213,6 @@ fun AboutSheet(
|
||||
}
|
||||
}
|
||||
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
)
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
val isScrolled by remember {
|
||||
derivedStateOf {
|
||||
@@ -236,12 +228,9 @@ fun AboutSheet(
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
|
||||
if (isPresented) {
|
||||
ModalBottomSheet(
|
||||
modifier = modifier.statusBarsPadding(),
|
||||
BitchatBottomSheet(
|
||||
modifier = modifier,
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = colorScheme.background,
|
||||
dragHandle = null
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
LazyColumn(
|
||||
|
||||
@@ -37,22 +37,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* Reactive helper to compute favorite state from fingerprint mapping
|
||||
* This eliminates the need for static isFavorite parameters and makes
|
||||
* the UI reactive to fingerprint manager changes
|
||||
*/
|
||||
@Composable
|
||||
fun isFavoriteReactive(
|
||||
peerID: String,
|
||||
peerFingerprints: Map<String, String>,
|
||||
favoritePeers: Set<String>
|
||||
): Boolean {
|
||||
return remember(peerID, peerFingerprints, favoritePeers) {
|
||||
val fingerprint = peerFingerprints[peerID]
|
||||
fingerprint != null && favoritePeers.contains(fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun TorStatusDot(
|
||||
@@ -246,39 +231,6 @@ fun ChatHeaderContent(
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
when {
|
||||
selectedPrivatePeer != null -> {
|
||||
// Private chat header - Fully reactive state tracking
|
||||
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
|
||||
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
|
||||
val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle()
|
||||
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
|
||||
|
||||
// Reactive favorite computation - no more static lookups!
|
||||
val isFavorite = isFavoriteReactive(
|
||||
peerID = selectedPrivatePeer,
|
||||
peerFingerprints = peerFingerprints,
|
||||
favoritePeers = favoritePeers
|
||||
)
|
||||
val sessionState = peerSessionStates[selectedPrivatePeer]
|
||||
|
||||
Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, isFav=$isFavorite, sessionState=$sessionState")
|
||||
|
||||
// Pass geohash context and people for NIP-17 chat title formatting
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||
|
||||
PrivateChatHeader(
|
||||
peerID = selectedPrivatePeer,
|
||||
peerNicknames = peerNicknames,
|
||||
isFavorite = isFavorite,
|
||||
sessionState = sessionState,
|
||||
selectedLocationChannel = selectedLocationChannel,
|
||||
geohashPeople = geohashPeople,
|
||||
onBackClick = onBackClick,
|
||||
onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) },
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
currentChannel != null -> {
|
||||
// Channel header
|
||||
ChannelHeader(
|
||||
@@ -304,148 +256,7 @@ fun ChatHeaderContent(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PrivateChatHeader(
|
||||
peerID: String,
|
||||
peerNicknames: Map<String, String>,
|
||||
isFavorite: Boolean,
|
||||
sessionState: String?,
|
||||
selectedLocationChannel: com.bitchat.android.geohash.ChannelID?,
|
||||
geohashPeople: List<GeoPerson>,
|
||||
onBackClick: () -> Unit,
|
||||
onToggleFavorite: () -> Unit,
|
||||
viewModel: ChatViewModel
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isNostrDM = peerID.startsWith("nostr_") || peerID.startsWith("nostr:")
|
||||
// Determine mutual favorite state for this peer (supports mesh ephemeral 16-hex via favorites lookup)
|
||||
val isMutualFavorite = remember(peerID, peerNicknames) {
|
||||
try {
|
||||
if (isNostrDM) return@remember false
|
||||
if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
||||
val noiseKeyBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKeyBytes)?.isMutual == true
|
||||
} else if (peerID.length == 16 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.isMutual == true
|
||||
} else false
|
||||
} catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
// Compute title text: for NIP-17 chats show "#geohash/@username" (iOS parity)
|
||||
val titleText: String = if (isNostrDM) {
|
||||
// For geohash DMs, get the actual source geohash and proper display name
|
||||
val (conversationGeohash, baseName) = try {
|
||||
val repoField = com.bitchat.android.ui.GeohashViewModel::class.java.getDeclaredField("repo")
|
||||
repoField.isAccessible = true
|
||||
val repo = repoField.get(viewModel.geohashViewModel) as com.bitchat.android.nostr.GeohashRepository
|
||||
val gh = repo.getConversationGeohash(peerID) ?: "geohash"
|
||||
val fullPubkey = com.bitchat.android.nostr.GeohashAliasRegistry.get(peerID) ?: ""
|
||||
val displayName = if (fullPubkey.isNotEmpty()) {
|
||||
repo.displayNameForGeohashConversation(fullPubkey, gh)
|
||||
} else {
|
||||
peerNicknames[peerID] ?: "unknown"
|
||||
}
|
||||
Pair(gh, displayName)
|
||||
} catch (e: Exception) {
|
||||
Pair("geohash", peerNicknames[peerID] ?: "unknown")
|
||||
}
|
||||
|
||||
"#$conversationGeohash/@$baseName"
|
||||
} else {
|
||||
// Prefer live mesh nickname; fallback to favorites nickname (supports 16-hex), finally short key
|
||||
peerNicknames[peerID] ?: run {
|
||||
val titleFromFavorites = try {
|
||||
if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
||||
val noiseKeyBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKeyBytes)?.peerNickname
|
||||
} else if (peerID.length == 16 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.peerNickname
|
||||
} else null
|
||||
} catch (_: Exception) { null }
|
||||
titleFromFavorites ?: peerID.take(12)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
// Back button - positioned all the way to the left with minimal margin
|
||||
Button(
|
||||
onClick = onBackClick,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = colorScheme.primary
|
||||
),
|
||||
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), // Reduced horizontal padding
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.offset(x = (-8).dp) // Move even further left to minimize margin
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.chat_back),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Title - perfectly centered regardless of other elements
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
) {
|
||||
|
||||
Text(
|
||||
text = titleText,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color(0xFFFF9500) // Orange
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
// Show a globe when chatting via Nostr alias, or when mesh session not established but mutual favorite exists
|
||||
val showGlobe = isNostrDM || (sessionState != "established" && isMutualFavorite)
|
||||
if (showGlobe) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Public,
|
||||
contentDescription = stringResource(R.string.cd_nostr_reachable),
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = Color(0xFF9B59B6) // Purple like iOS
|
||||
)
|
||||
} else {
|
||||
NoiseSessionIcon(
|
||||
sessionState = sessionState,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Favorite button - positioned on the right
|
||||
IconButton(
|
||||
onClick = {
|
||||
Log.d("ChatHeader", "Header toggle favorite: peerID=$peerID, currentFavorite=$isFavorite")
|
||||
onToggleFavorite()
|
||||
},
|
||||
modifier = Modifier.align(Alignment.CenterEnd)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star,
|
||||
contentDescription = if (isFavorite) stringResource(R.string.cd_remove_favorite) else stringResource(R.string.cd_add_favorite),
|
||||
modifier = Modifier.size(18.dp), // Slightly larger than sidebar icon
|
||||
tint = if (isFavorite) Color(0xFFFFD700) else Color(0x87878700) // Yellow or grey
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelHeader(
|
||||
@@ -633,7 +444,7 @@ private fun MainHeader(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
PeerCounter(
|
||||
connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID },
|
||||
connectedPeers = connectedPeers.filter { it != viewModel.myPeerID },
|
||||
joinedChannels = joinedChannels,
|
||||
hasUnreadChannels = hasUnreadChannels,
|
||||
isConnected = isConnected,
|
||||
|
||||
@@ -5,7 +5,6 @@ package com.bitchat.android.ui
|
||||
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
@@ -51,12 +50,15 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
||||
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
|
||||
val channelMessages by viewModel.channelMessages.collectAsStateWithLifecycle()
|
||||
val showSidebar by viewModel.showSidebar.collectAsStateWithLifecycle()
|
||||
val showCommandSuggestions by viewModel.showCommandSuggestions.collectAsStateWithLifecycle()
|
||||
val commandSuggestions by viewModel.commandSuggestions.collectAsStateWithLifecycle()
|
||||
val showMentionSuggestions by viewModel.showMentionSuggestions.collectAsStateWithLifecycle()
|
||||
val mentionSuggestions by viewModel.mentionSuggestions.collectAsStateWithLifecycle()
|
||||
val showAppInfo by viewModel.showAppInfo.collectAsStateWithLifecycle()
|
||||
val showMeshPeerListSheet by viewModel.showMeshPeerList.collectAsStateWithLifecycle()
|
||||
val privateChatSheetPeer by viewModel.privateChatSheetPeer.collectAsStateWithLifecycle()
|
||||
val showVerificationSheet by viewModel.showVerificationSheet.collectAsStateWithLifecycle()
|
||||
val showSecurityVerificationSheet by viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle()
|
||||
|
||||
var messageText by remember { mutableStateOf(TextFieldValue("")) }
|
||||
var showPasswordPrompt by remember { mutableStateOf(false) }
|
||||
@@ -85,8 +87,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
|
||||
// Determine what messages to show based on current context (unified timelines)
|
||||
// Legacy private chat timeline removed - private chats now exclusively use PrivateChatSheet
|
||||
val displayMessages = when {
|
||||
selectedPrivatePeer != null -> privateChats[selectedPrivatePeer] ?: emptyList()
|
||||
currentChannel != null -> channelMessages[currentChannel] ?: emptyList()
|
||||
else -> {
|
||||
val locationChannel = selectedLocationChannel
|
||||
@@ -101,7 +103,6 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
|
||||
// Determine whether to show media buttons (only hide in geohash location chats)
|
||||
val showMediaButtons = when {
|
||||
selectedPrivatePeer != null -> true
|
||||
currentChannel != null -> true
|
||||
else -> selectedLocationChannel !is com.bitchat.android.geohash.ChannelID.Location
|
||||
}
|
||||
@@ -132,7 +133,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
MessagesList(
|
||||
messages = displayMessages,
|
||||
currentUserNickname = nickname,
|
||||
meshService = viewModel.meshService,
|
||||
meshService = viewModel.meshServiceFacade,
|
||||
modifier = Modifier.weight(1f),
|
||||
forceScrollToBottom = forceScrollToBottom,
|
||||
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
|
||||
@@ -231,7 +232,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
selection = TextRange(mentionText.length)
|
||||
)
|
||||
},
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
selectedPrivatePeer = null,
|
||||
currentChannel = currentChannel,
|
||||
nickname = nickname,
|
||||
colorScheme = colorScheme,
|
||||
@@ -242,12 +243,12 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
// Floating header - positioned absolutely at top, ignores keyboard
|
||||
ChatFloatingHeader(
|
||||
headerHeight = headerHeight,
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
selectedPrivatePeer = null,
|
||||
currentChannel = currentChannel,
|
||||
nickname = nickname,
|
||||
viewModel = viewModel,
|
||||
colorScheme = colorScheme,
|
||||
onSidebarToggle = { viewModel.showSidebar() },
|
||||
onSidebarToggle = { viewModel.showMeshPeerList() },
|
||||
onShowAppInfo = { viewModel.showAppInfo() },
|
||||
onPanicClear = { viewModel.panicClearAllData() },
|
||||
onLocationChannelsClick = { showLocationChannelsSheet = true },
|
||||
@@ -264,28 +265,9 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
color = colorScheme.outline.copy(alpha = 0.3f)
|
||||
)
|
||||
|
||||
val alpha by animateFloatAsState(
|
||||
targetValue = if (showSidebar) 0.5f else 0f,
|
||||
animationSpec = tween(
|
||||
durationMillis = 300,
|
||||
easing = EaseOutCubic
|
||||
), label = "overlayAlpha"
|
||||
)
|
||||
|
||||
// Only render the background if it's visible
|
||||
if (alpha > 0f) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = alpha))
|
||||
.clickable { viewModel.hideSidebar() }
|
||||
.zIndex(1f)
|
||||
)
|
||||
}
|
||||
|
||||
// Scroll-to-bottom floating button
|
||||
AnimatedVisibility(
|
||||
visible = isScrolledUp && !showSidebar,
|
||||
visible = isScrolledUp,
|
||||
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
|
||||
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
|
||||
modifier = Modifier
|
||||
@@ -311,25 +293,6 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showSidebar,
|
||||
enter = slideInHorizontally(
|
||||
initialOffsetX = { it },
|
||||
animationSpec = tween(300, easing = EaseOutCubic)
|
||||
) + fadeIn(animationSpec = tween(300)),
|
||||
exit = slideOutHorizontally(
|
||||
targetOffsetX = { it },
|
||||
animationSpec = tween(250, easing = EaseInCubic)
|
||||
) + fadeOut(animationSpec = tween(250)),
|
||||
modifier = Modifier.zIndex(2f)
|
||||
) {
|
||||
SidebarOverlay(
|
||||
viewModel = viewModel,
|
||||
onDismiss = { viewModel.hideSidebar() },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Full-screen image viewer - separate from other sheets to allow image browsing without navigation
|
||||
@@ -373,12 +336,18 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
},
|
||||
selectedUserForSheet = selectedUserForSheet,
|
||||
selectedMessageForSheet = selectedMessageForSheet,
|
||||
viewModel = viewModel
|
||||
viewModel = viewModel,
|
||||
showVerificationSheet = showVerificationSheet,
|
||||
onVerificationSheetDismiss = viewModel::hideVerificationSheet,
|
||||
showSecurityVerificationSheet = showSecurityVerificationSheet,
|
||||
onSecurityVerificationSheetDismiss = viewModel::hideSecurityVerificationSheet,
|
||||
showMeshPeerListSheet = showMeshPeerListSheet,
|
||||
onMeshPeerListDismiss = viewModel::hideMeshPeerList,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatInputSection(
|
||||
fun ChatInputSection(
|
||||
messageText: TextFieldValue,
|
||||
onMessageTextChange: (TextFieldValue) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
@@ -513,8 +482,16 @@ private fun ChatDialogs(
|
||||
onUserSheetDismiss: () -> Unit,
|
||||
selectedUserForSheet: String,
|
||||
selectedMessageForSheet: BitchatMessage?,
|
||||
viewModel: ChatViewModel
|
||||
viewModel: ChatViewModel,
|
||||
showVerificationSheet: Boolean,
|
||||
onVerificationSheetDismiss: () -> Unit,
|
||||
showSecurityVerificationSheet: Boolean,
|
||||
onSecurityVerificationSheetDismiss: () -> Unit,
|
||||
showMeshPeerListSheet: Boolean,
|
||||
onMeshPeerListDismiss: () -> Unit,
|
||||
) {
|
||||
val privateChatSheetPeer by viewModel.privateChatSheetPeer.collectAsStateWithLifecycle()
|
||||
|
||||
// Password dialog
|
||||
PasswordPromptDialog(
|
||||
show = showPasswordDialog,
|
||||
@@ -567,4 +544,44 @@ private fun ChatDialogs(
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
// MeshPeerList sheet (network view)
|
||||
if (showMeshPeerListSheet){
|
||||
MeshPeerListSheet(
|
||||
isPresented = showMeshPeerListSheet,
|
||||
viewModel = viewModel,
|
||||
onDismiss = onMeshPeerListDismiss,
|
||||
onShowVerification = {
|
||||
onMeshPeerListDismiss()
|
||||
viewModel.showVerificationSheet(fromSidebar = true)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showVerificationSheet) {
|
||||
VerificationSheet(
|
||||
isPresented = showVerificationSheet,
|
||||
onDismiss = onVerificationSheetDismiss,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
|
||||
if (showSecurityVerificationSheet) {
|
||||
SecurityVerificationSheet(
|
||||
isPresented = showSecurityVerificationSheet,
|
||||
onDismiss = onSecurityVerificationSheetDismiss,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
|
||||
if (privateChatSheetPeer != null) {
|
||||
PrivateChatSheet(
|
||||
isPresented = true,
|
||||
peerID = privateChatSheetPeer!!,
|
||||
viewModel = viewModel,
|
||||
onDismiss = {
|
||||
viewModel.hidePrivateChatSheet()
|
||||
viewModel.endPrivateChat()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,11 +76,7 @@ class ChatState(
|
||||
|
||||
private val _passwordPromptChannel = MutableStateFlow<String?>(null)
|
||||
val passwordPromptChannel: StateFlow<String?> = _passwordPromptChannel.asStateFlow()
|
||||
|
||||
// Sidebar state
|
||||
private val _showSidebar = MutableStateFlow(false)
|
||||
val showSidebar: StateFlow<Boolean> = _showSidebar.asStateFlow()
|
||||
|
||||
|
||||
// Command autocomplete
|
||||
private val _showCommandSuggestions = MutableStateFlow(false)
|
||||
val showCommandSuggestions: StateFlow<Boolean> = _showCommandSuggestions.asStateFlow()
|
||||
@@ -122,6 +118,18 @@ class ChatState(
|
||||
// Navigation state
|
||||
private val _showAppInfo = MutableStateFlow<Boolean>(false)
|
||||
val showAppInfo: StateFlow<Boolean> = _showAppInfo.asStateFlow()
|
||||
|
||||
private val _showMeshPeerList = MutableStateFlow(false)
|
||||
val showMeshPeerList: StateFlow<Boolean> = _showMeshPeerList.asStateFlow()
|
||||
|
||||
private val _privateChatSheetPeer = MutableStateFlow<String?>(null)
|
||||
val privateChatSheetPeer: StateFlow<String?> = _privateChatSheetPeer.asStateFlow()
|
||||
|
||||
private val _showVerificationSheet = MutableStateFlow(false)
|
||||
val showVerificationSheet: StateFlow<Boolean> = _showVerificationSheet.asStateFlow()
|
||||
|
||||
private val _showSecurityVerificationSheet = MutableStateFlow(false)
|
||||
val showSecurityVerificationSheet: StateFlow<Boolean> = _showSecurityVerificationSheet.asStateFlow()
|
||||
|
||||
// Location channels state (for Nostr geohash features)
|
||||
private val _selectedLocationChannel = MutableStateFlow<com.bitchat.android.geohash.ChannelID?>(com.bitchat.android.geohash.ChannelID.Mesh)
|
||||
@@ -149,7 +157,7 @@ class ChatState(
|
||||
started = WhileSubscribed(5_000),
|
||||
initialValue = false
|
||||
)
|
||||
|
||||
|
||||
val hasUnreadPrivateMessages: StateFlow<Boolean> = _unreadPrivateMessages
|
||||
.map { unreadSet -> unreadSet.isNotEmpty() }
|
||||
.stateIn(
|
||||
@@ -172,7 +180,6 @@ class ChatState(
|
||||
fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value
|
||||
fun getShowPasswordPromptValue() = _showPasswordPrompt.value
|
||||
fun getPasswordPromptChannelValue() = _passwordPromptChannel.value
|
||||
fun getShowSidebarValue() = _showSidebar.value
|
||||
fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value
|
||||
fun getCommandSuggestionsValue() = _commandSuggestions.value
|
||||
fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value
|
||||
@@ -182,6 +189,10 @@ class ChatState(
|
||||
fun getPeerFingerprintsValue() = _peerFingerprints.value
|
||||
fun getShowAppInfoValue() = _showAppInfo.value
|
||||
fun getGeohashPeopleValue() = _geohashPeople.value
|
||||
|
||||
fun getShowMeshPeerListValue() = _showMeshPeerList.value
|
||||
fun getPrivateChatSheetPeerValue() = _privateChatSheetPeer.value
|
||||
|
||||
fun getTeleportedGeoValue() = _teleportedGeo.value
|
||||
fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value
|
||||
|
||||
@@ -245,11 +256,7 @@ class ChatState(
|
||||
fun setPasswordPromptChannel(channel: String?) {
|
||||
_passwordPromptChannel.value = channel
|
||||
}
|
||||
|
||||
fun setShowSidebar(show: Boolean) {
|
||||
_showSidebar.value = show
|
||||
}
|
||||
|
||||
|
||||
fun setShowCommandSuggestions(show: Boolean) {
|
||||
_showCommandSuggestions.value = show
|
||||
}
|
||||
@@ -302,6 +309,14 @@ class ChatState(
|
||||
fun setShowAppInfo(show: Boolean) {
|
||||
_showAppInfo.value = show
|
||||
}
|
||||
|
||||
fun setShowVerificationSheet(show: Boolean) {
|
||||
_showVerificationSheet.value = show
|
||||
}
|
||||
|
||||
fun setShowSecurityVerificationSheet(show: Boolean) {
|
||||
_showSecurityVerificationSheet.value = show
|
||||
}
|
||||
|
||||
fun setSelectedLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) {
|
||||
_selectedLocationChannel.value = channel
|
||||
@@ -323,4 +338,11 @@ class ChatState(
|
||||
_geohashParticipantCounts.value = counts
|
||||
}
|
||||
|
||||
fun setShowMeshPeerList(show: Boolean) {
|
||||
_showMeshPeerList.value = show
|
||||
}
|
||||
|
||||
fun setPrivateChatSheetPeer(peerID: String?) {
|
||||
_privateChatSheetPeer.value = peerID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Shield
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import java.text.SimpleDateFormat
|
||||
@@ -42,7 +42,7 @@ fun getRSSIColor(rssi: Int): Color {
|
||||
fun formatMessageAsAnnotatedString(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: BluetoothMeshService,
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
): AnnotatedString {
|
||||
@@ -162,7 +162,7 @@ fun formatMessageAsAnnotatedString(
|
||||
fun formatMessageHeaderAnnotatedString(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: BluetoothMeshService,
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
): AnnotatedString {
|
||||
|
||||
@@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
@@ -16,7 +15,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.R
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import kotlinx.coroutines.launch
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
|
||||
/**
|
||||
@@ -36,23 +35,18 @@ fun ChatUserSheet(
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
// Bottom sheet state
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
)
|
||||
|
||||
// iOS system colors (matches LocationChannelsSheet exactly)
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) // iOS green
|
||||
val standardBlue = Color(0xFF007AFF) // iOS blue
|
||||
val standardPurple = if (isDark) Color(0xFFBF5AF2) else Color(0xFFAF52DE) // iOS purple
|
||||
val standardRed = Color(0xFFFF3B30) // iOS red
|
||||
val standardGrey = if (isDark) Color(0xFF8E8E93) else Color(0xFF6D6D70) // iOS grey
|
||||
|
||||
if (isPresented) {
|
||||
ModalBottomSheet(
|
||||
BitchatBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
modifier = modifier
|
||||
) {
|
||||
Column(
|
||||
@@ -99,6 +93,33 @@ fun ChatUserSheet(
|
||||
|
||||
// Only show user actions for other users' messages or when no message is selected
|
||||
if (selectedMessage?.sender != viewModel.nickname.value) {
|
||||
// Send private message action
|
||||
item {
|
||||
UserActionRow(
|
||||
title = stringResource(R.string.action_private_message_title, targetNickname),
|
||||
subtitle = stringResource(R.string.action_private_message_subtitle),
|
||||
titleColor = standardPurple,
|
||||
onClick = {
|
||||
val selectedLocationChannel = viewModel.selectedLocationChannel.value
|
||||
if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) {
|
||||
if (selectedMessage?.senderPeerID?.startsWith("nostr:") == true) {
|
||||
val shortId = selectedMessage.senderPeerID!!.substring(6)
|
||||
viewModel.startGeohashDMByShortId(shortId)
|
||||
} else {
|
||||
viewModel.startGeohashDMByNickname(targetNickname)
|
||||
}
|
||||
} else {
|
||||
// Mesh chat
|
||||
val peerID = selectedMessage?.senderPeerID ?: viewModel.getPeerIDForNickname(targetNickname)
|
||||
if (peerID != null) {
|
||||
viewModel.showPrivateChatSheet(peerID)
|
||||
}
|
||||
}
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Slap action
|
||||
item {
|
||||
UserActionRow(
|
||||
|
||||
@@ -5,11 +5,17 @@ import android.util.Log
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitchat.android.favorites.FavoritesPersistenceService
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import com.bitchat.android.mesh.BluetoothMeshDelegate
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.service.MeshServiceHolder
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import com.bitchat.android.nostr.NostrIdentityBridge
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
|
||||
|
||||
@@ -19,6 +25,13 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
import kotlin.random.Random
|
||||
import com.bitchat.android.services.VerificationService
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.bitchat.android.noise.NoiseSession
|
||||
import com.bitchat.android.nostr.GeohashAliasRegistry
|
||||
import com.bitchat.android.util.dataFromHexString
|
||||
import com.bitchat.android.util.hexEncodedString
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Refactored ChatViewModel - Main coordinator for bitchat functionality
|
||||
@@ -26,8 +39,16 @@ import kotlin.random.Random
|
||||
*/
|
||||
class ChatViewModel(
|
||||
application: Application,
|
||||
val meshService: BluetoothMeshService
|
||||
initialMeshService: BluetoothMeshService,
|
||||
initialUnifiedMeshService: MeshService
|
||||
) : AndroidViewModel(application), BluetoothMeshDelegate {
|
||||
|
||||
// Made var to support mesh service replacement after panic clear
|
||||
var meshService: BluetoothMeshService = initialMeshService
|
||||
private set
|
||||
private var unifiedMeshService: MeshService = initialUnifiedMeshService
|
||||
private val mesh: MeshService
|
||||
get() = unifiedMeshService
|
||||
private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } }
|
||||
|
||||
companion object {
|
||||
@@ -46,6 +67,20 @@ class ChatViewModel(
|
||||
mediaSendingManager.sendImageNote(toPeerIDOrNull, channelOrNull, filePath)
|
||||
}
|
||||
|
||||
fun getCurrentNpub(): String? {
|
||||
return try {
|
||||
NostrIdentityBridge
|
||||
.getCurrentNostrIdentity(getApplication())
|
||||
?.npub
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun buildMyQRString(nickname: String, npub: String?): String {
|
||||
return VerificationService.buildMyQRString(nickname, npub) ?: ""
|
||||
}
|
||||
|
||||
// MARK: - State management
|
||||
private val state = ChatState(
|
||||
scope = viewModelScope,
|
||||
@@ -57,14 +92,15 @@ class ChatViewModel(
|
||||
|
||||
// Specialized managers
|
||||
private val dataManager = DataManager(application.applicationContext)
|
||||
private val identityManager by lazy { SecureIdentityStateManager(getApplication()) }
|
||||
private val messageManager = MessageManager(state)
|
||||
private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope)
|
||||
|
||||
// Create Noise session delegate for clean dependency injection
|
||||
private val noiseSessionDelegate = object : NoiseSessionDelegate {
|
||||
override fun hasEstablishedSession(peerID: String): Boolean = meshService.hasEstablishedSession(peerID)
|
||||
override fun initiateHandshake(peerID: String) = meshService.initiateNoiseHandshake(peerID)
|
||||
override fun getMyPeerID(): String = meshService.myPeerID
|
||||
override fun hasEstablishedSession(peerID: String): Boolean = hasEstablishedSessionOnAnyLocalTransport(peerID)
|
||||
override fun initiateHandshake(peerID: String) = initiateNoiseHandshakeOnBestLocalTransport(peerID)
|
||||
override fun getMyPeerID(): String = mesh.myPeerID
|
||||
}
|
||||
|
||||
val privateChatManager = PrivateChatManager(state, messageManager, dataManager, noiseSessionDelegate)
|
||||
@@ -75,8 +111,19 @@ class ChatViewModel(
|
||||
NotificationIntervalManager()
|
||||
)
|
||||
|
||||
private val verificationHandler = VerificationHandler(
|
||||
context = application.applicationContext,
|
||||
scope = viewModelScope,
|
||||
getMeshService = { mesh },
|
||||
identityManager = identityManager,
|
||||
state = state,
|
||||
notificationManager = notificationManager,
|
||||
messageManager = messageManager
|
||||
)
|
||||
val verifiedFingerprints = verificationHandler.verifiedFingerprints
|
||||
|
||||
// Media file sending manager
|
||||
private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager, meshService)
|
||||
private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager) { mesh }
|
||||
|
||||
// Delegate handler for mesh callbacks
|
||||
private val meshDelegateHandler = MeshDelegateHandler(
|
||||
@@ -87,8 +134,8 @@ class ChatViewModel(
|
||||
notificationManager = notificationManager,
|
||||
coroutineScope = viewModelScope,
|
||||
onHapticFeedback = { ChatViewModelUtils.triggerHapticFeedback(application.applicationContext) },
|
||||
getMyPeerID = { meshService.myPeerID },
|
||||
getMeshService = { meshService }
|
||||
getMyPeerID = { mesh.myPeerID },
|
||||
getMeshService = { mesh }
|
||||
)
|
||||
|
||||
// New Geohash architecture ViewModel (replaces God object service usage in UI path)
|
||||
@@ -120,7 +167,6 @@ class ChatViewModel(
|
||||
val passwordProtectedChannels: StateFlow<Set<String>> = state.passwordProtectedChannels
|
||||
val showPasswordPrompt: StateFlow<Boolean> = state.showPasswordPrompt
|
||||
val passwordPromptChannel: StateFlow<String?> = state.passwordPromptChannel
|
||||
val showSidebar: StateFlow<Boolean> = state.showSidebar
|
||||
val hasUnreadChannels = state.hasUnreadChannels
|
||||
val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages
|
||||
val showCommandSuggestions: StateFlow<Boolean> = state.showCommandSuggestions
|
||||
@@ -134,11 +180,27 @@ class ChatViewModel(
|
||||
val peerRSSI: StateFlow<Map<String, Int>> = state.peerRSSI
|
||||
val peerDirect: StateFlow<Map<String, Boolean>> = state.peerDirect
|
||||
val showAppInfo: StateFlow<Boolean> = state.showAppInfo
|
||||
val showMeshPeerList: StateFlow<Boolean> = state.showMeshPeerList
|
||||
val privateChatSheetPeer: StateFlow<String?> = state.privateChatSheetPeer
|
||||
val showVerificationSheet: StateFlow<Boolean> = state.showVerificationSheet
|
||||
val showSecurityVerificationSheet: StateFlow<Boolean> = state.showSecurityVerificationSheet
|
||||
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
|
||||
val isTeleported: StateFlow<Boolean> = state.isTeleported
|
||||
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
|
||||
val teleportedGeo: StateFlow<Set<String>> = state.teleportedGeo
|
||||
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
|
||||
val meshServiceFacade: MeshService
|
||||
get() = mesh
|
||||
val myPeerID: String
|
||||
get() = mesh.myPeerID
|
||||
|
||||
fun getMeshPeerFingerprint(peerID: String): String? = mesh.getPeerFingerprint(peerID)
|
||||
|
||||
fun getMeshPeerInfo(peerID: String): com.bitchat.android.mesh.PeerInfo? = mesh.getPeerInfo(peerID)
|
||||
|
||||
fun initiateMeshHandshake(peerID: String) {
|
||||
mesh.initiateNoiseHandshake(peerID)
|
||||
}
|
||||
|
||||
init {
|
||||
// Note: Mesh service delegate is now set by MainActivity
|
||||
@@ -163,7 +225,7 @@ class ChatViewModel(
|
||||
// Recompute unread set using SeenMessageStore for robustness across Activity recreation
|
||||
try {
|
||||
val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication())
|
||||
val myNick = state.getNicknameValue() ?: meshService.myPeerID
|
||||
val myNick = state.getNicknameValue() ?: mesh.myPeerID
|
||||
val unread = mutableSetOf<String>()
|
||||
byPeer.forEach { (peer, list) ->
|
||||
if (list.any { msg -> msg.sender != myNick && !seen.hasRead(msg.id) }) unread.add(peer)
|
||||
@@ -247,11 +309,14 @@ class ChatViewModel(
|
||||
// Initialize favorites persistence service
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication())
|
||||
|
||||
// Load verified fingerprints from secure storage
|
||||
verificationHandler.loadVerifiedFingerprints()
|
||||
|
||||
|
||||
// Ensure NostrTransport knows our mesh peer ID for embedded packets
|
||||
try {
|
||||
val nostrTransport = com.bitchat.android.nostr.NostrTransport.getInstance(getApplication())
|
||||
nostrTransport.senderPeerID = meshService.myPeerID
|
||||
nostrTransport.senderPeerID = mesh.myPeerID
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Note: Mesh service is now started by MainActivity
|
||||
@@ -269,7 +334,7 @@ class ChatViewModel(
|
||||
fun setNickname(newNickname: String) {
|
||||
state.setNickname(newNickname)
|
||||
dataManager.saveNickname(newNickname)
|
||||
meshService.sendBroadcastAnnounce()
|
||||
mesh.sendBroadcastAnnounce()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -315,7 +380,7 @@ class ChatViewModel(
|
||||
// MARK: - Channel Management (delegated)
|
||||
|
||||
fun joinChannel(channel: String, password: String? = null): Boolean {
|
||||
return channelManager.joinChannel(channel, password, meshService.myPeerID)
|
||||
return channelManager.joinChannel(channel, password, mesh.myPeerID)
|
||||
}
|
||||
|
||||
fun switchToChannel(channel: String?) {
|
||||
@@ -324,7 +389,7 @@ class ChatViewModel(
|
||||
|
||||
fun leaveChannel(channel: String) {
|
||||
channelManager.leaveChannel(channel)
|
||||
meshService.sendMessage("left $channel")
|
||||
mesh.sendMessage("left $channel", emptyList(), null)
|
||||
}
|
||||
|
||||
// MARK: - Private Chat Management (delegated)
|
||||
@@ -335,7 +400,7 @@ class ChatViewModel(
|
||||
ensureGeohashDMSubscriptionIfNeeded(peerID)
|
||||
}
|
||||
|
||||
val success = privateChatManager.startPrivateChat(peerID, meshService)
|
||||
val success = privateChatManager.startPrivateChat(peerID, mesh)
|
||||
if (success) {
|
||||
// Notify notification manager about current private chat
|
||||
setCurrentPrivateChatPeer(peerID)
|
||||
@@ -361,6 +426,8 @@ class ChatViewModel(
|
||||
setCurrentPrivateChatPeer(null)
|
||||
// Clear mesh mention notifications since user is now back in mesh chat
|
||||
clearMeshMentionNotifications()
|
||||
// Ensure sheet is hidden
|
||||
hidePrivateChatSheet()
|
||||
}
|
||||
|
||||
// MARK: - Open Latest Unread Private Chat
|
||||
@@ -370,7 +437,7 @@ class ChatViewModel(
|
||||
val unreadKeys = state.getUnreadPrivateMessagesValue()
|
||||
if (unreadKeys.isEmpty()) return
|
||||
|
||||
val me = state.getNicknameValue() ?: meshService.myPeerID
|
||||
val me = state.getNicknameValue() ?: mesh.myPeerID
|
||||
val chats = state.getPrivateChatsValue()
|
||||
|
||||
// Pick the latest incoming message among unread conversations
|
||||
@@ -401,20 +468,15 @@ class ChatViewModel(
|
||||
val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
|
||||
selectedPeerID = targetKey,
|
||||
connectedPeers = state.getConnectedPeersValue(),
|
||||
meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey },
|
||||
meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true },
|
||||
meshNoiseKeyForPeer = { pid -> mesh.getPeerInfo(pid)?.noisePublicKey },
|
||||
meshHasPeer = { pid -> mesh.getPeerInfo(pid)?.isConnected == true },
|
||||
nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) },
|
||||
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
|
||||
)
|
||||
canonical ?: targetKey
|
||||
}
|
||||
|
||||
startPrivateChat(openPeer)
|
||||
|
||||
// If sidebar visible, hide it to focus on the private chat
|
||||
if (state.getShowSidebarValue()) {
|
||||
state.setShowSidebar(false)
|
||||
}
|
||||
showPrivateChatSheet(openPeer)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "openLatestUnreadPrivateChat failed: ${e.message}")
|
||||
}
|
||||
@@ -431,25 +493,23 @@ class ChatViewModel(
|
||||
// Check for commands
|
||||
if (content.startsWith("/")) {
|
||||
val selectedLocationForCommand = state.selectedLocationChannel.value
|
||||
commandProcessor.processCommand(content, meshService, meshService.myPeerID, { messageContent, mentions, channel ->
|
||||
commandProcessor.processCommand(content, mesh, mesh.myPeerID, { messageContent, mentions, channel ->
|
||||
if (selectedLocationForCommand is com.bitchat.android.geohash.ChannelID.Location) {
|
||||
// Route command-generated public messages via Nostr in geohash channels
|
||||
geohashViewModel.sendGeohashMessage(
|
||||
messageContent,
|
||||
selectedLocationForCommand.channel,
|
||||
meshService.myPeerID,
|
||||
mesh.myPeerID,
|
||||
state.getNicknameValue()
|
||||
)
|
||||
} else {
|
||||
// Default: route via mesh + Wi‑Fi Aware
|
||||
meshService.sendMessage(messageContent, mentions, channel)
|
||||
try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(messageContent, mentions, channel) } catch (_: Exception) {}
|
||||
mesh.sendMessage(messageContent, mentions, channel)
|
||||
}
|
||||
})
|
||||
}, this)
|
||||
return
|
||||
}
|
||||
|
||||
val mentions = messageManager.parseMentions(content, meshService.getPeerNicknames().values.toSet(), state.getNicknameValue())
|
||||
val mentions = messageManager.parseMentions(content, mesh.getPeerNicknames().values.toSet(), state.getNicknameValue())
|
||||
// REMOVED: Auto-join mentioned channels feature that was incorrectly parsing hashtags from @mentions
|
||||
// This was causing messages like "test @jack#1234 test" to auto-join channel "#1234"
|
||||
|
||||
@@ -461,26 +521,30 @@ class ChatViewModel(
|
||||
selectedPeer = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
|
||||
selectedPeerID = selectedPeer,
|
||||
connectedPeers = state.getConnectedPeersValue(),
|
||||
meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey },
|
||||
meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true },
|
||||
meshNoiseKeyForPeer = { pid -> mesh.getPeerInfo(pid)?.noisePublicKey },
|
||||
meshHasPeer = { pid -> mesh.getPeerInfo(pid)?.isConnected == true },
|
||||
nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) },
|
||||
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
|
||||
).also { canonical ->
|
||||
if (canonical != state.getSelectedPrivateChatPeerValue()) {
|
||||
privateChatManager.startPrivateChat(canonical, meshService)
|
||||
privateChatManager.startPrivateChat(canonical, mesh)
|
||||
// If we're in the private chat sheet, update its active peer too
|
||||
if (state.getPrivateChatSheetPeerValue() != null) {
|
||||
showPrivateChatSheet(canonical)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Send private message
|
||||
val recipientNickname = meshService.getPeerNicknames()[selectedPeer]
|
||||
val recipientNickname = nicknameForPeer(selectedPeer)
|
||||
privateChatManager.sendPrivateMessage(
|
||||
content,
|
||||
selectedPeer,
|
||||
recipientNickname,
|
||||
state.getNicknameValue(),
|
||||
meshService.myPeerID
|
||||
mesh.myPeerID
|
||||
) { messageContent, peerID, recipientNicknameParam, messageId ->
|
||||
// Route via MessageRouter (mesh when connected+established, else Nostr)
|
||||
val router = com.bitchat.android.services.MessageRouter.getInstance(getApplication(), meshService)
|
||||
val router = com.bitchat.android.services.MessageRouter.getInstance(getApplication(), mesh)
|
||||
router.sendPrivate(messageContent, peerID, recipientNicknameParam, messageId)
|
||||
}
|
||||
} else {
|
||||
@@ -488,21 +552,21 @@ class ChatViewModel(
|
||||
val selectedLocationChannel = state.selectedLocationChannel.value
|
||||
if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) {
|
||||
// Send to geohash channel via Nostr ephemeral event
|
||||
geohashViewModel.sendGeohashMessage(content, selectedLocationChannel.channel, meshService.myPeerID, state.getNicknameValue())
|
||||
geohashViewModel.sendGeohashMessage(content, selectedLocationChannel.channel, mesh.myPeerID, state.getNicknameValue())
|
||||
} else {
|
||||
// Send public/channel message via mesh
|
||||
val message = BitchatMessage(
|
||||
sender = state.getNicknameValue() ?: meshService.myPeerID,
|
||||
sender = state.getNicknameValue() ?: mesh.myPeerID,
|
||||
content = content,
|
||||
timestamp = Date(),
|
||||
isRelay = false,
|
||||
senderPeerID = meshService.myPeerID,
|
||||
senderPeerID = mesh.myPeerID,
|
||||
mentions = if (mentions.isNotEmpty()) mentions else null,
|
||||
channel = currentChannelValue
|
||||
)
|
||||
|
||||
if (currentChannelValue != null) {
|
||||
channelManager.addChannelMessage(currentChannelValue, message, meshService.myPeerID)
|
||||
channelManager.addChannelMessage(currentChannelValue, message, mesh.myPeerID)
|
||||
|
||||
// Check if encrypted channel
|
||||
if (channelManager.hasChannelKey(currentChannelValue)) {
|
||||
@@ -511,25 +575,20 @@ class ChatViewModel(
|
||||
mentions,
|
||||
currentChannelValue,
|
||||
state.getNicknameValue(),
|
||||
meshService.myPeerID,
|
||||
mesh.myPeerID,
|
||||
onEncryptedPayload = { encryptedData ->
|
||||
// Send encrypted payload announcement over both transports for reachability
|
||||
meshService.sendMessage(content, mentions, currentChannelValue)
|
||||
try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, currentChannelValue) } catch (_: Exception) {}
|
||||
mesh.sendMessage(content, mentions, currentChannelValue)
|
||||
},
|
||||
onFallback = {
|
||||
meshService.sendMessage(content, mentions, currentChannelValue)
|
||||
try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, currentChannelValue) } catch (_: Exception) {}
|
||||
mesh.sendMessage(content, mentions, currentChannelValue)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
meshService.sendMessage(content, mentions, currentChannelValue)
|
||||
try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, currentChannelValue) } catch (_: Exception) {}
|
||||
mesh.sendMessage(content, mentions, currentChannelValue)
|
||||
}
|
||||
} else {
|
||||
messageManager.addMessage(message)
|
||||
meshService.sendMessage(content, mentions, null)
|
||||
try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendMessage(content, mentions, null) } catch (_: Exception) {}
|
||||
mesh.sendMessage(content, mentions, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -538,7 +597,7 @@ class ChatViewModel(
|
||||
// MARK: - Utility Functions
|
||||
|
||||
fun getPeerIDForNickname(nickname: String): String? {
|
||||
return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key
|
||||
return mesh.getPeerNicknames().entries.find { it.value == nickname }?.key
|
||||
}
|
||||
|
||||
fun toggleFavorite(peerID: String) {
|
||||
@@ -548,10 +607,10 @@ class ChatViewModel(
|
||||
// Persist relationship in FavoritesPersistenceService
|
||||
try {
|
||||
var noiseKey: ByteArray? = null
|
||||
var nickname: String = meshService.getPeerNicknames()[peerID] ?: peerID
|
||||
var nickname: String = mesh.getPeerNicknames()[peerID] ?: peerID
|
||||
|
||||
// Case 1: Live mesh peer with known info
|
||||
val peerInfo = meshService.getPeerInfo(peerID)
|
||||
val peerInfo = mesh.getPeerInfo(peerID)
|
||||
if (peerInfo?.noisePublicKey != null) {
|
||||
noiseKey = peerInfo.noisePublicKey
|
||||
nickname = peerInfo.nickname
|
||||
@@ -581,22 +640,9 @@ class ChatViewModel(
|
||||
|
||||
// Send favorite notification via mesh or Nostr with our npub if available
|
||||
try {
|
||||
val myNostr = com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(getApplication())
|
||||
val announcementContent = if (isNowFavorite) "[FAVORITED]:${myNostr?.npub ?: ""}" else "[UNFAVORITED]:${myNostr?.npub ?: ""}"
|
||||
// Prefer mesh if session established, else try Nostr
|
||||
if (meshService.hasEstablishedSession(peerID)) {
|
||||
// Reuse existing private message path for notifications
|
||||
meshService.sendPrivateMessage(
|
||||
announcementContent,
|
||||
peerID,
|
||||
nickname,
|
||||
java.util.UUID.randomUUID().toString()
|
||||
)
|
||||
} else {
|
||||
val nostrTransport = com.bitchat.android.nostr.NostrTransport.getInstance(getApplication())
|
||||
nostrTransport.senderPeerID = meshService.myPeerID
|
||||
nostrTransport.sendFavoriteNotification(peerID, isNowFavorite)
|
||||
}
|
||||
com.bitchat.android.services.MessageRouter
|
||||
.getInstance(getApplication(), mesh)
|
||||
.sendFavoriteNotification(peerID, isNowFavorite)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
@@ -612,6 +658,40 @@ class ChatViewModel(
|
||||
Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}")
|
||||
Log.i("ChatViewModel", "==============================")
|
||||
}
|
||||
|
||||
private fun isConnectedOnMesh(peerID: String): Boolean {
|
||||
return try {
|
||||
mesh.getPeerInfo(peerID)?.isConnected == true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasEstablishedSessionOnMesh(peerID: String): Boolean {
|
||||
return try {
|
||||
mesh.getPeerInfo(peerID)?.isConnected == true &&
|
||||
mesh.hasEstablishedSession(peerID)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasEstablishedSessionOnAnyLocalTransport(peerID: String): Boolean {
|
||||
return hasEstablishedSessionOnMesh(peerID)
|
||||
}
|
||||
|
||||
private fun initiateNoiseHandshakeOnBestLocalTransport(peerID: String) {
|
||||
mesh.initiateNoiseHandshake(peerID)
|
||||
}
|
||||
|
||||
private fun nicknameForPeer(peerID: String): String? {
|
||||
return state.peerNicknames.value[peerID]
|
||||
?: try { mesh.getPeerNicknames()[peerID] } catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
private fun sessionStateForPeer(peerID: String): NoiseSession.NoiseSessionState {
|
||||
return try { mesh.getSessionState(peerID) } catch (_: Exception) { NoiseSession.NoiseSessionState.Uninitialized }
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize session state monitoring for reactive UI updates
|
||||
@@ -636,7 +716,7 @@ class ChatViewModel(
|
||||
// Update session states
|
||||
val prevStates = state.getPeerSessionStatesValue()
|
||||
val sessionStates = currentPeers.associateWith { peerID ->
|
||||
meshService.getSessionState(peerID).toString()
|
||||
sessionStateForPeer(peerID).toString()
|
||||
}
|
||||
state.setPeerSessionStates(sessionStates)
|
||||
// Detect new established sessions and flush router outbox for them and their noiseHex aliases
|
||||
@@ -644,77 +724,154 @@ class ChatViewModel(
|
||||
val old = prevStates[peerID]
|
||||
if (old != "established" && newState == "established") {
|
||||
com.bitchat.android.services.MessageRouter
|
||||
.getInstance(getApplication(), meshService)
|
||||
.getInstance(getApplication(), mesh)
|
||||
.onSessionEstablished(peerID)
|
||||
}
|
||||
}
|
||||
// Update fingerprint mappings from centralized manager
|
||||
val fingerprints = privateChatManager.getAllPeerFingerprints()
|
||||
state.setPeerFingerprints(fingerprints)
|
||||
fingerprints.forEach { (peerID, fingerprint) ->
|
||||
identityManager.cachePeerFingerprint(peerID, fingerprint)
|
||||
val info = try { mesh.getPeerInfo(peerID) } catch (_: Exception) { null }
|
||||
val noiseKeyHex = info?.noisePublicKey?.hexEncodedString()
|
||||
if (noiseKeyHex != null) {
|
||||
identityManager.cachePeerNoiseKey(peerID, noiseKeyHex)
|
||||
identityManager.cacheNoiseFingerprint(noiseKeyHex, fingerprint)
|
||||
}
|
||||
info?.nickname?.takeIf { it.isNotBlank() }?.let { nickname ->
|
||||
identityManager.cacheFingerprintNickname(fingerprint, nickname)
|
||||
}
|
||||
}
|
||||
|
||||
// Merge nicknames from BLE and Wi‑Fi Aware to display names for all peers
|
||||
val bleNick = meshService.getPeerNicknames()
|
||||
val awareNickRaw = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerNicknamesMap() } catch (_: Exception) { null }
|
||||
val mergedNick = if (awareNickRaw != null) bleNick + awareNickRaw.filter { it.value != null }.mapValues { it.value!! }.filterKeys { it !in bleNick || bleNick[it].isNullOrBlank() } else bleNick
|
||||
state.setPeerNicknames(mergedNick)
|
||||
state.setPeerNicknames(mesh.getPeerNicknames())
|
||||
|
||||
val rssiValues = meshService.getPeerRSSI()
|
||||
val awareRssi = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerRSSI() } catch (_: Exception) { null }
|
||||
val mergedRssi = if (awareRssi != null) rssiValues + awareRssi.filterKeys { it !in rssiValues } else rssiValues
|
||||
state.setPeerRSSI(mergedRssi)
|
||||
state.setPeerRSSI(mesh.getPeerRSSI())
|
||||
|
||||
// Update directness per peer (driven by PeerManager state)
|
||||
try {
|
||||
val directMap = state.getConnectedPeersValue().associateWith { pid ->
|
||||
val ble = meshService.getPeerInfo(pid)?.isDirectConnection == true
|
||||
val aware = try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.getPeerInfo(pid)?.isDirectConnection == true } catch (_: Exception) { false }
|
||||
ble || aware
|
||||
mesh.getPeerInfo(pid)?.isDirectConnection == true
|
||||
}
|
||||
state.setPeerDirect(directMap)
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Flush any pending QR verification once a Noise session is established
|
||||
currentPeers.forEach { peerID ->
|
||||
if (sessionStateForPeer(peerID) is NoiseSession.NoiseSessionState.Established) {
|
||||
verificationHandler.sendPendingVerificationIfNeeded(peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - QR Verification
|
||||
|
||||
fun isPeerVerified(peerID: String, verifiedFingerprints: Set<String>): Boolean {
|
||||
if (peerID.startsWith("nostr_") || peerID.startsWith("nostr:")) return false
|
||||
val fingerprint = verificationHandler.getPeerFingerprintForDisplay(peerID)
|
||||
return fingerprint != null && verifiedFingerprints.contains(fingerprint)
|
||||
}
|
||||
|
||||
fun isNoisePublicKeyVerified(noisePublicKey: ByteArray, verifiedFingerprints: Set<String>): Boolean {
|
||||
val fingerprint = verificationHandler.fingerprintFromNoiseBytes(noisePublicKey)
|
||||
return verifiedFingerprints.contains(fingerprint)
|
||||
}
|
||||
|
||||
fun unverifyFingerprint(peerID: String) {
|
||||
verificationHandler.unverifyFingerprint(peerID)
|
||||
}
|
||||
|
||||
fun beginQRVerification(qr: VerificationService.VerificationQR): Boolean {
|
||||
return verificationHandler.beginQRVerification(qr)
|
||||
}
|
||||
|
||||
// MARK: - Debug and Troubleshooting
|
||||
|
||||
fun getDebugStatus(): String {
|
||||
return meshService.getDebugStatus()
|
||||
}
|
||||
|
||||
// Note: Mesh service restart is now handled by MainActivity
|
||||
// This function is no longer needed
|
||||
|
||||
fun setAppBackgroundState(inBackground: Boolean) {
|
||||
// Forward to notification manager for notification logic
|
||||
notificationManager.setAppBackgroundState(inBackground)
|
||||
return mesh.getDebugStatus()
|
||||
}
|
||||
|
||||
fun setCurrentPrivateChatPeer(peerID: String?) {
|
||||
// Update notification manager with current private chat peer
|
||||
notificationManager.setCurrentPrivateChatPeer(peerID)
|
||||
}
|
||||
|
||||
fun setCurrentGeohash(geohash: String?) {
|
||||
// Update notification manager with current geohash for notification logic
|
||||
notificationManager.setCurrentGeohash(geohash)
|
||||
}
|
||||
|
||||
fun clearNotificationsForSender(peerID: String) {
|
||||
// Clear notifications when user opens a chat
|
||||
notificationManager.clearNotificationsForSender(peerID)
|
||||
}
|
||||
|
||||
fun clearNotificationsForGeohash(geohash: String) {
|
||||
// Clear notifications when user opens a geohash chat
|
||||
notificationManager.clearNotificationsForGeohash(geohash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear mesh mention notifications when user opens mesh chat
|
||||
*/
|
||||
fun clearMeshMentionNotifications() {
|
||||
notificationManager.clearMeshMentionNotifications()
|
||||
}
|
||||
|
||||
private var reopenSidebarAfterVerification = false
|
||||
|
||||
fun showVerificationSheet(fromSidebar: Boolean = false) {
|
||||
if (fromSidebar) {
|
||||
reopenSidebarAfterVerification = true
|
||||
}
|
||||
state.setShowVerificationSheet(true)
|
||||
}
|
||||
|
||||
fun hideVerificationSheet() {
|
||||
state.setShowVerificationSheet(false)
|
||||
if (reopenSidebarAfterVerification) {
|
||||
reopenSidebarAfterVerification = false
|
||||
state.setShowMeshPeerList(true)
|
||||
}
|
||||
}
|
||||
|
||||
fun showSecurityVerificationSheet() {
|
||||
state.setShowSecurityVerificationSheet(true)
|
||||
}
|
||||
|
||||
fun hideSecurityVerificationSheet() {
|
||||
state.setShowSecurityVerificationSheet(false)
|
||||
}
|
||||
|
||||
fun showMeshPeerList() {
|
||||
state.setShowMeshPeerList(true)
|
||||
}
|
||||
|
||||
fun hideMeshPeerList() {
|
||||
state.setShowMeshPeerList(false)
|
||||
}
|
||||
|
||||
fun showPrivateChatSheet(peerID: String) {
|
||||
state.setPrivateChatSheetPeer(peerID)
|
||||
}
|
||||
|
||||
fun hidePrivateChatSheet() {
|
||||
state.setPrivateChatSheetPeer(null)
|
||||
}
|
||||
|
||||
fun getPeerFingerprintForDisplay(peerID: String): String? {
|
||||
return verificationHandler.getPeerFingerprintForDisplay(peerID)
|
||||
}
|
||||
|
||||
fun getMyFingerprint(): String {
|
||||
return verificationHandler.getMyFingerprint()
|
||||
}
|
||||
|
||||
fun resolvePeerDisplayNameForFingerprint(peerID: String): String {
|
||||
return verificationHandler.resolvePeerDisplayNameForFingerprint(peerID)
|
||||
}
|
||||
|
||||
fun verifyFingerprintValue(fingerprint: String) {
|
||||
verificationHandler.verifyFingerprintValue(fingerprint)
|
||||
}
|
||||
|
||||
fun unverifyFingerprintValue(fingerprint: String) {
|
||||
verificationHandler.unverifyFingerprintValue(fingerprint)
|
||||
}
|
||||
|
||||
// MARK: - Command Autocomplete (delegated)
|
||||
|
||||
fun updateCommandSuggestions(input: String) {
|
||||
@@ -728,7 +885,7 @@ class ChatViewModel(
|
||||
// MARK: - Mention Autocomplete
|
||||
|
||||
fun updateMentionSuggestions(input: String) {
|
||||
commandProcessor.updateMentionSuggestions(input, meshService, this)
|
||||
commandProcessor.updateMentionSuggestions(input, mesh, this)
|
||||
}
|
||||
|
||||
fun selectMentionSuggestion(nickname: String, currentText: String): String {
|
||||
@@ -745,10 +902,6 @@ class ChatViewModel(
|
||||
meshDelegateHandler.didUpdatePeerList(peers)
|
||||
}
|
||||
|
||||
fun onWifiPeersUpdated(peers: List<String>) {
|
||||
meshDelegateHandler.onWifiPeersUpdated(peers)
|
||||
}
|
||||
|
||||
override fun didReceiveChannelLeave(channel: String, fromPeer: String) {
|
||||
meshDelegateHandler.didReceiveChannelLeave(channel, fromPeer)
|
||||
}
|
||||
@@ -760,6 +913,14 @@ class ChatViewModel(
|
||||
override fun didReceiveReadReceipt(messageID: String, recipientPeerID: String) {
|
||||
meshDelegateHandler.didReceiveReadReceipt(messageID, recipientPeerID)
|
||||
}
|
||||
|
||||
override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
verificationHandler.didReceiveVerifyChallenge(peerID, payload)
|
||||
}
|
||||
|
||||
override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
verificationHandler.didReceiveVerifyResponse(peerID, payload)
|
||||
}
|
||||
|
||||
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
|
||||
return meshDelegateHandler.decryptChannelMessage(encryptedContent, channel)
|
||||
@@ -786,6 +947,11 @@ class ChatViewModel(
|
||||
privateChatManager.clearAllPrivateChats()
|
||||
dataManager.clearAllData()
|
||||
|
||||
// Clear seen message store
|
||||
try {
|
||||
com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear()
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Clear all mesh service data
|
||||
clearAllMeshServiceData()
|
||||
|
||||
@@ -794,6 +960,9 @@ class ChatViewModel(
|
||||
|
||||
// Clear all notifications
|
||||
notificationManager.clearAllNotifications()
|
||||
|
||||
// Clear all media files
|
||||
com.bitchat.android.features.file.FileUtils.clearAllMedia(getApplication())
|
||||
|
||||
// Clear Nostr/geohash state, keys, connections, bookmarks, and reinitialize from scratch
|
||||
try {
|
||||
@@ -813,10 +982,39 @@ class ChatViewModel(
|
||||
state.setNickname(newNickname)
|
||||
dataManager.saveNickname(newNickname)
|
||||
|
||||
Log.w(TAG, "🚨 PANIC MODE COMPLETED - All sensitive data cleared")
|
||||
|
||||
// Note: Mesh service restart is now handled by MainActivity
|
||||
// This method now only clears data, not mesh service lifecycle
|
||||
// Recreate mesh service with fresh identity
|
||||
recreateMeshServiceAfterPanic()
|
||||
|
||||
Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${mesh.myPeerID}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Recreate the mesh service with a fresh identity after panic clear.
|
||||
* This ensures the new cryptographic keys are used for a new peer ID.
|
||||
*/
|
||||
private fun recreateMeshServiceAfterPanic() {
|
||||
val oldPeerID = mesh.myPeerID
|
||||
|
||||
// Clear the holder so getOrCreate() returns a fresh instance
|
||||
MeshServiceHolder.clear()
|
||||
|
||||
// Create fresh mesh service with new identity (keys were regenerated in clearAllCryptographicData)
|
||||
val freshMeshService = MeshServiceHolder.getOrCreate(getApplication())
|
||||
val freshUnifiedMeshService = MeshServiceHolder.getUnifiedOrCreate(getApplication())
|
||||
|
||||
// Replace our reference and set up the new service
|
||||
meshService = freshMeshService
|
||||
unifiedMeshService = freshUnifiedMeshService
|
||||
mesh.delegate = this
|
||||
|
||||
// Restart mesh operations with new identity
|
||||
mesh.startServices()
|
||||
mesh.sendBroadcastAnnounce()
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"✅ Mesh service recreated. Old peerID: $oldPeerID, New peerID: ${mesh.myPeerID}"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -825,7 +1023,7 @@ class ChatViewModel(
|
||||
private fun clearAllMeshServiceData() {
|
||||
try {
|
||||
// Request mesh service to clear all its internal data
|
||||
meshService.clearAllInternalData()
|
||||
mesh.clearAllInternalData()
|
||||
|
||||
Log.d(TAG, "✅ Cleared all mesh service data")
|
||||
} catch (e: Exception) {
|
||||
@@ -839,11 +1037,11 @@ class ChatViewModel(
|
||||
private fun clearAllCryptographicData() {
|
||||
try {
|
||||
// Clear encryption service persistent identity (Ed25519 signing keys)
|
||||
meshService.clearAllEncryptionData()
|
||||
mesh.clearAllEncryptionData()
|
||||
|
||||
// Clear secure identity state (if used)
|
||||
try {
|
||||
val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication())
|
||||
val identityManager = SecureIdentityStateManager(getApplication())
|
||||
identityManager.clearIdentityData()
|
||||
// Also clear secure values used by FavoritesPersistenceService (favorites + peerID index)
|
||||
try {
|
||||
@@ -856,7 +1054,7 @@ class ChatViewModel(
|
||||
|
||||
// Clear FavoritesPersistenceService persistent relationships
|
||||
try {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.clearAllFavorites()
|
||||
FavoritesPersistenceService.shared.clearAllFavorites()
|
||||
Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships")
|
||||
} catch (_: Exception) { }
|
||||
|
||||
@@ -884,7 +1082,7 @@ class ChatViewModel(
|
||||
* End geohash sampling
|
||||
*/
|
||||
fun endGeohashSampling() {
|
||||
// No-op in refactored architecture; sampling subscriptions are short-lived
|
||||
geohashViewModel.endGeohashSampling()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -899,7 +1097,19 @@ class ChatViewModel(
|
||||
*/
|
||||
fun startGeohashDM(pubkeyHex: String) {
|
||||
geohashViewModel.startGeohashDM(pubkeyHex) { convKey ->
|
||||
startPrivateChat(convKey)
|
||||
showPrivateChatSheet(convKey)
|
||||
}
|
||||
}
|
||||
|
||||
fun startGeohashDMByNickname(nickname: String) {
|
||||
geohashViewModel.startGeohashDMByNickname(nickname) { convKey ->
|
||||
showPrivateChatSheet(convKey)
|
||||
}
|
||||
}
|
||||
|
||||
fun startGeohashDMByShortId(shortId: String) {
|
||||
geohashViewModel.startGeohashDMByShortId(shortId) { convKey ->
|
||||
showPrivateChatSheet(convKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -923,15 +1133,7 @@ class ChatViewModel(
|
||||
fun hideAppInfo() {
|
||||
state.setShowAppInfo(false)
|
||||
}
|
||||
|
||||
fun showSidebar() {
|
||||
state.setShowSidebar(true)
|
||||
}
|
||||
|
||||
fun hideSidebar() {
|
||||
state.setShowSidebar(false)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handle Android back navigation
|
||||
* Returns true if the back press was handled, false if it should be passed to the system
|
||||
@@ -943,11 +1145,6 @@ class ChatViewModel(
|
||||
hideAppInfo()
|
||||
true
|
||||
}
|
||||
// Close sidebar
|
||||
state.getShowSidebarValue() -> {
|
||||
hideSidebar()
|
||||
true
|
||||
}
|
||||
// Close password dialog
|
||||
state.getShowPasswordPromptValue() -> {
|
||||
state.setShowPasswordPrompt(false)
|
||||
@@ -955,7 +1152,7 @@ class ChatViewModel(
|
||||
true
|
||||
}
|
||||
// Exit private chat
|
||||
state.getSelectedPrivateChatPeerValue() != null -> {
|
||||
state.getSelectedPrivateChatPeerValue() != null || state.getPrivateChatSheetPeerValue() != null -> {
|
||||
endPrivateChat()
|
||||
true
|
||||
}
|
||||
@@ -985,5 +1182,6 @@ class ChatViewModel(
|
||||
*/
|
||||
fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
|
||||
return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import java.util.Date
|
||||
|
||||
@@ -29,21 +29,21 @@ class CommandProcessor(
|
||||
|
||||
// MARK: - Command Processing
|
||||
|
||||
fun processCommand(command: String, meshService: BluetoothMeshService, myPeerID: String, onSendMessage: (String, List<String>, String?) -> Unit, viewModel: ChatViewModel? = null): Boolean {
|
||||
fun processCommand(command: String, meshService: MeshService, myPeerID: String, onSendMessage: (String, List<String>, String?) -> Unit, viewModel: ChatViewModel? = null): Boolean {
|
||||
if (!command.startsWith("/")) return false
|
||||
|
||||
val parts = command.split(" ")
|
||||
val cmd = parts.first().lowercase()
|
||||
when (cmd) {
|
||||
"/j", "/join" -> handleJoinCommand(parts, myPeerID)
|
||||
"/m", "/msg" -> handleMessageCommand(parts, meshService)
|
||||
"/m", "/msg" -> handleMessageCommand(parts, meshService, viewModel)
|
||||
"/w" -> handleWhoCommand(meshService, viewModel)
|
||||
"/clear" -> handleClearCommand()
|
||||
"/pass" -> handlePassCommand(parts, myPeerID)
|
||||
"/block" -> handleBlockCommand(parts, meshService)
|
||||
"/unblock" -> handleUnblockCommand(parts, meshService)
|
||||
"/hug" -> handleActionCommand(parts, "gives", "a warm hug 🫂", meshService, myPeerID, onSendMessage)
|
||||
"/slap" -> handleActionCommand(parts, "slaps", "around a bit with a large trout 🐟", meshService, myPeerID, onSendMessage)
|
||||
"/hug" -> handleActionCommand(parts, "gives", "a warm hug 🫂", meshService, myPeerID, onSendMessage, viewModel)
|
||||
"/slap" -> handleActionCommand(parts, "slaps", "around a bit with a large trout 🐟", meshService, myPeerID, onSendMessage, viewModel)
|
||||
"/channels" -> handleChannelsCommand()
|
||||
else -> handleUnknownCommand(cmd)
|
||||
}
|
||||
@@ -77,7 +77,7 @@ class CommandProcessor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMessageCommand(parts: List<String>, meshService: BluetoothMeshService) {
|
||||
private fun handleMessageCommand(parts: List<String>, meshService: MeshService, viewModel: ChatViewModel?) {
|
||||
if (parts.size > 1) {
|
||||
val targetName = parts[1].removePrefix("@")
|
||||
val peerID = getPeerIDForNickname(targetName, meshService)
|
||||
@@ -96,8 +96,7 @@ class CommandProcessor(
|
||||
state.getNicknameValue(),
|
||||
getMyPeerID(meshService)
|
||||
) { content, peerIdParam, recipientNicknameParam, messageId ->
|
||||
// This would trigger the actual mesh service send
|
||||
sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId)
|
||||
sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId, viewModel)
|
||||
}
|
||||
} else {
|
||||
val systemMessage = BitchatMessage(
|
||||
@@ -129,7 +128,7 @@ class CommandProcessor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleWhoCommand(meshService: BluetoothMeshService, viewModel: ChatViewModel? = null) {
|
||||
private fun handleWhoCommand(meshService: MeshService, viewModel: ChatViewModel? = null) {
|
||||
// Channel-aware who command (matches iOS behavior)
|
||||
val (peerList, contextDescription) = if (viewModel != null) {
|
||||
when (val selectedChannel = viewModel.selectedLocationChannel.value) {
|
||||
@@ -248,7 +247,7 @@ class CommandProcessor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBlockCommand(parts: List<String>, meshService: BluetoothMeshService) {
|
||||
private fun handleBlockCommand(parts: List<String>, meshService: MeshService) {
|
||||
if (parts.size > 1) {
|
||||
val targetName = parts[1].removePrefix("@")
|
||||
privateChatManager.blockPeerByNickname(targetName, meshService)
|
||||
@@ -265,7 +264,7 @@ class CommandProcessor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUnblockCommand(parts: List<String>, meshService: BluetoothMeshService) {
|
||||
private fun handleUnblockCommand(parts: List<String>, meshService: MeshService) {
|
||||
if (parts.size > 1) {
|
||||
val targetName = parts[1].removePrefix("@")
|
||||
privateChatManager.unblockPeerByNickname(targetName, meshService)
|
||||
@@ -284,9 +283,10 @@ class CommandProcessor(
|
||||
parts: List<String>,
|
||||
verb: String,
|
||||
object_: String,
|
||||
meshService: BluetoothMeshService,
|
||||
meshService: MeshService,
|
||||
myPeerID: String,
|
||||
onSendMessage: (String, List<String>, String?) -> Unit
|
||||
onSendMessage: (String, List<String>, String?) -> Unit,
|
||||
viewModel: ChatViewModel?
|
||||
) {
|
||||
if (parts.size > 1) {
|
||||
val targetName = parts[1].removePrefix("@")
|
||||
@@ -306,7 +306,7 @@ class CommandProcessor(
|
||||
state.getNicknameValue(),
|
||||
myPeerID
|
||||
) { content, peerIdParam, recipientNicknameParam, messageId ->
|
||||
sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId)
|
||||
sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId, viewModel)
|
||||
}
|
||||
} else if (isInLocationChannel) {
|
||||
// Let the transport layer add the echo; just send it out
|
||||
@@ -423,7 +423,7 @@ class CommandProcessor(
|
||||
|
||||
// MARK: - Mention Autocomplete
|
||||
|
||||
fun updateMentionSuggestions(input: String, meshService: BluetoothMeshService, viewModel: ChatViewModel? = null) {
|
||||
fun updateMentionSuggestions(input: String, meshService: MeshService, viewModel: ChatViewModel? = null) {
|
||||
// Check if input contains @ and we're at the end of a word or at the end of input
|
||||
val atIndex = input.lastIndexOf('@')
|
||||
if (atIndex == -1) {
|
||||
@@ -503,19 +503,33 @@ class CommandProcessor(
|
||||
|
||||
// MARK: - Utility Functions
|
||||
|
||||
private fun getPeerIDForNickname(nickname: String, meshService: BluetoothMeshService): String? {
|
||||
private fun getPeerIDForNickname(nickname: String, meshService: MeshService): String? {
|
||||
return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key
|
||||
}
|
||||
|
||||
private fun getPeerNickname(peerID: String, meshService: BluetoothMeshService): String {
|
||||
return meshService.getPeerNicknames()[peerID] ?: peerID
|
||||
private fun getPeerNickname(peerID: String, meshService: MeshService): String {
|
||||
return meshService.getPeerNicknames()[peerID]
|
||||
?: peerID
|
||||
}
|
||||
|
||||
private fun getMyPeerID(meshService: BluetoothMeshService): String {
|
||||
private fun getMyPeerID(meshService: MeshService): String {
|
||||
return meshService.myPeerID
|
||||
}
|
||||
|
||||
private fun sendPrivateMessageVia(meshService: BluetoothMeshService, content: String, peerID: String, recipientNickname: String, messageId: String) {
|
||||
meshService.sendPrivateMessage(content, peerID, recipientNickname, messageId)
|
||||
private fun sendPrivateMessageVia(
|
||||
meshService: MeshService,
|
||||
content: String,
|
||||
peerID: String,
|
||||
recipientNickname: String,
|
||||
messageId: String,
|
||||
viewModel: ChatViewModel?
|
||||
) {
|
||||
if (viewModel != null) {
|
||||
com.bitchat.android.services.MessageRouter
|
||||
.getInstance(viewModel.getApplication(), meshService)
|
||||
.sendPrivate(content, peerID, recipientNickname, messageId)
|
||||
} else {
|
||||
meshService.sendPrivateMessage(content, peerID, recipientNickname, messageId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ package com.bitchat.android.ui
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitchat.android.nostr.GeohashMessageHandler
|
||||
import com.bitchat.android.nostr.GeohashRepository
|
||||
@@ -12,11 +16,18 @@ import com.bitchat.android.nostr.NostrProtocol
|
||||
import com.bitchat.android.nostr.NostrRelayManager
|
||||
import com.bitchat.android.nostr.NostrSubscriptionManager
|
||||
import com.bitchat.android.nostr.PoWPreferenceManager
|
||||
import com.bitchat.android.nostr.GeohashAliasRegistry
|
||||
import com.bitchat.android.nostr.GeohashConversationRegistry
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import java.security.SecureRandom
|
||||
import kotlin.random.asKotlinRandom
|
||||
|
||||
class GeohashViewModel(
|
||||
application: Application,
|
||||
@@ -26,9 +37,12 @@ class GeohashViewModel(
|
||||
private val meshDelegateHandler: MeshDelegateHandler,
|
||||
private val dataManager: DataManager,
|
||||
private val notificationManager: NotificationManager
|
||||
) : AndroidViewModel(application) {
|
||||
) : AndroidViewModel(application), DefaultLifecycleObserver {
|
||||
|
||||
companion object { private const val TAG = "GeohashViewModel" }
|
||||
companion object {
|
||||
private const val TAG = "GeohashViewModel"
|
||||
private val secureRandom = SecureRandom().asKotlinRandom()
|
||||
}
|
||||
|
||||
private val repo = GeohashRepository(application, state, dataManager)
|
||||
private val subscriptionManager = NostrSubscriptionManager(application, viewModelScope)
|
||||
@@ -53,7 +67,9 @@ class GeohashViewModel(
|
||||
private var currentGeohashSubId: String? = null
|
||||
private var currentDmSubId: String? = null
|
||||
private var geoTimer: Job? = null
|
||||
private var globalPresenceJob: Job? = null
|
||||
private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null
|
||||
private val activeSamplingGeohashes = mutableSetOf<String>()
|
||||
|
||||
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
|
||||
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
|
||||
@@ -61,6 +77,10 @@ class GeohashViewModel(
|
||||
|
||||
fun initialize() {
|
||||
subscriptionManager.connect()
|
||||
// Observe process lifecycle to manage background sampling
|
||||
kotlin.runCatching {
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||
}
|
||||
val identity = NostrIdentityBridge.getCurrentNostrIdentity(getApplication())
|
||||
if (identity != null) {
|
||||
// Use global chat-messages only for full account DMs (mesh context). For geohash DMs, subscribe per-geohash below.
|
||||
@@ -84,6 +104,10 @@ class GeohashViewModel(
|
||||
state.setIsTeleported(teleported)
|
||||
}
|
||||
}
|
||||
|
||||
// Start global presence heartbeat loop
|
||||
startGlobalPresenceHeartbeat()
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to initialize location channel state: ${e.message}")
|
||||
state.setSelectedLocationChannel(com.bitchat.android.geohash.ChannelID.Mesh)
|
||||
@@ -91,17 +115,77 @@ class GeohashViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun startGlobalPresenceHeartbeat() {
|
||||
globalPresenceJob?.cancel()
|
||||
globalPresenceJob = viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
// Reactively restart heartbeat whenever available channels change
|
||||
locationChannelManager?.availableChannels?.collectLatest { channels ->
|
||||
// Filter for REGION (2), PROVINCE (4), CITY (5) - precision <= 5
|
||||
val targetGeohashes = channels.filter { it.level.precision <= 5 }.map { it.geohash }
|
||||
|
||||
if (targetGeohashes.isNotEmpty()) {
|
||||
// Enter heartbeat loop for this set of channels
|
||||
// If channels change (e.g. user moves), collectLatest cancels this loop and starts a new one immediately
|
||||
while (true) {
|
||||
// Randomize loop interval (40-80s, average 60s)
|
||||
val loopInterval = secureRandom.nextLong(40000L, 80000L)
|
||||
var timeSpent = 0L
|
||||
|
||||
try {
|
||||
Log.v(TAG, "💓 Broadcasting global presence to ${targetGeohashes.size} channels")
|
||||
targetGeohashes.forEach { geohash ->
|
||||
// Decorrelate individual broadcasts with random delay (1s-5s)
|
||||
val stepDelay = secureRandom.nextLong(1000L, 10000L)
|
||||
delay(stepDelay)
|
||||
timeSpent += stepDelay
|
||||
|
||||
broadcastPresence(geohash)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Global presence heartbeat error: ${e.message}")
|
||||
}
|
||||
|
||||
// Wait remaining time to satisfy target average cadence
|
||||
val remaining = loopInterval - timeSpent
|
||||
if (remaining > 0) {
|
||||
delay(remaining)
|
||||
} else {
|
||||
delay(10000L) // Minimum guard delay
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun panicReset() {
|
||||
repo.clearAll()
|
||||
GeohashAliasRegistry.clear()
|
||||
GeohashConversationRegistry.clear()
|
||||
subscriptionManager.disconnect()
|
||||
currentGeohashSubId = null
|
||||
currentDmSubId = null
|
||||
geoTimer?.cancel()
|
||||
geoTimer = null
|
||||
globalPresenceJob?.cancel()
|
||||
globalPresenceJob = null
|
||||
try { NostrIdentityBridge.clearAllAssociations(getApplication()) } catch (_: Exception) {}
|
||||
initialize()
|
||||
}
|
||||
|
||||
private suspend fun broadcastPresence(geohash: String) {
|
||||
try {
|
||||
val identity = NostrIdentityBridge.deriveIdentity(geohash, getApplication())
|
||||
val event = NostrProtocol.createGeohashPresenceEvent(geohash, identity)
|
||||
val relayManager = NostrRelayManager.getInstance(getApplication())
|
||||
// Presence is lightweight, send to geohash relays
|
||||
relayManager.sendEventToGeohash(event, geohash, includeDefaults = false, nRelays = 5)
|
||||
Log.v(TAG, "💓 Sent presence heartbeat for $geohash")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to send presence for $geohash: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
@@ -141,22 +225,46 @@ class GeohashViewModel(
|
||||
}
|
||||
|
||||
fun beginGeohashSampling(geohashes: List<String>) {
|
||||
if (geohashes.isEmpty()) return
|
||||
Log.d(TAG, "🌍 Beginning geohash sampling for ${geohashes.size} geohashes")
|
||||
viewModelScope.launch {
|
||||
geohashes.forEach { geohash ->
|
||||
subscriptionManager.subscribeGeohash(
|
||||
geohash = geohash,
|
||||
sinceMs = System.currentTimeMillis() - 86400000L,
|
||||
limit = 200,
|
||||
id = "sampling-$geohash",
|
||||
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
|
||||
)
|
||||
if (geohashes.isEmpty()) {
|
||||
endGeohashSampling()
|
||||
return
|
||||
}
|
||||
|
||||
// Diffing logic to avoid redundant REQ and leaks
|
||||
val currentSet = activeSamplingGeohashes.toSet()
|
||||
val newSet = geohashes.toSet()
|
||||
|
||||
val toRemove = currentSet - newSet
|
||||
val toAdd = newSet - currentSet
|
||||
|
||||
if (toAdd.isEmpty() && toRemove.isEmpty()) return
|
||||
|
||||
Log.d(TAG, "🌍 Updating sampling: +${toAdd.size} new, -${toRemove.size} removed")
|
||||
|
||||
// Remove old subscriptions
|
||||
toRemove.forEach { geohash ->
|
||||
subscriptionManager.unsubscribe("sampling-$geohash")
|
||||
activeSamplingGeohashes.remove(geohash)
|
||||
}
|
||||
|
||||
// Add new subscriptions
|
||||
activeSamplingGeohashes.addAll(toAdd)
|
||||
if (isAppInForeground()) {
|
||||
toAdd.forEach { geohash ->
|
||||
performSubscribeSampling(geohash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun endGeohashSampling() { Log.d(TAG, "🌍 Ending geohash sampling") }
|
||||
fun endGeohashSampling() {
|
||||
if (activeSamplingGeohashes.isEmpty()) return
|
||||
Log.d(TAG, "🌍 Ending geohash sampling (cleaning up ${activeSamplingGeohashes.size} subs)")
|
||||
|
||||
activeSamplingGeohashes.toList().forEach { geohash ->
|
||||
subscriptionManager.unsubscribe("sampling-$geohash")
|
||||
}
|
||||
activeSamplingGeohashes.clear()
|
||||
}
|
||||
fun geohashParticipantCount(geohash: String): Int = repo.geohashParticipantCount(geohash)
|
||||
fun isPersonTeleported(pubkeyHex: String): Boolean = repo.isPersonTeleported(pubkeyHex)
|
||||
|
||||
@@ -168,12 +276,31 @@ class GeohashViewModel(
|
||||
val gh = (current as? com.bitchat.android.geohash.ChannelID.Location)?.channel?.geohash
|
||||
if (!gh.isNullOrEmpty()) {
|
||||
repo.setConversationGeohash(convKey, gh)
|
||||
com.bitchat.android.nostr.GeohashConversationRegistry.set(convKey, gh)
|
||||
GeohashConversationRegistry.set(convKey, gh)
|
||||
}
|
||||
onStartPrivateChat(convKey)
|
||||
Log.d(TAG, "🗨️ Started geohash DM with ${pubkeyHex} -> ${convKey} (geohash=${gh})")
|
||||
}
|
||||
|
||||
fun startGeohashDMByNickname(nickname: String, onStartPrivateChat: (String) -> Unit) {
|
||||
val pubkey = repo.findPubkeyByNickname(nickname)
|
||||
if (pubkey != null) {
|
||||
startGeohashDM(pubkey, onStartPrivateChat)
|
||||
} else {
|
||||
Log.w(TAG, "Cannot start geohash DM: nickname '$nickname' not found in repo")
|
||||
// Optionally notify user
|
||||
}
|
||||
}
|
||||
|
||||
fun startGeohashDMByShortId(shortId: String, onStartPrivateChat: (String) -> Unit) {
|
||||
val pubkey = repo.findPubkeyByShortId(shortId)
|
||||
if (pubkey != null) {
|
||||
startGeohashDM(pubkey, onStartPrivateChat)
|
||||
} else {
|
||||
Log.w(TAG, "Cannot start geohash DM: shortId '$shortId' not found in repo")
|
||||
}
|
||||
}
|
||||
|
||||
fun getNostrKeyMapping(): Map<String, String> = repo.getNostrKeyMapping()
|
||||
|
||||
fun blockUserInGeohash(targetNickname: String) {
|
||||
@@ -206,6 +333,7 @@ class GeohashViewModel(
|
||||
}
|
||||
|
||||
fun displayNameForNostrPubkeyUI(pubkeyHex: String): String = repo.displayNameForNostrPubkeyUI(pubkeyHex)
|
||||
fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String = repo.displayNameForGeohashConversation(pubkeyHex, sourceGeohash)
|
||||
|
||||
fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
|
||||
val seed = "nostr:${pubkeyHex.lowercase()}"
|
||||
@@ -234,7 +362,7 @@ class GeohashViewModel(
|
||||
|
||||
try {
|
||||
val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication())
|
||||
repo.updateParticipant(channel.channel.geohash, identity.publicKeyHex, Date())
|
||||
// We don't update participant here anymore; presence loop handles it via Kind 20001
|
||||
val teleported = state.isTeleported.value
|
||||
if (teleported) repo.markTeleported(identity.publicKeyHex)
|
||||
} catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") }
|
||||
@@ -260,7 +388,7 @@ class GeohashViewModel(
|
||||
handler = { event -> dmHandler.onGiftWrap(event, geohash, dmIdentity) }
|
||||
)
|
||||
// Also register alias in global registry for routing convenience
|
||||
com.bitchat.android.nostr.GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex)
|
||||
GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex)
|
||||
}
|
||||
}
|
||||
null -> {
|
||||
@@ -279,4 +407,35 @@ class GeohashViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
kotlin.runCatching {
|
||||
ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
Log.d(TAG, "🌍 App foregrounded: Resuming sampling for ${activeSamplingGeohashes.size} geohashes")
|
||||
activeSamplingGeohashes.forEach { performSubscribeSampling(it) }
|
||||
}
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
Log.d(TAG, "🌍 App backgrounded: Pausing sampling for ${activeSamplingGeohashes.size} geohashes")
|
||||
activeSamplingGeohashes.forEach { subscriptionManager.unsubscribe("sampling-$it") }
|
||||
}
|
||||
|
||||
private fun performSubscribeSampling(geohash: String) {
|
||||
subscriptionManager.subscribeGeohash(
|
||||
geohash = geohash,
|
||||
sinceMs = System.currentTimeMillis() - 86400000L,
|
||||
limit = 200,
|
||||
id = "sampling-$geohash",
|
||||
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
|
||||
)
|
||||
}
|
||||
|
||||
private fun isAppInForeground(): Boolean {
|
||||
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -39,7 +38,9 @@ import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.core.ui.component.button.CloseButton
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
|
||||
|
||||
/**
|
||||
* Location Channels Sheet for selecting geohash-based location channels
|
||||
@@ -62,7 +63,9 @@ fun LocationChannelsSheet(
|
||||
val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
|
||||
val selectedChannel by locationManager.selectedChannel.collectAsStateWithLifecycle()
|
||||
val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
|
||||
val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle()
|
||||
val appLocationEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle()
|
||||
val systemLocationEnabled by locationManager.systemLocationEnabled.collectAsStateWithLifecycle()
|
||||
val locationServicesEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle()
|
||||
|
||||
// Observe bookmarks state
|
||||
val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle()
|
||||
@@ -113,43 +116,27 @@ fun LocationChannelsSheet(
|
||||
val standardBlue = Color(0xFF007AFF) // iOS blue
|
||||
|
||||
if (isPresented) {
|
||||
ModalBottomSheet(
|
||||
modifier = modifier.statusBarsPadding(),
|
||||
BitchatBottomSheet(
|
||||
modifier = modifier,
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
dragHandle = null
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(top = 48.dp, bottom = 16.dp)
|
||||
contentPadding = PaddingValues(top = 64.dp, bottom = 16.dp)
|
||||
) {
|
||||
// Header Section
|
||||
item(key = "header") {
|
||||
Column(
|
||||
Text(
|
||||
text = stringResource(R.string.location_channels_desc),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.location_channels_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.location_channels_desc),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Permission controls if services enabled
|
||||
@@ -163,24 +150,7 @@ fun LocationChannelsSheet(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
when (permissionState) {
|
||||
LocationChannelManager.PermissionState.NOT_DETERMINED -> {
|
||||
Button(
|
||||
onClick = { locationManager.enableLocationChannels() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = standardGreen.copy(alpha = 0.12f),
|
||||
contentColor = standardGreen
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.grant_location_permission),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
}
|
||||
LocationChannelManager.PermissionState.DENIED,
|
||||
LocationChannelManager.PermissionState.RESTRICTED -> {
|
||||
LocationChannelManager.PermissionState.DENIED -> {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.location_permission_denied),
|
||||
@@ -212,20 +182,6 @@ fun LocationChannelsSheet(
|
||||
color = standardGreen
|
||||
)
|
||||
}
|
||||
null -> {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.checking_permissions),
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,10 +243,17 @@ fun LocationChannelsSheet(
|
||||
} else if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
|
||||
item {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp))
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.finding_nearby_channels),
|
||||
fontSize = 12.sp,
|
||||
@@ -545,50 +508,45 @@ fun LocationChannelsSheet(
|
||||
}
|
||||
|
||||
// TopBar (animated)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha))
|
||||
) {
|
||||
CloseButton(
|
||||
onClick = onDismiss,
|
||||
modifier = modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
BitchatSheetTopBar(
|
||||
onClose = onDismiss,
|
||||
modifier = modifier.align(Alignment.TopCenter),
|
||||
title = {
|
||||
BitchatSheetTitle(
|
||||
text = stringResource(R.string.location_channels_title)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle management: when presented, sample both nearby and bookmarked geohashes
|
||||
// Lifecycle management: when presented, manage location updates
|
||||
DisposableEffect(isPresented, permissionState, locationServicesEnabled) {
|
||||
if (isPresented && permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
|
||||
locationManager.refreshChannels()
|
||||
locationManager.beginLiveRefresh()
|
||||
}
|
||||
|
||||
onDispose {
|
||||
locationManager.endLiveRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
// Sampling management: update sampling when channels/bookmarks change
|
||||
LaunchedEffect(isPresented, availableChannels, bookmarks) {
|
||||
if (isPresented) {
|
||||
if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
|
||||
locationManager.refreshChannels()
|
||||
locationManager.beginLiveRefresh()
|
||||
}
|
||||
val geohashes = (availableChannels.map { it.geohash } + bookmarks).toSet().toList()
|
||||
viewModel.beginGeohashSampling(geohashes)
|
||||
} else {
|
||||
locationManager.endLiveRefresh()
|
||||
viewModel.endGeohashSampling()
|
||||
}
|
||||
}
|
||||
|
||||
// React to permission changes
|
||||
LaunchedEffect(permissionState) {
|
||||
if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
|
||||
locationManager.refreshChannels()
|
||||
}
|
||||
}
|
||||
|
||||
// React to location services enable/disable
|
||||
LaunchedEffect(locationServicesEnabled) {
|
||||
if (locationServicesEnabled && permissionState == LocationChannelManager.PermissionState.AUTHORIZED) {
|
||||
locationManager.refreshChannels()
|
||||
// Ensure cleanup when the composable is destroyed (e.g. removed from parent composition)
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
viewModel.endGeohashSampling()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -698,7 +656,7 @@ private fun meshTitleWithCount(viewModel: ChatViewModel): String {
|
||||
}
|
||||
|
||||
private fun meshCount(viewModel: ChatViewModel): Int {
|
||||
val myID = viewModel.meshService.myPeerID
|
||||
val myID = viewModel.myPeerID
|
||||
return viewModel.connectedPeers.value?.count { peerID ->
|
||||
peerID != myID
|
||||
} ?: 0
|
||||
@@ -707,7 +665,16 @@ private fun meshCount(viewModel: ChatViewModel): Int {
|
||||
@Composable
|
||||
private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int): String {
|
||||
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||||
val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount)
|
||||
|
||||
// For high precision channels (Neighborhood, Block) where we don't broadcast presence,
|
||||
// show "? people" instead of "0 people" to avoid misleading "nobody is here" indication.
|
||||
val isHighPrecision = channel.level.precision > 5
|
||||
val peopleText = if (isHighPrecision && participantCount == 0) {
|
||||
ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, 0, 0).replace("0", "?")
|
||||
} else {
|
||||
ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount)
|
||||
}
|
||||
|
||||
val levelName = when (channel.level) {
|
||||
com.bitchat.android.geohash.GeohashChannelLevel.BUILDING -> "Building" // iOS: precision 8 for location notes
|
||||
com.bitchat.android.geohash.GeohashChannelLevel.BLOCK -> stringResource(com.bitchat.android.R.string.location_level_block)
|
||||
@@ -722,7 +689,15 @@ private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int
|
||||
@Composable
|
||||
private fun geohashHashTitleWithCount(geohash: String, participantCount: Int): String {
|
||||
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||||
val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount)
|
||||
val level = levelForLength(geohash.length)
|
||||
val isHighPrecision = level.precision > 5
|
||||
|
||||
val peopleText = if (isHighPrecision && participantCount == 0) {
|
||||
ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, 0, 0).replace("0", "?")
|
||||
} else {
|
||||
ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount)
|
||||
}
|
||||
|
||||
return "#$geohash [$peopleText]"
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ fun LocationNotesButton(
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val locationManager = remember { LocationChannelManager.getInstance(context) }
|
||||
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
|
||||
val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle(false)
|
||||
val locationServicesEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(false)
|
||||
|
||||
// Check both permission AND location services enabled
|
||||
val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
@@ -12,9 +13,9 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowUpward
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
@@ -25,6 +26,9 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
|
||||
import com.bitchat.android.geohash.GeohashChannelLevel
|
||||
import com.bitchat.android.geohash.LocationChannelManager
|
||||
import com.bitchat.android.nostr.LocationNotesManager
|
||||
@@ -49,7 +53,6 @@ fun LocationNotesSheet(
|
||||
val isDark = isSystemInDarkTheme()
|
||||
|
||||
// iOS color scheme
|
||||
val backgroundColor = if (isDark) Color.Black else Color.White
|
||||
val accentGreen = if (isDark) Color.Green else Color(0xFF008000) // dark: green, light: dark green (0, 0.5, 0)
|
||||
|
||||
// Managers
|
||||
@@ -76,7 +79,21 @@ fun LocationNotesSheet(
|
||||
|
||||
// Scroll state
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val isScrolled by remember {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
|
||||
}
|
||||
}
|
||||
val topBarAlpha by animateFloatAsState(
|
||||
targetValue = if (isScrolled) 0.95f else 0f,
|
||||
label = "topBarAlpha"
|
||||
)
|
||||
|
||||
// Refresh location when sheet opens
|
||||
LaunchedEffect(Unit) {
|
||||
locationManager.refreshChannels()
|
||||
}
|
||||
|
||||
// Effect to set geohash when sheet opens
|
||||
LaunchedEffect(geohash) {
|
||||
notesManager.setGeohash(geohash)
|
||||
@@ -88,168 +105,133 @@ fun LocationNotesSheet(
|
||||
notesManager.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
|
||||
BitchatBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
modifier = modifier,
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
containerColor = backgroundColor,
|
||||
contentColor = if (isDark) Color.White else Color.Black
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.9f)
|
||||
) {
|
||||
// Header section (matches iOS headerSection)
|
||||
LocationNotesHeader(
|
||||
geohash = geohash,
|
||||
count = count,
|
||||
locationName = displayLocationName,
|
||||
state = state,
|
||||
accentGreen = accentGreen,
|
||||
backgroundColor = backgroundColor,
|
||||
onClose = onDismiss
|
||||
)
|
||||
|
||||
// ScrollView with notes content
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor)
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||
contentPadding = PaddingValues(top = 64.dp, bottom = 20.dp)
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
// Notes content (matches iOS notesContent)
|
||||
when {
|
||||
state == LocationNotesManager.State.NO_RELAYS -> {
|
||||
item {
|
||||
NoRelaysRow(
|
||||
onRetry = { notesManager.refresh() }
|
||||
)
|
||||
}
|
||||
}
|
||||
state == LocationNotesManager.State.LOADING && !initialLoadComplete -> {
|
||||
item {
|
||||
LoadingRow()
|
||||
}
|
||||
}
|
||||
notes.isEmpty() -> {
|
||||
item {
|
||||
EmptyRow()
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
items(notes, key = { it.id }) { note ->
|
||||
NoteRow(note = note)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
item(key = "notes_header") {
|
||||
LocationNotesHeader(
|
||||
locationName = displayLocationName,
|
||||
state = state,
|
||||
accentGreen = accentGreen,
|
||||
)
|
||||
}
|
||||
|
||||
// Notes content (matches iOS notesContent)
|
||||
when {
|
||||
state == LocationNotesManager.State.NO_RELAYS -> {
|
||||
item {
|
||||
NoRelaysRow(
|
||||
onRetry = { notesManager.refresh() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Error row (matches iOS errorRow)
|
||||
errorMessage?.let { error ->
|
||||
if (state != LocationNotesManager.State.NO_RELAYS) {
|
||||
item {
|
||||
ErrorRow(
|
||||
message = error,
|
||||
onDismiss = { notesManager.clearError() }
|
||||
)
|
||||
}
|
||||
state == LocationNotesManager.State.LOADING && !initialLoadComplete -> {
|
||||
item {
|
||||
LoadingRow()
|
||||
}
|
||||
}
|
||||
notes.isEmpty() -> {
|
||||
item {
|
||||
EmptyRow()
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
items(notes, key = { it.id }) { note ->
|
||||
NoteRow(note = note)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error row (matches iOS errorRow)
|
||||
errorMessage?.let { error ->
|
||||
if (state != LocationNotesManager.State.NO_RELAYS) {
|
||||
item {
|
||||
ErrorRow(
|
||||
message = error,
|
||||
onDismiss = { notesManager.clearError() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Divider before input (matches iOS overlay)
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f),
|
||||
thickness = 1.dp
|
||||
)
|
||||
|
||||
// Input section (matches iOS inputSection)
|
||||
LocationNotesInputSection(
|
||||
draft = draft,
|
||||
onDraftChange = { draft = it },
|
||||
sendButtonEnabled = sendButtonEnabled,
|
||||
accentGreen = accentGreen,
|
||||
backgroundColor = backgroundColor,
|
||||
onSend = {
|
||||
val content = draft.trim()
|
||||
if (content.isNotEmpty()) {
|
||||
notesManager.send(content, nickname)
|
||||
draft = ""
|
||||
}
|
||||
|
||||
// TopBar (animated)
|
||||
BitchatSheetTopBar(
|
||||
onClose = onDismiss,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
title = {
|
||||
BitchatSheetTitle(
|
||||
text = pluralStringResource(
|
||||
id = R.plurals.location_notes_title,
|
||||
count = count,
|
||||
geohash,
|
||||
count
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
){
|
||||
Column {
|
||||
// Divider before input (matches iOS overlay)
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f),
|
||||
thickness = 1.dp
|
||||
)
|
||||
|
||||
// Input section (matches iOS inputSection)
|
||||
LocationNotesInputSection(
|
||||
draft = draft,
|
||||
onDraftChange = { draft = it },
|
||||
sendButtonEnabled = sendButtonEnabled,
|
||||
accentGreen = accentGreen,
|
||||
onSend = {
|
||||
val content = draft.trim()
|
||||
if (content.isNotEmpty()) {
|
||||
notesManager.send(content, nickname)
|
||||
draft = ""
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Header section - matches iOS headerSection exactly
|
||||
* Shows: "#geohash • X notes", location name, description, and close button
|
||||
* Shows: "#geohash • X notes", location name, description
|
||||
*/
|
||||
@Composable
|
||||
private fun LocationNotesHeader(
|
||||
geohash: String,
|
||||
count: Int,
|
||||
locationName: String?,
|
||||
state: LocationNotesManager.State,
|
||||
accentGreen: Color,
|
||||
backgroundColor: Color,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor)
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 16.dp, bottom = 12.dp)
|
||||
.padding(bottom = 12.dp)
|
||||
) {
|
||||
// Title row with close button
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Localized title with ±1 and note count
|
||||
Text(
|
||||
text = pluralStringResource(
|
||||
id = R.plurals.location_notes_title,
|
||||
count = count,
|
||||
geohash,
|
||||
count
|
||||
),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 18.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
// Close button - iOS style with xmark icon
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clickable(onClick = onClose),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "✕",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Location name in green (building or block)
|
||||
locationName?.let { name ->
|
||||
if (name.isNotEmpty()) {
|
||||
@@ -469,7 +451,6 @@ private fun LocationNotesInputSection(
|
||||
onDraftChange: (String) -> Unit,
|
||||
sendButtonEnabled: Boolean,
|
||||
accentGreen: Color,
|
||||
backgroundColor: Color,
|
||||
onSend: () -> Unit
|
||||
) {
|
||||
val isDark = isSystemInDarkTheme()
|
||||
@@ -478,7 +459,7 @@ private fun LocationNotesInputSection(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor)
|
||||
.background(color = colorScheme.background)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp), // Match main chat padding
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp) // Match main chat spacing
|
||||
|
||||
@@ -9,9 +9,14 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
|
||||
import com.bitchat.android.geohash.GeohashChannelLevel
|
||||
import com.bitchat.android.geohash.LocationChannelManager
|
||||
import com.bitchat.android.R
|
||||
|
||||
/**
|
||||
* Presenter component for LocationNotesSheet
|
||||
@@ -27,6 +32,8 @@ fun LocationNotesSheetPresenter(
|
||||
val context = LocalContext.current
|
||||
val locationManager = remember { LocationChannelManager.getInstance(context) }
|
||||
val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
|
||||
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
|
||||
val isLoadingLocation by locationManager.isLoadingLocation.collectAsStateWithLifecycle()
|
||||
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||
|
||||
// iOS pattern: notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash
|
||||
@@ -44,6 +51,8 @@ fun LocationNotesSheetPresenter(
|
||||
nickname = nickname,
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
} else if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && isLoadingLocation) {
|
||||
LocationNotesAcquiringSheet(onDismiss = onDismiss)
|
||||
} else {
|
||||
// No building geohash available - show error state (matches iOS)
|
||||
LocationNotesErrorSheet(
|
||||
@@ -53,6 +62,43 @@ fun LocationNotesSheetPresenter(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading sheet when location is being acquired
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun LocationNotesAcquiringSheet(
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
BitchatBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "Acquiring Location",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(48.dp),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = "Please wait while your location is being determined",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error sheet when location is unavailable
|
||||
*/
|
||||
@@ -62,39 +108,50 @@ private fun LocationNotesErrorSheet(
|
||||
onDismiss: () -> Unit,
|
||||
locationManager: LocationChannelManager
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
ModalBottomSheet(
|
||||
BitchatBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "Location Unavailable",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Location permission is required for notes",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Button(onClick = {
|
||||
// UNIFIED FIX: Enable location services first (user toggle)
|
||||
locationManager.enableLocationServices()
|
||||
// Then request location channels (which will also request permission if needed)
|
||||
locationManager.enableLocationChannels()
|
||||
locationManager.refreshChannels()
|
||||
}) {
|
||||
Text("Enable Location")
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(top = 80.dp, bottom = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "Location Unavailable",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Location permission is required for notes",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Button(onClick = {
|
||||
// UNIFIED FIX: Enable location services first (user toggle)
|
||||
locationManager.enableLocationServices()
|
||||
// Then request location channels (which will also request permission if needed)
|
||||
locationManager.enableLocationChannels()
|
||||
locationManager.refreshChannels()
|
||||
}) {
|
||||
Text("Enable Location")
|
||||
}
|
||||
}
|
||||
|
||||
BitchatSheetTopBar(
|
||||
onClose = onDismiss,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
title = {
|
||||
BitchatSheetTitle(
|
||||
text = stringResource(R.string.cd_location_notes).uppercase()
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ fun MessageWithMatrixAnimation(
|
||||
message: com.bitchat.android.model.BitchatMessage,
|
||||
messages: List<com.bitchat.android.model.BitchatMessage> = emptyList(),
|
||||
currentUserNickname: String,
|
||||
meshService: com.bitchat.android.mesh.BluetoothMeshService,
|
||||
meshService: com.bitchat.android.mesh.MeshService,
|
||||
colorScheme: androidx.compose.material3.ColorScheme,
|
||||
timeFormatter: java.text.SimpleDateFormat,
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
@@ -124,7 +124,7 @@ fun MessageWithMatrixAnimation(
|
||||
private fun AnimatedMessageDisplay(
|
||||
message: com.bitchat.android.model.BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: com.bitchat.android.mesh.BluetoothMeshService,
|
||||
meshService: com.bitchat.android.mesh.MeshService,
|
||||
colorScheme: androidx.compose.material3.ColorScheme,
|
||||
timeFormatter: java.text.SimpleDateFormat,
|
||||
modifier: Modifier = Modifier
|
||||
@@ -241,7 +241,7 @@ private fun AnimatedMessageDisplay(
|
||||
private fun formatMessageAsAnnotatedStringWithoutTimestamp(
|
||||
message: com.bitchat.android.model.BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: com.bitchat.android.mesh.BluetoothMeshService,
|
||||
meshService: com.bitchat.android.mesh.MeshService,
|
||||
colorScheme: androidx.compose.material3.ColorScheme
|
||||
): AnnotatedString {
|
||||
// Get the full formatted text first
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.util.Log
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
@@ -16,8 +16,11 @@ class MediaSendingManager(
|
||||
private val state: ChatState,
|
||||
private val messageManager: MessageManager,
|
||||
private val channelManager: ChannelManager,
|
||||
private val meshService: BluetoothMeshService
|
||||
private val getMeshService: () -> MeshService
|
||||
) {
|
||||
// Helper to get current mesh service (may change after panic clear)
|
||||
private val meshService: MeshService
|
||||
get() = getMeshService()
|
||||
companion object {
|
||||
private const val TAG = "MediaSendingManager"
|
||||
private const val MAX_FILE_SIZE = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES // 50MB limit
|
||||
@@ -210,7 +213,6 @@ class MediaSendingManager(
|
||||
|
||||
Log.d(TAG, "📤 Calling meshService.sendFilePrivate to $toPeerID")
|
||||
meshService.sendFilePrivate(toPeerID, filePacket)
|
||||
try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendFilePrivate(toPeerID, filePacket) } catch (_: Exception) {}
|
||||
Log.d(TAG, "✅ File send completed successfully")
|
||||
}
|
||||
|
||||
@@ -265,7 +267,6 @@ class MediaSendingManager(
|
||||
|
||||
Log.d(TAG, "📤 Calling meshService.sendFileBroadcast")
|
||||
meshService.sendFileBroadcast(filePacket)
|
||||
try { com.bitchat.android.wifiaware.WifiAwareController.getService()?.sendFileBroadcast(filePacket) } catch (_: Exception) {}
|
||||
Log.d(TAG, "✅ File broadcast completed successfully")
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.bitchat.android.ui
|
||||
|
||||
import com.bitchat.android.mesh.BluetoothMeshDelegate
|
||||
import com.bitchat.android.ui.NotificationTextUtils
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -21,7 +21,7 @@ class MeshDelegateHandler(
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val onHapticFeedback: () -> Unit,
|
||||
private val getMyPeerID: () -> String,
|
||||
private val getMeshService: () -> BluetoothMeshService
|
||||
private val getMeshService: () -> MeshService
|
||||
) : BluetoothMeshDelegate {
|
||||
|
||||
override fun didReceiveMessage(message: BitchatMessage) {
|
||||
@@ -94,30 +94,13 @@ class MeshDelegateHandler(
|
||||
}
|
||||
}
|
||||
|
||||
private var blePeers: Set<String> = emptySet()
|
||||
private var wifiPeers: Set<String> = emptySet()
|
||||
|
||||
override fun didUpdatePeerList(peers: List<String>) {
|
||||
coroutineScope.launch {
|
||||
blePeers = peers.toSet()
|
||||
processPeerUpdate()
|
||||
processPeerUpdate(peers.distinct())
|
||||
}
|
||||
}
|
||||
|
||||
fun onWifiPeersUpdated(peers: List<String>) {
|
||||
coroutineScope.launch {
|
||||
wifiPeers = peers.toSet()
|
||||
processPeerUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun processPeerUpdate() {
|
||||
// Merge peers from multiple transports
|
||||
val mergedPeers = (blePeers + wifiPeers).toList()
|
||||
|
||||
// Update process-wide state as source of truth
|
||||
try { com.bitchat.android.services.AppStateStore.setPeers(mergedPeers) } catch (_: Exception) { }
|
||||
|
||||
private suspend fun processPeerUpdate(mergedPeers: List<String>) {
|
||||
state.setConnectedPeers(mergedPeers)
|
||||
state.setIsConnected(mergedPeers.isNotEmpty())
|
||||
notificationManager.showActiveUserNotification(mergedPeers)
|
||||
@@ -238,6 +221,14 @@ class MeshDelegateHandler(
|
||||
messageManager.updateMessageDeliveryStatus(messageID, DeliveryStatus.Read(recipientPeerID, Date()))
|
||||
}
|
||||
}
|
||||
|
||||
override fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
// Handled by ChatViewModel for verification flow
|
||||
}
|
||||
|
||||
override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
// Handled by ChatViewModel for verification flow
|
||||
}
|
||||
|
||||
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
|
||||
return channelManager.decryptChannelMessage(encryptedContent, channel)
|
||||
@@ -308,15 +299,27 @@ class MeshDelegateHandler(
|
||||
if (shouldSendReadReceipt) {
|
||||
android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID (message=${message.id})")
|
||||
val nickname = state.getNicknameValue() ?: "unknown"
|
||||
// Send directly for this message to avoid relying on unread queues
|
||||
getMeshService().sendReadReceipt(message.id, senderPeerID!!, nickname)
|
||||
// Ensure unread badge is cleared for this peer immediately
|
||||
try {
|
||||
val current = state.getUnreadPrivateMessagesValue().toMutableSet()
|
||||
if (current.remove(senderPeerID)) {
|
||||
state.setUnreadPrivateMessages(current)
|
||||
val mesh = getMeshService()
|
||||
val sent = try {
|
||||
val hasMesh = mesh.getPeerInfo(senderPeerID!!)?.isConnected == true && mesh.hasEstablishedSession(senderPeerID)
|
||||
if (hasMesh) {
|
||||
mesh.sendReadReceipt(message.id, senderPeerID, nickname)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
if (sent) {
|
||||
// Ensure unread badge is cleared for this peer immediately
|
||||
try {
|
||||
val current = state.getUnreadPrivateMessagesValue().toMutableSet()
|
||||
if (current.remove(senderPeerID)) {
|
||||
state.setUnreadPrivateMessages(current)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
} else {
|
||||
android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)")
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,7 @@ import android.content.Intent
|
||||
import android.net.Uri
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import com.bitchat.android.ui.media.VoiceNotePlayer
|
||||
@@ -58,7 +58,7 @@ import androidx.compose.ui.res.stringResource
|
||||
fun MessagesList(
|
||||
messages: List<BitchatMessage>,
|
||||
currentUserNickname: String,
|
||||
meshService: BluetoothMeshService,
|
||||
meshService: MeshService,
|
||||
modifier: Modifier = Modifier,
|
||||
forceScrollToBottom: Boolean = false,
|
||||
onScrolledUpChanged: ((Boolean) -> Unit)? = null,
|
||||
@@ -71,19 +71,14 @@ fun MessagesList(
|
||||
|
||||
// Track if this is the first time messages are being loaded
|
||||
var hasScrolledToInitialPosition by remember { mutableStateOf(false) }
|
||||
var followIncomingMessages by remember { mutableStateOf(true) }
|
||||
|
||||
// Smart scroll: auto-scroll to bottom for initial load, then only when user is at or near the bottom
|
||||
// Smart scroll: auto-scroll to bottom for initial load, then follow unless user scrolls away
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) {
|
||||
val layoutInfo = listState.layoutInfo
|
||||
val firstVisibleIndex = layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: -1
|
||||
|
||||
// With reverseLayout=true and reversed data, index 0 is the latest message at the bottom
|
||||
val isFirstLoad = !hasScrolledToInitialPosition
|
||||
val isNearLatest = firstVisibleIndex <= 2
|
||||
|
||||
if (isFirstLoad || isNearLatest) {
|
||||
listState.animateScrollToItem(0)
|
||||
if (isFirstLoad || followIncomingMessages) {
|
||||
listState.scrollToItem(0)
|
||||
if (isFirstLoad) {
|
||||
hasScrolledToInitialPosition = true
|
||||
}
|
||||
@@ -99,6 +94,7 @@ fun MessagesList(
|
||||
}
|
||||
}
|
||||
LaunchedEffect(isAtLatest) {
|
||||
followIncomingMessages = isAtLatest
|
||||
onScrolledUpChanged?.invoke(!isAtLatest)
|
||||
}
|
||||
|
||||
@@ -106,7 +102,8 @@ fun MessagesList(
|
||||
LaunchedEffect(forceScrollToBottom) {
|
||||
if (messages.isNotEmpty()) {
|
||||
// With reverseLayout=true and reversed data, latest is at index 0
|
||||
listState.animateScrollToItem(0)
|
||||
followIncomingMessages = true
|
||||
listState.scrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +137,7 @@ fun MessagesList(
|
||||
fun MessageItem(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: BluetoothMeshService,
|
||||
meshService: MeshService,
|
||||
messages: List<BitchatMessage> = emptyList(),
|
||||
onNicknameClick: ((String) -> Unit)? = null,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)? = null,
|
||||
@@ -204,7 +201,7 @@ fun MessageItem(
|
||||
message: BitchatMessage,
|
||||
messages: List<BitchatMessage>,
|
||||
currentUserNickname: String,
|
||||
meshService: BluetoothMeshService,
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
|
||||
@@ -280,6 +280,37 @@ class NotificationManager(
|
||||
Log.d(TAG, "Displayed notification for $contentTitle with ID $notificationId")
|
||||
}
|
||||
|
||||
fun showVerificationNotification(title: String, body: String, peerID: String? = null) {
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
if (peerID != null) {
|
||||
putExtra(EXTRA_OPEN_PRIVATE_CHAT, true)
|
||||
putExtra(EXTRA_PEER_ID, peerID)
|
||||
putExtra(EXTRA_SENDER_NICKNAME, body)
|
||||
}
|
||||
}
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
(System.currentTimeMillis() and 0x7FFFFFFF).toInt(),
|
||||
intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setShowWhen(true)
|
||||
.setWhen(System.currentTimeMillis())
|
||||
|
||||
notificationManager.notify((System.currentTimeMillis() and 0x7FFFFFFF).toInt(), builder.build())
|
||||
}
|
||||
|
||||
private fun showNotificationForActivePeers(peersSize: Int) {
|
||||
// Create intent to open the app
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user